Docker 中使用 --init 管理僵尸进程
什么是僵尸进程,为什么容器要关注它?
在 Linux 系统中,当一个子进程先于父进程退出,而父进程没有调用 wait() 或 waitpid() 来回收子进程的状态信息时,该子进程就会变成“僵尸进程”(Zombie Process)。僵尸进程不再消耗 CPU 或内存(进程已终止),但仍会占用内核进程表中的条目。如果大量僵尸进程堆积,可能导致进程表耗尽,使系统无法创建新进程。
在传统的宿主机上,init 进程(PID 1)会自动收养孤儿进程并回收其退出状态,从而避免僵尸进程。但在 Docker 容器中,默认情况下容器启动的 主进程就是 PID 1,而该进程可能并不具备传统 init 进程的“收养和回收”职责。如果 PID 1 是一个不会处理子进程退出状态的应用程序(如一个简单的 shell 脚本或 Node.js 服务),那么任何由其产生的子进程在异常退出或结束后都会变为僵尸进程,最终累积拖垮容器。
这正是 Docker 提供 --init 选项的原因。
Docker 容器中的进程模型与 PID 1 的陷阱
容器内 PID 1 的特殊性
当你运行 docker run 启动容器时,所指定的命令就是这个容器的 PID 1 进程。PID 1 在 Linux 中有两项特殊责任:
- 接管孤儿进程:当进程的父进程退出时,其子进程会被内核重新挂载到 PID 1 名下。
- 回收僵尸进程:PID 1 需要通过
wait()系统调用清理已经退出的子进程。
然而,大多数应用程序在设计时并未考虑作为 PID 1 运行。例如一个简单的 shell 脚本:
# start.sh
nginx &
node app.js
在这个脚本里,nginx 作为后台进程运行,当它退出后,由于 start.sh(PID 1)并没有主动调用 wait,nginx 就会成为僵尸。而 node app.js 也可能产生自己的子进程,同样面临无人回收的命运。
一个产生僵尸进程的示例
创建一个简单的 Dockerfile,用 shell 不断产生子进程,但不回收它们:
FROM alpine:latest
COPY zombie.sh /zombie.sh
RUN chmod +x /zombie.sh
CMD ["/zombie.sh"]
zombie.sh 内容:
#!/bin/sh
# 每隔5秒创建一个持续很短时间的子进程,且不等待它
while true; do
sleep 3 &
sleep 5 # 主进程继续阻塞,但 sleep 3 很快就会退出变成僵尸
done
构建并运行容器:
docker build -t zombie-demo .
docker run --name zombie-test zombie-demo
在另一个终端中进入容器查看进程状态:
docker exec -it zombie-test ps aux
你会看到类似输出:
PID USER TIME COMMAND
1 root 0:00 /bin/sh /zombie.sh
10 root 0:00 [sleep] <defunct> # <defunct> 表示僵尸进程
这些 <defunct> 进程会随着时间推移不断增加,而脚本中的 PID 1 永远不会回收它们。
使用 --init 标志优雅解决
Docker 内置的 --init 选项会在容器 PID 1 之前插入一个轻量级的 init 进程(默认使用 tini)。这个 init 进程会:
- 以 PID 1 的身份运行,负责接收信号并转发给应用进程。
- 自动接管孤儿进程并回收所有已退出的子进程,避免僵尸堆积。
它的工作原理很简单:容器启动后,tini 作为 PID 1 运行,它再以子进程方式启动你在 CMD 或 ENTRYPOINT 中指定的应用程序。所有孤儿进程都会被 tini 收养并回收。
如何在 docker run 中使用
只需加上 --init 参数:
docker run --init --name zombie-fixed zombie-demo
再次进入容器查看进程:
docker exec -it zombie-fixed ps aux
你会发现即使产生了子进程,它们退出后也不会留下 <defunct> 标记。tini 会自动清理。
查看容器内的进程树会发现 tini 是 PID 1,而你的脚本以子进程形式运行:
PID USER TIME COMMAND
1 root 0:00 /sbin/tini -- /zombie.sh
7 root 0:00 /bin/sh /zombie.sh
在 Docker Compose 中启用 init
对于使用 docker-compose 的场景,可以在服务的配置中添加 init: true:
version: '3.8'
services:
web:
image: zombie-demo
init: true
这样 docker-compose up 启动的容器会自动使用 init。
深入理解:Tini 与自定义 init 进程
Docker 自带了 tini 作为默认的 init 实现(通常位于 /usr/bin/docker-init)。你完全可以不使用 --init,而是显式地将 tini 作为镜像的一部分。例如,在 Dockerfile 中:
FROM alpine:latest
RUN apk add --no-cache tini
COPY app.sh /app.sh
ENTRYPOINT ["/sbin/tini", "--", "/app.sh"]
或者使用官方的 tini 基础镜像来构建:
FROM krallin/ubuntu-tini:bionic
COPY app.sh /app.sh
CMD ["/app.sh"]
这两种方式与 docker run --init 效果等价,但优点在于无需标记运行时参数,所有启动的容器都默认拥有了僵尸进程回收能力。
什么情况下可以不用 --init?
如果你的 主应用本身就是 PID 1 并能够正确处理子进程退出(例如,某些用 Python 或 Go 编写的程序,它们在主循环中调用了 wait),则不一定需要 --init。不过,大多数开发框架并没有为作为 PID 1 运行而优化,因此保守起见,建议永远开启 --init,除非你明确知道不需要。
最佳实践和常见问题
1. 信号处理
PID 1 不仅要回收僵尸进程,还要负责转发系统信号。使用 tini 可以确保 SIGTERM、SIGINT 等信号被正确传递给业务进程,从而让容器能够优雅停止。如果不使用 --init,有些应用程序可能会忽略信号(因为它们是 PID 1 的子进程,而非直接 PID 1),导致 docker stop 超时后被强制杀死。
2. 对内存和性能的影响
tini 是一个非常轻量的进程,内存占用极小(通常只有几百 KB),且几乎不消耗 CPU,因此对容器性能的影响可以忽略不计。
3. 排查僵尸进程
即使使用了 --init,仍建议定期检查容器内进程状态。可以使用:
docker exec <容器名> ps -o pid,stat,cmd | grep Z
如果出现 Z 状态(僵尸),说明当前 init 进程可能没有正常工作,需要排查。
4. 使用多进程容器
当容器内同时运行多个服务(不推荐,但实际中存在)时,强烈建议配合 --init 或 tini,因为它们更容易产生孤儿和僵尸进程。
5. Kubernetes 环境
在 Kubernetes Pod 中,容器的 PID 1 同样面临僵尸进程问题。可以通过开启 Pod 的 shareProcessNamespace 并使用 pause 容器作为 PID 1,但更简单的办法是在容器中使用 tini 或设置 Docker 启动参数(如果 CRI 支持 --init)。许多 Kubernetes 发行版建议将 tini 加入镜像。
总结
- 僵尸进程是由于父进程未回收已退出的子进程导致的,大量僵尸会耗尽系统进程表。
- 容器内的 PID 1 应用程序多数不具备回收僵尸的职责,从而导致问题。
- Docker 提供了
--init选项,它通过在容器 PID 1 前插入tini来自动处理僵尸进程和信号转发。 - 在
docker run中使用--init,在 Docker Compose 中使用init: true,或者将tini集成到镜像中,都是有效的解决方案。
一条简单规则:凡是看不懂子进程是否会变成僵尸的容器,一律加上 --init,它能帮你避免许多生产环境中的诡异故障。
现在,你可以尝试修改自己的 Docker 运行命令,加上 --init,告别 defunct 的困扰。