第 27 章 容器运行时深入:namespace/cgroup/runc/OCI,Pod 之下的地基
墨:第 8 章我们讲了”为什么是 Pod 不是容器”——pause 容器、CRI、探针。但那是在 K8s 这一层。你有没有想过:容器到底是怎么”隔”出来的? 一个 Pod 里的两个容器,凭什么能看到彼此的 localhost,却看不到宿主的其他进程?
kubectl exec进去后,ps为什么只看到自己那几个进程?你:这……不就是”容器隔离”嘛。但具体怎么隔的,我还真没往下挖过。
墨:这就是”会用不精通”和”能造工具”的分界线。K8s 的 Pod 是个逻辑概念,它底下真正的隔离是 Linux 内核的两个原语:namespace(隔视野)和 cgroup(隔资源)。再往下是 runc 把这两个原语编排成”一个容器”,OCI 标准把”镜像长啥样、运行时怎么跑”钉成规范。这章我们从
clone()系统调用一路挖到 OCI,把 Pod 的地基刨开。
27.1 先破除一个幻觉:容器不是”轻量虚拟机”
很多人脑里有张错图:容器 = 阉割版虚拟机,有自己内核。错。容器和宿主共享同一个 Linux 内核。容器没有自己的内核、没有自己的操作系统,它只是一组被限制了视野和资源的普通进程。
墨:如果容器就是宿主上的普通进程,那”隔离”靠什么实现?是某种虚拟化吗?
你:不是虚拟化。是靠 Linux 内核的两个机制——namespace 把”能看到什么”收起来,cgroup 把”能用多少 CPU/内存”收起来。进程还是那个进程,只是被戴了”眼罩”和”手铐”。
墨:比喻精准。namespace = 眼罩(你看不到别的进程/网络/挂载),cgroup = 手铐(你用不了超过配额的资源)。虚拟机是”另起一套操作系统”,容器是”同一套操作系统里给进程画个笼子”。所以容器的启动是毫秒级(就是起个进程),虚拟机是分钟级(要 boot 一个内核)——根本不是一类东西。
27.2 namespace:六种”眼罩”
Linux namespace 让一组进程”以为”自己独占某种全局资源。容器至少用上这几种:
| namespace | 隔离的”视野” | 现实效果 |
|---|---|---|
Mount (mnt) | 挂载点 | 容器有自己独立的文件系统树(/ 是镜像里的根) |
PID | 进程号 | 容器内 ps 只看到自己命名空间里的进程,PID 1 是自己的 init |
Network (net) | 网络栈 | 容器有独立 IP、端口、路由、网卡(Pod 共享同一个 net ns) |
IPC | 进程间通信 | 容器间 SysV/posix 消息队列隔离 |
UTS | 主机名/域名 | 容器可设自己的 hostname |
User | 用户映射 | 容器内是 root,映射到宿主的普通 uid(降权逃逸防护) |
墨:回到第 8 章的 pause 容器——现在你能解释”为什么一个 Pod 的多个容器共享 localhost”了吧?
你:因为 Pod 里的容器共享同一个 Network namespace(和 pause 容器共享)。所以它们看到的是同一块网卡、同一个 localhost、同一个端口空间。而它们各自有独立的 Mount/PID namespace,所以文件系统、进程列表互不干扰。Pause 容器就是”先建好这个 net ns,其他人挂上来”的占位符。
墨:对。Pause 容器本身几乎不干活,它的唯一使命是持有这个 net ns(和 IPC ns)不被回收——只要它活着,ns 就在,其他容器加入就有地方挂。这就是第 8 章那个”pause 为什么存在”的底层答案。
27.2.1 User namespace:root 的障眼法
这是安全关键(呼应第 26 章)。容器内 id 显示 root(uid 0),但宿主上它其实是某个普通 uid。靠 User namespace 的uid 映射:容器内的 0 映射到宿主的 100000+。于是容器内”我是 root 我能 chmod 777”的破坏,在宿主看来只是”普通用户在自己目录里折腾”,动不了宿主的文件。
📦 案例:没有 User namespace 时,容器里 root 在挂载了宿主目录的情况下,能直接改宿主文件(因为 uid 相同)。开了 User namespace 后,容器 root = 宿主 nobody,宿主文件对他只读或不可见。这是”纵深防御”在运行时层的又一刀。
27.3 cgroup:给进程戴”手铐”
namespace 解决”看得到什么”,cgroup 解决”用多少资源”。cgroup(control group)把进程分组,对每组施加上限:CPU、内存、IO、PID 数、设备访问。
墨:你写 resources.limits.cpu: "1",最后怎么变成”这个容器最多用 1 核”的?中间隔着几层?
你:kubelet → 容器运行时(containerd)→ 调 cgroup 接口,在 /sys/fs/cgroup/... 下写文件(cgroup v1 是各控制器独立目录,v2 是统一树)。内核的 CPU 调度器/内存管理器读到这些限制,强制执行。
墨:对。这正是第 15 章讲的 requests/limits → cgroups 落地的”最后一公里”。K8s 只发声明,真正的”手铐”是内核 cgroup 戴上的。两个版本差别巨大:
- cgroup v1:每个控制器(cpu、memory、pids…)一棵独立目录树,粗粒度,且”进程被同时限 CPU 又限内存”时语义割裂,OOM 判定不精确。
- cgroup v2:单一统一树,支持真正的内存+CPU 联合压制,且
memory.high/memory.max比 v1 的limit_in_bytes更细腻,支持pids.max防 fork 炸弹。K8s 1.25+ 正式支持 v2,生产推荐。
⚠️ 坑:cgroup v1 的 cpu.shares 是相对权重不是硬上限——limits.cpu 真正生效靠 cpu.cfs_quota_us(CFS 带宽控制)。有人以为设了 requests 就能限住,其实 requests 只是调度器的”记账”,limits 才是 cgroup 的”手铐”。第 15 章讲过,但这里再钉一次:不写 limits,cgroup 不戴 CPU 手铐,容器能吃掉整颗核。
27.4 runc:把 namespace+cgroup 编排成”一个容器”
内核给了零件(clone 带 namespace 标志、写 cgroup 文件),谁来组装?runc(OCI runtime)。它的工作:
- 接收一份 OCI runtime spec(JSON,描述根文件系统路径、要建哪些 namespace、cgroup 配置、要执行的 entrypoint);
- 调
clone()/unshare()创建带 namespace 的进程; - 把容器的根文件系统
pivot_root进 Mount namespace(让容器以为/是镜像解出来的层); - 写 cgroup 文件施加资源限制;
- 在容器内
exec用户的进程(作为 PID 1)。
墨:你 docker run 一个镜像,到底发生了几次”解包”?
你:镜像本身是多层叠加的只读层(第 20 章讲过分层)。runc 启动时,把只读层 + 一个可写层(overlay/overlayfs 联合挂载)挂成容器的根 fs,容器里的写只落可写层。所以镜像是”模板”,容器是”模板 + 一份增量”的运行时实例。
墨:对。overlayfs 是关键——它让上千个容器共享同一份基础镜像的只读块,只在可写层各自微分。这就是镜像”省空间、秒启动”的底层。runc 不负责拉镜像(那是 containerd 的活),它只负责”给我一个已解好的 rootfs 路径,我跑起来”。
27.5 OCI:把”镜像”和”运行时”钉成标准
早期 Docker 自己定义一切,别人没法插手。于是行业出了 OCI(Open Container Initiative) 两个规范:
- image-spec:镜像长啥样——分层、manifest、config 的格式(第 20 章的镜像分层就是按它来的)。
- runtime-spec:运行时怎么跑——上面说的那份 JSON spec(process、root、linux.namespaces、linux.resources…)。
墨:OCI 这套标准,对”系统设计”有什么启发?
你:它把”容器”这件复杂事拆成了两个可替换的接口:镜像格式固定(谁打的镜像另一个运行时都能读),运行时接口固定(谁调 runc 还是 gVisor 由上层决定)。于是 containerd、cri-o、Kata、gVisor 都能接进同一个生态——又是”定义接口、解耦实现”的老套路。K8s 的 CRI 就是在这之上又加了一层”K8s 和运行时之间的接口”(第 8 章)。
27.5.1 CRI → OCI 的调用链
完整链路(一次 kubectl run):
kubelet
└─(CRI, gRPC)→ containerd (拉镜像、管生命周期、管 snapshot)
└─(调)→ runc (OCI runtime,真正建 ns+cgroup+exec)
└─(系统调用)→ Linux 内核 (namespace/cgroup/overlayfs)
- CRI(Container Runtime Interface):K8s 和”容器运行时”之间的 gRPC 接口(第 8 章讲过,Dockershim 被移除就是因为 Docker 不直接实现 CRI)。
- OCI runtime:containerd 通过它调 runc(或你换 Kata/gVisor 当 runtime)。
所以”K8s 跑容器”其实是三层接口叠加:K8s↔CRI↔containerd↔OCI↔runc↔内核。每一层都是”定义接口、解耦实现”——这正是大规模系统设计的标配。
27.6 逃逸的底层:namespace 的边界在哪
第 26 章讲了容器逃逸。从 runtime 视角看,逃逸就是”突破 namespace/cgroup 的限制”:
- 特权容器(
privileged:true):不加任何 namespace 约束、拿到全部 capabilities,等于宿主 root。 - 危险挂载:
hostPath: /或hostPID直接把宿主的 Mount/PID namespace 暴露进容器,namespace 边界被自己拆了。 - 内核漏洞:namespace 是内核实现的,内核有 bug(如 Dirty Pipe 能写任意文件)就能越界。runc 本身也曾有 CVE-2019-5736(容器内的恶意二进制覆盖宿主 runc 程序)。
- capabilities 过度:默认容器有少量 capabilities(如
CHOWN、NET_BIND_SERVICE),drop 干净(capabilities.drop: ["ALL"])能收掉大部分提权路径。
墨:所以”容器安全”本质是什么?
你:本质是”namespace/cgroup 这层边界有没有被自己配松、有没有被内核漏洞击穿”。PSA restricted + drop ALL caps + User namespace,就是把这个边界焊死;补丁是修内核漏洞这个”边界本身的洞”。
墨:闭环了——第 26 章的”收暴露”,落到 runtime 层就是”把 namespace/cgroup 边界焊死 + 补边界上的洞”。安全是分层的,每一层都是同一句话:最小暴露。
27.7 强隔离运行时:当 namespace 不够用时
有些场景 namespace 隔离不够(多租户、跑不可信代码):
- Kata Containers:每个容器跑在一个轻量虚拟机(用精简内核,如 Firecracker/Cloud Hypervisor)里,有自己内核。隔离性≈虚拟机,但启动仍快(百毫秒级)。代价:多一份内核内存。
- gVisor:用用户态内核(runsc)拦截系统调用,容器不直接打宿主内核。隔离强、开销中,但兼容性有损(部分 syscall 未实现)。
- 对比:runc=共享内核(快、隔离弱),Kata=独立内核(慢点、隔离强),gVisor=拦截 syscall(中)。
墨:这又是一条演化线——为什么需要这些”非 runc”运行时?
你:因为 runc 共享内核,隔离天花板就是”内核 bug 不破”。多租户/不可信负载要更强隔离,于是演化出”在 namespace 之外再套一层”——Kata 套 VM、gVisor 套用户态内核。规模/信任边界逼出结构分化,又是第 18 章那套规律。
27.8 演化线小结
- 问题:K8s 的”Pod/容器”是个逻辑概念,真正隔离要靠底层原语;且这套原语必须标准化,否则生态分裂。
- 设计分离:
- 内核提供 namespace(隔视野)+ cgroup(隔资源)+ overlayfs(分层 rootfs);
- runc 把三者编排成”一个容器”(OCI runtime spec);
- OCI 把镜像格式 + 运行时接口钉成标准,解耦”谁打镜像/谁跑容器”;
- CRI 在 K8s 和运行时间再加一层接口,让 containerd/cri-o/Kata 可插拔;
- 强隔离场景演化出 Kata(独立内核)/gVisor(用户态内核)。
- 演化规律:又是”定义接口、解耦实现”+“规模/信任边界逼出结构分化”+“兼容优先(CRI 替代 Dockershim 渐进)“。容器运行时这层,是 K8s 整座大厦的混凝土地基——你看不见它,但 Pod、网络、存储、安全全压在上面。
27.9 思考题
🔧 思考题 1(namespace 本质):为什么说”容器共享宿主内核”是它和虚拟机的根本区别?这带来什么安全和性能上的双重后果?
【参考答案】虚拟机有独立 Guest 内核,容器与宿主共享同一个 Host 内核。性能上:容器只是起个受 namespace/cgroup 限制的进程,毫秒级、几乎零额外内存;虚拟机要 boot 内核、跑 Hypervisor,秒~分钟级、每份 VM 一份内核内存。安全上:容器隔离的天花板就是”宿主内核的 namespace/cgroup 实现无 bug + 没配松”;一旦内核有逃逸漏洞(如 Dirty Pipe),所有共享该内核的容器一起破——这是共享内核的固有风险。虚拟机因为各自内核,一个 VM 的内核漏洞不影响宿主和其他 VM。所以共享内核 = 快但同风险面;独立内核 = 慢但强隔离。这是容器”轻”和”弱隔离”同源的根本原因。
🔧 思考题 2(Pod 共享):一个 Pod 两个容器 A、B。A 写了 /tmp/x,B 能读到吗?A 监听了 0.0.0.0:8080,B 能 curl localhost:8080 吗?A ps 能看到 B 的进程吗?分别由哪个 namespace 决定?
【参考答案】① /tmp/x:取决于两者是否共享同一个 Mount namespace 和同一份可写层。默认同一 Pod 的容器共享 IPC 和 Network namespace,但不共享 Mount namespace——各自有独立根 fs,所以 A 的 /tmp/x B 默认读不到;除非用 emptyDir 卷挂载到两者同一个路径,才能共享。② localhost:8080:能。因为共享 Network namespace,同一块网卡、同一 localhost。③ ps 看 B 进程:不能。各自独立 PID namespace,A 只看到自己 ns 里的进程。一句话:Pod 共享 net+ipc,不共享 mnt+pid——这正是 pause 容器持有 net/ipc ns、各容器单独挂 mnt/pid 的设计。
🔧 思考题 3(cgroup v1/v2):为什么 K8s 1.25+ 推 cgroup v2?v1 在资源管控上有什么具体短板,导致 OOM 判定不精确?
【参考答案】cgroup v1 每个控制器一棵独立目录树,CPU、内存、IO 各自为政,无法做”内存+CPU 联合压制”;且内存 OOM 判定基于各控制器孤立视图,复杂负载下会误杀或漏杀。v2 改成单一统一树,支持 memory.high(节流)/ memory.max(硬限)的细腻分级、支持 pids.max 防 fork 炸弹、且 OOM 基于统一视图更精确(真正的”内存压力”能正确传导到该杀的进程)。生产推荐 v2,配合 K8s 1.25+ 的内存 QoS 更准。注意:启用 v2 需要宿主内核开启 cgroup_v2=1 且运行时(containerd/runc)支持。
🔧 思考题 4(进阶·链路):请画出 kubectl run nginx 从你敲回车到 nginx 进程在容器内作为 PID 1 起来的完整调用链,标出每一层”接口/标准”的名字(K8s↔?↔?↔?↔内核)。并说明哪一层负责”拉镜像”、哪层负责”建 namespace”、哪层负责”写 cgroup”。
【参考答案】完整链:
kubectl(构造 YAML)→ kube-apiserver(验证/落 etcd)→ kube-scheduler(选节点)→ 目标节点 kubelet(接到 Pod 调度)→ CRI(gRPC) → containerd(拉镜像、解分层、准备 rootfs/snapshot、管生命周期)→ OCI runtime-spec(JSON) → runc(真正干活)→ Linux 内核(clone/unshare 建 namespace、pivot_root 挂 rootfs、写 cgroup 文件、exec 用户进程)。
- 拉镜像:
containerd(调 registry,按 OCI image-spec 解层)。 - 建 namespace:
runc(调内核clone()/unshare(),按 runtime-spec 的linux.namespaces)。 - 写 cgroup:
runc(按 runtime-spec 的linux.resources,写/sys/fs/cgroup/...)。 - 接口/标准名:K8s↔CRI↔containerd↔OCI runtime-spec↔runc↔内核;镜像侧还有 OCI image-spec。三层接口(CRI/OCI image/OCI runtime)把”谁编排、谁拉镜像、谁建隔离、谁跑进程”解耦,所以你能把 runc 换成 Kata、把 containerd 换成 cri-o 而不动 K8s。
墨:这章把 Pod 的地基刨开了——namespace 是眼罩、cgroup 是手铐、runc 是装配工、OCI 是标准、CRI 是插拔口。你再回头看第 8 章的 pause/CRI/探针,应该全是”原来如此”了。下一站如果要继续加硬核章,我会往”源码级 Reconcile 实战(client-go informer/workqueue)“和”排障全链路案例集”走——那是把”会用 K8s”真正逼成”能造类似系统”的最后一公里。先到这里,剩下的深度,我们按你的节奏继续填。
(第 27 章 完)