在 PID namespace 中第一个子进程的 PID 是 1。这个数字不仅是用户界面的显示值:该进程承担接收孤儿进程、处理终止事件的职责。如果它结束,内核会终止此 PID namespace 中的其他进程。先掌握01 的 wait/信号和02 的 namespace 身份,否则容易把“应用没有退出”误判成运行时故障。

PID 号码与进程的两个视角

PID namespace 按层级组织。内侧看到的 PID 1 在外侧仍有宿主 PID,外侧能够观察内部进程,内部不能因此查看外侧所有进程。/proc 是一个挂载出来的进程信息视图;仅调用 unshare 而沿用旧 /proc 挂载,可能读到旧 namespace 的 PID,进而产生“shell 的 $$ 是 1,/proc/self/status 却有不同号码”的矛盾。应在私有挂载环境重新挂载 procfs,才用它检查当前 PID 视图;没有这个权限,明确标记观察限制,而不是补造一致的 PID 输出。

PID 1 对某些未安装处理函数的默认信号行为有特殊规则。它并不是“永远不会响应 TERM”:安装了 TERM handler 可以处理信号;不处理僵尸则仍可能把退出状态长期留在进程表里。容器入口程序如果启动后台子进程,必须明确考虑把信号传给工作进程、等待其退出以及超时后的处理;程序只在前台 exec 工作进程,与用 shell 启动后台任务再退出并不等价。SIGKILL 不能由处理函数捕获,而“退出码 137”不足以独自证明 OOM,仍需 cgroup 或内核证据。

先辨认谁才是 PID 1

unshare(CLONE_NEWPID) 作用于随后创建的子进程;调用 unshare 的进程本身不会突然得到新 namespace 里的 PID 1。unshare -p --fork 正是利用 fork 让新子进程成为第一个成员。如果在子进程里启动 sh -c '...',shell 可能是 PID 1;再从 shell 调用外部的 grep,grep 是另一个进程。$$ 是 shell 自己的 PID,而 grep NSpid /proc/self/status 打印的是 grep 的 status:即便文件名写着 self,它也不会替 shell 报告 NSpid。想观察 shell,要在尚存活时读取 /proc/$pid/status,并明确这个 procfs 是在哪个 PID namespace 挂载的。否则同时混入了“两个进程”与“两个 procfs 视图”这两个变量。

同一个线程组在祖先 PID namespace 有外侧 PID,在所在的子 namespace 有内侧 PID;NSpid 字段按观察者的 procfs 视图展示可见的层级,列出的数字不等于给目标创建了多份进程。保留目标的宿主 /proc/<pid>/stat 启动时间及 namespace inode,才能在容器 ID 和进程之间建立有时效的关联。/proc/<pid>/ns/pid 标识的是目标进程所属的 PID namespace,pid_for_children 还可能指向随后 fork 的子进程要进入的 namespace;把两者当作同一个字段,容易误判 unshare 尚未 fork 的中间状态。宿主 PID 可能重用,所以在目标退出后再用相同数字取证,不是同一次观测。

handler、默认动作和信号发送者

pid_namespaces(7) 的约束比“PID 1 忽略 TERM”更精确:同一 PID namespace 的其他进程只能向它发送已经建立 handler 的信号;祖先 namespace 的进程还须通过一般权限检查,但对没有 handler 的信号同样受限。祖先 namespace 发送的 SIGKILL、SIGSTOP 是例外,强制生效;两者都不能安装 handler。这里的发送者属于哪个 namespace 不是修饰信息,而会改变结论。因此比较 kill -TERM 1 与运行时从宿主 PID namespace 发出的终止请求,必须同时记录信号来源、目标身份和调用返回值,不能只截取入口程序有没有输出一行“收到 TERM”。

