ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

struct pid_namespace深度解析:容器PID隔离与进程回收的基石

struct pid_namespace深度解析:容器PID隔离与进程回收的基石

struct pid_namespace,这个名字在多数人眼里不过是一行C语言结构体声明,但Linux内核里整个容器世界的进程隔离、PID重编号、进程回收,底层全是它在兜底。我第一次认真抠它,是因为排查一个容器内进程级联退出的故障,翻到include/linux/pid_namespace.h时才发现之前很多想当然的判断都是错的。这篇文章我就用自己消化过的内容,把这个结构体的设计动机、每个字段的作用、以及怎么在运行时验证它的行为,完完整整讲一遍。适合正在读内核源码的、做容器运行时二次开发的,以及被"容器里PID对不上"这种问题折磨过的读者。你不需要先精通内核,只要写过C、用过Linux命令,就能跟着走下来。

1. 没有pid命名空间时,容器只是"看起来隔离"

1.1 进程视图共享引发的三大问题

先回到没有pid命名空间的时代。早期容器实现很粗糙,只是用chroot加cgroup把进程"圈"起来,但进程看到的进程表是全局共享的。这意味着在容器里执行ps -ef,你能看到宿主机上的所有进程,甚至能看到隔壁容器里的进程。这在隔离性上等于裸奔。

带来最直接的三个问题。第一,信息泄露:容器内可以轻易枚举宿主机上跑了哪些服务,进程的命令行参数、环境变量都暴露在/proc下面。第二,误操作风险:容器里的管理工具拿到全局PID后,一个kill -9 1717可能杀掉的不是你容器里的进程,而是宿主机上某个关键服务。第三,PID复用混淆:宿主机上进程创建销毁频繁,PID会被反复使用。你在容器里记录了一个PID,过一会儿再查,它可能已经变成了另一个完全不相干的进程。

要解决这些问题,就得让每个容器里的进程"认为自己是一台独立机器上的第一批进程"。于是内核引入了pid namespace机制,而struct pid_namespace就是承载这套机制的核心结构体。

1.2 struct pid_namespace在整个机制里的位置

要理解这个结构体,得先弄清楚它挂在哪个环节上。每个进程的task_struct里有一个nsproxy指针,nsproxy里挂着各类命名空间:mount namespace、uts namespace、ipc namespace、net namespace,以及我们今天的主角pid_ns_for_children。

注意这个名字,它叫"for_children"。它表示的是:当前进程后续fork出来的子进程,会被放进哪个pid namespace。当前进程自己属于哪个pid namespace,则记录在它的struct pid结构体里。struct pid才是进程在内核中的"身份证",里面存着该进程在所有相关pid namespace中的编号列表;而struct pid_namespace是"户籍系统",定义了一套编号规则、编号上限、回收孤儿进程的人选。

一个容器里的所有进程,共享同一个struct pid_namespace。它们各自的struct pid不同,但指向同一个namespace结构体。我在读代码时常犯一个误区,以为fork会复制整个pid namespace,其实完全不是这样。fork时copy_namespaces只是让子进程的nsproxy和父进程共享同一个pid_namespace,内部通过kref增加一次引用计数。也就是说,容器里每fork一个进程,不是多了一份"户籍系统",而是多了一个"户籍登记人"。只有调用unshare或clone带有CLONE_NEWPID标志时,才会真的创建一套新户籍系统。

2. 逐字段啃一遍struct pid_namespace

先放一个接近当前主线版本的简化定义,不同内核版本字段略有出入,但你手里那份源码大概率长这样:

struct pid_namespace { struct kref kref; // 引用计数 struct idr idr; // pid整数到struct pid的映射表 struct rcu_head rcu; // RCU延迟释放头 unsigned int pid_allocated; // 当前已分配的pid数量 unsigned int pid_limit; // pid数量上限 int last_pid; // 下次分配pid时从哪儿开始找 unsigned int nr_hashed; // 哈希表里的pid数量 struct task_struct *child_reaper; // 本命名空间负责收尸的init进程 struct kmem_cache *pid_cachep; // struct pid的slab缓存 unsigned int level; // 嵌套层数 struct pid_namespace *parent; // 父命名空间 struct user_namespace *user_ns; // 关联的用户命名空间 struct ucounts *ucounts; // 用户资源计数 kgid_t pid_gid; // /proc/sys/kernel/pid_max 的属组 int hide_pid; // /proc 的 hidepid 选项 struct file *proc_mnt; // 本命名空间关联的proc实例 };

光看名字能猜个大概,但每个字段为什么这样设计,才是真正值得花时间的地方。

2.1 生命周期三件套:kref、rcu、level/parent

kref是内核标准的引用计数器。为什么不用普通的atomic_t?因为kref提供了一套约定俗成的get/put语义:kref_get拿引用,kref_put释放引用,当计数降到0时自动调用release函数。这样可以避免自己手写原子操作时漏掉release回调。一个pid namespace被多少进程、多少挂载点引用,kref都记得清清楚楚。只要还有一个引用没释放,整个结构体就停留在内存里;容器内进程全退光,但/proc还挂在某个地方,这个struct pid_namespace就不会被销毁。

rcu_head配合RCU机制做延迟释放。为什么需要延迟?因为可能存在一些代码路径正在遍历pid namespace而没持有锁,这时如果直接把结构体释放掉,那些读者就会踩到已释放的内存。RCU的思路是:标记删除后,等一个宽限期,确保所有读者都离开临界区了,再真正执行free。这个字段就是让"等宽限期结束再释放"成为可能。

level和parent描述的是嵌套结构。pid namespace可以层层嵌套:你可以在一个容器里再创建一套pid namespace,形成父子关系。parent指向上层命名空间,level表示当前是第几层(最外层init namespace的level是0)。为了限制嵌套深度,内核定义了MAX_PID_NS_LEVEL,这个值通常是32。超过32层再创建新pid namespace会直接失败。很多人不知道这个限制,在做多层沙箱嵌套时踩到过。

2.2 分配器字段:idr、pid_allocated、pid_limit、last_pid

idr是本命名空间维护的PID分配表。内核需要根据一个整数PID快速找到对应的struct pid。idr内部是一棵基数树,查找、插入、删除都是对数级复杂度,比线性遍历快得多,也比普通的哈希表更适合这种整数ID稀疏分配的场景。

pid_allocated和pid_limit是配对的:前者记录这个namespace里已分配了多少个PID,后者是上限。pid_limit是从/proc/sys/kernel/pid_max继承来的,默认值通常是32768,大内存机器上可能是4194304。当pid_allocated逼近pid_limit时,分配器会扫描并回收已经僵死的PID编号。很多人把容器里fork失败以为只是内存不够,其实有时纯粹是pid_limit撞顶了。

我自己印象最深的是last_pid这个字段。它记录下一次分配PID时从哪个编号开始找。实现上,分配器不会每次从头扫描,而是从last_pid后面继续找空位,这样能利用局部性,让最近释放的编号优先被复用。但这也带来了PID回绕问题:当编号循环一圈回到某个刚被复用的值时,如果用户态还残存着旧进程的引用,就会发生"挂羊头卖狗肉"的混乱。

2.3 child_reaper:每个命名空间都要有自己的"孤儿院院长"

这是整个结构体里最具有"人文关怀"的字段。child_reaper指向本命名空间的init进程。它的职责是收养所有被父进程抛弃的孤儿进程,并且在子进程变成僵尸时负责回收。

一个Linux系统没有init进程是活不下去的——没人收尸,僵尸进程堆积,最终把PID表耗尽。所以每个pid namespace一创建,就必须有一个init进程,也就是PID 1。在容器里,你指定的entrypoint就是这里的init。child_reaper指向它。

一旦这个init进程退出,整个命名空间就失去了孤儿院院长。内核的做法很果断:通过zap_pid_ns_processes给该命名空间里的所有进程发送SIGKILL,把这个namespace清干净。你在容器里跑一个后台任务,主进程一退出,后台任务全部消失,底层逻辑就是这里。这也是很多人疑惑"为什么我的容器进程一退,其他子进程全没了"的真正原因。

2.4 user_ns、ucounts、hide_pid、pid_gid、proc_mnt:权限与proc绑定的字段

user_ns把pid namespace和user namespace绑定在一起。它限定了这个pid namespace归属哪个用户命名空间,权限检查时用的就是这一层关系。ucounts则负责记账,记录该命名空间内的进程数量,和RLIMIT_NPROC配合实现用户进程数限制。

pid_gid和hide_pid是给/proc文件系统用的。挂载proc时如果带上hidepid选项,普通进程就看不到其他进程的目录,这组字段就是为这个特性服务的。pid_gid指定哪个组有权限看全部信息,hide_pid保存挂载时传入的隐藏等级。

proc_mnt指向本命名空间对应的proc文件系统实例。每个pid namespace都有一套独立的/proc视图,这个实例的根就存在这里。注意,proc_mnt本身又是一个引用源——即使进程全退光了,只要还有地方保持着这个proc挂载的引用,kref就不归零,namespace就无法释放。这是我排查句柄泄漏时最关注的字段之一。

3. 用代码和系统调试验证结构体的行为

3.1 同一进程,两种PID

理解一个内核结构体,最好的办法是造一个场景看它的行为。最简单的验证是unshare:

# 宿主机上查看当前shell的PID,记下来 echo $$ # 进入新的pid namespace,并挂载新的proc unshare -pf --mount-proc bash # 此时在这个新shell里 echo $$

输出结果是1。原因是在新的pid namespace中,这个bash进程就是整个namespace的第一个进程,也就是init进程,自然拿到PID 1。但切换到另一个终端,在宿主机视角用ps -ef | grep bash找到这个进程,它的PID是另一个值,比如11452。

同一个task_struct,在宿主机的pid namespace里编号11452,在容器内的pid namespace里编号1。这不是"伪装",而是这个进程的struct pid里同时存了两个编号,每个编号对应一个namespace。这个过程就是struct pid_namespace里那条idr在新namespace中完成了第一次登记。

3.2 setns后的getpid不变:pid namespace的特殊规则

getpid缓存问题我专门做过测试。先写这么一段程序:

#define _GNU_SOURCE #include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <sched.h> int main(int argc, char **argv) { if (argc < 2) return 1; int fd = open(argv[1], O_RDONLY); if (fd < 0) return 1; if (setns(fd, 0) != 0) return 1; pid_t p = fork(); if (p == 0) printf("child in new ns: getpid()=%d\n", getpid()); else printf("parent after setns: getpid()=%d\n", getpid()); return 0; }

把这个程序编译后,先创建一个新pid namespace,再拿它的namespace文件路径作为参数运行。结果你会发现:父进程的getpid()没有任何变化,子进程拿到的却是一个和原编号完全不同的新PID。

原因是setns修改的是nsproxy->pid_ns_for_children,它并不会让调用者自己马上"搬"到新namespace。当前进程在自己的struct pid里的编号还是旧的,所以单独调用setns后打印getpid(),得到的还是旧值。只有下次fork时,子进程才会按照新的pid_ns_for_children去登记新编号。

这算是PID namespace和mount namespace一个很重要的区别。网上很多文章说setns进入pid namespace后getpid不变是glibc缓存的问题,那是把现象解释错了。真正的机制是:PID namespace对调用者本人就是"下次生效",glibc缓存只是加深了误解。如果你用/proc/self/stat直接解析PID,结果也一样不变,因为调用者真的没有换编号。

3.3 如何从用户空间推断level和parent

level和parent是内核内部字段,用户态没有直接接口能读。但可以通过/proc/self/ns/pid这个虚拟文件的inode号来间接判断两个进程是否处于同一个pid namespace:

#include <stdio.h> #include <sys/stat.h> int main(void) { struct stat st; if (stat("/proc/self/ns/pid", &st) == 0) printf("pid namespace inode: %lu\n", st.st_ino); return 0; }

同一namespace里的任何进程,这个inode号都相同;不同namespace则不同。当你在一层层嵌套的容器里运行这段程序,每次得到的inode号都不一样,这就是parent链逐层变化的外部表现。虽然看不到level的数值,但你可以通过"每进一层,inode变一次"来体会嵌套关系的存在。

有个很容易忽略的点:/proc本身也是跟着pid namespace走的。如果你在容器里没有重新挂载/proc,那读到的是挂载时刻那个namespace的视图,和当前进程实际所属的namespace可能已经不一致。这类"姿势错误"在内核调试现场几乎每周都能碰到。

3.4 压一下pid_limit:分配器是怎么扛不住的

试着在一个pid namespace里快速创建大量进程:

for i in $(seq 1 100000); do (sleep 30 &) done

一般跑到一半就会报Resource temporarily unavailable或者Cannot allocate memory。如果是前者,多半是触发了RLIMIT_NPROC;如果是后者,并且确认内存足够,就该怀疑是不是pid_allocated顶到pid_limit了。

在分配器逻辑里,到达上限后它会尝试扫描并回收stale pid,如果回收不过来,就返回-EAGAIN。用户态看到的就是fork失败。注意pid_limit是整个pid namespace共享的,不是每个进程私有。一个容器里PID被占满,其他进程哪怕只想fork一个线程都困难。这常被误报成"内存耗尽",实际上查/proc/sys/kernel/pid_max和当前分配量才是正路。

4. 理解结构体之后,那些经典故障就有了答案

4.1 进程列表和信号隔离的边界到底在哪

很多人以为容器里ps看不到宿主机进程,是因为容器运行时做了什么过滤。其实单纯的pid namespace并不能隐藏进程——关键在/proc的挂载方式。如果不带--mount-proc进入新pid namespace,你仍然看到的是旧的/proc,里面一样有宿主机进程。

同时,信号的边界是由PID编号决定的。内核在发送信号时,会解析目标进程在你当前namespace中的编号。你kill 1234,如果1234这个编号在你当前的pid namespace里指向的是你的容器进程,那就只影响它;如果这个编号没有对应进程,就报No such process。这才有了隔离的边界。

有个很实用的经验:判断一个进程能不能被某个容器操作,不是看它在不在容器里,而是看它的/proc/self/ns/pid和那个容器是不是同一个inode。用这个标准去排查误杀问题,比猜容器运行时行为可靠得多。

4.2 容器init进程退出后的级联清理

容器主进程退出,经常伴随容器内其他进程全部消失。这个现象用child_reaper来解释就很清晰。主进程就是当前pid namespace的init进程,它一旦退出,child_reaper就指向空。内核发现这个namespace失去了reaper,会立刻对所有剩余进程执行清理流程。

我排查过一个场景:容器里有个监控脚本,理论上主进程退出后脚本还能继续跑一会儿,但每次都是瞬间消失。最初以为是监控脚本被杀死,看了内核日志才发现是pid namespace的级联清理生效。这类问题如果你不理解child_reaper的角色,会花很长时间去找一个"根本不存在"的凶手。

4.3 僵尸进程堆积和namespace延迟释放

僵尸进程和parent exit有关,但很多人没意识到,僵尸进程没有完全消失之前,它持有的pid namespace引用也不会释放。换句话说,只要还有一个僵尸进程赖在某个已死容器里,那个容器的struct pid_namespace就会一直留在内存中,idr里的编号也一直占着。

更隐蔽的是proc_mnt引用。容器里的/proc被某个进程通过bind mount挂到了别的地方,或者有fd一直打开着,即使容器所有进程都退了,kref也降不到0。我在排查"容器停止后内存不释放"时,用lsof找出那些指向已删除/proc挂载点的fd,问题就水落石出了。这类泄漏查起来非常费劲,但一旦你知道struct pid_namespace有这些引用源,排查方向就清晰很多。

4.4 不要小看32层嵌套限制

MAX_PID_NS_LEVEL这个限制很少被测试到,但确实真实存在。它定义在include/linux/pid_namespace.h,值为32。当你尝试在第32层里再创建一个pid namespace,内核会直接拒绝。

实际应用中,一些沙箱产品会做多层嵌套:外层容器装沙箱,沙箱里再套一个隔离环境,一不注意就叠了五六层。虽然离32还有距离,但如果你做的是极深嵌套的运行时,就需要提前把这个限制考虑进去。还有一点,每多一层嵌套,进程编号的翻译链路就会多一跳,struct pid里的编号数组也会变长。所以不要无缘无故加深嵌套层级,对性能和复杂度都没好处。


最后分享一个我自己的调试习惯:遇到任何和PID相关的内核诡异问题,第一件事不是翻日志,而是先确定当前进程的struct pid_namespace是谁,/proc/self/ns/pid的inode号是多少,和怀疑对象是不是同一套。这个判断一旦做对,后面至少能省掉一半的排查时间。结构体里的字段一个个看过去,你会发现Linux的隔离设计并不是堆上去的奇技淫巧,而是每一个细节都在回答一个真实的工程问题。理解了它,你再回头看容器的一些"怪现象",基本都能猜到内核在背后干了什么。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进