TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

containerd 与 CRI 解读:kubelet 的 gRPC 如何一路直达 runc

状态:草稿(2026-10-17),包含部分实测数据。 基准版本:containerd/containerd v2.3.5(提交 1294c24a7da8),kubernetes/cri-api v0.34.0(提交 9b3ce5ec4798)。 与 1.7 的对比仅记录通过源码查阅 containerd v1.7.27(提交 05044ec0a9a7)确认过的内容。实测于 2026-10-17 在小型集群中 仅进行读取 操作(读取节点列表,以普通用户通过 ssh 登录两台 Worker 节点并读取 ps·mount·读取配置文件,无 sudo 权限)。 编写规则:以实测数据为准,未测量标记为“未实测”,未确认标记为“未确认”。

目录

1. 一句话总结:订单样式与后厨

kubelet 本身不直接创建容器。它只会通过 CRI(Container Runtime Interface) 这一固定格式发送诸如“为该 Pod 准备沙箱”、“用此镜像创建容器”、“启动”之类的指令。由于订单格式已固定,后厨(运行时)无论是 containerd 还是 CRI-O 都可以随意替换。containerd 接收订单后,取出原料(镜像层)并进行处理(快照),为每张桌子指派专属服务员(shim),而真正点火的操作则交给厨师(runc)。厨师将菜品放入后便下班离开,后续的服务则由专属服务员负责。

组件 职责 运行位置
kubelet 将 Pod 规范转换为 CRI 调用并发送 各节点上的 systemd 服务
CRI (cri-api) RuntimeService 与 ImageService 两个 gRPC 服务的规范 api.proto 单个文件
containerd CRI 插件 接收 CRI 请求并调用核心服务与 CNI containerd 进程内部
containerd 核心服务 容器元数据、任务、镜像、内容、快照、租约 (lease) containerd 进程内部
containerd-shim-runc-v2 维护单个 Pod 的容器进程并接收退出码 每个 Pod 一个进程
runc 利用 OCI 规范包创建命名空间和 cgroup 并启动进程 执行后随即退出
CNI 插件 为 Pod 网络命名空间挂载网卡与 IP 由 containerd 执行的二进制文件

2. 架构图

kubelet 에서 runc 까지: 한 노드 안의 호출 사슬 kubelet CRI 클라이언트 CRI gRPC (api.proto) unix 소켓 containerd.sock containerd 데몬 (프로세스 1개) CRI 플러그인 io.containerd.grpc.v1.cri runtime images podsandbox RunPodSandbox·CreateContainer·PullImage CNI 바이너리 코어 서비스 (같은 프로세스 안에서 직접 호출) containers tasks images content snapshots leases meta.db blobs/sha256 snapshots/N runtime v2 태스크 관리자 · shim 관리자 ttrpc (소켓 /run/containerd/s/…) containerd-shim-runc-v2 파드마다 1개(sandbox-id 로 묶음) · rootfs 마운트 runc create·start 후 종료 pause 앱 컨테이너들 읽는 법 파랑: 코어 보라: 런타임 주황: 데몬 밖 초록: 프로세스
图:从 kubelet 到 runc 的调用链与 containerd 内部结构
이미지 레이어가 컨테이너 rootfs 가 되기까지 overlayfs 스냅샷터의 디렉터리 4/fs 쓰기 층 (active) 3/fs 레이어 3 (committed) 2/fs 레이어 2 (committed) 1/fs 레이어 1 (committed) 맨 위 active 층만 쓰기 가능 ① Pull 레이어 blob → content 저장소 ② Unpack Prepare→Apply→Commit 반복 ③ 컨테이너 생성 위에 새 active 스냅샷 ④ 태스크 생성 shim 이 overlay 로 마운트 결과: 컨테이너 번들의 rootfs 에 overlay 하나 overlay on /run/containerd/io.containerd.runtime.v2.task/ k8s.io/(컨테이너 ID)/rootfs type overlay lowerdir=3/fs:2/fs:1/fs (왼쪽이 위층) upperdir=4/fs, workdir=4/work 실측 2026-10-17 (워커 두 대, 읽기만) 1.7 워커: overlay 50개 중 1층 22개 = pause 22개 2.3 워커: overlay 74개, 가장 깊은 것 52층
图:镜像层经过快照转换为 overlay 挂载的过程与实测

3. 代码导读

containerd 2.x 仓库是一个单一的 Go 模块(github.com/containerd/containerd/v2)。此处仅梳理初次阅读时最重要的部分。