应用若安装 TERM handler 但只修改一个布尔值、没有让主循环结束,内核确实投递了信号,进程仍可能继续存活;这与“信号未被投递”不是同一故障。若 handler 仅处理当前进程而没有把信号传给工作子进程,停止窗口内子进程还能处理请求,甚至在 PID 1 退出时被内核以 SIGKILL 清除。正确退出需要明确截止时间、停止接收新工作、通知已知子进程、等待它们,并在上层超时终止策略下承认未完成工作;是否能保障在途请求完整完成取决于应用协议与上游摘流,而不取决于是否使用某个命名为 init 的镜像。

为什么 reaping 不是“把程序杀掉”

子进程退出后,内核仍暂存其退出状态,直到父进程通过 wait 系列系统调用读取;这个状态在进程列表中可能标为 Z。如果父进程还活着却不调用 wait,连续生成短命子进程就可能积累僵尸,占用进程表项或与 pids 限额共同导致后续 fork 失败。kill 一个已经变为僵尸的子进程不能代替其父调用 wait;应核实父子关系与状态,并修正父进程的回收逻辑。父进程先退出的孤儿通常被本 namespace 的 init 收养,或依规则交给已设置的 subreaper,不能凭子进程原先的 PPid 推断它已经被回收。

“容器主进程退出,后台程序还在宿主运行”的说法也要限定实验边界:如果退出的是 PID namespace 的 init,内核按手册用 SIGKILL 终止该 namespace 中的其余进程;如果运行时根本没创建独立 PID namespace,或观察的是 namespace 内的普通工作进程而非 init,则不能套用这条规则。保持一个已死亡 init 所属 namespace 的 FD,不等于能继续向里面 fork:手册说明以后 fork 可能返回 ENOMEM。这种错误不一定意味着主机真没内存,先查发生于哪个 namespace 以及其 init 是否已退出。

正例与环境失败

本环境的完整命令与原始日志保留精确输入、退出码与实际输出(首轮记录亦保留)。unshare -Ur -p --fork 下,shell 报 pid=1;安装 trap 的 TERM 处理函数后向自己发送 TERM,输出 event=term_caught,随后继续 after_term 并正常返回 0。这是“有 handler 的 PID 1 处理 TERM”的正例,不能由此推断没装 handler 时的所有行为。该命令仅对刚创建的子命名空间发送信号,未触及宿主 PID 1。

原始日志里的 NSpid: 2537273 2 是 grep 进程的结果,不是 shell 的外侧 PID,也不是 shell 自己显示了内部 PID 2。当前唯一直接来自 shell 的 PID 数据是展开后的 $$=1。即使想根据 grep 的行列推测层级,也必须另记 /proc 的挂载来源,不能从这个字段推算 shell 宿主 PID。这种局部观测解释不了“其他进程向 init 发信号时怎样处理”、shell 是否回收多个子进程,也不能验证运行时发送 TERM 的策略;它只验证一次安装 trap 后的自身信号处理路径。

设计不混淆进程角色的对照实验

专用 VM 中固定程序和退出条件,再分两支运行:一支以 exec 让应用直接担任 PID 1,另一支让可回收子进程的最小入口程序担任 PID 1,应用作为它的子进程。两支使用同一个工作程序、相同请求和停止期限。记录外侧实际 PID、应用内侧 PID、/proc/<目标PID>/ns/pid、新挂载的 procfs mountinfo、收到 TERM 前后工作进程的输出、wait 得到的状态及容器级退出事件。若目标是测不设 handler 的动作,就另开一支无 handler程序,不能拿带 trap 的 shell 当作对照。用 timeout 给自建实验进程加上限,不向共享宿主其他进程发信号。

为检验回收,让程序创建少量有上限、立即退出的子进程,先有意延迟 wait 采样各自的状态,再逐个 waitpid,核对 Z 是否消失。实验之后退出该独立 PID namespace,确认外侧宿主没有这些子 PID;如果观察窗口太短或 /proc 沿用旧挂载,采不到僵尸只能记为“观察条件不足”,不能凭直觉补上状态行。正常分支与失败分支需要保留完全相同的采样位置,才可把差异归因于 handler 或 wait 行为,而不是宿主正在重启另一个容器。

