K8s 运行时深剖:Containerd 与 CRI-O 在 Pod Sandbox 创建流程上的底层机制差异
在 Kubernetes 架构中,Pod 是最小的调度单元,而 Pod 的物理实体在容器运行时(Container Runtime)眼中,首先表现为一个 Pod Sandbox(沙箱)。无论是轻量级的 Containerd,还是专为 Kubernetes 设计的 CRI-O,当 Kubelet 通过 CRI(Container Runtime Interface)发出 RunPodSandbox 请求时,它们都需要在底层拉起一个“暂停容器”(Pause Container)来占位,并初始化网络、IPC、UTS 等命名空间。
然而,这两者在具体的进程模型、监控机制以及资源消耗控制上,有着截然不同的工程实现。我们在大规模集群生产实践中遇到高频创建 Pod、节点 OOM 或系统调用延迟时,这些底层差异会直接决定系统的稳定极限。
本文将从源码与进程拓扑的视角,深入对比 Containerd 与 CRI-O 在处理 Sandbox 创建时的机制差异。
1. Containerd 的 Sandbox 创建机制:基于 Shim-v2 的编排
Containerd 最初是从 Docker 中剥离出来的,它的设计目标是“通用性”。为了对接 Kubernetes CRI,Containerd 引入了内置的 cri 插件。
1.1 进程拓扑与调用链
在 Containerd 的世界里,管理容器生命周期的核心纽带是 Shim(垫片进程)。目前主流使用的是 containerd-shim-v2。
当 Kubelet 发起 RunPodSandbox 请求时,其简化的底层调用链如下:
Kubelet (gRPC)
└── containerd (CRI Plugin)
└── 创建 containerd-shim-v2 进程
├── Shim 调用 OCI Runtime (如 runc) 创建 Pause 容器
└── Shim 进程驻留,持有 Namespace 并等待后续容器加入
- gRPC 接收:Containerd 守护进程接收到
RunPodSandbox请求,cri插件解析配置。 - 启动 Shim:Containerd 并不是直接 fork-exec 一个 OCI 运行时(如 runc),而是先启动一个独立的守护进程
containerd-shim-runc-v2。 - OCI 交互:Shim 进程通过 stdin/stdout 与
runc交互,执行runc create和runc start来运行pause镜像。 - Namespace 持有:
pause容器启动后,其对应的 Linux Namespaces(Net, IPC, UTS)被成功创建。此时,Shim 进程会一直运行,作为该 Pod 的“管理员”。
1.2 进程树特征
在节点上执行 pstree -ap,你会看到类似如下的结构:
systemd
└─containerd
└─containerd-shim -namespace k8s.io -id <sandbox_id> -address /run/containerd/containerd.sock
└─pause (PID: 12345, 共享命名空间的源头)
关键设计点:在 Containerd 中,默认情况下整个 Pod 共享同一个 Shim 进程。也就是说,后续通过 CreateContainer 创建的业务容器,也会由这个已经存在的 containerd-shim-v2 进程拉起并监管。这种设计减少了系统中 Shim 进程的总数。
2. CRI-O 的 Sandbox 创建机制:基于 Conmon 的极简主义
CRI-O 的诞生宣言非常纯粹:“Kubernetes is the only API”。它不需要考虑 Docker 的兼容性,也不需要服务于非 K8s 场景,因此其架构完全贴合 CRI 规范。
2.1 进程拓扑与调用链
CRI-O 放弃了复杂的通用 Shim 守护进程设计,转而使用一个用 C 语言编写的、极轻量级的监控程序——Conmon(Container Monitor)。
当 Kubelet 向 CRI-O 发起 RunPodSandbox 请求时:
Kubelet (gRPC)
└── crio (Daemon)
└── 启动 conmon 进程
├── conmon 调用 OCI Runtime (如 runc) 创建 Pause 容器
└── runc 退出,conmon 接管 pause 的 FDs 并持续监听
- CRI-O 接收:
crio进程收到请求,直接准备 OCI 规范的config.json。 - 启动 Conmon:CRI-O fork 并 exec 一个
conmon进程。 - OCI 交互:
conmon负责调用runc去真正创建pause容器。 - 脱离父进程(Double Fork):一旦容器启动成功,
runc进程退出。conmon会通过 double fork 脱离 CRI-O,将其父进程挂载到systemd(PID 1)下。
2.2 进程树特征
在 CRI-O 节点上,你会观察到如下进程树:
systemd (PID 1)
├─crio (CRI-O 守护进程本身)
├─conmon --api-version 1.0.0 --cgroup-manager systemd ... -r /usr/bin/runc ...
│ └─pause (Sandbox 占位进程)
关键设计点:CRI-O 采用的是 Conmon-per-container 模型。这意味着不仅 Sandbox 有一个 conmon,后续加入该 Pod 的每一个业务容器,都会拥有自己专属的 conmon 监控进程。
3. 核心机制对比与深度解析
为了更清晰地理解两者的优劣,我们需要拆解它们在性能、资源、网络及容错方面的具体行为差异。
3.1 内存与系统资源开销(Overhead)
在大规模高密度部署的节点上(例如单节点运行 100+ Pod),运行时的自身内存占用(Runtime Overhead)非常关键。
- Containerd (Shim-v2):
- Shim 是用 Go 编写的。Go 运行时由于垃圾回收(GC)机制和内置的 runtime 消耗,单个 Shim 进程的常驻内存(RSS)通常在 15MB - 30MB 左右。
- 优势:由于一个 Pod 共享一个 Shim,当一个 Pod 内有 5 个容器时,依然只有一个 Shim,内存开销相对可控。
- CRI-O (Conmon):
- Conmon 是用 C 语言 编写的。它没有任何 runtime 包袱,不进行垃圾回收。单个
conmon的 RSS 仅为 数百 KB 到 1MB。 - 劣势:由于 CRI-O 坚持每个容器一个
conmon,如果一个 Pod 包含较多 Sidecar 容器,单个 Pod 占用的conmon数量会线性上升。但在实际场景中,即便容器数量较多,C 编写的conmon总开销通常依然低于 Go 编写的 Containerd Shim。
- Conmon 是用 C 语言 编写的。它没有任何 runtime 包袱,不进行垃圾回收。单个
3.2 容器退出与状态监控(Monitoring & Reaper)
Sandbox 容器(Pause 进程)必须稳定运行。如果它因为意外退出,运行时必须能感知并上报给 Kubelet。
- Containerd 的机制:
containerd-shim-v2扮演了subreaper的角色。它通过系统调用(如waitpid)直接监听pause进程以及后续业务进程的退出状态。- 这种设计在 Go 层完成了高度抽象,与 Containerd 的事件流(Event Stream)结合得非常紧密。
- CRI-O 的机制:
conmon作为pause的父进程,在pause退出时会捕获到SIGCHLD信号。conmon会将退出码(exit code)写入到指定的磁盘文件(例如/var/lib/containers/storage/.../exit),并向 CRI-O 写入事件。即使crio守护进程发生崩溃或重启,conmon作为独立挂在 PID 1 下的进程,依然可以安全地收集退出状态,等crio重启后再行读取。这提供了极佳的容灾隔离性。
3.3 网络命名空间(Network Namespace)创建与 CNI 调用
CNI(Container Network Interface)的触发时机和环境准备在两者之间也有细微差异:
【Containerd 模式】
Kubelet -> RunPodSandbox -> Containerd (CRI Plugin)
│
├── 1. 创建 NetNS 并锁定
├── 2. 调用 CNI 插件 (在此 NetNS 内配置网卡)
└── 3. 启动 Shim 运行 Pause 容器 (关联此 NetNS)
【CRI-O 模式】
Kubelet -> RunPodSandbox -> CRI-O
│
├── 1. 准备 Sandbox 目录与 NetNS 路径
├── 2. 启动 Conmon/Pause 容器 (由 runc 产生 NetNS)
└── 3. 调用 CNI 插件 (针对已存在的 NetNS 进行网络配置)
- Containerd 的内置 CRI 插件会先通过 Linux
ip netns类似的操作,在主机上创建一个通用的网络命名空间路径(通常挂载在/var/run/netns/下),然后直接调用 CNI 插件对该命名空间进行网络配置(如分配 IP、创建 veth 对),最后再把这个网络命名空间路径传递给 OCI Runtime 去启动pause容器。 - CRI-O 则更倾向于让 OCI Runtime 在创建
pause容器的过程中自然生成 Namespace 管道,然后再通过调用 CNI 对该进程对应的/proc/<pid>/ns/net进行网络注入。这种方式减少了运行时手动维护外部 NetNS 挂载点的复杂性。
4. 生产环境下的差异总结
| 维度 | Containerd (runc-v2) | CRI-O (conmon) |
|---|---|---|
| 核心监控组件 | containerd-shim-runc-v2 (Go 编写) |
conmon (C 语言编写) |
| 进程共享模式 | 一个 Pod 共享一个 Shim 进程 | 一个容器独占一个 Conmon 进程 |
| 单组件内存开销 | 较高 (~15MB - 30MB) | 极低 (~1MB 以内) |
| 组件父进程 | containerd |
systemd (PID 1, 独立脱离) |
| 设计哲学 | 通用容器运行时平台,支持非 K8s 生态 | 100% 专为 Kubernetes CRI 裁剪 |
| 调试友好度 | 可通过 ctr (Containerd 专属) 与 crictl |
纯粹依赖 crictl |
诊断与排错建议
当你需要深入底层排查 Sandbox 异常(如 CNI 泄露、Pause 容器死锁)时,可以利用以下特征:
查看 Namespace 挂载:
- 在 Containerd 节点上,你可以通过
/run/netns/目录寻找处于存活状态的沙箱网络命名空间。 - 在 CRI-O 节点上,更推荐使用
crictl inspectp <sandbox-id>获取其 PID,直接去/proc/<PID>/ns/net查看。
- 在 Containerd 节点上,你可以通过
热升级行为:
- 如果升级
crio守护进程,由于conmon挂在systemd下,存量的 Pod Sandbox 不会受到任何干扰,业务流量零中断。 - Containerd 虽然也支持热升级(通过将 Shim 与 Containerd 守护进程解耦),但在早期版本或特定配置下,守护进程的重启可能会对 Shim 的状态同步造成一定抖动。
- 如果升级
结语
Containerd 的强项在于其高内聚的设计和庞大的通用生态支持,在各种非 K8s 容器化场景中具有绝对的统治力;而 CRI-O 则凭借 C 语言编写的轻量级 Conmon、彻底脱离 Daemon 的独立进程模型,在纯粹的 Kubernetes 大规模集群(特别是红帽生态、金融级高敏计算集群)中展现出了极致的资源利用率与故障隔离弹性。理解这两者在 Sandbox 创建时的本质差异,是做好 K8s 底层性能调优与故障排查的基本功。