上周,我遇到了一个棘手的问题:僵尸进程导致我的演示服务器崩溃 🧟♂️ 如果你有过在 Go 或 Docker 中处理进程管理的经验,你可能会对这段经历感同身受。下面是对这个问题的深入技术分析,以及我是如何追踪并解决它的。
我们有一个功能,在 Stormkit 中可以根据自托管用户的需求启动 Node.js 服务器,使用动态端口分配在同一服务器上运行多个实例。它是用 Go 编写的,利用 os/exec
来管理进程。该系统一直非常稳定——没有宕机时间,用户也很满意。
最近,我设置了一个用于托管 Next.js 和 Svelte 服务器端应用的演示服务器。一切看起来都正常,直到服务器开始随机出现 Redis 发布/订阅错误而崩溃。
我将 Redis 升级到了 7.x 版本,检查了日志并尝试在本地重现问题——没有发现任何问题。崩溃是随机且难以捉摸的。然后,我禁用了 Next.js 应用,崩溃就停止了。我怀疑是 Next.js 本身的问题,于是深入研究了其运行时行为,但没有发现什么异常。
查看服务器指标时,我发现内存使用率在崩溃前会激增。快速运行 ps aux
命令后,我发现有一堆遗留的 Next.js 进程没有被终止。我们的关机逻辑失败了,导致内存泄漏,最终耗尽了服务器资源。
罪魁祸首在于我们的 Go 代码。我使用 os.Process.Kill
来终止进程,但没有杀死由 npm 启动的子进程(例如, npm run start
启动了 next start
)。这导致孤儿进程不断累积。这里有一个原始代码的简化版本:
|
|
我通过启动一个带有子进程的 Node.js 进程并杀死父进程来本地重现了这个问题。果然,子进程还在运行。在 Go 中, os.Process.Kill
向进程发送信号,但不处理其子进程。
解决尝试:进程组
要杀死子进程,我修改代码使用了进程组。通过使用 syscall.SysProcAttr
设置进程组 ID(PGID),我可以向整个组发送信号。这是更新后的代码(简化版):
|
|
这在本地工作了:杀死父进程也终止了子进程。我将一个测试版本部署到我们的远程服务器上,期望胜利。但 ps aux
显示 <defunct>
接近进程旁边——僵尸进程!🧠
僵尸进程 101
在 Linux 中,当一个子进程终止,但其父进程没有收集其退出状态(通过 wait 或 waitpid)时,就会出现僵尸进程。该进程会停留在进程表中,标记为 <defunct>
。少量的僵尸进程是无害的,但当它们积累时,会耗尽进程表,阻止新进程的启动。
在本地,我的 Go 二进制文件可以正常回收进程。但在远程,僵尸进程仍然存在。关键区别在于远程服务器在 Docker 容器中运行了 Stormkit。
Docker 的僵尸进程问题
Docker 将 PID 1 分配给容器的入口点(即我们的 Go 二进制文件)。在 Linux 中,PID 1( init/systemd
)负责收养孤儿进程并回收其自身的僵尸子进程,包括它已经收养的前孤儿进程。如果 PID 1 没有处理 SIGCHLD
信号并调用 wait,僵尸进程就会累积。我们的 Go 程序并没有设计成一个初始化系统,所以它忽略了孤儿进程。
解决方案:Tini
经过进一步调查,我发现回收僵尸进程是 Docker 长期存在的问题,所以市场上已经有了解决方案。最终我找到了 Tini,这是一个为容器设计的轻量级初始化系统。Tini 作为 PID 1 运行,通过处理 SIGCHLD 和 wait 所有进程来正确回收僵尸进程。我更新了我们的 Dockerfile:
|
|
或者,我可以使用 Docker 的 –init 标志,该标志会自动添加 Tini。
使用 Tini 部署后, ps aux
干净了——没有僵尸进程!🎉 服务器稳定了,Redis 错误消失了,因为它们是资源耗尽的副作用。
总结
- Go 进程管理:os.Process.Kill 不处理子进程。使用进程组或适当的信号处理以实现干净终止。
- Docker PID 1:如果你的应用程序作为 PID 1 运行,它需要回收僵尸进程或委托给像 Tini 这样的初始化系统。
- 调试提示:处理崩溃时,请始终检查
ps aux
查看进程。 - 根本原因很重要:Redis 错误只是一个误导——僵尸进程导致的内存耗尽才是真正的问题。