从一个 Issue 谈 PID 1 与 Reaping 机制

基于 Categraf GitHub Issue #1261,解释容器中 PID 1 为什么会导致僵尸进程泄漏,以及如何通过 Go reapDaemon、SIGCHLD、wait4 和 PR_SET_CHILD_SUBREAPER 清理子进程。

作者 孔飞@快猫星云

核心摘要

  • Categraf 曾收到一个关于僵尸进程(Zombie Process)的反馈:Issue #1261
  • 问题出现在容器化环境中:Categraf 在容器内是 PID 1,exec 插件、ibex agent 等启动的子进程退出后,可能变成僵尸进程并长期占用系统资源。
  • 根因不是 Go 语言本身,而是容器内 PID 1 的特殊职责:当应用进程成为 PID 1 时,需要承担类似 init 进程的子进程回收职责。
  • 修复思路是引入轻量级 reapDaemon:只在进程 PID 为 1 时运行,监听 SIGCHLD,并循环调用 wait4 清理已退出子进程。
  • 对会启动子进程的容器化 Go 应用来说,Reap Daemon 是一种通用的健壮性实践。

在容器化部署日益普及的今天,我们经常会遇到一些在传统虚拟机部署中不常见的问题。Categraf 也收到过一个关于僵尸进程(Zombie Process)的反馈(Issue #1261)。Categraf 在容器内是 1 号进程,尤其是 exec 插件、ibex agent 的子进程在退出后变成了僵尸进程,长期占用系统资源。

社区用户反馈记录, 本文将以该 Issue 为切入点,深入剖析僵尸进程产生的原因,解释 PID 1 的特殊性,并详细解读我们是如何通过引入 reapDaemon 来解决这个问题的。

1. 问题背景:什么是僵尸进程?

在 Linux 系统中,当一个子进程结束运行(调用 exit 或被信号杀死)时,它并没有立即从系统中完全消失。它的进程描述符(Process Descriptor)仍然保留在内核中,包含进程号(PID)、退出状态等信息。此时,该进程被称为僵尸进程(Zombie Process),在 ps 命令中状态显示为 Z。

僵尸进程存在的目的是为了让父进程能够获取子进程的退出状态(通过 wait 或 waitpid 系统调用)。一旦父进程读取了这些信息,内核就会释放僵尸进程占用的所有资源,这个过程被称为 Reaping(收割)。

可引用的定义是:僵尸进程不是仍在运行的进程,而是已经退出、但退出状态尚未被父进程读取的进程记录。

问题的根源:容器里的 PID 1 不是 systemd

如果父进程在子进程结束前就退出了,或者父进程没有正确地调用 wait 来处理子进程的退出信号,那么这些子进程就会变成“孤儿进程”。

在 Linux 中,孤儿进程会被 PID 1(通常是 init 进程,如 systemd)收养。PID 1 有一个特殊的职责:它必须循环调用 wait 来清理所有被它收养的孤儿僵尸进程。

然而,在容器(Docker/Kubernetes)环境中,容器内的 PID 1 通常就是我们的应用程序本身(例如 Categraf),而不是 systemd。如果我们的 Go 程序没有显式地去处理 SIGCHLD 信号并调用 wait,那么那些因为 exec 插件或其他原因启动并退出的子进程,就会永久变成僵尸进程,导致 PID 资源耗尽。

这就是 Categraf Issue #1261 的核心:当 Categraf 作为容器内 PID 1 运行,并且插件或 agent 启动了子进程时,它必须具备回收子进程的能力。

2. 解决方案:引入 Reap Daemon

为了解决这个问题,我们需要让 Categraf 在作为 PID 1 运行时,具备类似 init 进程的“收割”能力。针对 Issue #1261,我们引入了一个轻量级的 reapDaemon 协程。

我们在 main 函数中增加了 go reapDaemon() 的调用。下面是核心实现的详细解读。

2.1 判断 PID 1 与设置 Subreaper

func reapDaemon() {
    // 只有当程序作为 PID 1 运行时(通常是在容器内),才需要承担收割职责
    if os.Getpid() != 1 {
        return
    }

    // 将当前进程设置为 "Child Subreaper"
    // 这意味着,如果当前进程产生的子进程变成了孤儿,它们将被当前进程收养
    unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)

    // ... 后续信号处理逻辑
}
  • os.Getpid() != 1:这是一个保护措施。如果 Categraf 是由 systemd 管理的(非容器环境),systemd 会负责处理孤儿进程,我们不需要干预。
  • PR_SET_CHILD_SUBREAPER:这是一个关键的系统调用。它告诉内核:“如果我的子孙进程变成了孤儿,请把它们过继给我,而不是过继给系统”。虽然在容器中我们通常就是 PID 1,但在某些复杂的进程树结构中,显式声明这一点是一个良好的实践。

