守护进程与 systemd:你的进程为什么活不过 ssh 断开

难度 ★★★☆☆ · 前置:strace 与 lsof:看不见的东西怎么观察 | 一句话结论:「后台跑」不等于「守护进程」。守护进程是一组属性:独立会话、无控制终端、fd 妥善处理、父进程为 1。现代做法是前台跑交给 systemd——Type= 选错是「服务在跑但访问不到」的头号原因。

nohup 加了,还是没了

刚入职那年照着博客上流传的一篇 2015 年的教程,在一台测试机上起了个自建服务,命令抄得一字不差,nohup 也加了,重定向也做了,exit 也没加,看着 ps 里那个进程稳稳地待着,然后关掉终端,第二天早上重新登上去,ps -ef | grep server,什么都没有(我当时的第一反应是这破命令大概是假的,因为它明明写在了一篇被转了两万次的教程里,而且评论区一片感谢,我甚至怀疑是自己的发行版有问题,还专门换了台机器重来一遍,结果一模一样)。

1
nohup ./server > server.log 2>&1 &

日志最后一行停在这里。

1
ps -ef | grep server

跑完只剩一行,后面什么都没有:

1
2026-11-13 20:31:02 started

没有任何异常,没有报错,没有 core,也没有 OOM 的痕迹,就那么没了,干净得像是我从来没启动过它。

它没假,它做的事只有一件,把 SIGHUP 设成忽略。这是那篇教程唯一说对的地方,也是唯一说清楚的地方,剩下的地方它一个都没提,因为作者自己大概也没遇到,或者遇到了归结成了别的原因。但真正杀掉进程的不止这一枪,sshd 退出时内核会给这个会话里的所有进程发 SIGHUP,这一半 nohup 确实挡住了,可如果进程还没脱离控制终端,终端关闭时 tty 层自己就会再发一次,而且发的范围是整个前台进程组,nohup 只保护了它自己启动的那一个,你起的子进程、子进程再起的孙进程,一个都护不住。

还有更隐蔽的一层,标准输入还挂在终端上,某些程序读到 read() 返回 EIO 就自己退了,日志里什么都不留,因为它退得很体面,main 正常 return,只不过返回的是个非零值,而你不知道该去哪儿看那个值。

要彻底解决,靠的是 setsid,不是 nohup。

后台跑和守护进程不是一回事

守护进程(daemon)的定义不是后台跑的进程,而是一组具体的属性,缺一条就不算。

属性 为什么要这样
不在任何控制终端下 终端关了影响不到它
是独立的会话首进程 不归属 shell 那个会话
不持有继承来的 fd(尤其 0/1/2) 不占终端,也不怕读到 EIO
工作目录是 / 不阻止文件系统卸载
umask 设成合适值 不受启动者影响
父进程是 1(init 或 systemd) 退出能被正确回收

& 只是放到后台,它还在你的会话里、进程组里、终端下,那一列 TT 还写着 pts/0,看起来一切正常,其实三根线一根都没断。真正难的不是记住这六条,是理解这六条背后是同一个东西,会话。会话决定了信号发给谁,控制终端决定了断开时谁收到通知,fd 决定了它会不会被一个 EIO 顶死,这三件事其实是一件事的三个面,你把会话这一层想通,剩下五条都是自然的推论。

System V 那套六步

传统写法,几十年来几乎没变过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pid_t pid = fork();
if (pid > 0) _exit(0); /* 父退,子成了孤儿,交给 init */

setsid(); /* 关键:新建会话,自己当首进程 */

/* 第二次 fork:防止以后 open 一个终端又拿回控制终端 */
pid = fork();
if (pid > 0) _exit(0);

chdir("/"); /* 不占着任何挂载点 */
umask(0); /* 或者 022,看需要 */

/* 关掉 0/1/2 或者重新打开到 /dev/null */
close(0); close(1); close(2);
int fd = open("/dev/null", O_RDWR);
dup2(fd, 0); dup2(fd, 1); dup2(fd, 2);

为什么第一次 fork 之后要 setsid,setsid 这个系统调用有个限制,调用者不能是进程组首进程,否则直接失败,错误码是 EPERM。fork 出来的子进程不是进程组首(它的 pgrp 跟父进程一样),所以裸调 setsid(2) 能成功,这也是为什么必须先 fork 一次,直接在 main 里调是不行的,除非父进程本来就不是组首。不过命令行里的 setsid 命令替你兜住了这一层,它自己发现已是进程组首时会先 fork 一次,所以直接在 shell 里敲 setsid ./server 不会失败,这条特性后面会带来一个坑,见四种写法那张表。

