这个系列走到第三篇,意味着你大概率已经亲手跑过 unshare、nsenter,也见识过 mount namespace 切换目录视图那套玩法了。part3-advanced 这一篇,我想集中聊那些在真实环境里才会暴露出来的问题:rootless 容器到底靠什么跑起来,权限是怎么一层层算出来的;容器明明已经退出了,系统里的 namespace 为什么还在、把资源白白占住;以及怎样把多个 namespace 组合成一套生产可用的隔离方案,而不是只会在终端里开着玩。这篇更适合两类读者:一类是想彻底搞明白容器运行时底层干了什么的人,另一类是已经碰过一鼻子灰、正在排查 namespace 泄漏或者 uid 映射问题的人。如果你对 namespace 的印象还停留在“隔离一下进程、挂载、网络”,那这篇正好帮你把最后一块拼图补上。
1. 为什么第三部分才叫 advanced:从"会用"到"会设计"的分界线
1.1 基础实验和进阶问题的真正差距
基础篇的实验是"打开一个 namespace 看看效果",但实际工程里没人会只开一个 namespace 玩。生产环境的容器,几乎都是同时 clone 出 mount、pid、net、uts、ipc、user 等一堆 namespace,再叠加 capability 计算和 cgroup 限制。这里面的难点不在于某个 namespace 怎么用,而在于它们组合在一起时,权限和责任是怎么划分的。
举个最常见的例子:unshare --mount 只是切换了挂载视图,但你想在新视图里对某个目录做 bind mount,内核还要检查你要修改的那个挂载点归谁所有。如果你的进程没有在那个挂载点所属的用户命名空间里拥有 CAP_SYS_ADMIN,就算你在宿主上开了 root,照样被拒。这种问题靠背命令是解决不了的,你必须理解每个 namespace 的"归属关系"和"能力边界"。
所以进阶内容看起来复杂,拆开了其实就是三条主线:权限维度,对应 user namespace 和 uid/gid 映射;生命周期维度,对应引用计数和资源回收;组合维度,对应多个 namespace 的嵌套与协作。这篇文章后面所有的内容,基本都绕着这三条线走。
1.2 进阶内容的三条主线
| 主线 | 核心接口/文件 | 典型场景 |
|---|---|---|
| 权限与映射 | user namespace、uid_map、gid_map、capabilities | rootless 容器、多租户隔离 |
| 生命周期 | nsfs、/proc/ /ns/*、引用计数、bind mount | 资源回收、namespace 泄漏排查 |
| 组合与嵌套 | clone/unshare 的标志位、setns、nsenter | 嵌套容器、沙盒、网络固定 |
很多人觉得 user namespace 只是给非 root 用户用的,这是误解。它本质上是一套把"特权"按层级隔离的机制。你在一个新建的 user namespace 里拥有全套 capability,但这份权力出不了这个 namespace 的边界。现代容器运行时之所以能实现 rootless 模式,靠的就是这套机制:宿主机上的普通用户,在容器内部却是 uid 0、拥有完整的 root 权限,但这份 root 权限对内有效、对外无效。
这也是我为什么把 user namespace 放在最前头讲。理解了它,你再看容器运行时的权限设计、看 setuid 辅助程序、看 subuid 分配,都会觉得顺理成章。
1.3 容易被忽略的关键观念:每个 namespace 都有"主人"
除了 user namespace 本身,其他所有 namespace(mount、pid、net、uts、ipc、cgroup、time)都有一个 owner user namespace。这个概念很多人没意识到。也就是说,你在某个 namespace 里拥有的能力,只对"归属于你这个 user namespace 的对象"生效。
举个具体的排查场景:你想进入一个由其他用户启动的容器网络 namespace,打算抓包或者配路由。即使你在宿主机上是 root,setns 进那个 net namespace 时也可能被拒,因为那个 net namespace 的 owner 是容器对应的 user namespace,而你的 capability 不在那条 owner 链路上。内核会沿着 owner 链路一层层查能力,而不是简单看你是不是 uid 0。
这个"归属"观念,是后面排查权限问题最关键的一把钥匙。遇到莫名其妙的 permission denied,第一反应应该先问一句:当前进程的 user namespace,和要操作的资源所属的 user namespace,是什么关系?
2. 核心机制拆解:user namespace 的权限模型与映射规则
2.1 uid_map 和 gid_map 就是一张 uid 翻译表
user namespace 里最核心的两个文件是 /proc/self/uid_map 和 /proc/self/gid_map。它们的格式很简单,每行三个数字:
ID-inside-ns ID-outside-ns length意思是:namespace 内部的 ID 区间 [ID-inside-ns, ID-inside-ns+length) 映射到父 namespace 的 [ID-outside-ns, ID-outside-ns+length)。比如:
0 1000 1就表示内部 uid 0 对应父 namespace 的 uid 1000。如果你把这段映射写进去,在 namespace 内创建的文件,在宿主机上 ls 出来属主就是你本人(uid 1000),而不是 65534 的 nobody。
这个翻译不仅在进程的 credentials 上生效,还体现在文件系统上。内核在访问 inode 时会根据挂载点所属的 user namespace 和当前进程所属的 user namespace,动态计算 uid/gid 的显示值。这也是为什么很多人在 rootless 容器里遇到"文件属主全都是 nobody"的现象——本质上是映射没配好,或者有些文件属于另一个 user namespace,内部进程的 uid 翻译过去之后落到了 overflowuid。
2.2 写映射的三个潜规则
第一,每个文件只能写一次。一旦某个进程开始使用映射,内核就不允许再次修改,否则整个权限体系都会乱套。你 open 那个文件再写,只会得到 EPERM。所以写之前想清楚,别想着后续还能追加。
第二,一次写入的多段范围不能重叠。比如你写两行,一行是 0 1000 1,另一行是 1 1001 1,没问题;但如果你写 0 100000 65536 和 1000 200000 10,两个范围内层有重叠,内核直接拒绝,报 Invalid argument。
第三,父侧的 ID 必须已经在父 user namespace 里映射过。如果你在一个嵌套的 user namespace 里写映射,而父 namespace 本身没有把你要映射的那些 uid 翻译到更上层,这一步也过不去。这意味着你没法凭空创建一段父命名空间里不存在的 uid 范围。
2.3 capability 在 namespace 边界上的魔术与局限
进程新建一个 user namespace 的瞬间,内核会给它发放这个 namespace 内的全部 capability,包括 CAP_SYS_ADMIN、CAP_NET_ADMIN、CAP_SETUID 这些。听起来很吓人,对吧?但实际上这些 cap 都被限制在 namespace 边界内。
怎么理解这个边界?我习惯用一个类比:新 user namespace 里的 root 像分公司总经理,权限在本公司内部说了算,到了总公司必须重新授权。对应到内核,就是资源本身也有归属。一个 ext4 块设备,它的 inode 归属于初始 user namespace;你在这个块设备裸奔,可挂载操作却需要 CAP_SYS_ADMIN 作用于该块设备所属的 user namespace。所以 rootless 容器里你能挂 tmpfs、能挂 proc,但直接 mount 宿主机的 ext4 块设备,几乎必然失败。
这个机制解决了传统容器的一个大痛点:非 root 用户也能创建完整可用的容器,而且攻击面大大缩小。因为即使容器内被攻破,攻击者拿到的也只是 namespace 内的全套 cap,对外层资源无能为力。代价是复杂度上来了——所有涉及权限的操作,都要先问一句"这个资源归属于哪个 user namespace"。
2.4 嵌套深度、继承关系和内核为何设上限
user namespace 可以无限嵌套吗?不行。内核限制了一个 user namespace 链的最大深度,通常是 32 层。超出之后,创建新的 user namespace 会直接失败。这个限制主要是为了防止递归操作导致内核栈溢出,属于安全设计。
实测中你可以连续跑 unshare --user 看什么时候被拒,也能感受到每层映射的累加效应。其实不只是 user namespace,pid namespace 也有类似的嵌套场景。你在最内层跑 ps 时,进程号是从 1 开始编号的,但 /proc/ /status 里的 NSpid 字段会列出从最外层到最内层的完整 PID 链。诊断嵌套容器时,这个字段是最好用的信息源,一眼就能看出某个进程处在哪一层。
3. 实操:三个能直接抄走的进阶实验
3.1 实验一:非特权用户也能做出的 mount + PID 沙盒
不要用 root,就直接用普通用户跑:
unshare --user --map-root-user bash id # uid=0(root) gid=0(root) cat /proc/self/uid_map # 0 1000 1此时你已经在一个全新的 user namespace 里,内部 uid 0 映射到外部 uid 1000。注意这里只映射了 1 个 uid,所以你在 namespace 里是 root,但只有 root 这一个 uid 可用。
接着在这个 user namespace 里创建隔离的进程和挂载视图:
unshare --pid --fork --mount-proc bash ps auxps 的输出里只剩当前 namespace 的进程了,宿主机上那一大堆进程都看不到。这三个参数缺一不可:
- --pid 申请新的 pid namespace。
- --fork 必须有。因为 unshare 本身不会把当前进程移进新 pid namespace,只有 fork 出来的子进程才会。你如果不加 --fork,bash 还是旧 pid namespace 的进程,换个 namespace 的意义就没有了。
- --mount-proc 也不能省。proc 文件系统每个 pid namespace 都要一份,你不在新 pid namespace 里挂载新的 proc,ps 读到的还是旧 namespace 的进程表。
刚才为什么不直接用 root 跑?因为普通用户直接 unshare --pid --fork --mount-proc 多半会被拒绝,挂载 proc 需要 CAP_SYS_ADMIN,而这个能力要从 user namespace 里拿。所以正确顺序是:先创造 user namespace 拿到全部 cap,再借助这份 cap 去建 pid 和 mount 隔离。这也是 rootless 容器运行时在底层做的第一步。
3.2 实验二:用 subuid 把单用户映射成整套 uid 空间
实验一里 uid_map 只有一行,内部只能用一个 root。真实容器里经常需要容器内部出现多个用户,那就需要把一整段 uid 映射进去。
先确认系统里给当前用户分配了子 uid 段,编辑 /etc/subuid 和 /etc/subgid:
# /etc/subuid yourusername:100000:65536 # /etc/subgid yourusername:100000:65536然后普通用户执行:
unshare --user bash echo $$另开一个终端,拿到刚才的 PID 后执行:
newuidmap <PID> 0 100000 65536 echo 'deny' > /proc/<PID>/setgroups newgidmap <PID> 0 100000 65536回到第一个终端看:
cat /proc/self/uid_map # 0 100000 65536 id # uid=0(root) gid=0(root)写 gid_map 之前必须先向 /proc/ /setgroups 写入 deny,这是内核的一道安全门。不这么做,非 root 用户写 gid_map 会直接得到 Operation not permitted。我在第一次踩这个坑的时候,以为是权限不够,绕了一大圈才发现只是没处理 setgroups。
为什么要用 newuidmap 而不是直接 echo 写文件?因为要把一整段父命名空间里不属于你的 uid 映射进子命名空间,普通用户直接写会被内核拒绝。newuidmap 是 setuid 的辅助程序,它读取 /etc/subuid 配置后,代表你在目标进程的 uid_map 里写入合法映射。主流容器运行时的 rootless 模式,底层用的就是这套东西。
映射生效后,你在 namespace 里可以随意使用 100000 到 165535 之间的任何 uid,创建出的文件在宿主机上也能正确对应到子 uid 段,不会出现一片 nobody 的怪像。
3.3 实验三:setns 的真实用法和"当前进程不迁移"的坑
实操中更大的需求是进入一个已经存在的 namespace,比如跑到别人容器的网络 namespace 里排查网络问题。这一步看起来简单,坑却不少。
先找到目标进程并查看它的 namespace:
pid=$(pgrep -f 'sleep 3600' | head -1) ls -l /proc/$pid/ns/输出类似:
lrwxrwxrwx 1 root root 0 ... mnt -> mnt:[4026531841] lrwxrwxrwx 1 root root 0 ... net -> net:[4026531993]然后用 nsenter 进入:
nsenter -t $pid -m -u -i -n -p bash这条命令会进入目标进程的 mount、uts、ipc、net、pid namespace。背后的系统调用是 setns,但 setns 对 pid namespace 有个特殊限制:它不会改变调用者自身的 pid namespace,只对之后 fork 出来的子进程生效。
所以 nsenter 的真实实现是:先 fork 一个子进程,在子进程里调用 setns,再替换成 bash。理解了这一点,你就知道为什么有时候你手动写了 setns 却感觉"进去了又好像没进去"——可能是你忘了 fork,或者直接在当前进程里调用了,导致 pid namespace 压根没切换。
另外,setns 进别人的 net namespace 也需要对应 owner user namespace 里的 CAP_SYS_ADMIN。这个权限检查会在最容易被忽略的地方给你一刀,尤其是目标容器是 rootless 启动的时候。所以排除这类问题,先确认一下你自己的 user namespace 和它之间有没有亲属关系。
3.4 用 inode 编号快速判断两个进程共享哪些 namespace
日常排查中还有个高频操作:判断两个进程到底是不是在同一个 namespace 里。方法极其简单,比任何监控工具都靠谱:
readlink /proc/<pid1>/ns/net readlink /proc/<pid2>/ns/net如果输出里的 inode 编号相同,比如都在 net:[4026531993],那这两个进程一定共享同一个网络 namespace。这个思路可以用来确认容器是否复用了宿主网络、两个 Pod 是否共享了网络栈、或者某个进程是不是意外逃逸到了容器外。
要查更多信息,可以用 lsns:
lsns -p <pid>它会列出该进程所属的全部 namespace 类型和对应 inode,比一个个 readlink 方便得多。我在排查集群网络问题时,通常先 lsns 快照,再用 readlink 做精确比较,两分钟就能锁定范围。
4. namespace 不是进程退了就回收:引用计数与泄漏排查
4.1 到底是谁在"抱住"namespace 不放
很多人以为 namespace 是个临时概念,进程没了它就应该被清理。实际上,namespace 的存活依赖引用计数,而这个引用的来源比你想象的多:进程本身算一个引用;打开指向 nsfs 文件的文件描述符算一个引用;把 /proc/ /ns/* 文件 bind mount 到某个路径也算一个引用;子 namespace 对父 namespace 同样持有引用。
我用一个生活化的比喻:namespace 像一间共享办公室,进程是里面办公的人,文件描述符是门禁卡,bind mount 是挂在门口的招牌。人走了、卡没还、招牌还挂着,这间办公室就不会被物业回收。
在这个模型下,最典型的泄漏场景就是:容器的主进程已经退出了,但某个后台 worker 还活着,或者变成了僵尸进程没人回收。僵尸进程也是进程表中的一条记录,它同样占用 pid namespace 的引用。这时候你会发现,容器明明退出了,但对应的 net namespace 或 pid namespace 始终在 lsns 里挂着,系统资源一天天被吃掉。
4.2 生产环境里最常见的三种泄漏现场
第一种是容器 init 进程设计不当。很多镜像直接用 bash 当 PID 1,bash 不会主动 wait 子进程,于是容器里的后台任务变成孤儿进程,如果没人收养就会变僵尸。这些僵尸卡住了 pid namespace 的回收,连带整个 namespace 链都释放不掉。生产级容器为什么推崇用专门的 init 程序?不只是为了处理信号,更是为了在退出前统一回收子进程,避免 namespace 泄漏。
第二种是代码里打开了 namespace 文件描述符没有关闭。比如你为了记录某个容器的网络 namespace,在监控进程里 open 了一下 /proc/ /ns/net,之后忘了 close。这段监控进程只要一直在跑,那个 net namespace 就永远有活着的引用。排查这种问题可以用:
lsof 2>/dev/null | grep nsfs看哪条进程还握着 namespace 的 fd。
第三种是把 namespace bind mount 到了固定路径,清理的时候只杀了进程、没 umount。比如:
touch /var/run/netns/demo mount --bind /proc/<pid>/ns/net /var/run/netns/demo这个操作本身没有错,网络插件经常这么干,为的是让 namespace 在容器退出后依然存在。但如果你清理时只删了进程,忘了 umount /var/run/netns/demo,那 namespace 和挂载点就会一直留在系统里,甚至导致你后来想删这个文件都删不掉,因为 mount point 还占着路径。对应地,检查命令是:
mount | grep nsfs4.3 利用 bind mount 让 namespace"保鲜"的标准姿势
namespace 引用不全是坏事,很多时候我们恰恰需要这种"保鲜"能力。最典型的场景是网络 namespace:你想预先创建一个网络 namespace,在里面配好网桥、路由、iptables 规则,等容器启动时直接 setns 进去,而不是每次从零建。
做法就是上面那三步:touch 一个路径、把 /proc/ /ns/net bind mount 过去、让所有进程退出。此后这个 namespace 依然存活,其他进程随时可以通过 nsenter --net=<路径> 进入。
记得用完一定要 umount,再删掉路径文件。顺序反了会留下一个无法删除的挂载目录,到时候还是要回头处理。我见过有人为了删一个被 mount 占用的目录把整个节点搞得鸡飞狗跳,其实只要先 umount 再 rm 就行了。
5. 进阶实战中的常见问题速查
5.1 问题速查表
| 现象 | 常见原因 | 对策 |
|---|---|---|
| 普通用户执行 unshare --user 报 Operation not permitted | 内核配置关闭了用户命名空间,或 seccomp 拦截 | 检查 /proc/sys/user/max_user_namespaces 是否为 0,查看平台 seccomp 配置 |
| 写 uid_map 报 Invalid argument | 映射范围重叠、父侧 uid 未映射、写入格式错误 | 用 newuidmap 一次写完整段,确认父侧的 uid 已存在 |
| 非 root 写 gid_map 报 Operation not permitted | 没有先设置 setgroups 为 deny | 先执行 echo 'deny' > /proc/self/setgroups |
| unshare --pid 后 ps 看到的还是宿主机进程 | 没加 --fork、没挂载新的 proc | 用 unshare --pid --fork --mount-proc |
| 容器内部看到 uid 全是 nobody | uid_map/gid_map 映射没成功或只映射了一部分 | 查看 /proc/ /uid_map 确认完整段,检查运行时映射配置 |
| setns 进入 pid namespace 后感觉没变化 | 忘了 setns 只影响后续子进程 | 用 nsenter 代替手动 setns,或者在调用后 fork |
| 容器退出后 lsns 里还有一堆 namespace | namespace 被 fd 或 bind mount 引用,或存在僵尸进程 | lsof 查 nsfs fd,mount 查 nsfs 挂载,查容器内 defunct 进程 |
5.2 一套我常用的排查顺序
处理 namespace 问题时,我习惯按照"快照、成员、引用、日志"四步走。
第一步快照,先跑 lsns 记录当前系统里所有的 namespace 状态;第二步看成员,针对可疑的 namespace,用 ls -l /proc/*/ns/<类型> 找出还有哪些进程属于它;第三步查引用,结合 lsof 和 mount 找 namespace 文件描述符和 nsfs 挂载;第四步看日志,dmesg 里经常有被拒绝时的内核提示,能帮你确认到底卡在哪一层权限检查。
这套顺序看着简单,但避免了我很多次在错误方向上浪费精力。尤其是"先看快照、再看成员"这一步,能快速区分是进程还没退出、还是进程早没了但被资源引用,这两种情况的处理完全不一样。
最后聊点我自己的体会
namespace 这套机制,我最近几年反复实操下来的感受是:把它当成权限问题和生命周期问题来理解,远比死记命令有效。你背会了 unshare 的参数,不代表你能解释清楚"为什么这个容器在 rootless 模式下可以挂载 proc,却不能操作宿主机网卡"。核心还是那两问:当前进程在哪个 user namespace 里?要操作的资源归属于哪个 user namespace?
另外给个非常实用的习惯:每次做 namespace 实验之前,先 lsns 拍一张快照,实验做完再 lsns 看一遍差异。这个习惯帮我抓住了好几次诡异问题,比如某次容器网络反复不通,最后发现是测试脚本里遗留了一个 bind mount 的 net namespace,抢占了资源。先有快照、再有对照,你才能知道系统里多出来的是什么东西、该不该存在,而不是对着一个已经出问题的状态瞎猜。
如果你已经被 uid_map、setgroups、nsfs 这些概念绕得有点晕,不用急。找一台闲置的虚拟机,用普通用户依次跑一遍文中的三个实验,每个实验配着 /proc 下的对应文件看输出,亲眼看一次映射前后的区别,比读十篇文章都管用。