容器内僵尸进程不影响宿主机是因为PID namespace隔离,但容器PID 1不回收会导致pid_max耗尽而卡死;必须用tini等init或手动wait4轮询处理僵尸。

为什么容器里 ps aux | grep Z 一堆僵尸,但宿主机完全没影响?
因为容器 PID namespace 隔离了进程视图——僵尸进程只存在于容器自己的进程表中,宿主机的 /proc 看不到它们,也不会耗尽宿主机的 PID 资源。真正危险的是:容器内 PID 1 进程不回收,pid_max(默认 32768)很快被占满,新 fork() 失败,应用直接卡死。
- 容器内
PID 1必须主动调用wait4()或等价系统调用,内核不会代劳;普通进程退出时发SIGCHLD,但没人处理就一直卡在Z状态 - Go、Python、Node.js 等 runtime 默认不实现 init 行为,若直接作为 PID 1 启动(如
CMD ["./app"]),子进程退出后必留僵尸 -
docker run --init是最轻量解法:它自动注入tini(一个约 100KB 的 init 可执行文件),接管 PID 1 职责,无需改代码
Go 程序当 PID 1 时,不加 wait4 循环就是埋雷
Go runtime 不注册 SIGCHLD 处理器,也不后台轮询 wait4,哪怕你用 cmd.Process.Signal(syscall.SIGTERM) 终止子进程,漏掉 cmd.Wait() 就立刻变僵尸。
- 正确做法是:在主 goroutine 中启动非阻塞轮询,例如
for { _, err := syscall.Wait4(-1, nil, syscall.WNOHANG, nil); if err != nil && err != syscall.ECHILD { break } } - 别用
signal.Notify捕获SIGCHLD后再wait——信号可能丢失,且 Go 的 signal 包不保证原子性 - 如果程序必须自己当 PID 1,记得忽略
SIGCHLD:signal.Ignore(syscall.SIGCHLD),否则可能被反复中断轮询逻辑
tini 和 dumb-init 怎么选?别被名字骗了
两者都解决同一问题,但行为细节不同:tini 默认转发所有信号给子进程,dumb-init 默认只转发 SIGTERM 和 SIGINT,其余丢弃。这意味着如果你的应用依赖 SIGHUP 重载配置,用 dumb-init 就收不到。
- 推荐优先用
tini:Docker 官方--init默认就是它,Alpine 镜像里apk add tini即可,ENTRYPOINT ["/sbin/tini", "--"]一行搞定 - 避免手写
ENTRYPOINT ["tini", "--", "./app"]却忘了加--分隔符,否则tini会把./app当作自己的参数解析失败 - 不用
docker-init:它是 Docker Desktop 旧版绑定的二进制,已停止维护,且和 containerd 兼容性差
Kubernetes 里光靠 pod.spec.restartPolicy 压根不管僵尸
Pod 重启策略只管容器退出码,对容器内僵尸进程完全无感。即使你设了 Always,只要容器没退出,僵尸就能一直堆到 pid_max 溢出。
- 必须在
pod.spec.containers[].securityContext中显式启用 init:runAsUser: 0+privileged: false(确保能挂载/dev/init),再配合镜像内预装tini - 别信 “K8s 会自动清理” —— 它只负责拉起/销毁整个容器,不介入容器内部的进程树管理
- 监控要落到容器内:通过
exec进容器跑ps -eo stat= | grep -c Z,而不是查宿主机的/proc/[pid]/status
容器 PID 1 的责任边界很窄,但错一点就全盘失效:它不处理 SIGCHLD,不轮询 wait4,不转发信号,不回收僵尸——这些不是“可选项”,是 Linux 内核强加给 PID 1 的硬性契约。










