在 Go 和 Docker 中搜寻僵尸进程

上周,我遇到了一个棘手的问题:僵尸进程导致我的演示服务器崩溃 🧟‍♂️ 如果你有过在 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 )。这导致孤儿进程不断累积。这里有一个原始代码的简化版本:

1
2
3
4
5
6
7
func stopProcess(cmd *exec.Cmd) error {
  if cmd.Process != nil {
    return cmd.Process.Kill()
  }

  return nil
}

我通过启动一个带有子进程的 Node.js 进程并杀死父进程来本地重现了这个问题。果然,子进程还在运行。在 Go 中, os.Process.Kill 向进程发送信号,但不处理其子进程。

解决尝试:进程组

要杀死子进程,我修改代码使用了进程组。通过使用 syscall.SysProcAttr 设置进程组 ID(PGID),我可以向整个组发送信号。这是更新后的代码(简化版):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
  "log"
  "os/exec"
  "syscall"
)

func startProcess() (*exec.Cmd, error) {
  cmd := exec.Command("npm", "run" "start")
  cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Assign PGID

  if err := cmd.Start(); err != nil {
    return nil, err
  }

  return cmd, nil
}

func stopProcess(cmd *exec.Cmd) error {
  if cmd.Process == nil {
    return nil
  }

  // Send SIGTERM to the process group
  pgid, err := syscall.Getpgid(cmd.Process.Pid)

  if err != nil {
      return err
  }

  return syscall.Kill(-pgid, syscall.SIGTERM) // Negative PGID targets group
}

这在本地工作了:杀死父进程也终止了子进程。我将一个测试版本部署到我们的远程服务器上,期望胜利。但 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:

1
2
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/stormkit"]

或者,我可以使用 Docker 的 –init 标志,该标志会自动添加 Tini。

使用 Tini 部署后, ps aux 干净了——没有僵尸进程!🎉 服务器稳定了,Redis 错误消失了,因为它们是资源耗尽的副作用。

总结

  • Go 进程管理:os.Process.Kill 不处理子进程。使用进程组或适当的信号处理以实现干净终止。
  • Docker PID 1:如果你的应用程序作为 PID 1 运行,它需要回收僵尸进程或委托给像 Tini 这样的初始化系统。
  • 调试提示:处理崩溃时,请始终检查 ps aux 查看进程。
  • 根本原因很重要:Redis 错误只是一个误导——僵尸进程导致的内存耗尽才是真正的问题。

参考

本文翻译于 Hunting Zombie Processes in Go and Docker

本文阅读量 次, 总访问量 ,总访客数
Built with Hugo .   Theme Stack designed by Jimmy