Containerd 源码解析:(六)Containerd 创建容器流程总结

大家好,我是费益洲。前面几篇文章阐述了 Containerd 创建容器的流程在源码中的具体实现和调用顺序,本文将使用更直观的图文方式,对 Containerd 创建容器的流程进行总结。
前置条件
Containerd 源码版本:v1.6.33
Containerd 项目地址:https://github.com/containerd/containerd
创建并启动容器
Containerd 中,Task 是容器进程的动态表达,创建容器其实就是创建并启动 Task,总体流程如下所示:

如图所示,容器的创建及启动在 Containerd 中并不是一次“单阶段动作”,而是围绕 Task 拆分为两个职责明确的阶段:先 Create,再 Start。结合源码主线看,这种拆分本质上是在控制面与执行面之间建立清晰边界:Containerd 负责请求编排、状态管理和运行时路由,真正的进程生命周期操作则下沉到 shim 与 OCI runtime。
从入口上看,无论是 docker run 还是 ctr run,最终都会进入 Containerd 的 gRPC 接口。以 ctr run 为例,调用顺序是先创建容器元数据,再创建 Task,最后启动 Task。这里“创建容器”主要是把镜像、规范(spec)和 runtime 配置落入元数据存储;“创建 Task”则是基于这些静态信息构建可执行上下文;“启动 Task”才会真正触发容器进程运行。这也是为什么在源码中能看到 Create 与 Start 分别通过 Tasks/Create、Tasks/Start 两条链路进入不同阶段的处理逻辑。
进一步看执行链路,Containerd 在 Runtime v2 层并不直接拉起容器业务进程,而是先建立 shim 这一中间层,再由 shim 对接具体 OCI runtime(如 runc)。这种设计带来的关键价值是:容器进程与 containerd 守护进程解耦,容器生命周期管理可以稳定收敛在 shim/runtime 侧,避免核心守护进程与业务进程形成强耦合关系。也正因为有这层隔离,Containerd 才能在统一控制面的前提下,兼容不同 runtime 实现并保持运行时路径的一致性。
从整体实现意图总结,Containerd 的容器创建与启动可以理解为一条“先准备、后执行”的流水线:先用 Create 完成运行前置准备和托管关系建立,再用 Start 触发状态迁移与进程拉起。后续两节将沿着这条主线,分别总结 Task 创建和 Task 启动在源码中的关键交互过程。
创建 Task

结合前文对源码的分析可以看到,Task 的创建本质上是 Containerd 将「容器静态定义」转换为「运行时可管理实体」的过程。这个阶段并不会直接把业务进程拉起,而是先把运行所需的执行上下文准备完整。
首先,客户端通过 container.NewTask 发起 Tasks/Create 请求,Task Service 在进入运行时前会做三件关键事情:根据 ContainerID 读取容器元数据、按容器 runtime 选择对应的 Runtime v2 实例、校验 Task 是否已存在。也就是说,Containerd 会先把“该用谁执行、能不能执行”这两个问题在控制面处理清楚,再进入真正的运行时路径。
随后进入 Runtime v2 的创建主线。TaskManager.Create 会先通过 ShimManager.Start 准备并拉起 shim:创建 bundle(包含 config.json、work 等运行上下文),启动 containerd-shim-runc-v2,并建立到 shim 的 ttrpc 通道。这个顺序非常关键,原因是 Containerd 并不直接管理容器进程生命周期,而是通过 shim 完成进程托管与状态上报,确保容器与 containerd 守护进程解耦。
当 shim 通道可用后,Containerd 才会继续调用 containerd.task.v2.Task/Create。在 shim 侧进入 runc task service 后,最终会落到 runc create --bundle:完成 runc init 初始化并创建容器对应的进程上下文。到这里,Task 已经创建成功,容器处于可启动状态(created),但还没有进入真正的业务运行态。
从实现意图看,创建 Task 这一步解决的是“把运行环境准备好并交给正确的 runtime/shim 托管”,而不是“立即执行用户进程”。这也是后续 Start 阶段能够做到职责单一、路径清晰的前提。
启动 Task

Task 启动阶段的核心目标很明确:在已创建的 Task 上触发实际进程运行,并把运行结果回传给上层调用方。源码主线可以概括为“定位目标进程 -> 下发启动指令 -> runtime 执行 -> 回填状态”。
客户端调用 task.Start 后,Containerd 进入 Tasks/Start。Task Service 的 local.Start 会先根据 ContainerID 从 runtime 中获取 Task,再根据 ExecID 决定启动对象:在常见的 ctr run 主流程里,ExecID 为空,因此默认启动的是 init 进程。这个设计保证了同一套启动接口既能处理容器主进程,也能处理后续 exec 进程。
确定启动对象后,Containerd 通过 shim client 发起 containerd.task.v2.Task/Start。shim 侧的 runc task service 获取容器对象并调用 container.Start,随后进入 Init.Start -> createdState.Start -> runtime.Start 这条状态机路径,最终执行 runc start 将容器从 created 推进到 running。
完成启动后,Containerd 会读取进程状态并将 pid 回填到 Start 响应中。这里还需要注意一个边界:Wait 属于调用方在 Start 成功后的后续行为(例如前台模式等待退出),并不是 Start RPC 的必经步骤。
从架构视角看,Start 阶段之所以足够轻量,是因为创建阶段已经把 bundle、shim 通道和运行时对象都准备好了。两阶段拆分之后,Containerd 既能保持控制面的清晰边界,也能把进程生命周期管理稳定地收敛在 shim/runc 这条执行链上。
夜雨聆风