第二次 fork 又是为了什么。setsid 之后,这个进程成了新会话的首进程,同时没有控制终端,但这里有个坑,会话首进程如果打开一个终端设备,那个终端会自动成为它的控制终端,等于绕了一圈又回到起点。第二次 fork 之后,孙子进程不是会话首进程(它爸才是),所以它打开终端也拿不到控制终端,这就是双 fork 的唯一目的,不是什么玄学,就是一个身份问题。

现代系统上这个细节已经不太重要了,因为大部分服务根本不打开终端设备,所以很多现代实现干脆省掉了第二次 fork,省掉它一般也不会出事,只是理论上留了个缺口。

这套东西现在不该自己写了

因为 systemd 会把这一切做完,而且做得比任何手写实现都细致。

systemd 做的 自己写 daemon 的对应动作
从调用者的会话里摘出来 setsid
标准输入输出接到 journal dup2 到 /dev/null,然后日志只能进文件
设置工作目录(WorkingDirectory=) chdir
设置 umask(UMask=) umask
崩溃自动重启(Restart=always) 自己写个监视进程
限制资源(MemoryMax=、CPUQuota=) 自己调 setrlimit
开机自启、依赖顺序 自己写 init 脚本
日志收集、轮转 自己写 logrotate 配置

手动守护化在今天基本只有一种理由,目标机器上没有 systemd,比如老发行版、精简过的嵌入式镜像、某些 BSD。容器里通常是 PID 1 直接跑应用,也不该自己 daemon 化,因为在容器里 fork 出去之后,那个父进程一退,容器就结束了,你反而把服务搞死了,而且容器里的 PID 1 还担着收僵尸的责任,你一让位,收尸的人就没了,积几个僵尸之后 nproc 直接告急。这条我见过不止一个人踩,把 systemd 时代的思维硬搬到容器里,怎么写都活不过三秒,日志里还特别干净,因为进程确实正常退出了,它只是退出得比你计划的早了一点,早到你还在看启动日志的时候它就已经是个历史进程了。

会话、进程组、控制终端

这三个概念是理解那句话的关键,为什么活不过 ssh 断开。

概念 是什么 谁创建
进程组(process group) 一组进程,共享信号接收目标 setpgid,或 shell 用 jobs
会话(session) 一组进程组 setsid
控制终端(controlling tty) 会话可关联一个终端设备 会话首进程第一次 open 终端时

一个终端(/dev/pts/0)对应一个会话,sshd 给每个登录创建一个会话,你的 shell 是会话首进程,同时也是前台进程组的组长,这两件事经常被混在一起说,其实它们是两个身份,一个进程可以同时是它们,也可以只是其中一个,后面那几种写法玩的都是这两个身份的排列组合。

(这两列我在 ps 里跳过好几年,直到要把一个服务从终端里摘出来,才第一次认真看 SID 那一列,看懂了以后前面那些教程里的说法一下子就不需要背了。)

级联关系长这样。

1
2
3
4
5
6
会话 S1(首进程:sshd 的 shell,PID 3021)
├── 进程组 P1(前台,pgrp = 3021)
│ └── bash 3021
│ └── ./server 3120 ← 你的服务,在同一个前台组
└── 进程组 P2(后台)
└── sleep 3200

终端关闭时,内核做的第一件事是给会话首进程发 SIGHUP,sshd 的 shell 收到就退,退了之后它那个会话的所有成员会被清理,注意是清理,不是发信号,清理的方式是内核逐个把剩下的成员杀掉。nohup ./server & 让 server 忽略了 SIGHUP,但它还是在 P1 里、还在 S1 里,会话消失的时候它被强杀,忽略位在这一层根本不起作用。setsid ./server 会新建一个会话,server 自己当首进程,跟 S1 彻底断干净,这时候关终端跟它无关,因为终端本来就是 S1 的属性。

四个写法,只有最后一个全绿

