you are better than you think

从一个 Issue 谈 PID 1 与 Reaping 机制

· by thur · Read in about 2 min · (336 Words)
Categraf

在容器化部署日益普及的今天,我们经常会遇到一些在传统虚拟机部署中不常见的问题。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(收割)。

问题的根源

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

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

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

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),
        })
    }
}

关键技术点解析:

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

3. 总结

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

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

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

参考资料:

Categraf Issue #1261

Linux man pages: wait4(2), prctl(2)

Docker and the PID 1 zombie reaping problem

Comments