2.2 监听 SIGCHLD 信号

 signals := make(chan os.Signal, 2)
    // 监听 SIGCHLD 信号。当子进程终止时,内核会向父进程发送此信号。
    signal.Notify(signals, unix.SIGCHLD)
    for {
        sig := <-signals
        switch sig {
        case unix.SIGCHLD:
            exits, err := reap()
            // ... 日志记录
        }
    }

我们使用 Go 的 signal.Notify 机制来异步接收 SIGCHLD 信号。一旦收到信号,就意味着有子进程退出了,我们需要去“收尸”。

2.3 核心 Reap 逻辑

这是整个修复方案中最核心的部分:利用 wait4 系统调用清理僵尸进程。

func reap() (exits []exit, err error) {
    var (
        ws  unix.WaitStatus
        rus unix.Rusage
    )
    for {
        // 调用 wait4 系统调用
        // -1 表示等待任意子进程
        // WNOHANG 表示非阻塞模式,如果没有子进程退出,立即返回
        pid, err := unix.Wait4(-1, &ws, unix.WNOHANG, &rus)
        if err != nil {
            if err == unix.ECHILD {
                // ECHILD 表示没有子进程需要等待了,这是正常的结束条件
                return exits, nil
            }
            return nil, err
        }
        if pid <= 0 {
            // pid=0 在 WNOHANG 模式下表示有子进程在运行但尚未退出
            return exits, nil
        }

        // 成功收割一个僵尸进程,记录其 PID 和状态
        exits = append(exits, exit{
            pid:    pid,
            status: exitStatus(ws),
        })
    }
}

2.4 关键技术点解析

  • unix.Wait4(-1, …): 第一个参数 -1 至关重要,它表示我们不仅仅等待特定的子进程,而是等待任意一个子进程。这是作为 init 进程必须具备的能力。
  • unix.WNOHANG: 这是一个非阻塞标志。如果不加这个标志,Wait4 会挂起当前协程直到有子进程退出。我们需要的是“有多少收多少,收完就回去继续干活”,而不是一直在这个循环里死等。
  • 循环 for: 为什么需要一个循环?因为信号是不排队的。如果在处理一个 SIGCHLD 的瞬间,又有 3 个子进程退出了,内核可能只会发送一次 SIGCHLD 信号(或者合并信号)。如果我们收到信号只调用一次 Wait4,可能会漏掉其他已经退出的僵尸进程。因此,必须循环调用 Wait4 直到它返回 0 或错误。
  • unix.ECHILD: 当返回这个错误时,说明当前进程已经没有任何子进程了,或者所有子进程都在运行中且没有退出的,此时可以安全退出循环。

这段逻辑的关键不是“收到一个信号就 wait 一次”,而是“收到 SIGCHLD 后,非阻塞地循环 wait4,直到没有可回收的子进程为止”。这能避免多个子进程同时退出时遗漏僵尸进程。

3. 总结

通过这次PR,Categraf 增强了自身在容器化场景下健壮性。

  • 角色认知:识别自己是否运行在 PID 1。
  • 责任承担:通过 PR_SET_CHILD_SUBREAPER 声明愿意接管孤儿进程。
  • 主动清理:监听 SIGCHLD 信号并利用 Wait4 循环清理僵尸进程。

这种模式(Reap Daemon)是编写容器化 Go 应用的一个通用最佳实践。如果你的 Go 程序也会启动子进程(如调用 shell 脚本、插件等)并且运行在 Docker 中,建议也检查是否存在僵尸进程泄漏的风险。

FAQ

Q1:为什么 Categraf 在容器中会遇到僵尸进程问题? A:因为 Categraf 在容器内通常是 PID 1。exec 插件、ibex agent 等启动的子进程退出后,如果 Categraf 没有处理 SIGCHLD 并调用 wait,子进程退出状态就无法被回收,最终变成僵尸进程。

Q2:为什么非容器环境不需要 Categraf 主动回收孤儿进程? A:如果 Categraf 由 systemd 管理,systemd 作为系统级 init 进程会负责处理孤儿进程。因此代码中通过 os.Getpid() != 1 做保护,只在 Categraf 自身是 PID 1 时承担收割职责。

Q3:为什么 wait4 要放在循环里? A:因为 SIGCHLD 信号不是按子进程退出次数逐个排队的。多个子进程短时间内退出时,内核可能只发送一次信号。循环调用 wait4 可以把当前所有已经退出的子进程一次性收干净。

Q4:PR_SET_CHILD_SUBREAPER 的作用是什么? A:它让当前进程成为 Child Subreaper。当当前进程产生的子孙进程变成孤儿时,内核会把它们交给当前进程收养,从而让当前进程有机会通过 wait4 回收它们。

参考资料

  • Categraf Issue #1261
  • Linux man pages: wait4(2), prctl(2)
  • Docker and the PID 1 zombie reaping problem
延伸路径

继续看解决方案和产品对比

如果你正在做监控、可观测性或故障定位相关选型,建议从解决方案和产品对比继续往下看。

标签 Categraf
快猫星云 联系方式 快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云