线上服务突然 fork 不出新进程?聊聊我排查僵尸进程的那些事
前阵子半夜被告警叫醒,一台线上机器的 worker 进程起不来了。打开监控一看,CPU 不高、内存也还行,但服务就是 fork 失败。第一反应是 ulimit 的 nproc 打满了?跑了一下 ps aux | wc -l,进程数确实很高,再仔细一看——一堆 <defunct> 的进程静静地躺在那。
好家伙,僵尸进程爆了。
僵尸进程到底是什么鬼
说白了,僵尸进程就是一个已经执行完了的子进程,但它的父进程没有调用 wait() 来回收它的退出状态。这个子进程已经"死了"——不占 CPU,不占内存,但它仍然在内核的进程表里占着一个 PID 的坑位。
你可以把它想象成快递柜里一个没人取的包裹。包裹送到了(子进程执行完了),快递员也走了(进程代码已经退出),但取件码还在占着格子(进程表里还有条目),你不去签收(父进程不 wait),这个格子就一直被占着。
一个两个僵尸进程无所谓,但如果大量堆积起来就麻烦了。Linux 64 位系统默认 PID 空间最大是 4194304,听着很大对吧?但实际生产环境里 pid_max 经常被调小,而且 nproc 的 ulimit 限制通常更紧。一旦进程表被僵尸填满,任何需要新进程的操作全部歇菜——新连接进不来、worker 起不了、cron job 跑不了。
之前看到一个案例挺典型的:某 K8s 集群里一个 DNS pod 因为 Golang 的 channel 泄漏,单个 node 上堆积了超过 26000 个僵尸进程,直接把整个集群的 DNS 解析搞挂了。
和孤儿进程搞清楚区别
很多人容易搞混僵尸进程和孤儿进程,但这俩完全不是一回事:
- 僵尸进程(Zombie):子进程已经退出了,但父进程没回收。子进程是"死的",状态是 Z。
- 孤儿进程(Orphan):父进程先挂了,子进程还活着。子进程会被 init(PID 1)领养,还在正常运行。
孤儿进程其实不算问题,因为 init/systemd 会自动接管并在合适的时候回收它们。真正头疼的是僵尸——它已经死了,你 kill -9 都杀不掉它,因为它本来就不在运行。
实战排查流程
回到那天晚上的故事。发现一堆 defunct 之后,我的排查流程大概是这样的:
第一步:确认僵尸进程数量
ps aux | grep -w Z | grep -v grep | wc -l
或者直接 top 看头部信息:
top -bn1 | grep zombie
那天一看,300 多个。心一凉。
第二步:找到它们的父进程
僵尸进程你杀不掉,关键是找到谁生了它们又不管。
ps -eo pid,ppid,stat,comm | grep -w Z
输出大概长这样:
3457 3425 Z my-worker
3533 3425 Z my-worker
3612 3425 Z my-worker
...
好嘛,PPID 全是 3425。看看 3425 是啥:
ps -p 3425 -o pid,comm,args
发现是我们的一个任务调度进程,它 fork 子进程去执行任务,但执行完之后没有正确 wait。
第三步:用 pstree 看清楚父子关系
pstree -p -s 3457
这个命令会从 init 一路画到目标进程,树状结构很清晰:
systemd(1)───supervisord(1205)───task-scheduler(3425)───[my-worker](3457)
一目了然。
第四步:尝试通知父进程回收
先温柔地来:
kill -SIGCHLD 3425
发一个 SIGCHLD 给父进程,意思是"嘿,你的子进程退出了,快 wait 一下"。如果父进程代码写得还行——比如注册了 SIGCHLD handler——那它就会老老实实调 waitpid 把僵尸收了。
等了几秒再看,嗯,僵尸数没变。说明这个父进程压根没处理 SIGCHLD。
第五步:重启父进程
没办法了,直接重启:
kill 3425
父进程一死,它下面的僵尸就会被 init/systemd 接管,然后立刻被回收。
ps aux | grep -w Z | wc -l
# 0
清爽了。然后再把调度进程拉起来,盯了一会儿确认不会再产生新的僵尸。
当然,实际生产中你不能总这么暴力,因为杀父进程意味着它正在处理的所有子任务也全部中断。如果是重要服务,需要先评估影响。
根因分析和修复
排查完之后就该看代码了。翻了一下那个 task-scheduler 的源码,问题一目了然——它 fork 出子进程之后,用了一个 epoll 循环等事件,但 SIGCHLD 信号被忽略了,也没有任何地方调用 waitpid。
修复方案有几种,看场景选:
方案一:直接忽略子进程退出状态
如果你不关心子进程的退出码,最简单的方式:
signal(SIGCHLD, SIG_IGN);
设了这个之后,子进程退出时内核会自动清理,根本不会产生僵尸。缺点是你拿不到子进程的 exit status。
方案二:异步回收
注册一个 SIGCHLD 的 handler,在里面循环 waitpid:
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
// 注册
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
WNOHANG 是关键,它让 waitpid 非阻塞地回收所有已退出的子进程。这个方案更推荐,因为你还可以在 handler 里做些日志记录。
方案三:Python 的话注意 subprocess
如果是 Python 写的服务,用 subprocess.run() 就不会有这个问题,因为它内部会自动 wait。但如果用了 Popen 又没有调 proc.wait() 或 proc.communicate(),那僵尸就来了:
# 错误写法 - 会产生僵尸
import subprocess
proc = subprocess.Popen(['./worker.sh'])
# 然后就不管了...
# 正确写法
proc = subprocess.Popen(['./worker.sh'])
proc.wait() # 或者 proc.communicate()
方案四:Bash 脚本里别忘了 wait
如果你的脚本 fork 了后台任务:
./task1.sh &
./task2.sh &
./task3.sh &
# 别漏了这行
wait
没有 wait 的话,脚本如果一直在跑(比如是个 daemon 脚本),后台任务结束后就变僵尸了。
systemd 层面的防护
如果你的服务是 systemd 管理的,unit 文件里有几个配置可以帮你兜底:
[Service]
KillMode=control-group
TimeoutStopSec=30
WatchdogSec=60
KillMode=control-group 确保服务停止时,整个 cgroup 里的进程(包括子进程)都会被清理。WatchdogSec 让 systemd 监控你的服务,如果它卡死不响应就自动重启——这能兜住那种父进程 hang 住不 wait 的场景。
监控建议
经历了这次之后,我加了个简单的监控脚本,每 5 分钟跑一次:
#!/bin/bash
ZOMBIE_COUNT=$(ps aux | grep -w Z | grep -v grep | wc -l)
THRESHOLD=10
if [ "$ZOMBIE_COUNT" -gt "$THRESHOLD" ]; then
echo "Warning: $ZOMBIE_COUNT zombie processes detected" | \
mail -s "Zombie Process Alert" ops@example.com
fi
还可以更进一步,监控 PID 使用率:
CURRENT=$(ls /proc | grep -c '^[0-9]')
MAX=$(cat /proc/sys/kernel/pid_max)
USAGE=$((CURRENT * 100 / MAX))
if [ "$USAGE" -gt 80 ]; then
echo "PID table usage at ${USAGE}%"
fi
其实 Prometheus 的 node_exporter 本身就会暴露 node_processes_zombies 这个指标,Grafana 里设个告警更省事。
还有个容器环境的坑
最后提一嘴容器里的情况。在 Docker/K8s 里,容器内的 PID 1 默认是你的应用进程,不是 init/systemd。如果你的应用 fork 了子进程但不 wait,那些僵尸在容器里永远没人收。
解决办法有几个:
- 用
tini作为容器的 init 进程:
RUN apt-get install -y tini
ENTRYPOINT ["tini", "--"]
CMD ["./your-app"]
tini 会自动回收所有孤儿和僵尸。
- Docker 自带的
--init参数:
docker run --init your-image
- K8s 的话可以设置
shareProcessNamespace: true,让 pod 的 pause 容器充当 PID 1。
这些容器环境的坑踩过一次就记住了,现在我写 Dockerfile 基本都会默认加 tini。
僵尸进程这个东西,平时不出事你都想不起它,一出事就是大半夜的事。说到底就是父进程不负责任——生了孩子不管,系统替你兜着。养成好习惯:写代码的时候记得 wait,线上服务配好监控和 systemd 保护,容器里别忘了 init 进程。这些都做到位了,基本不会再被它坑。
如果觉得这篇文章对你有帮助,欢迎点赞、转发、在看三连,让更多人看到。
更多推荐

所有评论(0)