命令 忽略 SIGHUP 脱离会话 脱离终端 重定向
./server & ✗ ✗ ✗ ✗
nohup ./server & ✓ ✗ ✗ 自动重定向到 nohup.out(如果不改)
setsid ./server ✗ ✓ ✓ ✗
setsid ./server </dev/null & ✗ ✓ ✓ ✓(0 号)
nohup setsid ./server > log 2>&1 & ✓ ✓ ✓ ✓

最后一行才是完整的,四个格子全绿。只用 setsid 的话,如果程序是会话首进程又打开终端,还是会拿回控制终端,但实践中很少,因为大部分服务不碰终端设备;反过来只用 nohup 的,会话断了就死,前面已经验过了。这两件事互相不能替代,一个管信号,一个管归属,缺哪一半都会在那天晚上出问题。

我缺的就是那一半。

setsid 还有个小坑,如果传入的命令已经是进程组首,setsid 会 fork 一次再执行(为了避免 EPERM),所以 setsid 之后 $! 拿到的 PID 可能不是真正的那个。我第一次用它写启动脚本时就踩了,脚本里拿 $! 写进 pid 文件,后来 kill 的时候杀的是个已经退出的中间进程,真正的服务还在跑,杀了两遍都没反应,还以为是权限问题查了半天,最后是 ps -eo pid,ppid,sid,comm 一行一行比出来的,那一下才知道自己一直盯错了对象。带 & 的时候尤其容易混,因为 shell 记的是它启动那一个,而那个可能只是 setsid 的外壳,真正干活的孙子进程换了 PID,pid 文件就这么错了一位,写进去的是个早就退出的进程号,后面所有依赖它的脚本全部失效,而它们都不会报错,只会安静地杀空。

Type= 是 unit 文件里最大的坑

现代做法是写一个 unit,四十几行,把前面六步全替掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Unit]
Description=My Service
After=network.target

[Service]
Type=simple
ExecStart=/opt/myapp/server --config /etc/myapp.toml
WorkingDirectory=/opt/myapp
User=myapp
Group=myapp
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

六个字段撑起了整份文件,其余的都可以先不管。

字段 意思
Type 进程模型,见下表
ExecStart 启动命令,必须是绝对路径
Restart no / always / on-failure / on-abnormal
RestartSec 重启前等几秒,防止疯狂重启
User / Group 以谁的身份跑
WantedBy 挂在哪个 target 下(决定开机启动)

Type= 才是要命的那个,它决定了 systemd 什么时候认为启动成功了。

Type systemd 何时认为「启动成功」 你的程序长什么样
simple(默认) ExecStart 一 fork 就认为好了 前台跑、不 fork
forking 父进程退出后才认为好了 自己 daemon 化的老程序
notify 收到 sd_notify(READY=1) 才认为好了 用 sd_notify 的现代程序
exec fork + exec 之后 simple 的加强版
oneshot 进程跑完才认为好了 一次性的初始化任务

选错 simple 和 forking 的后果各有一个,两个方向都很典型,下面两条我都在线上撞过。

  • 一个会 fork 自己的老程序配上 Type=simple:systemd 看主进程立刻退出了,以为服务死了,触发 Restart,无限重启。
  • 一个前台跑的程序配上 Type=forking:systemd 一直等父进程退出,等不到,systemctl start 挂 90 秒然后超时。

这个错误的表现是服务状态明明 active 但访问不到,或者启动总是 timeout,两种现象看起来毫不相干,根因却是同一行配置。排查时第一个要看的不是日志,是 Type,因为日志里什么都不会有,程序跑得好好的,只是 systemd 不知道它跑好了。

(Type=forking 还要配 PIDFile=,不然 systemd 不知道该盯哪个 PID,重启一次就丢一个进程。)

日志该进 journal,不该自己写文件

自己写文件就要自己管轮转,管不正确就吃满磁盘,之前讲的那个 (deleted) 就是典型后果,rm 了但进程还开着,df 说满了,du 找不到,那块空间就一直悬着。

1
2
3
4
journalctl -u myapp -f            # 跟踪
journalctl -u myapp --since today # 今天
journalctl -u myapp -p err # 只要 error 及以上的
journalctl -u myapp -o json-pretty # 结构化

-p 的等级是 syslog 那套,0 emerg、1 alert、2 crit、3 err、4 warning、5 notice、6 info、7 debug,数字越小越严重,所以 -p err 实际是 0 到 3 全都给你,这一点跟直觉相反,我第一次用的时候以为只给 3。

