全家福:八兄弟里被略过的四位
第 8 篇讲容器底座时介绍了 mount、UTS、network 与 PID 命名空间的基本盘,user namespace 深水区那篇又单独拆过特权映射;这一篇补全家福的剩余部分:PID namespace 的完整语义、IPC namespace、time namespace 与 cgroup namespace,最后用 /proc/self/ns 把「一进程多命名空间」的观察视角收拢。Linux 目前共八类命名空间:mnt、pid、net、ipc、uts、user、cgroup、time,容器只是它们的组合应用——理解命名空间比理解容器更本质,因为前者是内核原语,后者是工程组合。
命名空间的本质一句话:让同一份内核数据结构在不同「视图」里呈现不同的子集。实现上每类命名空间是一个可引用计数、可比较的对象,进程描述符通过 nsproxy 结构间接持有八张门票;clone 时带对应标志即可让子进程落进新视图,unshare 让当前进程中途换视图,setns 则允许进程进入另一个进程的视图——三个系统调用构成全部操作面。
把八张门票的分工先列成速查表放进脑子:mnt 管挂载视图,uts 管主机名,net 管网络栈,pid 管进程号码空间,ipc 管老三样通信原语,user 管身份映射,cgroup 管限制的显示根,time 管单调钟。速查表的意义在排障定位:症状涉及「看不见」先查视图类,涉及「身份与特权」先查 user,涉及「限额没生效」先查 cgroup——第一轮定位走对门,后面全提速。
还有一个工程直觉值得先建立:命名空间只管「看见什么」,不管「能用多少」。看得见但用不了的组合(可见的内存、不可用的配额)由 cgroup 控制器兜底,两者正交。排查容器类问题时先把「视图问题」与「配额问题」分开,能省掉一半的弯路。
还有一个安全视角的边界要先立住:命名空间隔离的是视图,不是权限。进入新命名空间的进程仍然是同一个 uid、同一套能力判断,没有 user namespace 配合的命名空间切换只是「换窗户」,不是「换门锁」。容器安全的讨论里常有人把两者混为一谈,本篇立个界桩:视图问题查 ns,权限问题查 user namespace 与能力,限额问题查 cgroup——三查各归各位。
PID namespace:init=1 与可见性规则
CLONE_NEWPID 创建 PID 命名空间,新空间里第一个创建的进程得到本地 pid 1,同时它在父空间里仍有一个真实 pid——同一个进程在不同层级视角下号码不同,这是 PID ns 最烧脑也最精巧的性质:内核给每个进程维护「每层命名空间各一个号码」,procfs 里你看到的取决于查询者所在的层。本地 1 号承担 init 职责:收养孤儿、收割僵尸;若它退出,内核向该空间所有进程发 SIGKILL,整个命名空间团灭——容器主进程退出即容器终止,就是这个机制在工作。
init 职责还有一层现实含义:容器内 1 号进程通常是应用本身,而多数应用没有实现僵尸收割——子进程变成僵尸堆积,是容器环境的高频故障。成熟的方案是让专门的 init 替身(tini 一类)当 1 号,应用当它的子进程;内核也提供了一个取巧选项,让 1 号进程开启一个标志后由内核代为收割孤儿。「容器里要配 init」这条工程惯例的技术根源就在这里,不是玄学,是 PID ns 的 init 语义。
可见性规则单向:子空间看不到父空间的进程,父空间看得到子空间全部。容器里的 ps 只列出容器内进程,是因为 procfs 反映的是「挂载者」所在命名空间——容器内必须重新挂 /proc 才能看到新视角,unshare --pid --fork --mount-proc 一条龙命令就是把这些步骤串好的糖衣。由此还有一套信号规则:父空间的进程可以杀子空间任何进程,反向不行;嵌套层级有上限(内核允许最深 32 层),实践上两三层就够用了。运维提醒:防容器内 fork 炸弹不要指望 PID ns(它不限进程数),pids cgroup 控制器才是限额工具——PID ns 管可见性,不管数量,这句话能挡掉一类真实的误配。
排查视角上,PID ns 还带来一组「号码对不上」的麻烦:宿主上看到容器进程是 4321,容器内 ps 里它是 7;kill 用哪个号码取决于你在哪一层执行。经验法则是:容器内操作用容器内号码,宿主操作用宿主号码,跨层诊断时用 /proc 下核对真实身份(同一进程在宿主 /proc 的目录与容器内 ps 的行是一一对应的),别拿号码猜身份。
亲手跑一遍是最快的理解方式:
unshare --pid --fork --mount-proc bash
echo $$ # 看到自己是 1 号
ps -ef # 只能看到本命名空间的进程
第一条命令同时做三件事:新建 PID 命名空间、fork 出第一个进程、在新视图里重挂 procfs——缺了重挂这步,ps 读到的还是旧视图。再记一个嵌套语义的细节:当前进程不能直接「平移」进另一个 PID 命名空间(号码语义会乱),规范做法是切换后立刻 fork,让子进程落地新空间;进入多层嵌套也要按从外到内的顺序逐层来,顺序错了会得到语义混乱的半吊子视图。
IPC namespace:老三样与新两样的边界
IPC namespace 隔离的是 System V IPC 三件套:消息队列(msgget)、信号量集(semget)、共享内存段(shmget)。不同命名空间里同样的 key 对应不同的对象,ipcs 看到的列表互不可见,ipcrm 也只作用于本空间——老式中间件、System V 共享内存的数据库迁移进容器时靠它隔离,这是三类「命名空间前时代」的进程间通信原语的最后一块拼图。
一个高频误解要划清:POSIX 接口不归 IPC namespace 管。shm_open 落在 /dev/shm(tmpfs 挂载),mq_open 的 POSIX 消息队列落在 mqueue 伪文件系统——两者都是「挂载物」,隔离边界是 mount namespace 而不是 IPC ns。所以两个容器即使共享了 IPC namespace,只要 /dev/shm 挂载不同,POSIX 共享内存依旧互不可见;反过来,共享了 /dev/shm 挂载的两个容器即使 IPC ns 不同,POSIX 对象也能互通。排查「容器里 ipcs 看不到、/dev/shm 却又满」这类问题时,先分清对象属于哪一套体系,再决定动哪个 namespace 或哪个挂载。
动手体验只要三条命令:用 ipcmk 在命名空间里建一个共享内存段,ipcs 确认在列,换一个新 IPC 命名空间再 ipcs 就空了。运行时侧还提供共享 IPC 命名空间的选项(与宿主或另一个容器共用),这是老式协作软件容器化的救命稻草:两个进程必须共用一套 System V 信号量时,共享 IPC ns 比改造软件现实得多——代价是隔离面收缩,安全评审时要把「为什么共享」写清楚。
容量与限额的账也要分开记:容器里跑老数据库时给 /dev/shm 配够容量(它默认上限通常是内存的一半),而 System V 共享内存的限额来自内核 IPC 参数而不是 tmpfs 大小——同一份「共享内存」概念,SysV 与 POSIX 两套体系的限额机制完全不同,容量报警从哪条路径来,先看对象类型再动手调。
工程上还有一条值得写进清单的经验:容器里跑老软件时给 /dev/shm 配够容量(它默认大小通常是内存的一半,老数据库或消息中间件可能不够),而 System V 共享内存的限额来自内核 IPC 参数而不是 tmpfs 大小——同一份「共享内存」概念,SysV 与 POSIX 两套体系的限额机制完全不同,容量报警从哪条路径来,先看对象类型再调参。
time namespace:CLOCK_MONOTONIC 的偏移魔法
time namespace 是全家福里最年轻的一员,只虚拟化两样时钟:CLOCK_MONOTONIC(单调钟)与 CLOCK_BOOTTIME(含睡眠时间的单调钟)。用法相当克制:用 unshare 的对应标志创建(unshare 命令的 -T),在第一个进程进入之前,向 /proc/self/timens_offsets 写偏移行,比如 monotonic 100000000000 0 表示把单调钟前移一百秒;之后该空间内所有进程读到的时间都叠加偏移。CLOCK_REALTIME 不虚拟化——系统日历时间必须全局一致,各容器各改各的「现在」会把日志时间戳、TLS 证书校验、分布式事务搅成一锅粥,这是设计上刻意的保守。
它存在的动机直接来自检查点恢复(CRIU 一类):进程被快照、隔天恢复后,若单调钟突然前跳或回跳,所有基于单调钟的超时——网络栈的重传定时器、应用的 epoll 超时、各种「deadline 减 now」的逻辑——全部错乱。给恢复进程一个「看起来时间没跳」的偏移,检查点迁移才成立。此外还有个测试向的小用途:验证「进程运行了很久」的边界行为不用真等——偏移一写,代码里的 uptime 判断立刻成立,压力测试的构造时间成本归零。
运维侧知道两件事即可:其一,time ns 不改变 CLOCK_REALTIME,所以「容器时间不对」的问题与它无关,那是时区配置或宿主时钟同步的问题;其二,offsets 文件在命名空间有成员后即锁定,改偏移必须趁命名空间还空着——这个时机约束决定了它几乎只在「先建空间、再放进程」的编排流程里可用。
偏移的写入形态一眼即可看懂:
unshare -T bash
echo 'monotonic 100000000000 0' > /proc/$$/timens_offsets
cat /proc/uptime # 读到的运行时长已经前移
偏移量以纳秒为单位写进 offsets 文件,本命名空间内所有进程随后读到的时间同步平移,含睡眠时间的单调钟另有一行同格式配置。测试「进程运行很久」的边界行为时,这套偏移就是时间机器——不用真等,代码里的时长判断立刻成立。
配套的边界行为也交代两条:其一,带偏移的空间里,运行时长类接口与定时器语义都会跟着偏移走,监控探针放进去会产出「时间错乱」的数据——部署监控 agent 时要避开这类空间;其二,它与检查点恢复的配合是设计目标,但普通容器运行时并不默认启用它,日常环境里见到它多半是显式配置的结果,先查配置再查内核。
cgroup namespace 与 /proc/self/ns 视角
cgroup namespace 虚拟化进程对 cgroup 归属的「视角」:/proc/self/cgroup 里显示的路径、以及挂载的 cgroupfs 根目录,都以本空间的虚拟根为基准。它解决的是隐私与一致性——没有它,容器内进程看到的是宿主全路径(带着宿主的层级布局与容器 ID),信息泄露且不同环境不一致;配上 user namespace 的身份映射,非特权进程也能得到「自己就是 cgroup 树根」的干净视图。注意它不改变任何实际限制,只改显示,限流本体仍是 cgroup v2 控制器(系列前文已详述);日志里看到 cgroup 路径变短,是视角变了,不是限制没了。
cgroup namespace 与 user namespace 的配合还有一个实际收益:非特权用户创建的容器(无 root 容器)里,进程本来就在映射后的身份下运行,配上 cgroup ns 后,它看到的 cgroup 树是「以自己为根」的干净视图——权限检查、路径判断、自监控都不再需要特判宿主路径。这类「两个 ns 搭档」的模式在容器运行时的实现里反复出现,读懂这一对,其他组合也就有了模板。
最后一层是观察工具箱。ls -l /proc/self/ns/ 列出进程挂的全部命名空间,readlink 得到 pid:[4026531836] 这样的标识——方括号里是 namespace 实例的 inode 号,相同即同空间,这是判断「两个进程是否在同一视图」的最硬证据;lsns 按类型列出全系统的命名空间及代表进程,一眼看全容器与共享视图;nsenter -t 目标pid -n -m -p 进入目标进程的空间集合做现场排查,配 -p 进 PID 空间后连 ps 的号码都能对齐。把这套工具收进肌肉记忆,容器「看不见、连不上、杀不掉」三类问题的排查就都有了统一起点:先问「我在哪个视图里」,再问「它想在哪个视图里」。
判断两个进程是否同视图的标准动作:
readlink /proc/1234/ns/pid
readlink /proc/5678/ns/pid
# 输出的 inode 号一致即同一 PID 命名空间
进阶排查组合是 lsns 看全景、nsenter 再入场:lsns 列出每类命名空间的实例与代表进程,先找到目标进程所在的视图;nsenter 按类型逐张门票进入(网络、挂载、PID 各自独立开关),现场验证假设。这套组合不依赖任何容器运行时的内部接口,纯内核视图操作,跨运行时通用。
小结
命名空间是内核的「视图原语」,容器只是它的组合应用。本篇补全的四位各有性格:PID ns 管进程号码与可见性(管可见不管数量),IPC ns 管老三样(POSIX 家族划归挂载),time ns 管单调钟偏移(为检查点恢复而生),cgroup ns 管限制的显示视角(不改限制本体)。
两条横贯的工程原则值得带走:命名空间只管看见什么,配额交给 cgroup;每类对象(SysV 共享内存、POSIX 共享内存、挂载物)先归类再排查,避免动错开关。/proc/self/ns 的 inode 对比是判断视图同一性的最硬证据,lsns 与 nsenter 是跨视图排查的一双钥匙。
至此命名空间全家福补齐:mnt、uts、net 在容器底座篇,user 在深水区篇,pid、ipc、time、cgroup 在本篇。八张门票各自能换什么视图、有什么坑,够支撑绝大多数容器类故障的定性分析。下一篇回到一个用了很久却很少打开内核看的机制:epoll 的实现内幕。
读者留言
COMMENTS 暂无还没有留言,来说第一句?