这个系列走到第三篇意味着你大概率已经亲手跑过 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、capabilitiesrootless 容器、多租户隔离生命周期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 本身其他所有 namespacemount、pid、net、uts、ipc、cgroup、time都有一个 owner user namespace。这个概念很多人没意识到。也就是说你在某个 namespace 里拥有的能力只对归属于你这个 user namespace 的对象生效。举个具体的排查场景你想进入一个由其他用户启动的容器网络 namespace打算抓包或者配路由。即使你在宿主机上是 rootsetns 进那个 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-nslength) 映射到父 namespace 的 [ID-outside-ns, ID-outside-nslength)。比如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 namespacepid namespace 也有类似的嵌套场景。你在最内层跑 ps 时进程号是从 1 开始编号的但 /proc/ /status 里的 NSpid 字段会列出从最外层到最内层的完整 PID 链。诊断嵌套容器时这个字段是最好用的信息源一眼就能看出某个进程处在哪一层。3. 实操三个能直接抄走的进阶实验3.1 实验一非特权用户也能做出的 mount PID 沙盒不要用 root就直接用普通用户跑unshare --user --map-root-user bash id # uid0(root) gid0(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 出来的子进程才会。你如果不加 --forkbash 还是旧 pid namespace 的进程换个 namespace 的意义就没有了。--mount-proc 也不能省。proc 文件系统每个 pid namespace 都要一份你不在新 pid namespace 里挂载新的 procps 读到的还是旧 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 # uid0(root) gid0(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 里排查网络问题。这一步看起来简单坑却不少。先找到目标进程并查看它的 namespacepid$(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 是否共享了网络栈、或者某个进程是不是意外逃逸到了容器外。要查更多信息可以用 lsnslsns -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 1bash 不会主动 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/setgroupsunshare --pid 后 ps 看到的还是宿主机进程没加 --fork、没挂载新的 proc用 unshare --pid --fork --mount-proc容器内部看到 uid 全是 nobodyuid_map/gid_map 映射没成功或只映射了一部分查看 /proc/ /uid_map 确认完整段检查运行时映射配置setns 进入 pid namespace 后感觉没变化忘了 setns 只影响后续子进程用 nsenter 代替手动 setns或者在调用后 fork容器退出后 lsns 里还有一堆 namespacenamespace 被 fd 或 bind mount 引用或存在僵尸进程lsof 查 nsfs fdmount 查 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 下的对应文件看输出亲眼看一次映射前后的区别比读十篇文章都管用。