状态:草稿(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. 一句话总结:订单样式与后厨
- 2. 架构图
- 3. 代码导读
- 4. 核心路径追踪
- 5. 与用户贡献的关联
- 6. 实测:无权限读取 1.7 与 2.3 的 Worker 节点
- 7. 从哪里开始读起
- 8. 适合首次贡献的地方
- 9. 局限性
- 参考资料
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. 架构图
- 自上而下展示了一次 Pod 创建的完整流程。仅 kubelet 与 containerd 之间通过 gRPC(Unix 套接字)通信,而 CRI 插件与核心服务之间则是 同一进程内的函数调用。
- 三个圆柱体代表持久化在磁盘上的状态:
meta.db(bolt 元数据)、blobs/sha256(内容存储)、snapshots/N(overlayfs 快照器)。 - containerd 与 shim 之间使用 ttrpc(轻量级 gRPC 协议)。每个 Pod 对应一个 shim,而 runc 在工作完成后会消亡。
- 由于 CNI 是 containerd 外部的二进制文件,因此单独用橙色绘制。
- 左侧堆栈是 overlayfs 快照器的
snapshots/<번호>/fs目录。下方三层为已提交的只读层,最上方为每个容器新创建的可写层。 - 右侧的 ①~④ 是 4.4 节将要追踪的函数执行顺序。
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 的差异(仅列出源码确认过的内容)
- CRI 的位置。 在 1.7.27 中,
pkg/cri/下存在两套实现:默认的server/(108 个 Go 文件)与实验性的sbserver/(98 个文件),通过环境变量ENABLE_CRI_SANDBOXES进行选择 (pkg/cri/cri.go:102)。而 2.x 将沙箱侧的实现作为默认并移到了internal/cri/中,同时将插件拆分为三个:接收 gRPC 的io.containerd.grpc.v1.cri,以及它所依赖的io.containerd.cri.v1.runtime·io.containerd.cri.v1.images(cmd/containerd/builtins/cri.go). - 负责启动 pause 的主体。 1.7 的
RunPodSandbox在一个函数中创建 pause 容器(sandbox_run.go:178),调用 CNI(第 338 行),并启动任务(第 481 行)。而 2.x 中,CRI 服务器只负责到网络部分,pause 则交由名为podsandbox的 沙盒控制器插件处理(4.2 节)。 - 配置版本。 2.x 的配置版本是 4(
version/version.go:41#{@_@}config.go:48的migrations)。版本 4 是将服务器属性([grpc]·[ttrpc]等)迁移到io.containerd.server.v1.*插件配置后的版本。
4. 跟踪核心路径
4.1 守护进程通过插件图进行组装
containerd 的几乎所有功能都是插件。各个包在 init() 中将自己注册到 registry.Register ,而
builtins.go 仅仅通过空导入(_)引入这些包。是否将它们包含在构建中由一行导入语句决定。
- 插件通过 类型和 ID 来标识。类型是类似
io.containerd.grpc.v1,io.containerd.snapshotter.v1,io.containerd.cri.v1的字符串(见plugins/types.go:35附近)。配置文件中的[plugins."io.containerd.cri.v1.images"]正是直接使用了「类型.ID」。 - 注册包含
Requires(所需类型列表)。CRI gRPC 插件要求十一种类型,包括 CRI 服务、沙盒控制器、NRI、事件、lease、transfer、shim 等(plugins/cri/cri.go:46init). - 守护进程在
LoadPlugins中按依赖顺序对registry.Graph(...)进行排序(第 386 行),并在New的循环中(第 161 行)逐个进行p.Init(第 189 行)。图排序本身位于单独的模块github.com/containerd/pluginv1.1.0 中,因此不在此仓库中。 - 服务通常分为 两层。一层是负责实际工作的
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 容器」。
criService.RunPodSandbox首先使用沙盒 ID 创建一个 lease (第 97 行)。只要该 lease 存活,沙盒使用的内容和快照就不会被垃圾回收(GC)。hostNetwork如果不是netns.NewNetNS,则在/var/run/netns/下创建一个具名的网络命名空间(第 209 行)。- 接着,
setupPodNetwork调用 CNI 的Setup(第 496 行)。 在启动任何容器之前 ,就已经先连接了网络。获取到的 IP 会被缓存到沙盒状态中,避免每次PodSandboxStatus时都去查找命名空间。 - 然后调用沙盒服务的
CreateSandbox(第 281 行)和StartSandbox(第 299 行)。在这里,实际工作由沙盒控制器完成。 - 默认控制器
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
CreateContainer将 CRI 的ContainerConfig转换为 OCI 运行时规范,通过WithNewSnapshot(第 378 行)准备镜像的可写层,并调用client.NewContainer(第 462 行)。这一阶段 仅创建元数据 。此时还没有进程。StartContainer如果沙盒拥有任务 API 地址,则将其作为选项附加(第 197 行),并调用container.NewTask(第 206 行)→task.Start(第 247 行)。- 客户端的
NewTask在handleMounts(第 328 行)中调用快照器的Mounts接收它并将 rootfs 挂载列表放入请求中。tasks 服务的local.Create接收该请求并将其传递给 runtime v2 的TaskManager.Create。 ShimManager.Start与binary.Start将 shim 二进制文件作为start子命令执行。shim 会返回其 ttrpc 套接字地址,并脱离守护进程。- 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 ¶ms, 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
- CRI 的
PullImage会传递到CRIImageService.PullImage。从 2.1 开始,默认值是 transfer 服务,如果开启use_local_image_pull,则会使用旧的client.Pull路径(第 184 行)。 transfer 路径为:pullImageWithTransferService→Transfer(第 326 行)→localTransferService.pull。 - 下载的清单、配置和层都会进入 内容寻址存储库。路径直接使用摘要
blobs/sha256/<hex>(blobPath)。 即使有多个镜像使用相同的层,磁盘上也只会存储一份。 - 解压过程是:
Unpacker.unpack针对每个层重复执行 快照器Prepare(第 415 行)→ diff 服务Apply(解压并写入 tar 包,第 542 行)→Commit(第 494 行)。已提交快照的名称是 该层为止的 chainID ,因此即使镜像不同,相同的层链也只需解压一次。 - 创建容器时,
WithNewSnapshot以最顶层的 chainID 作为父级Prepare来创建一个空的 active 快照(第 276 行)。如果镜像尚未解压,CRI 端的包装器会通过unpackImage进行解压并重试。 - 最后,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=…)
读取结果:
- shim 数量 = Pod 数量。 两个版本完全相同。这说明 4.3 节的
sandbox-id绑定机制在 1.7 中就已经存在了。-id中记录的值 不是容器,而是沙箱 ID。ps的 comm 被截断为containerd-shim是因为 Linux 会将进程名称限制在 15 个字符以内。 - hostNetwork Pod 中也存在 pause。 22 个 pause 中有 10 个属于 hostNetwork Pod,网络命名空间数量 (12) 与 其余 Pod 数量完全一致。4.2 节中「如果不是 hostNetwork 则创建 netns」的分支逻辑在数字上得到了印证。
- 任务目录 50 = 沙箱 22 + 容器 28 。pause 也被算作一个容器。工作节点 B 计算为 31 + 42 = 73,多出一个,但由于没有列表权限,无法确认属于哪个容器(未确认)。
- 1 层 overlay 22 个 = pause 22 个。 pause 镜像只有一层,因此只有一个 lowerdir。工作节点 B 的 33 个似乎包含 31 个 pause 和 2 个单层镜像(推测)。
- 工作节点 B 的 lowerdir
snapshots/1是此节点上首次解压的快照,upper 编号 3774 意味着在此期间创建并删除的快照数量非常多。 由于编号是storage.CreateSnapshot根据此 bolt 存储桶的NextSequence分配的(第 252 行),因此已删除的编号不会被重复使用。 - 如果将 1.7 节点的配置直接交给 2.3.5 守护进程,则每次启动时都会执行版本 2 → 4 的迁移并留下警告(第 3 节)。实际并未进行升级测试(未实测)。
在 kubelet 方面,两个版本均为 containerRuntimeEndpoint: unix:///run/containerd/containerd.sock(2.x 节点显示为 /var/run/... ),
cgroupDriver: systemd 均为 SystemdCgroup = true 。这与运行时的
完美契合。
api.proto7. 从哪里开始阅读RuntimeService·ImageService只需浏览注释。契约要求的内容以及交给运行时处理的内容一目了然。plugins/cri/cri.go与server.go了解插件是如何结合的。sandbox_run.go→podsandbox/sandbox_run.go按顺序启动一个 Pod。container_start.go→shim_manager.go→manager_linux.go一直深入到进程级别。unpacker.go与overlay.go了解磁盘端。docs/snapshotters/·docs/content-flow.md配合食用更佳。
像 k3s 和 RKE2 这样内置 containerd 并进行分发的发行版是如何包装这种架构的,可以继续阅读 k3s·RKE2·k0s 架构比较。
8. 适合首次贡献的良好切入点
- CRI 配置文件中的版本号已过时。
docs/cri/config.md的 「Config versions」小节中提到版本 3 是最新的,版本 2 会「自动转换为版本 3」,并且「Full configuration」示例也是version = 3。 同一标签的RELEASES.md则指出最新版本是 4(从 v2.3.0 开始),且代码中的ConfigVersion也是 4。可以通过提交一个文档 PR 来修复它。 - 两个 TODO。
plugins/cri/cri.go的initCRIService末尾留下了「Closer 在哪里被调用」以及「如何停止 containerd」的 TODO。仅仅追踪在终止路径中Close是否被调用并将其整理成 issue 就会很有帮助。 - 沙箱平台判定的临时处理。
sandbox_run.go内部第 180 行附近有一条注释:「控制器无法提早告知平台,因此通过是否存在 Linux 小节来进行推测,这是一个 hack」。 这需要对控制器接口进行讨论,作为首个 PR 来说可能有点大,但它是阅读 issue 背景的一个好入口。 - cri-api README 中的各版本变更列表停留在 v1.32(基于 v0.34.0 标签,master 未确认)。提交的目标应为 kubernetes/kubernetes 的 staging 目录。
- 标签:containerd 使用
exp/beginner标签(截至 2026-10-17 有 2 个处于开启状态)。AI 规则位于 CONTRIBUTING 的「Automated and AI-generated contributions」小节中。PR 必须由人工开启,AI 生成的代码、正文和评论必须由作者进行全面审查。
9. 局限性
- 未打开 kubelet 源码。kubelet 以什么顺序调用 CRI(例如
PullImage与CreateContainer之间的重试机制)超出了本文的范围。 - 未涉及 NRI、CDI(设备注入)、用户命名空间、检查点、流式传输(exec·attach·port-forward)服务器以及 Windows 路径。
- 未深入探究 transfer 服务与本地拉取的区别、GC 调度器的周期与阈值,仅查看了函数名。
- 由于实测采用的是无权限读取,因此未对内容存储库大小、bolt DB 内容以及快照数量进行实测。也没有实际进行 1.7 → 2.3 版本升级的记录(未实测)。
- 与 1.7 的对比仅限于对照 v1.7.27 源码的文件结构和少量函数。
Tee 语录。 kubelet 只管下订单,每张桌子都有专属的服务员(shim)负责。所以主厨(containerd)偶尔休息一下,客人的盘子也依然稳稳当当!
参考资料
所有资料均于 2026-10-17 查阅。
- kubernetes/cri-api v0.34.0
pkg/apis/runtime/v1/api.proto, CONTRIBUTING.md - containerd v2.3.5:
plugins/cri/cri.go,cmd/containerd/server/server.go,internal/cri/server/sandbox_run.go,internal/cri/server/podsandbox/sandbox_run.go,core/runtime/v2/shim_manager.go,cmd/containerd-shim-runc-v2/manager/manager_linux.go,plugins/snapshots/overlay/overlay.go,core/unpack/unpacker.go,docs/cri/architecture.md,docs/containerd-2.0.md,RELEASES.md,CONTRIBUTING.md - containerd v1.7.27:
pkg/cri/cri.go,pkg/cri/server/sandbox_run.go - 引用代码:containerd v2.3.5,Apache License 2.0。