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

守护进程与 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 | pid_t pid = fork(); |
为什么第一次 fork 之后要 setsid,setsid 这个系统调用有个限制,调用者不能是进程组首进程,否则直接失败,错误码是 EPERM。fork 出来的子进程不是进程组首(它的 pgrp 跟父进程一样),所以裸调 setsid(2) 能成功,这也是为什么必须先 fork 一次,直接在 main 里调是不行的,除非父进程本来就不是组首。不过命令行里的 setsid 命令替你兜住了这一层,它自己发现已是进程组首时会先 fork 一次,所以直接在 shell 里敲 setsid ./server 不会失败,这条特性后面会带来一个坑,见四种写法那张表。
第二次 fork 又是为了什么。setsid 之后,这个进程成了新会话的首进程,同时没有控制终端,但这里有个坑,会话首进程如果打开一个终端设备,那个终端会自动成为它的控制终端,等于绕了一圈又回到起点。第二次 fork 之后,孙子进程不是会话首进程(它爸才是),所以它打开终端也拿不到控制终端,这就是双 fork 的唯一目的,不是什么玄学,就是一个身份问题。
现代系统上这个细节已经不太重要了,因为大部分服务根本不打开终端设备,所以很多现代实现干脆省掉了第二次 fork,省掉它一般也不会出事,只是理论上留了个缺口。
graph TD A["sshd 的 shell<br/>session S1, pgrp P1"] --> B["fork: 子进程<br/>session S1, pgrp P1"] B --> C["setsid()"] C --> D["新进程<br/>session S2(自己), pgrp P2"] D --> E["fork: 孙子进程<br/>session S2, pgrp P2"] D --> F["父进程退出"] E --> G["session S2 但不是首进程<br/>打开终端也拿不到控制终端"]
这套东西现在不该自己写了
因为 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 | 会话 S1(首进程:sshd 的 shell,PID 3021) |
终端关闭时,内核做的第一件事是给会话首进程发 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 | [Unit] |
六个字段撑起了整份文件,其余的都可以先不管。
| 字段 | 意思 |
|---|---|
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 | journalctl -u myapp -f # 跟踪 |
-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 | sudo mkdir -p /var/log/journal |
或者改 /etc/systemd/journald.conf 里的 Storage=persistent,两个办法效果一样,前者是让目录存在,后者是明确告诉它要存。
然后给 journal 设个上限,不然它也会吃满盘,尤其是那些每秒刷几百行日志的服务。
1 | # /etc/systemd/journald.conf |
找一列没人看的 SID
先亲手看一个进程在哪个会话里,这一列 SID 平时基本没人看,但它是这一切的钥匙。
1 | ps -eo pid,ppid,pgid,sid,tty,stat,comm | head -8 |
1 | PID PPID PGID SID TT STAT COMMAND |
读法,PGID 是进程组 ID,SID 是会话 ID,bash 自己的 PGID == PID == SID,说明它是会话首进程且是进程组首,两个身份叠在它一个人身上。sleep 的 SID 和 bash 一样(3021),同一个会话,这就是它活不过断开的原因,而它的 PGID 是 3121 跟 PID 一样,说明它是另一个进程组的组长,因为 shell 给每个作业单独开了组,方便一次性给整组发信号。
现在 setsid 一个,对比看。
1 | setsid sleep 300 & |
1 | 3125 1 3125 3125 ? Ss sleep |
三个变化,PPID 变成 1(被 init 收养)、PGID == SID == 3125(新会话的首进程)、TT 变成 ?(没有控制终端)。这三条凑齐就是守护进程的本质,跟它叫不叫 daemon、带不带 -d 参数一点关系都没有。
再看 nohup 做不到的部分,同一个 sleep,换成 nohup 起。
1 | nohup sleep 300 > /dev/null 2>&1 & |
TT 还是 pts/0,SID 还是 shell 的,PPID 还是 3021,一个都没变。nohup 没动会话,只是设了 SIGHUP 的忽略位,也就是说它只挡了六条属性里的零条,连一条都算不上。
这个差别可以用 /proc/PID/status 里的 SigIgn 验证,$! 只在本行还指向刚才那个后台任务,中间别插别的命令,插一条就指到别处去了。
1 | nohup sleep 300 > /dev/null 2>&1 & |
会有一个非零值,第 1 位(SIGHUP)被置了,那一位就是 nohup 干的全部事情,一个 bit。
然后看一眼真实的 unit 长什么样,别只看自己写的那个玩具。
1 | systemctl cat sshd |
1 | [Unit] |
注意 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 | ● sshd.service - OpenBSD Secure Shell server |
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 之间到底分了几个区。











