Linux PID namespace 中 init 进程怎样回收子进程
在 PID namespace 里,最先创建的进程会拿到命名空间内的 PID 1。它不只是一个普通业务进程:当其他进程的父进程退出后,内核会把孤儿进程重新挂到合适的 init 或 subreaper 上;如果这个命名空间里的 init 进程不及时调用 wait 或 waitpid,退出的子进程就可能长期停留为僵尸进程。
实用结论是:把 namespace init 当作一个小型进程管理器设计。它至少要安装必要的信号处理逻辑,持续回收自己接管的子进程,并把“PID 1 退出会导致整个命名空间失效”当作生命周期约束,而不是普通服务重启细节。
PID namespace 中的 init 负责接管孤儿并回收已退出子进程;真正执行回收的是 wait 系列系统调用。PID 1 一旦退出,内核会终止该命名空间内的其他进程,不能再把它当作可热替换的普通父进程。
PID namespace 的 1 号进程到底特殊在哪里
新 PID namespace 的进程号从 1 开始。进程在自身命名空间和祖先命名空间中可能拥有不同的 PID,祖先命名空间可以观察子命名空间中的进程,而子命名空间不能反向看到祖先中的进程。因此,容器内看到的 PID 1 和宿主机看到的同一进程,通常不是同一个数字。
“init”这个名字表示职责,而不要求进程必须是 systemd、BusyBox init 或某个固定实现。一个很小的 C、Go 或 shell 包装进程也可以成为命名空间内的 PID 1,但它需要主动承担信号转发、退出状态和子进程回收等工作。
孤儿进程为什么会交给 init
假设业务父进程先退出,而它创建的子进程仍在运行。这个子进程不能保留一个已经不存在的父进程,内核会把它重新父子关系挂到父进程所在 PID namespace 的 init 进程;如果更近的祖先设置了 PR_SET_CHILD_SUBREAPER,则可能由这个 subreaper 接管。
这里有一个容易弄错的边界:接管者取决于“父进程所在的命名空间”,不一定就是子进程当前视角里最靠近的容器 PID 1。跨 namespace 创建和 setns、unshare 会让进程的父子关系与命名空间树不完全重合,所以排查时要同时看父进程关系和 namespace 层级。
被接管并不等于已经回收。进程退出后,内核仍要保留退出码、资源使用等少量信息,直到父进程调用 wait 系列系统调用读取这些信息;这个等待动作完成后,僵尸进程对应的任务表项才会被释放。
PID 1 怎样用 SIGCHLD 和 waitpid 回收
常见做法是让 SIGCHLD 处理器只设置一个轻量标记,把真正的 waitpid 循环放到主循环或事件循环中。这样可以避免在信号处理器里塞入日志、内存分配和复杂业务逻辑。
#include#include #include #include static volatile sig_atomic_t child_event = 0; static void on_sigchld(int signo) { (void)signo; // 信号处理器只记账,避免在异步上下文中做复杂工作。 child_event = 1; } static void reap_children(void) { int status; for (;;) { // -1 表示回收当前进程有权等待的任意子进程;WNOHANG 避免阻塞主循环。 pid_t pid = waitpid(-1, &status, WNOHANG); if (pid > 0) { // 这里可以记录 pid、退出码或信号,但不要把业务逻辑塞进信号处理器。 continue; } if (pid == 0) { // 当前没有更多已经退出的子进程,下一次 SIGCHLD 再继续处理。 break; } if (errno == EINTR) { // 被其他信号打断时重试,避免漏掉待回收的子进程。 continue; } // ECHILD 表示已经没有可等待的子进程,其他错误应交给生命周期策略处理。 break; } } int main(void) { struct sigaction action = {0}; action.sa_handler = on_sigchld; sigemptyset(&action.sa_mask); // 建立 SIGCHLD 处理逻辑后,PID 1 才能稳定感知子进程退出。 sigaction(SIGCHLD, &action, NULL); for (;;) { // 真实服务可在这里处理信号、管道或 socket 事件。 pause(); if (child_event) { child_event = 0; reap_children(); } } }
这段代码只是回收骨架,不负责启动容器,也没有假设任何具体业务协议。核心是 waitpid(-1, ..., WNOHANG) 要循环调用:一次信号到达时可能对应多个已经退出的子进程,单次调用会留下未回收的僵尸。
生产实现还应明确三件事:退出码是否需要传给上层、收到 SIGTERM 后是否先停止拉起新子进程、以及主循环被其他信号打断时怎样继续回收。若直接把 SIGCHLD 设置为忽略或使用 SA_NOCLDWAIT,内核可以避免保留僵尸,但应用也会失去读取子进程退出状态的机会,不能无条件替代显式 waitpid。
普通父进程回收和命名空间回收不是一回事
普通服务通常只需要等待自己直接创建的子进程;namespace init 还要准备接管那些原父进程已经退出的后代。于是它的回收范围不是“我启动的那几个 PID”,而是“当前有权等待的全部子进程”。这也是推荐使用 waitpid(-1, ...) 循环,而不是维护一份可能过期的 PID 白名单的原因。
/proc 的视图也要一起考虑。procfs 显示哪些进程,取决于挂载它的进程所在 PID namespace。创建新的 PID namespace 后,通常还要在对应的 mount namespace 中挂载新的 procfs,容器内的诊断工具才能看到与容器 PID 号一致的列表;否则你可能误以为 PID 1 没有子进程,实际只是看到了另一个层级的视图。
可以用下面的命令表达一个“创建 PID namespace 并把 procfs 挂载到新视图”的思路。它是操作示意,不代表本文在本机执行过。
# --pid 创建新的 PID namespace,--fork 让后续 shell 成为其中的 PID 1。 # --mount-proc 让 procfs 按新的 PID 视图呈现,便于观察容器内进程关系。 unshare --pid --fork --mount-proc /bin/sh # 在命名空间内查看进程树;输出只用于观察,不替代 waitpid 回收职责。 ps -o pid,ppid,stat,comm
PID 1 退出时为什么会连带终止整个 namespace
Linux 把 namespace init 视为命名空间正确运行所必需的进程。如果它退出,内核会向该 PID namespace 中的进程发送 SIGKILL;之后再尝试向这个已经没有 init 的命名空间创建进程,会失败并返回 ENOMEM。因此,PID 1 的主循环不能只关心业务子进程,还要定义“收到终止信号后如何有序结束”和“所有关键子进程结束后自己何时退出”。
从容器运行时的角度看,这个约束解释了两个常见设计:一是让一个专门的 init wrapper 作为容器入口,再由它启动业务程序;二是让 wrapper 明确转发终止信号,并在回收全部子进程后返回合理退出码。wrapper 不是为了“伪造”一个 init,而是为了补上单进程应用经常缺少的子进程管理职责。
一张表记住回收边界
| 场景 | 内核关系 | PID 1 应做什么 |
|---|---|---|
| 直接子进程退出 | 保留退出信息,等待父进程读取 | 处理 SIGCHLD,并循环 waitpid |
| 原父进程提前退出 | 孤儿重新挂到合适的 init 或 subreaper | 不要只等待自己启动的固定 PID |
| 容器内 procfs 视图不匹配 | 看到的 PID 列表来自另一个挂载层级 | 在正确的 mount namespace 中挂载 procfs |
| namespace init 退出 | 命名空间内进程被 SIGKILL,后续创建受限 | 把信号转发、清理和退出策略放在生命周期设计里 |
常见问题
PID 1 必须使用 systemd 吗?
不必须。关键是它要实现与场景匹配的信号处理、子进程回收和退出策略。极简容器可以使用专门的 init wrapper,完整系统才可能选择 systemd。
为什么只调用一次 waitpid 还会有僵尸?
一次 SIGCHLD 可能对应多个子进程退出。单次 waitpid 只取走一个退出状态,应该在返回正数时持续循环,直到返回 0 或确认没有可等待的子进程。
子 namespace 里的 PID 1 能看到宿主机进程吗?
不能按子 namespace 的视角反向观察祖先 namespace。祖先可以观察子 namespace 中的进程,但同一进程在不同层级使用的 PID 可能不同。
本文依据的 Linux 资料在哪里?
Linux PID namespace 的生命周期、PID 1、孤儿接管和 procfs 视图可参考:https://man7.org/linux/man-pages/man7/pid_namespaces.7.html。本文示例只用于解释结构与职责,图像也是静态说明图,不是运行截图。
把 namespace init 设计成“会回收子进程、能处理信号、知道何时退出”的小型进程管理器,PID namespace 的核心边界就清楚了:孤儿由合适的 init 接管,退出状态由 wait 系列消费,而 PID 1 的退出会结束整个命名空间的可用生命周期。