有个经常踩的坑,journal 默认不持久化。日志存在 /run/log/journal/,那是个 tmpfs,重启就没了,而 systemctl restart 也算重启,所以排障排到一半手滑重启了一下服务容器,日志跟着一起消失,那种感觉很难形容。想留下得这么做。

1
2
sudo mkdir -p /var/log/journal
sudo systemctl restart systemd-journald

或者改 /etc/systemd/journald.conf 里的 Storage=persistent,两个办法效果一样,前者是让目录存在,后者是明确告诉它要存。

然后给 journal 设个上限,不然它也会吃满盘,尤其是那些每秒刷几百行日志的服务。

1
2
# /etc/systemd/journald.conf
SystemMaxUse=500M

找一列没人看的 SID

先亲手看一个进程在哪个会话里,这一列 SID 平时基本没人看,但它是这一切的钥匙。

1
ps -eo pid,ppid,pgid,sid,tty,stat,comm | head -8
1
2
3
4
 PID    PPID    PGID     SID TT       STAT COMMAND
3021 3018 3021 3021 pts/0 Ss bash
3120 3021 3120 3021 pts/0 S sleep
3121 3021 3121 3021 pts/0 R+ ps

读法,PGID 是进程组 ID,SID 是会话 ID,bash 自己的 PGID == PID == SID,说明它是会话首进程且是进程组首,两个身份叠在它一个人身上。sleep 的 SID 和 bash 一样(3021),同一个会话,这就是它活不过断开的原因,而它的 PGID 是 3121 跟 PID 一样,说明它是另一个进程组的组长,因为 shell 给每个作业单独开了组,方便一次性给整组发信号。

现在 setsid 一个,对比看。

1
2
setsid sleep 300 &
ps -eo pid,ppid,pgid,sid,tty,stat,comm | grep -E 'sleep|COMMAND'
1
3125       1    3125    3125 ?        Ss   sleep

三个变化,PPID 变成 1(被 init 收养)、PGID == SID == 3125(新会话的首进程)、TT 变成 ?(没有控制终端)。这三条凑齐就是守护进程的本质,跟它叫不叫 daemon、带不带 -d 参数一点关系都没有。

再看 nohup 做不到的部分,同一个 sleep,换成 nohup 起。

1
2
nohup sleep 300 > /dev/null 2>&1 &
ps -eo pid,ppid,pgid,sid,tty,comm | grep sleep

TT 还是 pts/0,SID 还是 shell 的,PPID 还是 3021,一个都没变。nohup 没动会话,只是设了 SIGHUP 的忽略位,也就是说它只挡了六条属性里的零条,连一条都算不上。

这个差别可以用 /proc/PID/status 里的 SigIgn 验证,$! 只在本行还指向刚才那个后台任务,中间别插别的命令,插一条就指到别处去了。

1
2
nohup sleep 300 > /dev/null 2>&1 &
grep SigIgn /proc/$!/status

会有一个非零值,第 1 位(SIGHUP)被置了,那一位就是 nohup 干的全部事情,一个 bit。

然后看一眼真实的 unit 长什么样,别只看自己写的那个玩具。

1
systemctl cat sshd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Unit]
Description=OpenBSD Secure Shell server
After=network.target auditd.service
ConditionPathExists=!/etc/ssh/sshd_not_to_be_run

[Service]
EnvironmentFile=-/etc/default/ssh
ExecStartPre=/usr/sbin/sshd -t
ExecStart=/usr/sbin/sshd -D $SSHD_OPTS
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
Restart=on-failure
RestartPreventExitStatus=255
Type=notify

[Install]
WantedBy=multi-user.target

注意 Type=notify 和 ExecStart 里的 -D,-D 是不要 daemon 化、前台跑的意思,你起它的时候它不 fork、不 setsid、不关 fd,什么都不干,就在前台待着,直到跑完。这正是现代做法,程序自己不 fork,把守护化交给 systemd,两边的职责分得干干净净,各自只做自己擅长的那一小块。而 Type=notify 说明它会在准备就绪时主动喊一声,sd_notify(READY=1),这样启动顺序才排得准,依赖它的服务不会在它还没好的时候就冲上来。这个模型有个好处是状态可观测,systemctl start 返回的时候要么真的好了,要么真的失败了,不会出现中间那种既没起来又说自己 active 的尴尬状态,而 Type=simple 恰恰会给你那个状态,因为它在 fork 成功那一刻就认为大功告成,跟程序内部到底准没准备好没有半点关系。