路径 内容 初次阅读文件
cmd/containerd/ 守护进程入口、内置插件列表、插件加载 builtins/builtins.go, server/server.go
plugins/ 插件注册表。服务、快照器、内容、CRI、GC 等 types.go, cri/cri.go
internal/cri/ CRI 实现主体(服务器、沙箱控制器、镜像服务、配置) server/sandbox_run.go
core/ 领域逻辑:元数据 (bolt)、runtime v2、解压 (unpack)、传输 (transfer) runtime/v2/shim_manager.go
client/ Go 클라이언트. CRI 플러그인도 이것을 「메모리 안」으로 씀 container.go
cmd/containerd-shim-runc-v2/ runc 专用 shim manager/manager_linux.go
api/ containerd 自身的 gRPC/ttrpc proto services/tasks/v1/tasks.proto

kubernetes/cri-api 几乎只有一个文件。 pkg/apis/runtime/v1/api.proto 其中包含了所有 35 个 RPC 及消息定义,其余部分为生成的 Go 代码。

1.7 与 2.x 的差异(仅列出源码确认过的内容)

4. 跟踪核心路径

4.1 守护进程通过插件图进行组装

containerd 的几乎所有功能都是插件。各个包在 init() 中将自己注册到 registry.Register ,而 builtins.go 仅仅通过空导入(_)引入这些包。是否将它们包含在构建中由一行导入语句决定。

  1. 插件通过 类型和 ID 来标识。类型是类似 io.containerd.grpc.v1, io.containerd.snapshotter.v1, io.containerd.cri.v1 的字符串(见plugins/types.go:35 附近)。配置文件中的 [plugins."io.containerd.cri.v1.images"] 正是直接使用了「类型.ID」。
  2. 注册包含 Requires(所需类型列表)。CRI gRPC 插件要求十一种类型,包括 CRI 服务、沙盒控制器、NRI、事件、lease、transfer、shim 等(plugins/cri/cri.go:46 init).
  3. 守护进程在 LoadPlugins 中按依赖顺序对 registry.Graph(...) 进行排序(第 386 行),并在 New 的循环中(第 161 行)逐个进行 p.Init (第 189 行)。图排序本身位于单独的模块 github.com/containerd/plugin v1.1.0 中,因此不在此仓库中。
  4. 服务通常分为 两层。一层是负责实际工作的 io.containerd.service.v1 类型的「local」实现(plugins/services/tasks/local.go:92),另一层是将其导出为 gRPC 的轻量级 io.containerd.grpc.v1 包装(containers/service.go:34)。

为什么要分两层?CRI 插件就是答案。 initCRIService 会创建一个 containerd 客户端,但传入了 containerd.WithInMemoryServices(ic)(第 121 行)。外部的 ctr 和 nerdctl 通过套接字调用 gRPC 包装,而同一进程内的 CRI 直接调用 local 实现。这种设计旨在避免一次序列化开销的同时,保持 客户端代码唯一 。CRI 请求使用的命名空间在此也被固定为 k8s.io 。因此,必须通过 ctr -n k8s.io 才能看到 Kubernetes 容器。

4.2 RunPodSandbox:网络优先,pause 属于控制器

CRI 协议的第一个 RPC 是 RunPodSandbox 。「成功后沙盒必须处于就绪状态」这句话就是契约的全部,至于沙盒是什么则由运行时决定。containerd 的回答是:「网络命名空间 + 持有该命名空间的 pause 容器」。

  1. criService.RunPodSandbox 首先使用沙盒 ID 创建一个 lease (第 97 行)。只要该 lease 存活,沙盒使用的内容和快照就不会被垃圾回收(GC)。
  2. hostNetwork 如果不是 netns.NewNetNS ,则在 /var/run/netns/ 下创建一个具名的网络命名空间(第 209 行)。
  3. 接着, setupPodNetwork 调用 CNI 的 Setup (第 496 行)。 在启动任何容器之前 ,就已经先连接了网络。获取到的 IP 会被缓存到沙盒状态中,避免每次 PodSandboxStatus 时都去查找命名空间。
  4. 然后调用沙盒服务的 CreateSandbox(第 281 行)和 StartSandbox(第 299 行)。在这里,实际工作由沙盒控制器完成。
  5. 默认控制器 podsandbox 的 Start 查找 pause 镜像(第 86 行),在该镜像之上准备快照并创建容器(第 209 行),在没有标准输入输出的情况下创建任务(第 253 行),设置退出监视(Wait,第 270 行)后启动(第 295 行)。

为什么要这样划分?为了不把沙盒死板地限定为「pause 容器」。由于控制器是一个插件,因此像基于 VM 的运行时那样以虚拟机作为沙盒的实现,也可以接入到同一个 CRI 服务器下。配置中的 pause 镜像名称不再是简单的 sandbox_image 一行,而是变为了 pinned_images 表中的 sandbox 项(配置结构体 config.go:307),这也是出于同样的演进思路。注释将其定义为「CRI 客户端不得删除的镜像」,并通过键供其他插件查找使用。pause 只是其中之一。

