当容器导致节点失效:理解僵尸进程和 PID 1
Source: Dev.to
引子
在我职业生涯的早期,我目睹了一件事,这件事彻底改变了我对容器的看法。我们在 Kubernetes 上使用 Rocky Linux 节点运行 MySQL。一切看似正常,直到节点一个接一个地挂掉。罪魁祸首是什么?僵尸进程——数以百计的僵尸进程悄然累积,最终导致节点不堪重负。
这次事件让我领悟到一个根本真理:容器并不是轻量级的虚拟机。它们只是进程。
在 Linux 中,当一个进程结束执行时,它并不会直接消失。它会进入 僵尸 状态:进程已经完成,但它的条目仍然保留在进程表中。父进程必须使用 wait() 读取子进程的退出状态。只要父进程没有调用 wait(),子进程就会保持为僵尸。
父进程
Parent Process
|
|--- fork() ---> Child Process
| |
| | (does work)
| |
| v
| Exits (becomes zombie)
| |
|<--- wait() ---------+
|
v
Zombie cleaned up
在普通的 Linux 系统中,这并不是一个大问题。如果父进程在未调用 wait() 的情况下死亡,孤儿子进程会被 init 进程(PID 1)收养,后者会定期回收这些僵尸进程。
为什么容器会破坏此模型
当你在容器中运行而没有 init 进程时,你的应用程序会成为 PID 1。没有传统的 init 进程,因此你的应用现在需要负责回收僵尸进程。
FROM mysql:8.0
# MySQL process becomes PID 1
# It was never designed to be an init system
大多数应用程序——包括 MySQL——都不是为成为 init 进程而设计的。它们不会对孤儿子进程调用 wait(),所以当子进程死亡时,它们会变成僵尸进程,没人去清理它们。在节点上,ps aux | grep Z 显示出数百个僵尸 MySQL 辅助进程,每个进程虽然已经死亡,但仍占用进程表中的条目。
每个僵尸进程都包含:
- 进程表中的一条记录
- 一个 PID(而 PID 是有限的)
最终,你会耗尽 PID 或填满进程表,导致无法生成新进程。节点变得不稳定,服务会崩溃。
修复方案:Tini
解决方案出乎意料地简单:使用专为容器设计的合适 init 进程。Tini 是专为容器构建的极简 init 系统。它:
- 以 PID 1 运行
- 将你的应用程序作为子进程启动
- 正确转发信号
- 通过调用
wait()来回收僵尸进程
实现
选项 1:在 Dockerfile 中安装
FROM mysql:8.0
# 安装 tini
RUN apt-get update && apt-get install -y tini
# 将 tini 设置为 entrypoint
ENTRYPOINT ["/usr/bin/tini", "--"]
# 你的实际命令
CMD ["mysqld"]
选项 2:使用 Docker 内置的 init
docker run --init mysql:8.0
选项 3:Kubernetes
apiVersion: v1
kind: Pod
spec:
containers:
- name: mysql
image: mysql:8.0
# 对于 Kubernetes,建议将 tini 烘焙进镜像或使用已包含它的基础镜像
在 Kubernetes 中,最安全的做法是将类似 Tini 的 init 程序烘焙进镜像,因为依赖运行时标志在不同环境下并不具备可移植性。
更大的教训
这次事件挑战了我对容器的认知模型。我过去把容器当作“轻量级虚拟机”——一个运行自己小世界的隔离箱子。实际情况并非如此:容器本质上只是带有高级隔离(命名空间、cgroup)的进程。它与宿主共享内核。当该进程出现僵尸进程、消耗内存或耗尽 PID 等异常行为时,宿主系统也会受到影响。
理解这一点会改变你在以下方面的做法:
- 调试容器问题
- 设计容器镜像
- 考虑资源限制与隔离
快速参考
| 场景 | 会发生什么 | 解决方案 |
|---|---|---|
| App as PID 1, spawns children | 僵尸进程累积 | Use Tini or --init |
| App crashes without signal handling | 孤儿子进程变成僵尸进程 | Proper init + signal forwarding |
| Too many zombies | PID 用尽,节点不稳定 | Prevention via init system |
- 僵尸进程是正常的;只有在未被回收时才会成为问题。
- 容器默认没有传统的 init。
- 除非专为此设计,否则你的应用不应是 PID 1。
- Tini(或
dumb-init)是简单的解决方案,应该作为标准做法。 - 容器是进程,而不是虚拟机——永远不要忘记这一点。