前阵子半夜被告警叫醒,一台线上机器的 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,那些僵尸在容器里永远没人收。

解决办法有几个:

  1. tini 作为容器的 init 进程:
RUN apt-get install -y tini
ENTRYPOINT ["tini", "--"]
CMD ["./your-app"]

tini 会自动回收所有孤儿和僵尸。

  1. Docker 自带的 --init 参数:
docker run --init your-image
  1. K8s 的话可以设置 shareProcessNamespace: true,让 pod 的 pause 容器充当 PID 1。

这些容器环境的坑踩过一次就记住了,现在我写 Dockerfile 基本都会默认加 tini。


僵尸进程这个东西,平时不出事你都想不起它,一出事就是大半夜的事。说到底就是父进程不负责任——生了孩子不管,系统替你兜着。养成好习惯:写代码的时候记得 wait,线上服务配好监控和 systemd 保护,容器里别忘了 init 进程。这些都做到位了,基本不会再被它坑。

如果觉得这篇文章对你有帮助,欢迎点赞、转发、在看三连,让更多人看到。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