
这期继续写 Linux 内核容器技术里最容易被翻来覆去问的 namespace。上一篇已经把七类 namespace 的轮廓拉了一遍如果只是把名字和 clone flag 背下来那离“排障”还差得远。Part2 我打算直接从几个“看着简单、用起来全是坑”的类型下手PID namespace 里那个 PID 1 为什么不按常理出牌mount namespace 的挂载传播为什么能让宿主机和容器互相“传染”network namespace 的 veth 到底是怎么把两个隔离的网络栈串起来的还有 user namespace 里那个绕来绕去的 uid_map。写这些是因为我自己在排查容器问题的时候这几个点每次都能绕进去一会儿。如果你是已经在用容器、想往内核底层钻一钻的开发者这篇应该能对上胃口。全程不搞空谈所有结论都配命令你可以直接复制跑。1. 先把 namespace 的家底盘一遍Part2 要啃哪几块硬骨头1.1 第一篇到第二篇的定位差异上一篇是“面”这一篇是“点”。面讲的是七类 namespace 分别隔离什么资源、什么时候用哪个 flag点到为止。Part2 则默认你已经知道下面的对照关系namespace隔离对象clone flagMount挂载点、文件系统视图CLONE_NEWNSUTShostname、NIS domain nameCLONE_NEWUTSIPCSystem V IPC、POSIX 消息队列CLONE_NEWIPCPID进程号空间、PID 1 语义CLONE_NEWPIDNetwork网络设备、协议栈、路由、iptablesCLONE_NEWNETUserUID/GID 映射、capabilitiesCLONE_NEWUSERCgroupcgroup 根目录视图CLONE_NEWCGROUP表里最容易被忽略的是 mount namespace 的 flag 为什么叫 CLONE_NEWNS 而不是 CLONE_NEWMOUNT。这是历史遗留内核最早引入挂载隔离时叫“new namespace”后来发现要隔离的东西越来越多但 flag 名字已经改不了了。读老代码、看 strace 输出时如果遇到 CLONE_NEWNS别觉得奇怪。Part2 如果想真正吃透不能只是看功能表要看这些 namespace 对象在内核里到底怎么跟进程绑定、生命周期怎么管理。所以这篇不会急着讲“容器怎么用”而是先把机制本身掰开。1.2 namespace 对象是怎么挂在进程上的nsproxy 与 proc/self/ns内核里每个进程的 task_struct 上有一个 nsproxy 指针它指向一组 namespace 对象的集合。这组对象包括 mnt_ns、uts_ns、ipc_ns、pid_ns_for_children、net_ns、cgroup_ns各自对应一个内存对象。调用 clone 或 unshare 时如果你传了 CLONE_NEWNET、CLONE_NEWPID 之类标记内核就会创建对应的新 namespace 对象然后用新对象替换 nsproxy 里对应位置的指针。如果不传标记子进程直接共享父进程的 namespace 对象。这里有个容易让人懵的点CLONE_NEWPID 改的不是“当前进程”的 PID namespace而是 nsproxy 里的 pid_ns_for_children也就是将来 fork 出的子进程用什么 PID namespace。正因如此用 unshare 命令创建 PID namespace 时必须再带一个 --fork 参数否则当前 shell 不会进入新的 PID namespace只有它 fork 出来的子进程才会。这个坑后面会详细说。每个 namespace 对象都能通过 /proc 下的 magic symlink 拿到一个文件描述符。比如打开 /proc/self/ns/net返回的 fd 就指向当前进程的网络 namespace。这个机制非常重要namespace 不一定非要有进程在里面才存活只要有一个打开的 fd内核里那个对象就会一直活着。后面说生命周期的时候还会用这个特性来做“续命”。2. PID namespace容器里“第一个进程”的错觉2.1 局部 PID 与全局 PID 是怎么映射的在一个 PID namespace 内部所有进程的进程号都从 1 开始重新编。这个“局部 PID”和宿主机上的“全局 PID”是两套编号体系内核通过进程的 upid 结构在不同层级的 PID namespace 里维护不同的编号。举个例子你在容器里看到 PID 1 的进程从宿主机上看它可能是 23456。反过来宿主机上有一堆 sshd、crond 进程在容器内用 ps 完全看不到。原因很简单容器内的 proc 文件系统挂载在容器自己的 PID namespace 视图下而宿主机进程根本不在这个 namespace 里自然就不会出现在这个视图里。这个机制的底层逻辑是内核在每次 fork 时给新进程在“它所属的每个祖先 PID namespace”里都分配一个编号。所以一个进程在不同层级会有不同编号但只有最内层那个编号对进程自己可见。调试时最容易犯的错误是在容器外看到某个进程 PID直接进容器后按这个 PID 去找找不到。不是进程没了是两套编号体系之间没有直接换算关系必须通过进程所属的 PID namespace 来理解。2.2 用 unshare 手工做一个独立 PID 视图先看最典型的做法sudo unshare --pid --fork --mount-proc bash进入之后你就在一个新的 PID namespace 里。此时在 bash 里执行echo $$ ps aux你会发现自己的 shell 是 PID 1ps 看到的进程列表非常干净只有当前 namespace 里的进程。这里有两个细节必须说清楚。第一为什么必须加 --fork。unshare 系统调用创建了新的 PID namespace 后当前调用进程自己是不会自动变成新 namespace 成员的它只是把“以后子进程要用的 namespace”换掉了。如果不 forkbash 还是老 namespace 的成员那你进入的其实是个空壳。加了 --fork 之后unshare 会 fork 一个子进程这个子进程才是新 namespace 里的第一个进程也就是新 PID namespace 的 init。第二为什么还要 --mount-proc。进程的 PID namespace 换了但 /proc 还是原来那个挂载ps 读取 /proc 时依然会看到宿主机的完整进程表。--mount-proc 会自动为新 namespace 挂一份新的 procfs让 ps 拿到正确的进程视图。如果忘了加你会看到自己的 shell 在容器里是 PID 1但 ps 里却有一堆不属于这个 namespace 的宿主进程第一次看到会非常困惑。2.3 容器里那个“杀不死的 PID 1”PID namespace 里的 init 进程也就是 PID 1跟普通进程的差别很大。内核给它加了很多特殊语义其中一个直接体现在信号处理上来自同一个 PID namespace 内部的普通信号如果 PID 1 没有显式注册 handler内核会直接忽略掉默认动作是终止的信号在它身上并不按常规执行。这就是为什么很多容器里执行 kill 1 没反应因为业务进程直接当 PID 1 时往往根本没写 SIGTERM 的 handler。但注意SIGKILL 和 SIGSTOP 是特例。这两种信号本来就无法被捕获或忽略从 namespace 外部比如宿主机 root发送时仍然可以把容器里的 PID 1 干掉。所以不存在“绝对杀不死”的进程只是在 namespace 内部的信号投递规则跟普通进程不一样。另一个容易被忽略的职责是孤儿进程回收。普通进程的父进程退出后孤儿进程会被过继给最近的 subreaper如果没有 subreaper就过继给 PID namespace 的 init。如果 PID 1 不负责 wait这些变成僵尸的子进程就一直挂在系统里。这也是很多生产容器喜欢用专门 init 程序当 PID 1 的原因它负责信号转发和僵尸回收业务进程反而可以专注干活。实操里的一个心得是别把 pid namespace 当成进程数限制器。一个 PID namespace 里能创建的进程号数量受 pid_max 限制但一般我们担心的“容器里进程太多拖垮宿主机”要靠 cgroup 的 pids 控制器去限制两码事。3. Mount namespace文件系统视图的隔离与“挂载传染”3.1 挂载传播为什么有人说 mount namespace 最微妙mount namespace 隔离的是整个挂载点视图听起来简单但内核在挂载点之间引入了一套“传播关系”常把新手搞得头疼。传播关系分几种shared、slave、private、unbindable。shared同组挂载点之间一个挂载或卸载事件会互相传播。slave能接收 master 的传播事件但自己产生的事件不反向传播。private完全独立不传播任何事件。unbindable不仅不传播连 bind mount 都不允许。你可能会问为什么容器里的 mount namespace 会跟宿主机的挂载互相“传染”根源在于现代发行版的初始化进程会把根挂载点设成 shared。这样一来如果创建新的 mount namespace 后不调整传播类型新 namespace 里的挂载点也继承 shared 关系你在容器内 mount 一块 tmpfs宿主机上可能同步看到宿主机新插的 U 盘自动挂载容器里也可能会冒出来。这个问题的正确解法是在进入新的 mount namespace 之后、执行真正的业务挂载之前先把传播关系断开unshare --mount --fork bash mount --make-rprivate /--make-rprivate 会递归地把当前 namespace 里的所有挂载点都改成 private彻底切断双向传染。容器运行时在启动容器时一般也会做类似处理只是它们更喜欢设成 slave 而不是 private。slave 的好处是宿主机上本来就有的文件系统挂载在容器里仍然可见但容器内新 mount 不会反向影响宿主。这符合大部分业务需求机器上的 /etc/xxx 要能看到但容器内的临时挂载不能漏回宿主机。3.2 用 pivot_root 做一次真正的“换根”chroot 只是把当前进程的根目录换到一个路径但进程如果有能力拥有旧根目录的文件描述符理论上还有越狱的可能。pivot_root 要彻底得多它会把当前 mount namespace 的根挂载点切换成新 rootfs同时把原来的根挂载点挂到新根下面的一个目录里之后还能卸载掉。真正把进程锁在隔离的文件系统视图里。手动演示一版核心流程# 先准备好一个最小 rootfs至少要有静态编译的 shell 和基本命令 mkdir -p /tmp/newroot/bin /tmp/newroot/proc /tmp/newroot/oldroot # 关键把 newroot 变成一个挂载点 mount --bind /tmp/newroot /tmp/newroot cd /tmp/newroot pivot_root . oldroot exec chroot . /bin/sh mount -t proc proc /proc umount /oldroot这个流程里有几个硬规矩。第一pivot_root 的 new_root 参数必须是一个挂载点。如果你直接拿一个普通目录当 new_root内核会返回 EINVAL。所以上面先 mount --bind 把目录绑定到自己身上让它成为挂载点。第二put_old 必须位于 new_root 之下这样内核才能把旧根移动进去。第三pivot_root 之后要尽快把旧根卸载掉否则只要旧根路径还在等于给进程留了一个回程通道。这里也顺带解释一个常见疑问为什么容器运行时不直接用 chroot 而是用 pivot_root。除了安全考虑之外pivot_root 能干净地把根目录从宿主机的挂载树里剥离开让容器内看到的文件系统拓扑跟宿主机完全无关。chroot 只是改变了路径解析起点挂载树层面的牵连还在。3.3 实操心法如何防止挂载泄漏我见过不少手工做容器实验的人在 unshare 出 mount namespace 后直接 mount 了一个 tmpfs然后发现宿主机也多了一个挂载点。一开始还以为是命令写错了其实是传播关系没断开。排查方法很简单看 /proc/self/mountinfo 里挂载点的可选字段有没有 shared 标记cat /proc/self/mountinfo | head -1如果最右边出现 shared:123 之类的内容说明当前挂载点处于 shared peer group 中。想确认整个树的状态可以findmnt -o TARGET,PROPAGATION -R /一旦发现有很多 shared就不要犹豫在创建新 mount namespace 后立刻执行 mount --make-rprivate /把所有挂载点拍扁成 private。顺序很重要必须是先改传播类型再挂载新文件系统。如果先 mount 再改已经进入 peer group 的传播事件可能已经把挂载传出去了。另外一个容易忽略的细节是bind mount 也会参与传播。所以有些时候不是“临时挂载”漏出去了而是 bind 宿主目录时把事件带出去了。问题不在 bind 本身而在 bind 之前挂载点还挂在 shared peer group 里。把传播属性理清楚mount namespace 的很多“灵异事件”都能解释通。4. Network namespace容器网络栈独立背后的机制4.1 一个 network namespace 里到底装着什么网络 namespace 隔离的不是“一块网卡”这么简单它拥有完整的网络栈独立的网络设备列表、IPv4/IPv6 协议栈、路由表、netfilter 规则集、还有已经创建的 socket。你可以把它理解成一台没有显示器的小型“虚拟机”只不过它跟宿主共享同一个内核只是网络状态完全隔离。刚创建一个新的网络 namespace 时里面只有一个 loopback 设备而且这个 lo 默认还是 down 的。所以很多人在容器里 ping localhost 不通问题就在这lo 都没 up。手动操作一下ip netns add ns1 ip netns exec ns1 ip link set lo up另外要记住网络 namespace 并不负责 DNS 配置。/etc/resolv.conf 是文件系统里的东西归 mount namespace 管。很多容器里的 DNS 问题排查到最后发现是挂载视图和网络视图不一致而不是网络不通。还有一个容易踩的点一个 socket 属于创建它时的那个网络 namespace。也就是说已经打开的 socket 不能通过 setns 从 A namespace “搬”到 B namespace只能销毁后重建。这一点在排查长连接问题时很重要你以为切了 namespace 就能继承已有 socket实际上是做不到的。4.2 亲手搓一对 veth把两个 namespace 连起来veth 是成对出现的虚拟网卡像一根交叉线插在两个 namespace 之间。数据从一端发出会以内核收包的形式出现在另一端。很多人把它理解成“隧道”其实更准确地说它就是把一对网卡的内核路径直接连起来。先看两个网络 namespace 直连的场景ip netns add ns1 ip netns add ns2 ip link add veth1 type veth peer name veth2 ip link set veth1 netns ns1 ip link set veth2 netns ns2 ip netns exec ns1 ip addr add 192.168.15.1/24 dev veth1 ip netns exec ns1 ip link set veth1 up ip netns exec ns2 ip addr add 192.168.15.2/24 dev veth2 ip netns exec ns2 ip link set veth2 up ip netns exec ns1 ping -c3 192.168.15.2这套命令跑下来两个本来看不见对方的 namespace 就能互相 ping 通。但要注意veth 设备创建后默认还是挂在当前 namespace 里的必须用 ip link set ... netns 指定归属。名字也容易混veth1 和 veth2 两个名字只是端点的代号不是自动表明某个端点属于哪个 namespace。真正的容器网络通常不是点对点而是所有容器接到同一个 Linux bridge 上。大致流程ip link add br0 type bridge ip link set br0 up ip link add veth-ns1 type veth peer name veth-ns1-peer ip link add veth-ns2 type veth peer name veth-ns2-peer ip link set veth-ns1-peer netns ns1 ip link set veth-ns2-peer netns ns2 ip link set veth-ns1 master br0 up ip link set veth-ns2 master br0 up ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth-ns1-peer ip netns exec ns1 ip link set veth-ns1-peer up ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth-ns2-peer ip netns exec ns2 ip link set veth-ns2-peer up原理上每个 namespace 的“对端网卡”插进了宿主的 bridgebridge 像一个二层交换机负责在 namespace 之间转发帧。加了 bridge 之后同一网段内多个 namespace 就能互相访问这也是很多容器网络插件最基层的模型。4.3 网络 namespace 清障三板斧定位不通网络不通时第一件事不是怀疑内核而是先看链路状态ip netns exec ns1 ip link show确认端口 state 是不是 UPMAC 地址有没有到手有没有 HUP 之类的异常。第二板斧是查 ARP 和路由。ping 不通但链路正常时往往是对端 MAC 解析不出来ip netns exec ns1 ip neigh show ip netns exec ns1 ip route如果 ARP 表是空的优先检查对端接口有没有配置 IP、有没有 up。veth 的收包路径走的是完整协议栈对端接口 down 的话包根本进不了协议栈。第三板斧是检查宿主机的 iptables 规则和 rp_filter。特别是加了 bridge 之后宿主机的 FORWARD 链可能默认 DROP导致跨 namespace 的包在二层转发后被三层拦掉。有些发行版还会开反向路径过滤源地址不具备对称路由的包会被直接丢。排查命令sysctl net.ipv4.conf.all.rp_filter iptables -L FORWARD -n -v另外有个细节veth 的 MTU 默认 1500如果在隧道或 overlay 场景下报文被再封装容易出现 ping 通但大包不通的情况。可以调小 MTU 验证也可以直接用ip link set ... mtu 1400规避。5. User namespace 和剩下那几位权限隔离是门玄学5.1 uid_map 和 gid_map容器里 root 为什么不是真 rootuser namespace 的核心是 UID/GID 映射。每个进程的 /proc/PID/uid_map 文件里存着“namespace 内 UID”到“父 namespace UID”的映射关系格式是三列起始 ID、起始长度、范围长度。例如一行0 1000 1表示这个 user namespace 里的 UID 0 对应父 namespace 里的 UID 1000。最常见的验证姿势unshare -U -r bash id -u cat /proc/self/uid_map如果当前登录用户是 UID 1000上述命令跑完你会发现自己在新 namespace 里是 UID 0也就是 root但 uid_map 显示0 1000 1。也就是说你这个“root”只是映射到宿主机普通用户的 root在宿主机上没有任何特权。这就是 rootless 容器的核心基础在隔离的权限空间里当 root而不是直接获得宿主机的 root。gid_map 比 uid_map 多一个坑写 gid_map 之前必须先往 /proc/PID/setgroups 里写deny否则内核会拒绝修改 gid 映射。原因很直接setgroups 系统调用允许普通进程把附加组切换到任意组如果不先禁止gid_map 的权限检查就没有意义。映射还有一个不可逆性一旦写入 uid_map就不能再改了。所以那些需要给容器分配多段 UID 映射的场景必须在子进程启动前用专门的 setuid helper 一次性写好。5.2 capabilities 在 user namespace 里的“加减法”user namespace 之所以能实现“非特权用户变成 root 但不危害宿主机”关键在于 capabilities 的作用范围被限定在 namespace 树里。创建新的 user namespace 时当前进程会获得这个新 namespace 中完整的 capabilities 集合包括 CAP_SYS_ADMIN、CAP_NET_ADMIN 这些敏感权限。但它只对当前 user namespace 及后续子 namespace 中的对象有效一旦要操作系统外层资源就原形毕露。举个实际例子普通用户直接mount -t tmpfs tmpfs /mnt大概率被拒绝但在新的 user namespace 叠加 mount namespace 之后就可以unshare -Urn bash mount -t tmpfs tmpfs /mnt mount | grep mnt-U创建 user namespace-r把当前用户映射成 0-n创建新的 network namespace。进入之后你在这个 user namespace 里拥有挂载权限tmpfs 又是白名单里的可挂载文件系统所以能成功。但如果你试图把宿主机的物理磁盘或某些敏感文件系统挂进来内核会拒绝因为权限作用域不够。理解了这个“权限作用域”就能解释很多安全问题容器里的 root 能不能破坏宿主机取决于它有没有在对应的父 user namespace 里的 capabilities。user namespace 的嵌套层数也被内核限制上限是 32 层。写递归脚本时如果不小心很容易踩到 EUSERS 类错误。5.3 UTS 和 IPC namespace最常被忽略的两个小透明UTS namespace 隔离的是 hostname 和 NIS domain name。它是所有 namespace 里最轻量的一类甚至很多人觉得它“没用”。但容器没有自己的 hostname日志、监控、网络标识全都会混在一起。手动体验一下unshare --uts bash hostname container-demo hostname执行后新 namespace 里看到的是 container-demo宿主机的 hostname 不会变。这里有个细节某些应用除了读 hostname还会读 /etc/hosts 和 /etc/hostname而这些是文件系统内容需要 mount namespace 配合才能真正做到“内外各叫各的”。所以很多容器里改了 hostname 但程序还是显示旧值不是 UTS 没隔离是文件视图没跟上。IPC namespace 隔离的是 System V IPC 对象也就是消息队列、信号量、共享内存也包含 POSIX 消息队列。对单机业务影响不大但如果你的应用重度依赖共享内存做进程间通信跨 namespace 的进程就访问不到同一块共享内存。这个机制本来是隔离但有时候会成为排障盲区应用从宿主机移进容器后日志里全是“共享内存不存在”第一反应往往是权限问题其实是 IPC namespace 换了IPC 对象的 namespace 归属对不上。cgroup namespace 则是 Linux 4.6 之后加的一类 namespace隔离的是 cgroup 根目录视图。它让容器内看到的是相对路径不会把宿主机整套 cgroup 树暴露给容器。这个特性在 cgroup v2 下尤其重要但由于涉及 cgroup 本身的面太广这篇文章不展开等后面单独聊 cgroup 控制器时再细说。6. namespace 的生命周期、调试实战与容器运行时串联6.1 让 namespace“多活一秒”的 fd 魔法namespace 对象的生命周期不跟随“创建它的进程”走而是跟着引用计数走。只要还有进程在使用某个 namespace或者还有打开的文件描述符指向它它就不会被销毁。最常见的续命手法是把 /proc/PID/ns/ 下的符号链接 bind mount 到固定路径mkdir -p /tmp/ns touch /tmp/ns/net mount --bind /proc/$$/ns/net /tmp/ns/net执行完后哪怕之前那个进程退出/tmp/ns/net 这个 bind mount 仍然会让网络 namespace 存活。这个特性非常有用尤其是手工调试后不想让 namespace 随进程消失时可以先续命再做后续分析。反过来如果 namespace 里没有进程、也没有 fd 引用它内核会直接回收。很多“网络 namespace 消失”的现象本质就是引用计数清零了。看到这里你应该能明白为什么 ip netns add 创建的 namespace 能一直存在ip 命令为它在 /var/run/netns/ 下放了一个绑定文件相当于一直持有引用。6.2 nsenter 和 lsns调试 namespace 的实用三板斧光会创建 namespace 还不够生产环境里更多是“进入一个已经存在的 namespace”去排查问题。util-linux 里的 lsns 和 nsenter 是干这个事的利器。lsns 可以列出当前系统里的 namespacelsns -t net -o NS,PROC,PATH,COMMAND这样能看到每个网络 namespace 的编号、里面的代表进程、proc 路径和对应命令快速定位容器对应的 namespace 从属。如果要进入某个进程的网络 namespace用 nsenter 指定目标进程 ID只切网络部分nsenter -t 1234 -n bash ip addr这里的-n只切 network namespace。默认情况下 nsenter 如果不加参数会把目标进程的所有 namespace 都切过去但实际排障时我们往往只希望切某一个维度。比如只看网络配置就不要切 mount namespace否则你连宿主机的 /etc 都看不到了。需要注意进入 PID namespace 的场景更微妙因为 PID namespace 对“调用进程是否能直接切换”有额外限制。日常使用中如果只是要进入容器的网络、挂载或 UTS namespacensenter 足够顺手。6.3 容器运行时是怎么把 namespace 全部串起来的理解了单个 namespace 的创建和切换再把容器运行时启动一个容器的流程拼起来就顺理成章了。容器进程在创建时运行时会在 clone 阶段传入一串 CLONE_NEWNS、CLONE_NEWPID、CLONE_NEWNET 等标志。新进程由此出生在一套全新的 namespace 集合里。紧接着运行时会在这个新进程里完成 rootfs 准备通常是挂载 rootfs、调整传播类型、pivot_root 换根最后 exec 用户指定的程序。网络 namespace 创建出来后可能是空的需要再配置接口和路由。这一整套动作的顺序是有讲究的先隔离再配置最后启动业务。如果只是给一个正在运行的容器追加“执行命令”比如进入容器里跑 ps底层就用到 setns 系统调用。运行时拿到目标容器进程的 namespace fd然后用 setns 把当前辅助进程切换进那组 namespace再 fork 出最终执行命令的子进程。不过 PID namespace 的 setns 相比其它类型要多绕一步因为它涉及“当前进程知道自己是否属于某个 PID namespace”的内核语义所以常用做法是 fork 一次让子进程成为目标空间里的新成员。从这个角度看容器不过是一组 namespace 对象加一套文件系统视图的集合。进程可以自由地“属于多个 namespace”但每类资源的隔离边界完全由内核对象决定。排障时如果先把“这个进程到底在哪个 net namespace、哪个 pid namespace”理清楚问题基本就解决一半了。最后分享一个小技巧我自己做实验时经常先手动 bind mount 一个 namespace fd防止 namespace 被回收。比如想保留某个容器的网络 namespace就先把 /proc/PID/ns/net 绑到 /tmp/ns/net之后不管容器怎么重启我都能用 nsenter 进去看残留的 socket、路由和 iptables 规则。调试完再 umount干净利落。踩了几次“namespace 消失”的坑之后你就会明白一个 fd 的价值很多时候比一堆内存分析工具都管用。