WEBKT

K8s 运行时深剖:Containerd 与 CRI-O 在 Pod Sandbox 创建流程上的底层机制差异

15 0 0 0

在 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 并等待后续容器加入
  1. gRPC 接收:Containerd 守护进程接收到 RunPodSandbox 请求,cri 插件解析配置。
  2. 启动 Shim:Containerd 并不是直接 fork-exec 一个 OCI 运行时(如 runc),而是先启动一个独立的守护进程 containerd-shim-runc-v2
  3. OCI 交互:Shim 进程通过 stdin/stdout 与 runc 交互,执行 runc createrunc start 来运行 pause 镜像。
  4. 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 并持续监听
  1. CRI-O 接收crio 进程收到请求,直接准备 OCI 规范的 config.json
  2. 启动 Conmon:CRI-O fork 并 exec 一个 conmon 进程。
  3. OCI 交互conmon 负责调用 runc 去真正创建 pause 容器。
  4. 脱离父进程(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。

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 容器死锁)时,可以利用以下特征:

  1. 查看 Namespace 挂载

    • 在 Containerd 节点上,你可以通过 /run/netns/ 目录寻找处于存活状态的沙箱网络命名空间。
    • 在 CRI-O 节点上,更推荐使用 crictl inspectp <sandbox-id> 获取其 PID,直接去 /proc/<PID>/ns/net 查看。
  2. 热升级行为

    • 如果升级 crio 守护进程,由于 conmon 挂在 systemd 下,存量的 Pod Sandbox 不会受到任何干扰,业务流量零中断。
    • Containerd 虽然也支持热升级(通过将 Shim 与 Containerd 守护进程解耦),但在早期版本或特定配置下,守护进程的重启可能会对 Shim 的状态同步造成一定抖动。

结语

Containerd 的强项在于其高内聚的设计和庞大的通用生态支持,在各种非 K8s 容器化场景中具有绝对的统治力;而 CRI-O 则凭借 C 语言编写的轻量级 Conmon、彻底脱离 Daemon 的独立进程模型,在纯粹的 Kubernetes 大规模集群(特别是红帽生态、金融级高敏计算集群)中展现出了极致的资源利用率与故障隔离弹性。理解这两者在 Sandbox 创建时的本质差异,是做好 K8s 底层性能调优与故障排查的基本功。

基础架构专栏 KubernetesContainerdCRI-O

评论点评