4.3 CreateContainer·StartContainer → tasks → shim → runc

  1. CreateContainer 将 CRI 的 ContainerConfig 转换为 OCI 运行时规范,通过 WithNewSnapshot(第 378 行)准备镜像的可写层,并调用 client.NewContainer(第 462 行)。这一阶段 仅创建元数据 。此时还没有进程。
  2. StartContainer 如果沙盒拥有任务 API 地址,则将其作为选项附加(第 197 行),并调用 container.NewTask(第 206 行)→ task.Start(第 247 行)。
  3. 客户端的 NewTask 在 handleMounts(第 328 行)中调用快照器的 Mounts 接收它并将 rootfs 挂载列表放入请求中。tasks 服务的 local.Create 接收该请求并将其传递给 runtime v2 的 TaskManager.Create 。
  4. ShimManager.Start 与 binary.Start 将 shim 二进制文件作为 start 子命令执行。shim 会返回其 ttrpc 套接字地址,并脱离守护进程。
  5. shim 端的 manager.Start 中包含了本文最重要的几行代码。
grouping := id
spec, err := readSpec()
...
for _, group := range groupLabels {
	if groupID, ok := spec.Annotations[group]; ok {
		grouping = groupID
		break
	}
}
...
s, err := newShimSocket(ctx, socketDir, opts.GetContainerdGrpcAddress(), grouping, false)
if err != nil {
	if errdefs.IsAlreadyExists(err) {
		params.Address = s.addr
		return &params, nil

来源:containerd v2.3.5 cmd/containerd-shim-runc-v2/manager/manager_linux.go,Apache-2.0

groupLabels 中包含 io.kubernetes.cri.sandbox-id (第 67 行)。同一个 Pod 的容器会拥有相同的沙箱 ID 并获取相同的套接字名称, 如果套接字已存在,则 不会启动新的 shim,而是返回现有的地址。 因此,shim 不是每个容器一个,而是每个 Pod 一个。 第 6 节的实测数据完美地验证了这一点。 6. shim 的 service.Create 是 runc.NewContainer 在 bundle 下的 rootfs 上由 shim 直接挂载 overlay(第 120 行),且 Init.Create 执行 runc create (第 149 行)。 Start 是 start 中的 runc start 。

为什么要把 shim 独立出来?如果容器进程的父进程是 containerd,那么当重启或升级 containerd 时,所有的容器都会受到影响。 如果让 shim 扮演父进程角色(收集退出码、维护标准输入输出),守护进程就可以随时停止并重启,通过套接字重新连接。 不让 runc 常驻也是出于同样的道理。长寿进程越少,升级就越容易。

4.4 镜像拉取与快照:层如何成为 rootfs

  1. CRI 的 PullImage 会传递到 CRIImageService.PullImage 。从 2.1 开始,默认值是 transfer 服务,如果开启 use_local_image_pull ,则会使用旧的 client.Pull 路径(第 184 行)。 transfer 路径为: pullImageWithTransferService → Transfer(第 326 行)→ localTransferService.pull 。
  2. 下载的清单、配置和层都会进入 内容寻址存储库。路径直接使用摘要 blobs/sha256/<hex> (blobPath)。 即使有多个镜像使用相同的层,磁盘上也只会存储一份。
  3. 解压过程是: Unpacker.unpack 针对每个层重复执行 快照器 Prepare(第 415 行)→ diff 服务 Apply(解压并写入 tar 包,第 542 行)→ Commit(第 494 行)。已提交快照的名称是 该层为止的 chainID ,因此即使镜像不同,相同的层链也只需解压一次。
  4. 创建容器时, WithNewSnapshot 以最顶层的 chainID 作为父级 Prepare 来创建一个空的 active 快照(第 276 行)。如果镜像尚未解压,CRI 端的包装器会通过 unpackImage 进行解压并重试。
  5. 最后,overlayfs 快照器的 mounts 会生成挂载选项。
if s.Kind == snapshots.KindActive {
	options = append(options,
		fmt.Sprintf("workdir=%s", o.workPath(s.ID)),
		fmt.Sprintf("upperdir=%s", o.upperPath(s.ID)),
	)
}
...
parentPaths := make([]string, len(s.ParentIDs))
for i := range s.ParentIDs {
	parentPaths[i] = o.upperPath(s.ParentIDs[i])
}
options = append(options, fmt.Sprintf("lowerdir=%s", strings.Join(parentPaths, ":")))

来源:containerd v2.3.5 plugins/snapshots/overlay/overlay.go,Apache-2.0

父快照的 fs 目录直接变为 lowerdir ,自身的 fs·work 变为 upperdir·workdir 。由于容器使用的文件全部只堆叠在 upper 中,因此使用相同镜像的一百个容器也共享底层。将这一思路应用到虚拟机磁盘上的故事位于 学生虚拟机的启动过程 4中。

将这一切串联起来的账本是 bolt DB。在命名空间下有镜像、容器、快照、内容和 lease 存储桶 (buckets.go:60 的注释图解), GarbageCollect 会清理任何镜像、容器或 lease 都未引用的内容和快照。拉取过程中的层之所以不会被删除,是因为该操作持有了 lease。 其设计原则是以「无引用则删除」为基础,仅对正在进行的操作通过 lease 设置例外。

5. 与用户贡献的关联

截至 2026-10-17,本文作者尚未向 containerd 和 kubernetes/cri-api 提交过 PR。在贡献规划中,将「容器运行时底层(containerd、CRI-O、runc 等)」列为正在调研的领域,本文即是为此进行的预热运动。第 8 节中的候选项是在撰写本文时直接从源码中发现的。

在此提前说明一点:由于 kubernetes/cri-api 是由机器人同步 kubernetes/kubernetes 的 staging/src/k8s.io/cri-api 的仓库,因此直接向该仓库发送的 PR 将会被忽略(参考仓库 CONTRIBUTING)。对 CRI 端的修改必须发送至 kubernetes/kubernetes,并且需遵循 Kubernetes 的 AI 使用规则(必须在 PR 正文中声明,禁止使用 AI 编写的提交信息及标注共同作者)。

6. 实测:以无权限方式读取 1.7 与 2.3 工作节点

2026-10-17,在小型集群(8 个节点)中仅进行了读取操作。节点运行时的分布情况为 kubectl get nodes -o custom-columns=...containerRuntimeVersion ,在节点内部仅通过普通用户 ssh 进行了 ps·mount·/etc/containerd/config.toml 读取。 /run/containerd·/var/lib/containerd 因权限不足无法读取列表(未使用 sudo),且 crictl 未安装。

项目 工作节点 A(containerd 1.7.27) 工作节点 B(containerd 2.3.5)
同版本节点数 4 台 4 台
runc 1.2.5 1.5.1
运行中的 Pod(其中 hostNetwork) 22 (10) 31 (6)
应用容器数 28 42
containerd-shim-runc-v2 进程 22 31
pause 进程 22 31
任务目录数(目录链接数 − 2) 50 = 22 + 28 74
overlay 挂载 50 74
其中 1 层(单个 lowerdir) 22 33
最深 lowerdir 42 层 52 层
/var/run/netns 项目 12 = 22 − 10 25 = 31 − 6
shim RSS 平均值 / containerd RSS 15.7 MiB / 74 MiB 12.6 MiB / 153 MiB
配置版本 version = 2 version = 4
CRI 配置块 io.containerd.grpc.v1.cri 一个 io.containerd.cri.v1.images·.runtime 拆分为多个
pause 镜像 sandbox_image = pause:3.8 pinned_images.sandbox = pause:3.10.2
snapshotter / cgroup overlayfs / SystemdCgroup = true overlayfs / SystemdCgroup = true

一条 shim 记录看起来像这样(ID 已隐藏):

containerd-shim /usr/bin/containerd-shim-runc-v2 -namespace k8s.io -id 0c1e…(64자) -address /run/containerd/containerd.sock
overlay on /run/containerd/io.containerd.runtime.v2.task/k8s.io/0c1e…/rootfs type overlay (rw,relatime,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1/fs,upperdir=…/snapshots/3774/fs,workdir=…)

读取结果:

在 kubelet 方面,两个版本均为 containerRuntimeEndpoint: unix:///run/containerd/containerd.sock(2.x 节点显示为 /var/run/... ), cgroupDriver: systemd 均为 SystemdCgroup = true 。这与运行时的

完美契合。

  1. api.proto 7. 从哪里开始阅读 RuntimeService·ImageService 只需浏览注释。契约要求的内容以及交给运行时处理的内容一目了然。
  2. plugins/cri/cri.go 与 server.go 了解插件是如何结合的。
  3. sandbox_run.go → podsandbox/sandbox_run.go 按顺序启动一个 Pod。
  4. container_start.go → shim_manager.go → manager_linux.go 一直深入到进程级别。
  5. unpacker.go 与 overlay.go 了解磁盘端。 docs/snapshotters/·docs/content-flow.md 配合食用更佳。

像 k3s 和 RKE2 这样内置 containerd 并进行分发的发行版是如何包装这种架构的,可以继续阅读 k3s·RKE2·k0s 架构比较。

8. 适合首次贡献的良好切入点

9. 局限性

Tee 语录。 kubelet 只管下订单,每张桌子都有专属的服务员(shim)负责。所以主厨(containerd)偶尔休息一下,客人的盘子也依然稳稳当当!

参考资料

所有资料均于 2026-10-17 查阅。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论