「容器是不是轻量虚拟机」是面试高频题,但更准确的答案是:容器根本不涉及虚拟化——它没有 guest 内核,进程跑的就是宿主内核,所谓隔离只是内核的三个老特性各司其职:namespace 让进程看不见别人,overlayfs 让镜像层层复用,cgroup 让资源各付各账。Docker 的魔法其实是打包与编排的工程整合,底座从 Linux 2.4 时代就在慢慢长成。理解了这一点,容器的一切性能怪谈与安全问题都有了追问的坐标——隔离边界就是 namespace 的边界,性能税就是 overlayfs 与 cgroup 的税。本篇把这三根支柱拆开讲透,最后落到 K8s 工程师最痛的 OOM 误杀问题。
一、namespace:八面镜子各照一段现实
namespace 的哲学是「全局资源变成进程视角的局部资源」——每种 namespace 隔离一类全局量。到 2026 年为止共八种,合入时间线本身就是一部容器史:mount(2.4.19,2002)隔离挂载点,UTS(2.6.19)隔离主机名,IPC(2.6.19)隔离 System V 信号量与共享内存,PID(2.6.24,2008)让容器内进程重新从 1 号开始,network(2.6.29,2009)隔离网卡、路由表与端口空间,user(3.8,2013)让容器内 root 不等于宿主 root,cgroup(4.6,2016)隔离 cgroup 根目录视角,time(5.6,2020)隔离单调时钟与开机时钟——最后这个是为了 CRIU 做检查点恢复时修正时间感知补上的最后一块。
创建方式是 clone() 的一组 CLONE_NEW* 标志(对应关系见 namespaces(7)),切换根文件系统靠 pivot_root(比 chroot 严格:真正的根切换,配合 mount namespace 后旧根可完全卸载)。一个进程在每个 namespace 里都有独立的身份视图,/proc/<pid>/ns/ 下的链接就是它的八张身份证。几个 namespace 的工程细节值得点名。PID namespace 里,容器的 1 号进程要替全容器收尸——孤儿进程的父指针全部归它,1 号不 reap 僵尸,容器里就尸横遍野,这正是 tini 这类 init 进程存在的理由。network namespace 通常与 veth 虚拟网线成对出现:一头插在容器里、一头插在宿主网桥上,容器 IP 在宿主视角只是另一条 veth。mount namespace 最微妙,因为挂载传播(shared/slave/private)决定容器内的挂载会不会泄漏回宿主,K8s 对 subtree 的处理一直在与它博弈。容器运行时(runc,OCI 规范的参考实现)做的核心工作,就是按配置调 clone 拉起这八面镜子、pivot_root 换根、再交给 cgroup 限流——没有一行是「虚拟化」。
二、user namespace:安全争议的风暴眼
八种 namespace 里,user namespace 最特殊也最有争议。它的价值极大:非特权用户也能创建自己的 namespace,容器内 uid 0 映射到宿主普通用户——rootless 容器(Docker rootless、Podman、Flatpak)整个生态都建立在它上面。但安全社区对它的评价分裂:给每个非特权用户开放「创建内核隔离域」的能力,等于把一大片内核代码面暴露给了任何本地用户。Debian 多年默认 kernel.unprivileged_userns_clone=0 关闭它;Edera 的分析称 userns 放大约 262% 的内核攻击面;Ubuntu 23.10/24.04 走了第三条路——不关,改用 AppArmor 逐程序授权,代价是弄坏了一批容器与构建工具链。
这场拉锯没有标准答案,只有场景选择:多租户桌面与 CI 机器倾向收紧(攻击面优先),依赖 rootless 构建的平台倾向放开(生态优先)。工程上的务实结论:** userns 是功能开关不是安全边界**——把它当安全边界用的团队,和把 DMZ 当唯一防线的团队,犯的是同一种错。
三、overlayfs:镜像分层的时-copy-up 机制
镜像为什么能几百 MB 复用给上百个容器?答案在 overlayfs(Linux 3.18,2014 年 12 月合入主线——与 eBPF 同一个版本,两大基础设施同年上主线的巧合)。结构只有三层:多层只读的 lowerdir(镜像的每一层)、一个可写的 upperdir(容器层)、辅助原子操作的 workdir。合并视图按层叠放,规则只有两条:copy-up——首次修改 lower 层文件时,把整个文件复制进 upper 层再改(粒度是文件,改一个字节也要整个文件上来);whiteout——删除 lower 层文件时,在 upper 层创建一个 0:0 字符设备做「墓碑」,合并视图里这个文件就消失了。
Docker 的 overlay2 驱动正是这套语义的直译:镜像层全是 lowerdir(只读、跨容器共享,这就是「拉镜像只拉缺的层」的原理),容器可写层是 upperdir。工程含义有两个:容器写大文件代价高(copy-up 全文件);容器层是易失的——所以数据库类服务的持久化必须挂 volume 而不是写容器层,这不是最佳实践,是机制的必然。反过来,层数与层体积直接决定拉取速度与磁盘占用:多阶段构建、合并 RUN 指令、按变更频率排层序,全是在给 copy-up 与分层复用机制打工。
四、cgroup v2:从多棵树到一本账
cgroup 管「配额与度量」,v1 时代每个子系统(cpu、memory、blkio…)一棵独立树,同一进程可出现在多棵树的不同位置——容器运行时要在几棵树之间对齐状态,K8s 的 memory 与 cpu 策略经常打架。v2 用统一层级终结了这场混乱:所有控制器共享同一棵树,并立了 no-internal-process 规则——除根外,分资源的组内不允许再有进程,资源要么给组、要么给进程,账目从此唯一。
v2 的接口也收敛成四个高频文件:cpu.max(「quota period」两段式,100000 100000 即 1 核)、cpu.weight(1–10000,默认 100,CPU 争夺时的相对权重)、memory.max(硬限,触顶触发容器内 OOM)、memory.high(软限,先节流逼回收)。部署时间线:Fedora 31(2019-10)首个默认 v2 的发行版,Ubuntu 自 21.10 跟进,Kubernetes 1.25(2022-08)宣布 cgroup v2 GA——kubelet 把 CPU request 映射成 cpu.weight、memory limit 映射成 memory.max。注意 2026-01 K8s 官方还修订了 v1→v2 的 CPU 换算公式:旧公式把 1 核请求折算成仅约 39 的 weight(v2 权重刻度与 v1 shares 非线性对应),升级节点后 CPU 权重分布会变,混部集群值得专门复核。内存记账还有一个反直觉条款:page cache 也计入 memory.max——容器里读热点文件,页缓存会记到这个 cgroup 头上,回收时优先驱逐;所以「容器只读文件也 OOM」并非都市传说,读大文件一样能把 limit 顶满。观测压力有比 OOM 日志更早的信号:cgroup v2 的 memory.pressure/cpu.pressure(PSI,压力失速信息)直接告诉你资源等了多久,memory.high 触发节流时它先响。
五、容器里的 OOM:谁死、死几个、为什么
容器内存超限的判决链要分两段。第一段是 cgroup 级:容器内存触到 memory.max,内核在这个 cgroup 里执行 OOM kill——与第 3 篇讲的系统级 OOM 是同一机制、不同记账范围。第二段是 节点级:没有 limit 的 pod 把压力传导到整机,触发系统级 OOM killer,按 oom_score_adj 挑牺牲者——K8s 给 Guaranteed pod 打 -997(几乎免死)、BestEffort 打 1000(先死)。两个著名事故形态都出在这条链上:「隐形 OOM kill」——OOM killer 杀的是容器内子进程而非 init 进程,容器照常显示 Running 而业务已残废;Talos Linux 还出现过更极端的:kubelet 自己被 OOM killer 杀掉,节点彻底失控。
K8s 把调度语义下沉到内核判决的接口是 QoS 等级与 oom_score_adj 的对应,值得背下来:Guaranteed(requests 等于 limits)-997 近乎免死,BestEffort 1000 最先死,Burstable 按实际用量与请求的比例插值——节点内存告急时,牺牲顺序在 pod 创建时就已写好。
社区用两个开关收紧了这段链:K8s 1.28 起,cgroup v2 节点上 kubelet 默认置 memory.oom.group=1——容器内任一进程触发 OOM,整个 cgroup 一起死(宁可全容器重启,不留半死不活的状态,可用 singleProcessOOMKill 关掉)。工程结论:容器时代「进程死了但容器活着」比「全死」更危险,健康检查必须探到业务进程本体,内存 limit 的余量设计要给 fork 型负载(回忆第 1 篇 Redis BGSAVE 的 CoW 峰值)留出翻倍空间。
六、工程落地清单
- 持久化数据绝不写容器层。overlayfs 的 upperdir 易失且 copy-up 昂贵,volume 是机制要求不是习惯。
- 镜像层的设计按「改整文件」想。频繁变更的文件单独成层,避免 copy-up 与拉取浪费。
- limit 不是可选项。没 limit 的 pod 在 v2 节点上的 CPU weight 与 OOM 优先级都是最不利档位。
- 升级 cgroup v2 节点复核 CPU 换算。K8s 2026-01 新公式后,weight 分布与旧集群不可比。
- userns 按场景开关,别当安全边界。rootless 生态要它,多租户敏感机要关它。
- 健康检查探业务进程本体。
memory.oom.group之后「容器 Running」不再等于「服务健康」。 - 把 oom 计数纳入巡检。cgroup v2 的
memory.events记录 oom 与 oom_kill 次数,非零就是复盘入口,别等用户报障。 - fork 型负载的 limit 预留翻倍余量。Redis BGSAVE 类 CoW 峰值(回忆第 1 篇)在容器里就是 OOM 触发器。
结语
容器的全部魔法是三个单词:namespace、overlayfs、cgroup——隔离、镜像、配额,三个 2002–2014 年间逐步成形的内核特性,被 Docker 用一个打包格式与一套 UX 缝合成「革命」。理解这一点的好处是:容器的一切怪问题都能顺着机制找到根源——隐形 OOM 是记账范围问题、容器写盘慢是 copy-up 问题、时钟跳变是 time namespace 问题。下一篇讲这个底座上最新的一层:eBPF——当内核开放了可编程接口,可观测与网络的面貌被再次改写。
参考资料
- namespaces(7) man page — man7.org——八种 namespace 与 clone 标志的一手定义。
- Control Group v2 — kernel.org——统一层级、no-internal-process 与接口文件语义。
- Overlay Filesystem — kernel.org——lowerdir/upperdir/copy-up/whiteout 的官方文档。
- OverlayFS storage driver — Docker Docs——overlay2 与镜像层复用的官方实现说明。
- Fedora 31 and Control Group v2 — Red Hat——首个默认 v2 发行版的迁移说明。
- Kubernetes 1.25: cgroup v2 graduates to GA — kubernetes.io——K8s 侧 v2 时间线与映射规则。
- New cgroup v1 to v2 CPU conversion formula — kubernetes.io——2026-01 的换算修订公告。
- Understanding AppArmor user-namespace restriction — Ubuntu Discourse——userns 争议的 Ubuntu 路线。
读者留言
COMMENTS 暂无还没有留言,来说第一句?