Docker 中使用 --init 管理僵尸进程

FreeGuideOnline 最新 2026-07-07

什么是僵尸进程,为什么容器要关注它?

在 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 中有两项特殊责任:

  1. 接管孤儿进程:当进程的父进程退出时,其子进程会被内核重新挂载到 PID 1 名下。
  2. 回收僵尸进程:PID 1 需要通过 wait() 系统调用清理已经退出的子进程。

然而,大多数应用程序在设计时并未考虑作为 PID 1 运行。例如一个简单的 shell 脚本:

# start.sh
nginx &
node app.js

在这个脚本里,nginx 作为后台进程运行,当它退出后,由于 start.sh(PID 1)并没有主动调用 waitnginx 就会成为僵尸。而 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 运行,它再以子进程方式启动你在 CMDENTRYPOINT 中指定的应用程序。所有孤儿进程都会被 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 可以确保 SIGTERMSIGINT 等信号被正确传递给业务进程,从而让容器能够优雅停止。如果不使用 --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. 使用多进程容器

当容器内同时运行多个服务(不推荐,但实际中存在)时,强烈建议配合 --inittini,因为它们更容易产生孤儿和僵尸进程。

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 的困扰。