一台测试机上的同步脚本停了。日志最后一次写入是凌晨两点四十。启动方式是 nohup ./sync.sh &,目录里躺着 nohup.out,文件还在,进程没了。
同事的第一反应是:不是加了 nohup 吗。
问题就在这个「不就行了」上。nohup 挡住的只是所有能让进程死掉的原因里的一个,剩下的它一概不管。搞清楚它管什么、不管什么,比记住命令本身有用。
终端关闭的那一刻,信号走了两级
先建模型。Linux 里跟终端相关的层级有三个:进程、进程组、会话。一个终端对应一个会话,会话里可以挂多个进程组,shell 把每个作业放进一个进程组。
登录 shell PID 12001 SID 12001 TTY pts/0
├── 作业 A PGID 12345 ── sleep 1000 (PID 12345)
└── 作业 B PGID 12350 ── vim (PID 12350)
终端断开时,SIGHUP 不是广播给所有进程的,它走两级传递。
第一级在内核。pty 检测到对端关闭,内核向这个终端的会话首进程发 SIGHUP。通常情况下,那就是你登录的 shell。
第二级在 shell。shell 收到 SIGHUP 之后,会把它转发给自己当前管理的所有作业,后台作业也算在内。
所以「关了终端,后台进程也一起死」,多数情况下不是内核干的,是 shell 替你转发的。这里留一个推论,后面要用:进程如果已经不在任何 shell 的作业表里,就没人给它转发 SIGHUP 了。
nohup 做的事只有一件
nohup 在 exec 之前,把 SIGHUP 的处理方式设成「忽略」,然后启动目标程序。就这一件。
顺带它还顺手收拾了一下文件描述符:stdout 挂在终端上,就追加写到 nohup.out;stderr 挂在终端上,就并到 stdout;stdin 挂在终端上,就把它从终端上摘掉。
那一行熟悉的提示就是这么来的:
$ nohup ./sync.sh &
[1] 12345
nohup: ignoring input and appending output to 'nohup.out'
ignoring input 说的是 stdin,appending output 说的是 stdout。
有个容易被忽略的点:nohup 没有新建会话,也没有让进程脱离控制终端。进程还在原来的会话里,控制终端还是那个 pts,只是它对 SIGHUP 免疫了。
想确认这一点,读 /proc 就可以:
$ cat /proc/12345/status | grep SigIgn
SigIgn: 0000000000000001
SigIgn 是一个 64 位掩码,第 N 号信号对应第 N-1 位。SIGHUP 是 1 号信号,占第 0 位,所以末尾这个 1 的意思就是:SIGHUP 已忽略。
那它挡不住什么
四类情况,nohup 全都无能为力。
SIGTERM 和 SIGKILL。nohup 只忽略 SIGHUP。kill 默认发的是 SIGTERM,照收;kill -9 更是拦不住。脚本被别人的部署流程顺手清掉,nohup 在旁边什么也做不了。
前台进程组收到的 Ctrl+C。终端上的 Ctrl+C 发给的是整个前台进程组,不是某一个进程。nohup ./app 不带 & 时,app 仍在前台进程组里,一个 Ctrl+C 就能把它带走。
机器重启。nohup 和开机自启没有任何关系。
OOM killer。内存不够时内核挑进程杀,用的是 SIGKILL。
顺便把信号本身的规则列清楚,判断起来会快很多:
| 信号 | 编号 | 常见来源 | 能否被忽略 |
|---|---|---|---|
| SIGHUP | 1 | 终端断开 | 能,nohup 忽略的就是它 |
| SIGINT | 2 | Ctrl+C | 能 |
| SIGQUIT | 3 | Ctrl+\ | 能 |
| SIGTERM | 15 | kill 默认发这个 | 能 |
| SIGKILL | 9 | kill -9 | 不能 |
| SIGSTOP | 19 | kill -STOP | 不能 |
前四个都可以被进程忽略或捕获。最后两个由内核直接执行,进程没有发言权。
四种比 nohup 更彻底的做法
nohup 是在信号层做文章:让进程不怕 SIGHUP。另一条路是在「谁给它发信号」这一层做文章,让 SIGHUP 根本发不到它。
| 方式 | 实际动作 | 适合什么情况 |
|---|---|---|
nohup | 忽略 SIGHUP | 临时跑一次性的活,人还在机器旁边 |
disown | 从 shell 作业表摘掉,shell 退出时不再转发 | 进程已经跑起来了,不想重来 |
setsid | 新建会话,脱离控制终端 | 不想让进程和终端有任何关系 |
tmux / screen | 用一个新的伪终端接住它 | 需要随时回去看、回去敲命令 |
systemd | 交给 init 托管,可重启、有日志、能开机自启 | 生产环境 |
setsid 值得单独说。它把进程放进一个全新的会话,成为该会话的首进程,并且没有控制终端。终端断开时,内核只会给「那个终端对应的会话首进程」发 SIGHUP,而 setsid 出来的进程早就不在那个会话里了,自然收不到。
$ setsid ./sync.sh
disown 适合补救已经在跑的进程:
$ jobs
[1]+ Running ./sync.sh &
$ disown -h %1
-h 的意思是只标记这一点:shell 退出时不要给它发 SIGHUP。作业仍留在作业表里,fg、bg 照常能用。不加 -h 则是彻底从表里移除,之后引用不到它了。
生产环境的标准答案是 systemd:
[Unit]
Description=Data sync worker
After=network-online.target
[Service]
Type=simple
User=ops
WorkingDirectory=/opt/sync
ExecStart=/opt/sync/sync.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Restart=on-failure 会把挂掉的进程拉起来,journald 收走 stdout 和 stderr,开机自启由 WantedBy 负责。这三件事恰好是 nohup 一件都不管的。
没人管的 nohup.out
nohup.out 是磁盘写满的常见来源。nohup 只在 stdout 指向终端时才用它,如果当前目录不可写,会退到 $HOME/nohup.out。实际路径就写在提示行里,很多人没看。
长期跑的服务,显式重定向更稳:
nohup ./sync.sh >> /var/log/sync/sync.log 2>&1 &
用 >> 而不是 >,避免下次启动把上一轮日志冲掉。文件增长交给 logrotate:
/var/log/sync/sync.log {
daily
rotate 14
compress
missingok
notifempty
copytruncate
}
copytruncate 在这里是必要的。进程一直持有这个文件的描述符,不做截断的话,rotate 之后它还会往老 inode 上写。
自查清单
交给 systemd 之前,可以用这几条确认一个 nohup 起来的进程是不是真的稳了。
| 检查项 | 命令 | 期望结果 |
|---|---|---|
| 有没有控制终端 | ps -o pid,tty,cmd -p <PID> | TT 列是 ? 最稳 |
| SIGHUP 是否被忽略 | grep SigIgn /proc/<PID>/status | 第 0 位为 1 |
| 关终端后还在不在 | 另开一个会话执行 ps -p <PID> | 进程还在 |
| 重启后还在不在 | 重启机器再看 | 只有 systemd 或 cron @reboot 能保证 |
最后
回到开头那个脚本。它加了 nohup,也确实没有因为终端断开而死。它死于 SIGTERM,来自一次没人记得的批量清理。
nohup 的承诺只有一条:终端挂了,进程不挂。别的它没承诺过。这个名字起得挺实在。
你好,世界