同一环境中 --mount-proc 创建新视图失败,报 Operation not permitted(见02 的原始记录)。日志里 /proc/self/status 给出外侧 PID 与内部层级痕迹,不能把它当已经挂载成功的新 procfs。缺少可信容器运行时及挂载权限,本篇预定的“对比无 handler 与有 handler、两侧 PID、子进程回收”的整套容器实验尚未通过。专用 Linux VM 的补跑方法见入口和清理说明;最小源码包见附件。

现象 首先核验 判定边界
内部 $$ 是 1,procfs 数字不同 /proc 挂载及 PID namespace inode 旧 procfs 不代表新视图
TERM 后继续运行 handler、目标 PID、发送者 namespace 不等于 TERM 永远无效
子进程已退但仍为 Z PID 1 的 wait 调用 没有等待不等于还在工作

排障时可从三个互不等价的疑问开始:发送 TERM 的系统调用是否成功、目标是否安装了处理函数并完成实际投递、目标是否结束且其父进程已读取退出状态。第一问查发送者身份与返回码;第二问查目标进程和 handler 侧的事件;第三问查存活、wait 与剩余子进程。只有这三层都有可复核证据,才能说某次停止流程完成。若终止是 SIGKILL,继续查谁在什么时点发出它,以及 memory.events、内核或运行时事件是否支持 OOM;137 只是常见的 128+9 表示,单独不能承担因果归因。

什么时候需要一个额外的 init

选择入口结构取决于应用本身是否启动子进程和是否已经实现终止协议,而不取决于镜像里是否有一个名叫 init 的文件。单一前台程序能可靠处理信号并等待自己的孩子,通常可以直接作为 PID 1;让 shell 只负责 exec app,exec 后 PID 不变,shell 本身不再是需要转发信号的常驻中间人。若必须并行启动多个服务,单纯 app & wait 不必然足够:shell 的 trap 在执行同步 wait 时的处理时机、一次 wait 对应哪个孩子、TERM 是否送达整个工作进程树,都要针对具体 shell 和命令做实验。需要一个小型 init 时,须明确它转发给谁、如何处理未知孤儿及何时退出;“加上 init 就不会有僵尸”只有在它实际调用 wait 且有机会收养相应孤儿时才成立。

例如主服务启动一个短命 helper 后没有等待,helper 退出会留下状态;若主服务仍在运行,额外的 init 并不会越过仍活着的直接父进程替它 wait。若主服务先退出、helper 仍存活,收养关系才可能改变,此时要观察 helper 的 PPid、是否成为孤儿和 init 是否回收。另一个边界是特意选择共享宿主 PID namespace 的工作负载:它不拥有独立 namespace 的 init 身份,不能机械地对它套用子 namespace 的信号限制。判断入口是否需要替换的最小证据是进程树、信号来源、退出码和 wait 的结果,而非依赖 Dockerfile 最后一行命令长什么样。

这也解释了为什么健康检查不能代替进程回收检查:/health 只说明当时仍有进程能回答请求,主程序留下的僵尸可能同时存在。反过来,看到一个 Z 也不等于服务请求已经失败;要同时观察 pid 限额、子进程创建速率和实际请求。若没有权限读取目标进程的状态,应明确缺失采样的原因并留下补跑入口,不凭健康接口补写内核状态。

练习

  1. 先预测同一 PID 1 不设处理函数与安装 TERM handler 的区别;在专用隔离 VM 各启动可信小程序,记录发送者、目标内外 PID、信号结果和清理;当前宿主只能验证安装 handler 的一支。
  2. 先预测“容器内杀死 PID 1”之后后台进程的存活情况;在专用 VM 用宿主 /proc 与容器内新挂载 procfs 对照,同时记录应用退出原因,不能对共享宿主 PID 1 发信号。

上一篇:02:namespace。更完整的节点对象关系见kubelet 与容器运行时,该旧文不是本篇实测记录。

系列总目录:从进程隔离到运行时与编排。

参考资料