第 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)。它的工作:

  1. 接收一份 OCI runtime spec(JSON,描述根文件系统路径、要建哪些 namespace、cgroup 配置、要执行的 entrypoint);
  2. 调 clone()/unshare() 创建带 namespace 的进程;
  3. 把容器的根文件系统 pivot_root 进 Mount namespace(让容器以为 / 是镜像解出来的层);
  4. 写 cgroup 文件施加资源限制;
  5. 在容器内 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 章 完)