如果说 runc 是容器的”引擎”,那 containerd 就是容器的”操作系统”,它管理镜像的拉取和解压、容器的创建和启动、任务的监控和重启、事件的发布和订阅。Docker 用它管理容器生命周期,Kubernetes 通过 CRI 接口与它交互,cloud-native 生态的每一个角落都有它的身影。
containerd 的设计哲学是简洁且可组合,它只做容器运行时该做的事,不做编排(那是 Kubernetes 的事),不做网络(那是 CNI 的事),不做存储(那是 CSI 的事)。这种”只做一件事并做好”的设计,让 containerd 成为了云原生基础设施的基础组件。
containerd 的诞生源于 Docker 架构的一次关键拆分。早期的 Docker 是一个单体架构,docker daemon 同时负责镜像管理、容器生命周期、网络配置、存储挂载,所有功能耦合在一个进程中。2016 年,Docker 将容器生命周期管理拆分为独立的 containerd 项目,docker daemon 只保留上层 API 和构建功能。2017 年,containerd 加入 CNCF 孵化,2019 年成为 CNCF 毕业项目。从 Docker 的”内部组件”到”行业标准容器运行时”,containerd 完成了身份的蜕变,它不再只是 Docker 的附属,而是 Kubernetes、云平台、边缘计算等场景的默认选择。containerd “只做容器运行时该做的事”,因为它的诞生就是为了从 Docker 的”大而全”中剥离出”小而美”。
前置知识
Ch06 runc 源码分析:containerd 调用 runc 创建容器,理解 runc 是理解 containerd 的前提
Ch05 OCI 规范详解:containerd 遵循 OCI 规范管理镜像和容器
gRPC 基础:containerd 的所有 API 通过 gRPC 暴露
containerd 的设计哲学是”可组合”,它通过插件体系扩展功能,核心只做容器生命周期管理。这种设计让它既轻量又灵活。
下面看 containerd 如何在 runc 之上构建完整的容器管理能力。
一、containerd 整体架构
1.1 架构全景
1.2 核心组件
| 组件 | 职责 | 存储后端 |
|---|---|---|
| Content Store | 存储镜像层和配置的原始内容 | 文件系统 |
| Metadata Store | 存储镜像、容器、快照的元数据 | BoltDB |
| Snapshot Service | 管理文件系统快照(OverlayFS) | overlayfs/btrfs/zfs |
| Diff Service | 计算文件系统层之间的差异 | — |
| Runtime Service | 管理容器运行时(shim+runc) | — |
| Event Service | 发布/订阅容器事件 | — |
1.3 containerd 的数据流
二、gRPC API
2.1 API 服务列表
containerd 通过 gRPC 暴露以下服务:
| 服务 | 功能 | 关键方法 |
|---|---|---|
| Images | 镜像管理 | List, Get, Create, Delete |
| Containers | 容器管理 | List, Get, Create, Delete, Update |
| Tasks | 任务管理 | Create, Start, Kill, Delete, Wait, Exec |
| Content | 内容管理 | List, Info, Read, Write, Delete |
| Snapshots | 快照管理 | Prepare, Commit, Mounts, Remove |
| Diff | 差异计算 | Compare, Apply |
| Leases | 租约管理 | Create, Delete, List |
| Events | 事件订阅 | Subscribe |
| Introspection | 自省 | Plugins, ServerInfo |
2.2 为什么是 gRPC 而不是 REST
containerd 的调用者不是浏览器,而是 Docker daemon、Kubelet 这类同节点进程,通过 Unix domain socket 通信,单次拉取镜像涉及数十次 API 调用。这种场景下 gRPC 的优势明显:
- 二进制序列化:protobuf 比 JSON 体积小 30%-50%,编解码更快,频繁传输镜像元数据时差距会累积
- 原生双向流:拉取镜像进度、容器日志需要流式传输,gRPC 原生支持,REST 只能靠 chunked 或 WebSocket 模拟
- 强类型契约:API 定义在
.proto文件,Go 客户端代码由 protoc 自动生成,接口变更编译期就能发现 - HTTP/2 多路复用:同一连接上并发多个请求,Kubelet 同时拉多个镜像不会因慢响应阻塞
- protobuf 模式演进:字段编号机制天然向后兼容,旧客户端忽略未知字段即可,无需 JSON API 那样的版本协商逻辑
反过来,REST 的优势(人类可读、curl 友好、穿透防火墙)对 containerd 毫无意义:它的 socket 在本地,调用者是程序而非人类。
ctr 命令行工具是 containerd gRPC API 的薄封装,不是 REST 客户端。如果你习惯用 curl 调试 Docker API,在 containerd 里需要改用 ctr 或 grpcurl。
2.3 服务边界的设计逻辑
containerd 把 API 拆成 Images、Containers、Tasks、Content、Snapshots 等独立服务,每个服务对应一个明确的领域概念。这不是随意分组,而是遵循”数据归属”原则:每个服务拥有自己管理的资源,跨服务的操作通过客户端编排,而非在服务端耦合。
先看每个服务为什么独立:
| 服务 | 管理的资源 | 为什么独立 |
|---|---|---|
| Images | 镜像引用与元数据 | 镜像是一等公民,拉取/删除/列举是独立操作,不依赖容器是否存在 |
| Containers | 容器配置与元数据 | 容器是静态定义(spec + rootfs 引用),与运行状态解耦 |
| Tasks | 运行中的容器进程 | Task 生命周期短(进程退出即消失),与 Container 的持久元数据性质完全不同 |
| Content | 原始 blob 数据 | Content 是底层存储,镜像和快照都依赖它,但它的读写模式(大块顺序 I/O)与元数据操作差异大 |
| Snapshots | 文件系统快照 | 快照操作(prepare/commit/mount)与 OverlayFS 紧密绑定,替换存储后端时只需替换这个服务 |
还有一层不容易看到的维度:namespace 隔离。containerd 的每个 API 调用都携带 namespace 参数(Kubernetes 用 k8s.io,Docker 用 moby),不同 namespace 之间的镜像、容器、快照完全隔离。如果 Images 和 Containers 合并,namespace 的隔离逻辑就要在同一个服务内部处理两类资源的可见性,增加出错的概率。独立服务意味着每类资源的隔离逻辑各自维护,互不干扰。
这些服务边界还直接支撑了 containerd 的插件可替换性。containerd 的每个核心服务都对应一个插件接口,替换 Snapshots 服务的实现(从 overlayfs 切换到 btrfs)不需要改任何其他服务的代码。如果服务边界模糊,比如 Content 和 Snapshots 合并,替换快照后端时就会牵连内容存储的逻辑,插件的可替换性名存实亡。
如果把粒度做粗,比如把 Images、Containers、Tasks 合并为一个 ContainerLifecycle 服务,看似调用更方便(一次调用完成”拉镜像、建容器、启任务”),但代价是:
- 客户端失去组合自由度。Kubernetes CRI 不需要”拉镜像”和”启任务”耦合在一起,它需要分别控制。
- 服务端实现变复杂。一个服务要同时处理 blob 下载、元数据持久化、进程管理,任何一环出问题都影响整体。
- 替换变困难。你想把 OverlayFS 换成 btrfs snapshotter,在当前架构下只需替换 Snapshots 服务,合并后就要改整个 ContainerLifecycle 服务。
- 隔离失效。不同 namespace 的资源混在一个服务里管理,隔离逻辑从”天然分离”变成”代码里做过滤”,bug 的温床。
如果把粒度做细,比如把 Tasks 拆成 TaskLifecycle(create/start/kill)和 TaskIO(attach/exec/log)两个服务,看似职责更单一,但:
- Task 的生命周期和 I/O 本质上绑定在同一个 shim 进程上,拆成两个服务意味着跨服务协调 shim 的状态,增加复杂度。
- 客户端需要同时持有两个服务的引用,创建 Task 后还要单独初始化 I/O,调用模式更繁琐。
- containerd 的实际使用中,Task 操作几乎总是”创建并启动”或”杀死并删除”,拆分后这些常见操作需要多次跨服务调用。
当前的服务边界,是在”组合自由度”和”调用便利性”之间找到的平衡点:每个服务管理一类资源,服务之间无调用依赖,客户端按需组合。
2.4 镜像管理 API
镜像管理 API 的调用序列是 Pull → NewContainer,下面只演示这两步(任务创建、启动等生命周期操作见 2.5 与第四章)。本文代码基于 containerd v2 基准(Go 模块路径为 github.com/containerd/containerd/v2);containerd v1 的模块路径没有 v2 后缀,import 时注意区分:
// containerd 镜像管理:拉取镜像并创建容器package main
import ( "context" "fmt" "log"
containerd "github.com/containerd/containerd/v2/client")
func main() { client, err := containerd.New("/run/containerd/containerd.sock") if err != nil { log.Fatal(err) } defer client.Close()
ctx := context.Background()
// 拉取镜像 image, err := client.Pull(ctx, "docker.io/library/nginx:latest", containerd.WithPullUnpack, // 自动解压 ) if err != nil { log.Fatal(err) } fmt.Printf("Pulled image: %s\n", image.Name())
// 创建容器(只声明镜像与快照,还不涉及运行) container, err := client.NewContainer(ctx, "my-nginx", containerd.WithImage(image), containerd.WithNewSnapshot("my-nginx-snapshot", image), containerd.WithNewSpec(containerd.WithImageConfig(image)), ) if err != nil { log.Fatal(err) } defer container.Delete(ctx, containerd.WithSnapshotCleanup)}2.5 容器管理 API
# 使用 ctr 命令行操作 containerd
# 镜像操作ctr images pull docker.io/library/nginx:latestctr images listctr images inspect docker.io/library/nginx:latest
# 容器操作ctr containers create docker.io/library/nginx:latest mynginxctr containers list
# 任务操作ctr tasks start mynginxctr tasks listctr tasks kill mynginxctr tasks attach mynginx
# 快照操作ctr snapshots listctr snapshots prepare my-snapshotctr snapshots commit my-snapshot my-committed-snapshot三、镜像管理
3.1 镜像拉取流程
containerd 拉取镜像的完整流程:
- 解析引用:将
nginx:latest解析为完整的 registry 路径 - 获取 Manifest:从 Registry 拉取镜像 Manifest
- 下载 Blob:按需下载 Config 和 Layer Blob
- 存储 Content:将 Blob 存储到 Content Store
- 解压 Layer:将 Layer 解压到 Snapshot Service
- 注册 Image:在 Metadata Store 中注册镜像
# 查看 containerd 的内容存储ls /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/
# 查看已下载的 blobctr content list
# 查看快照ctr snapshots list3.2 Snapshot Service
Snapshot Service 管理容器的文件系统快照,基于 OverlayFS 实现:
| 操作 | 说明 | OverlayFS 对应 |
|---|---|---|
| Prepare | 创建可写快照 | 创建 upperdir + merged |
| Commit | 提交可写快照为只读 | 将 upperdir 转为 lowerdir |
| Mounts | 获取挂载点 | 返回 overlay mount 选项 |
| Remove | 删除快照 | 删除 upperdir/lowerdir |
# 查看 containerd 的 OverlayFS 快照ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/
# 查看快照的元数据ctr snapshots list
# 查看容器的 OverlayFS 挂载mount | grep overlay3.3 镜像层与快照的映射
四、任务管理
4.1 Container vs Task
containerd 区分 Container 和 Task 两个概念:
| 概念 | 说明 | 生命周期 |
|---|---|---|
| Container | 容器的配置和元数据 | 持久化(直到删除) |
| Task | 容器的运行实例 | 临时(进程退出即结束) |
# Container 可以没有 Task(已停止的容器)ctr containers list# NAME IMAGE# mynginx nginx:latest
ctr tasks list# TASK PID STATUS# (空:容器未运行)
# 启动 Taskctr tasks start mynginxctr tasks list# TASK PID STATUS# mynginx 12345 RUNNING4.2 Task 的创建流程
// containerd 的 Task 创建流程(简化)func (c *container) NewTask(ctx context.Context, ioCreator IOCreator) (Task, error) { // 1. 获取容器的 rootfs 挂载点 mounts, err := c.snapshotter.Mounts(ctx, c.snapshotID)
// 2. 准备 OCI Bundle bundle := &Bundle{ ID: c.id, Path: c.bundlePath, Rootfs: mounts, Spec: c.spec, }
// 3. 启动 containerd-shim shim, err := shim.Start(ctx, bundle, c.runtime) if err != nil { return nil, err }
// 4. 通过 shim 创建 Task task, err := shim.Create(ctx, &task.CreateRequest{ ID: c.id, Bundle: bundle.Path, Rootfs: mounts, Terminal: true, Stdin: "/dev/null", Stdout: "/dev/null", Stderr: "/dev/null", })
return task, nil}五、插件体系
5.1 containerd 的插件架构
containerd 采用插件架构,所有功能都是插件:
# 查看 containerd 的插件列表ctr plugins list
# 输出示例:# TYPE ID PLATFORMS STATUS# io.containerd.content.v1 content - ok# io.containerd.snapshotter.v1 overlayfs linux/amd64 ok# io.containerd.diff.v1 walking linux/amd64 ok# io.containerd.runtime.v2 task/shim - ok# io.containerd.service.v1 diff - ok# io.containerd.service.v1 images - ok# io.containerd.service.v1 containers - ok# io.containerd.service.v1 tasks - ok# io.containerd.grpc.v1 containers - ok# io.containerd.grpc.v1 content - ok# io.containerd.grpc.v1 diff - ok# io.containerd.grpc.v1 images - ok# io.containerd.grpc.v1 introspection - ok# io.containerd.grpc.v1 leases - ok# io.containerd.grpc.v1 namespaces - ok# io.containerd.grpc.v1 snapshots - ok# io.containerd.grpc.v1 tasks - ok# io.containerd.cri.v1 cri - ok5.2 插件类型
| 插件类型 | 示例 | 功能 |
|---|---|---|
| Snapshotter | overlayfs, btrfs, zfs, native | 文件系统快照管理 |
| Diff | walking, plugin | 层差异计算 |
| Runtime | task/shim, io.containerd.runc.v2 | 容器运行时 |
| Service | images, containers, tasks, content | 核心服务 |
| GRPC | containers, content, diff, images | gRPC API |
5.3 自定义 Snapshotter 示例
// 自定义 Snapshotter 插件(简化骨架)package mysnapshotter
import ( "context" "github.com/containerd/containerd/v2/plugins/snapshots")
type mySnapshotter struct { root string}
func NewSnapshotter(root string) snapshots.Snapshotter { return &mySnapshotter{root: root}}
func (s *mySnapshotter) Prepare(ctx context.Context, key, parent string, opts ...snapshots.Opt) ([]mount.Mount, error) { // 创建可写快照 return nil, nil}
func (s *mySnapshotter) Commit(ctx context.Context, name, key string, opts ...snapshots.Opt) error { // 提交快照为只读 return nil}
func (s *mySnapshotter) Mounts(ctx context.Context, key string) ([]mount.Mount, error) { // 返回挂载点 return nil, nil}
func (s *mySnapshotter) Remove(ctx context.Context, key string) error { // 删除快照 return nil}六、事件系统
6.1 事件类型
containerd 通过事件系统发布容器生命周期事件:
| 事件 | 触发时机 |
|---|---|
ImageCreate | 镜像创建 |
ImageDelete | 镜像删除 |
ContainerCreate | 容器创建 |
ContainerDelete | 容器删除 |
TaskCreate | 任务创建 |
TaskStart | 任务启动 |
TaskExit | 任务退出 |
TaskOOM | 任务 OOM |
SnapshotPrepare | 快照准备 |
SnapshotCommit | 快照提交 |
# 订阅 containerd 事件ctr events
# 输出示例:# ENVELOPE TIMESTAMP TOPIC EVENT# 1 2021-08-14T10:00:00Z /containers/create {"id":"mynginx",...}# 2 2021-08-14T10:00:01Z /tasks/create {"id":"mynginx",...}# 3 2021-08-14T10:00:01Z /tasks/start {"id":"mynginx",...}6.2 事件在 Kubernetes 中的应用
Kubernetes 通过 CRI 接口订阅 containerd 事件,感知容器状态变化:
// CRI 事件处理(简化)func (c *criService) handleEvent(event *eventtypes.Event) { switch event.Topic { case "/tasks/exit": // 容器退出,更新 Pod 状态 c.handleContainerExit(event) case "/tasks/oom": // 容器 OOM,记录事件 c.handleContainerOOM(event) }}七、CRI 插件
7.1 CRI 接口
Kubernetes 通过 CRI(Container Runtime Interface)与 containerd 交互:
# containerd 的 CRI 配置version = 2
[plugins."io.containerd.grpc.v1.cri"] # k8s.gcr.io 已于 2022 年弃用,官方改用 registry.k8s.io;pause 镜像建议用 3.9 及以上 sandbox_image = "registry.k8s.io/pause:3.9"
[plugins."io.containerd.grpc.v1.cri".containerd] default_runtime_name = "runc"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2" runtime_engine = "" runtime_root = ""
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc] runtime_type = "io.containerd.runsc.v1" # gVisor
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata] runtime_type = "io.containerd.kata.v2" # Kata Containers7.2 CRI 与 containerd 的交互
containerd 的 gRPC socket(/run/containerd/containerd.sock)默认没有认证机制。任何能访问该 socket 的进程都可以创建、删除容器和拉取镜像。在生产环境中,务必通过文件权限限制 socket 的访问,或启用 TLS 认证。Kubernetes 节点上,只有 kubelet 和 root 用户应该有权访问该 socket。
八、动手实践
8.1 用 Go 客户端操作 containerd
package main
import ( "context" "fmt" "log" "os" "syscall"
containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/pkg/namespaces")
func main() { ctx := namespaces.WithNamespace(context.Background(), "default")
client, err := containerd.New("/run/containerd/containerd.sock") if err != nil { log.Fatal(err) } defer client.Close()
// 拉取镜像 image, err := client.Pull(ctx, "docker.io/library/redis:alpine", containerd.WithPullUnpack, ) if err != nil { log.Fatal(err) } fmt.Printf("Pulled: %s\n", image.Name())
// 创建容器 container, err := client.NewContainer(ctx, "my-redis", containerd.WithImage(image), containerd.WithNewSnapshot("redis-snap", image), containerd.WithNewSpec( containerd.WithImageConfig(image), ), ) if err != nil { log.Fatal(err) } defer container.Delete(ctx, containerd.WithSnapshotCleanup)
// 创建任务 task, err := container.NewTask(ctx, containerd.NewIO(os.Stdin, os.Stdout, os.Stderr)) if err != nil { log.Fatal(err) } defer task.Delete(ctx)
// 启动任务 if err := task.Start(ctx); err != nil { log.Fatal(err) }
// 等待退出 statusC, err := task.Wait(ctx) if err != nil { log.Fatal(err) }
// 发送 SIGTERM task.Kill(ctx, syscall.SIGTERM) status := <-statusC fmt.Printf("Redis exited: %v\n", status)}8.2 containerd 调试技巧
# 查看 containerd 日志journalctl -u containerd -f
# 查看 containerd 的 gRPC 调试端点curl --unix-socket /run/containerd/containerd.sock http://localhost/v2/
# 查看容器详情ctr containers inspect mynginx
# 查看任务详情ctr tasks inspect mynginx
# 查看镜像层ctr images inspect docker.io/library/nginx:latest | jq .rootfs
# 查看快照信息ctr snapshots inspect mynginx-snap
# 查看内容信息ctr content inspect sha256:abc123...九、本章小结
上一章理解了 runc 源码与容器创建流程。
| 组件 | 职责 | 关键接口 |
|---|---|---|
| Content Store | 存储镜像 blob | content API |
| Metadata Store | 存储元数据 | BoltDB |
| Snapshot Service | 文件系统快照 | snapshotter API |
| Runtime Service | 容器运行时 | task API |
| Event Service | 事件发布订阅 | events API |
| CRI Plugin | Kubernetes 集成 | CRI API |
可组合性并非免费午餐。插件之间通过 gRPC 通信,每次调用都有序列化开销;当容器启动失败时,你需要逐层排查,是 Snapshotter 解压失败?是 shim 启动超时?还是 runc 权限不足?调试链路比单体架构长得多。此外,自定义 Snapshotter 或 Runtime 的行为不受 containerd 核心控制,版本兼容性问题可能在你升级 containerd 后才暴露。选择可组合性,就是选择灵活性,同时接受更高的排错成本和更长的故障定位链路。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