ExecReload=/bin/kill -HUP $MAINPID 顺带解释了前面讲信号那篇提过的,SIGHUP 常被用来重载配置,这是 nginx、sshd 这些服务的惯例。$MAINPID 是 systemd 的一个变量,指向主进程的 PID,注意它跟 $! 不是一回事,一个是 systemd 记的,一个是 shell 记的。

再看 KillMode,默认值经常被误解。

值 停止服务时 systemd 杀谁
control-group(默认) 整个 cgroup 里所有进程
mixed 给主进程发 SIGTERM,然后 SIGKILL 整个组
process 只杀主进程
none 不发信号

默认的 control-group 是对的,它保证不会留下孤儿。改成 process 会留下孤儿进程,端口还被占着,下次启动直接绑不上,报 Address already in use,然后你开始怀疑是不是上一次没退干净,其实是你自己把清理范围缩小了。

最后查一个服务的实际状态。

1
systemctl status sshd
1
2
3
4
5
● sshd.service - OpenBSD Secure Shell server
Loaded: loaded (/lib/systemd/system/ssh.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-11-13 20:00:12 CST; 42min ago
Process: 901 ExecStartPre=/usr/sbin/sshd -t (code=exited, status=0/SUCCESS)
Main PID: 902 (sshd)

Main PID 那一行最有用,出问题要 strace -p 的时候用的就是它,而 Process: 那一行是 ExecStartPre 的,别拿错了,那个进程早就退出了。

nohup、&、disown,一个都救不了会话

nohup 能让进程一直在,这是最常见的误解,它只忽略 SIGHUP,不脱离会话,会话没了照样死;& 就是后台运行,也只是放到后台进程组,还在同一个会话和终端下,关终端就死;disown 等价于 daemon 化,其实只是从 shell 的作业表里摘掉,进程本身一点没变,SIGHUP 该收还是收。

双 fork 是必须的,这是 System V 时代的严谨做法,现代服务自己不做这些,全交给 systemd,容器里更不该 daemon 化。Type=simple 是安全默认值,这话对前台程序成立,对会 fork 的老程序它会导致无限重启,Type 才是第一优先级的配置项。服务写了 User= 就降权了,User= 是 systemd 在 fork 之后 setuid 再 exec,效果和程序自己降权一样,但 Group= 也要写,不然主组可能还是 root,这一点很容易漏。

journalctl 的日志重启后还在,默认在 /run,重启就没,要持久化得 mkdir /var/log/journal。kill -TERM 之后服务会干净退出,默认 KillMode=control-group 会把整个 cgroup 都杀了,你的 handler 可能还没跑完,SIGKILL 就到了(TimeoutStopSec 默认 90 秒),要留时间就把 TimeoutStopSec 调大,或者让程序收到 SIGTERM 后尽快退,不要在一个 handler 里做需要几十秒的收尾。

两件事被混成了一件事

后台跑不等于守护进程。守护进程是一组属性,独立会话、无控制终端、fd 妥善处理、父进程为 1,四个条件缺一个都不完整,而 nohup 只忽略 SIGHUP,setsid 才真正脱离会话,两个都要,缺一半就是那台测试机上的结局。双 fork 是为了让进程不成为会话首进程,防止打开终端时拿回控制终端,这个细节在今天几乎不再必要,但它解释了你读到的每一份老代码。

systemd 把这些全做了,还多了日志、重启、资源限制、开机自启,四个方向一次解决。现代服务应该前台跑,交给 systemd,而 Type= 选错是服务在跑但访问不到的头号原因,这个错误的隐蔽之处在于它没有任何报错,只是不工作。journal 默认不持久化,要落盘得建目录。

进程这一块到这里就交代完了。往前走到这里,每一条结论都能用一条命令验证,我们自己动手量过内存、看过状态、收过僵尸、拦过信号、追过系统调用、也把进程从一个会话里拽出来过。再往后,页、缓存、状态机这些东西大多看不见,只能靠工具间接推断,而且工具给出的数字本身往往还需要解释。下一站从进程的地址空间讲起,0x0 到 0xffffffffffffffff 之间到底分了几个区。