为什么你的长任务总是断在半路
在服务器上跑数据导入、批量压缩、大文件传输、爬虫采集这类任务,最让人崩溃的不是任务本身失败,而是跑到一半 SSH 断了,任务跟着一起死了。更气人的是有些任务跑到 90% 才断,前面的时间全白费。
原因其实很直白:你 SSH 登录之后执行命令,这个命令的父进程是你的 shell,而 shell 的父进程是 sshd 派生出来的会话。当你关掉终端或者网络抖动导致连接断开,sshd 收到 SIGHUP 信号,它会把这个信号转发给整个前台进程组,你的任务就收到了 SIGHUP 并默认退出。这不是 bug,是 Unix 的会话管理机制。
解决思路有三层:第一层是让进程忽略 SIGHUP,第二层是让它脱离控制终端成为独立会话,第三层是给它一个可以随时回来查看的持久化终端。这三层分别对应 nohup、setsid、screen/tmux。很多人只知道 nohup 加个 &,但不知道它解决不了的问题是什么,也不知道什么时候必须上 screen。这篇文章把三层讲清楚,并且给出一个可以复用的任务管理脚本。
第一层:nohup 与 & 到底做了什么
最常见的写法是:
nohup ./long_task.sh > task.log 2>&1 &这一行里有三个部分,各自解决一个不同的问题,缺一个都会出问题。
nohup 的作用是让命令忽略 SIGHUP。它会在执行命令前把 SIG_HUP 的处理方式设为 SIG_IGN,这样即使 sshd 发来 SIGHUP,进程也不理会。同时如果标准输出是终端,nohup 会自动把输出重定向到 nohup.out。
> task.log 2>&1 是把标准输出和标准错误都写进文件。这一步不是可选的。因为如果不重定向,进程的 stdout 还连着那个已经断掉的 ssh 伪终端。虽然进程不会因为 SIGHUP 死掉,但当它往 stdout 写数据时,会遇到 broken pipe,某些程序(尤其是用 print/echo 输出进度的那种)会因为这个而异常退出。2>&1 的顺序也不能写反——2>&1 > file 的意思是先把 stderr 指向当前 stdout(还是终端),再改 stdout 到文件,结果是错误信息还是会打到终端,等于没重定向。
末尾的 & 是让命令变成后台作业,shell 立即返回提示符,你不用等它跑完才能敲下一条命令。
nohup 不够用的时候
nohup 的问题是:任务变成孤儿进程之后,你就很难再跟它交互了。它的 stdout 进了文件,你只能用 tail -f 看;想给它输个确认、想按 Ctrl+C 中止、想看看它现在卡在哪一步,都做不到。
另一个容易忽略的点是:nohup 只是忽略 SIGHUP,进程的会话和控制终端并没有真正脱离。某些程序会去检查自己有没有控制终端,如果没有就改变行为(比如变成非交互模式、或者干脆拒绝启动)。还有的程序会自己去设置 SIGHUP 处理器覆盖掉 nohup 的设置。这时候就轮到 setsid 出场了。
setsid ./long_task.sh > task.log 2>&1 < /dev/null &setsid 让命令在一个全新的会话里运行,这个新会话没有控制终端。这样即使有 SIGHUP 也无从发来,比 nohup 的"忽略"更彻底。同时把 stdin 重定向到 /dev/null,避免程序试图从已经不存在的终端读取输入。对于那种"后台跑着跑着突然卡住不动"的任务,很多时候就是因为它在等 stdin,加上 < /dev/null 就好了。
第二层:screen 与 tmux,让任务有个"可以回去的终端"
如果你需要的不只是让任务活着,还要能随时回来看进度、能中途交互、能开好几个并行窗口,那就该用终端复用器。这类工具会创建一个常驻的守护进程持有伪终端,你的 SSH 会话只是"附着"到它上面,断线只是断开附着,守护进程和里面的程序照常运行。
screen 是最老牌的选择,几乎所有发行版的仓库里都有,也几乎是预装的:
apt install -y screen
screen --versiontmux 功能更强、配置更灵活,是现在更主流的选择:
apt install -y tmux
tmux -V两者核心概念一样:先创建一个会话(session),里面可以开多个窗口(window),每个窗口可以切分成多个面板(pane)。断线重连就是重新 attach 到同一个会话。
screen 的必备操作
screen 的所有命令都以 Ctrl+A 为前缀(叫 escape key),按下之后松开,再按功能键。最常用的几个:
screen -S import # 新建一个名为 import 的会话
# 在会话里正常执行任务
# Ctrl+A 然后按 d # detach,会话继续在后台跑
screen -ls # 列出所有会话
screen -r import # 重新附着到 import 会话
screen -r 12345 # 名字冲突时用 PID 附着
screen -S import -X quit # 彻底杀掉这个会话
几个实际会遇到的坑:screen -ls 里如果显示 (Attached),说明这个会话已经被某个终端附着着(通常是你在另一个 SSH 里进去过,或者上一次断线没清理干净)。这时候直接 screen -r 会报 There is a screen on: ... (Attached) 然后拒绝。解决方式是 screen -d -r import,先强制 detach 原有的附着者再重新附着。
如果显示 (Dead ???),说明会话所在的进程已经死了但 socket 文件还留着。用 screen -wipe 清理掉这些僵尸记录,否则 screen -ls 会一直显示它们,看着很乱。
tmux 的等价操作
tmux 默认前缀是 Ctrl+B:
tmux new -s import # 新建会话
# Ctrl+B 然后按 d # detach
tmux ls # 列出会话
tmux attach -t import # 附着
tmux kill-session -t import # 杀掉会话
tmux attach -d -t import # 强制踢掉原有附着者再附着
tmux 有一个比 screen 好用的地方:可以在会话里直接 Ctrl+B 然后 " 水平分割、% 垂直分割,同时看着日志和操作命令。screen 虽然也能分屏(Ctrl+A 然后 | 或 S),但操作手感差不少。
tmux 掉线后尺寸错乱的问题
这是 tmux 用户最常抱怨的现象:断线重连之后,窗口变成很小一块,或者内容显示错位、重叠。原因是 tmux 把客户端的终端尺寸保存在会话里,断线时那个客户端的尺寸可能被设成了 0 或者很小,重连时没有及时刷新。
手动修一下:Ctrl+B 然后按 : 进入命令模式,输入 refresh-client -C 回车。或者更省事的做法是在 ~/.tmux.conf 里加一行,让 tmux 自动处理:
set -g aggressive-resize on
set -g window-size latest
改完 tmux source-file ~/.tmux.conf 让配置生效。window-size latest 让窗口跟随最近活跃的客户端尺寸,多设备切换时就不会互相把尺寸压小。
第三层:让长任务真正可靠的一套脚本
上面三层工具解决的是"任务不断",但一个可靠的批处理任务还需要解决另外两件事:失败重试和状态可查。下面是一套可以直接用的模板。
带锁与日志轮转的任务包装
#!/bin/bash
# /usr/local/bin/run_task.sh
set -euo pipefail
TASK_NAME="${1:?用法: run_task.sh <任务名>}"
BASE=/var/lib/tasks
LOG="$BASE/$TASK_NAME.log"
LOCK="$BASE/$TASK_NAME.lock"
STATUS="$BASE/$TASK_NAME.status"
mkdir -p "$BASE"
# 用 flock 防止同一个任务被重复启动
exec 9>"$LOCK"
if ! flock -n 9; then
echo "任务 $TASK_NAME 已在运行,退出" >&2
exit 1
fi
# 超过 50MB 就把日志轮转掉,避免无限增长
if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG")" -gt 52428800 ]; then
mv "$LOG" "$LOG.old"
fi
echo "START $(date -Is)" >> "$LOG"
if "$BASE/$TASK_NAME.sh" >> "$LOG" 2>&1; then
echo "OK $(date -Is)" >> "$STATUS"
echo "DONE $(date -Is)" >> "$LOG"
else
RC=$?
echo "FAIL rc=$RC $(date -Is)" >> "$STATUS"
echo "FAILED rc=$RC $(date -Is)" >> "$LOG"
exit $RC
fi
flock -n 9 这个用法值得解释一下:exec 9>"$LOCK" 打开一个文件描述符,flock -n 尝试对它加排它锁,-n 表示非阻塞,加不上就立即返回失败。这样即使你手滑把这个脚本放进了每小时的 cron,而任务要跑两小时,第二小时的实例会直接退出,不会两个进程互相踩数据。用 PID 文件判断的方法不可靠,因为 PID 会被复用,进程崩溃时残留的 PID 文件还可能对应上一个无关的新进程。
用日志判断卡死还是正常
任务跑了很久没动静时,第一件事是确认它是真的在干活还是卡住了。看两个东西:日志的最后修改时间和进程的 CPU/IO 状态。
# 日志多久没更新了
stat -c '%y %n' /var/lib/tasks/import.log
# 进程状态
ps -o pid,stat,etime,time,cmd -p "$(cat /var/lib/tasks/import.lock)" 2>/dev/null
ps aux | grep import_task | grep -v grep
关键看 STAT 列:S 是可中断睡眠(正常,可能在等 IO)、R 是运行中、D 是不可中断睡眠(大概率卡在磁盘或网络 IO 上,这种进程连 kill -9 都杀不掉,只能等 IO 超时或者重启)、Z 是僵尸。
TIME 列是累计 CPU 时间,ETIME 是已经运行了多久。如果 ETIME 是两小时但 TIME 只有 3 秒,说明这个进程绝大部分时间在等待 IO 而不是在算——如果它本应是 CPU 密集型的,那基本可以确定卡在网络或磁盘上了。
进一步定位卡在哪,用 strace 附加到进程看它最后在做什么系统调用:
sudo strace -p <PID> -f -tt -T
# 如果只想看网络相关的
sudo strace -p <PID> -e trace=network -T看到类似 read(5, ... 一直没返回,就用 lsof -p <PID> 查一下 fd 5 是什么文件或 socket,基本就能定位到是卡在哪个连接或者哪个文件上了。
什么时候该换掉 screen,上 systemd
如果你已经习惯用 screen 保持着五个窗口跑任务,是时候考虑一下 systemd 了。screen/tmux 的会话是"人手动管"的:机器重启之后它们全没了,你得自己记得把任务重新拉起来;任务失败了你也不一定及时发现。
把这些任务写成 systemd 的 service 或者 timer,能拿到这些好处:机器重启后自动拉起(WantedBy=multi-user.target)、失败自动重启(Restart=on-failure)、日志自动进 journald 并且可以被 journalctl -u 按服务过滤、有 systemctl status 统一看状态。
[Unit]
Description=每日数据导入
After=network-online.target postgresql.service
[Service]
Type=oneshot
User=appuser
WorkingDirectory=/opt/app
ExecStart=/usr/local/bin/run_task.sh import
TimeoutStartSec=infinity
[Install]
WantedBy=multi-user.target
Type=oneshot 适合"跑完就退出"的任务,配合 TimeoutStartSec=infinity 避免 systemd 在默认 90 秒后认为启动失败而杀掉它——这个默认超时是长任务上 systemd 最常见的坑,很多人把任务搬过来之后发现跑到 90 秒就被 kill,还不知道是 systemd 干的。
如果任务已经是一个常驻进程而不是一次性任务,用 Type=simple 并且不加 TimeoutStartSec。另外注意 systemd 环境下没有 TTY,所有交互式提示都会失败,所以脚本里不能有 read 等待输入的语句——这一点和 setsid ... < /dev/null 的要求是一样的。
什么时候继续用 screen/tmux 是合理的?探索性的、一次性的、需要你盯着看和随时手动干预的任务。什么时候该用 systemd?周期性的、无人值守的、需要重启后自动恢复的任务。把这两类混在一起用 screen 硬扛,是很多人在服务器上留下七八个不知道在跑什么的会话的原因。