前阵子半夜被告警叫醒一台线上机器的 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父进程先挂了子进程还活着。子进程会被 initPID 1领养还在正常运行。孤儿进程其实不算问题因为 init/systemd 会自动接管并在合适的时候回收它们。真正头疼的是僵尸——它已经死了你kill -9都杀不掉它因为它本来就不在运行。实战排查流程回到那天晚上的故事。发现一堆 defunct 之后我的排查流程大概是这样的第一步确认僵尸进程数量psaux|grep-wZ|grep-vgrep|wc-l或者直接 top 看头部信息top-bn1|grepzombie那天一看300 多个。心一凉。第二步找到它们的父进程僵尸进程你杀不掉关键是找到谁生了它们又不管。ps-eopid,ppid,stat,comm|grep-wZ输出大概长这样3457 3425 Z my-worker 3533 3425 Z my-worker 3612 3425 Z my-worker ...好嘛PPID 全是 3425。看看 3425 是啥ps-p3425-opid,comm,args发现是我们的一个任务调度进程它 fork 子进程去执行任务但执行完之后没有正确 wait。第三步用 pstree 看清楚父子关系pstree-p-s3457这个命令会从 init 一路画到目标进程树状结构很清晰systemd(1)───supervisord(1205)───task-scheduler(3425)───[my-worker](3457)一目了然。第四步尝试通知父进程回收先温柔地来kill-SIGCHLD3425发一个 SIGCHLD 给父进程意思是嘿你的子进程退出了快 wait 一下。如果父进程代码写得还行——比如注册了 SIGCHLD handler——那它就会老老实实调 waitpid 把僵尸收了。等了几秒再看嗯僵尸数没变。说明这个父进程压根没处理 SIGCHLD。第五步重启父进程没办法了直接重启kill3425父进程一死它下面的僵尸就会被 init/systemd 接管然后立刻被回收。psaux|grep-wZ|wc-l# 0清爽了。然后再把调度进程拉起来盯了一会儿确认不会再产生新的僵尸。当然实际生产中你不能总这么暴力因为杀父进程意味着它正在处理的所有子任务也全部中断。如果是重要服务需要先评估影响。根因分析和修复排查完之后就该看代码了。翻了一下那个 task-scheduler 的源码问题一目了然——它 fork 出子进程之后用了一个 epoll 循环等事件但 SIGCHLD 信号被忽略了也没有任何地方调用 waitpid。修复方案有几种看场景选方案一直接忽略子进程退出状态如果你不关心子进程的退出码最简单的方式signal(SIGCHLD,SIG_IGN);设了这个之后子进程退出时内核会自动清理根本不会产生僵尸。缺点是你拿不到子进程的 exit status。方案二异步回收注册一个 SIGCHLD 的 handler在里面循环 waitpidvoidsigchld_handler(intsig){while(waitpid(-1,NULL,WNOHANG)0);}// 注册structsigactionsa;sa.sa_handlersigchld_handler;sigemptyset(sa.sa_mask);sa.sa_flagsSA_RESTART|SA_NOCLDSTOP;sigaction(SIGCHLD,sa,NULL);WNOHANG是关键它让 waitpid 非阻塞地回收所有已退出的子进程。这个方案更推荐因为你还可以在 handler 里做些日志记录。方案三Python 的话注意 subprocess如果是 Python 写的服务用subprocess.run()就不会有这个问题因为它内部会自动 wait。但如果用了Popen又没有调proc.wait()或proc.communicate()那僵尸就来了# 错误写法 - 会产生僵尸importsubprocess procsubprocess.Popen([./worker.sh])# 然后就不管了...# 正确写法procsubprocess.Popen([./worker.sh])proc.wait()# 或者 proc.communicate()方案四Bash 脚本里别忘了 wait如果你的脚本 fork 了后台任务./task1.sh./task2.sh./task3.sh# 别漏了这行wait没有wait的话脚本如果一直在跑比如是个 daemon 脚本后台任务结束后就变僵尸了。systemd 层面的防护如果你的服务是 systemd 管理的unit 文件里有几个配置可以帮你兜底[Service] KillModecontrol-group TimeoutStopSec30 WatchdogSec60KillModecontrol-group确保服务停止时整个 cgroup 里的进程包括子进程都会被清理。WatchdogSec让 systemd 监控你的服务如果它卡死不响应就自动重启——这能兜住那种父进程 hang 住不 wait 的场景。监控建议经历了这次之后我加了个简单的监控脚本每 5 分钟跑一次#!/bin/bashZOMBIE_COUNT$(psaux|grep-wZ|grep-vgrep|wc-l)THRESHOLD10if[$ZOMBIE_COUNT-gt$THRESHOLD];thenechoWarning:$ZOMBIE_COUNTzombie processes detected|\mail-sZombie Process Alertopsexample.comfi还可以更进一步监控 PID 使用率CURRENT$(ls/proc|grep-c^[0-9])MAX$(cat/proc/sys/kernel/pid_max)USAGE$((CURRENT*100/MAX))if[$USAGE-gt80];thenechoPID 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参数dockerrun--inityour-imageK8s 的话可以设置shareProcessNamespace: true让 pod 的 pause 容器充当 PID 1。这些容器环境的坑踩过一次就记住了现在我写 Dockerfile 基本都会默认加 tini。僵尸进程这个东西平时不出事你都想不起它一出事就是大半夜的事。说到底就是父进程不负责任——生了孩子不管系统替你兜着。养成好习惯写代码的时候记得 wait线上服务配好监控和 systemd 保护容器里别忘了 init 进程。这些都做到位了基本不会再被它坑。如果觉得这篇文章对你有帮助欢迎点赞、转发、在看三连让更多人看到。