Docker 的容器运行时架构经历了多次演进,从早期 Docker 直接调用 LXC,到引入 libcontainer,再到将运行时拆分为 containerd 和 runc,最终形成了如今被 Kubernetes 广泛采用的 CRI 标准体系。理解从 dockerd 到 containerd 再到 runc 的完整调用链,是排查容器启动失败、分析镜像拉取异常、调试 Kubernetes Pod 状态、以及理解容器安全边界的前提。本文将从 OCI 规范出发,逐层深入 containerd 内部架构、shim 机制、runc 源码实现,并对比当前主流的运行时生态,最后给出自定义 Runtime 的开发思路与生产环境调试技巧。
调用链全景图
用户执行 docker run → dockerd (Docker Daemon)
↓
containerd (container runtime)
↓
containerd-shim-runc-v2 (shim)
↓
runc (OCI Runtime)
↓
Linux Kernel (cgroups/namespaces)
当用户在终端输入 docker run 时,dockerd 并不会直接创建容器进程。相反,dockerd 作为 Docker 引擎的守护进程,主要职责是处理 Docker API 请求、管理镜像构建缓存、维护网络配置,并将实际的容器生命周期管理委托给 containerd。这种分层设计使得 Docker 可以在不重启 dockerd 的情况下升级底层运行时,也为后来 Kubernetes 绕过 dockerd 直接对接 containerd 奠定了基础。containerd 接收到请求后,通过内部的 Task Service 创建容器任务,生成一个 containerd-shim 进程作为中间层,最终由 shim 调用 runc 执行 OCI bundle,完成 namespace 隔离、cgroups 资源限制和 seccomp 系统调用过滤。
OCI 运行时规范
Open Container Initiative(开放容器计划,简称 OCI)是一个由 Linux 基金会托管的开源项目,旨在制定容器运行时的开放标准。OCI 定义了两个核心规范,它们共同确保了不同厂商实现的容器运行时和镜像格式可以互操作:
| 规范 | 作用 | 典型文件 |
|---|---|---|
runtime-spec | 描述容器的运行状态和标准,包括进程配置、环境变量、挂载点、Linux namespace、cgroup 路径等 | config.json, runtime.json |
image-spec | 定义镜像的格式和元数据,包括镜像层、清单文件、配置文件和媒体类型 | manifest.json, layer.tar |
runc 是 OCI runtime-spec 的参考实现,也是目前使用最广泛的 OCI 运行时。创建容器时,containerd 首先拉取镜像并将其解压为 rootfs,然后生成 OCI bundle(目录中包含 config.json 和 rootfs 目录),最后调用 runc create 启动容器。OCI bundle 的 config.json 是一份标准的 JSON 配置文件,runc 会严格按照其中的定义来配置容器环境。
OCI Image Spec 镜像结构详解
一个符合 OCI Image Spec 的镜像在仓库中以内容寻址的方式存储,核心文件包括三个部分:
- Config(配置):描述镜像的元数据,如环境变量、工作目录、启动命令、层历史记录等。文件名为镜像的 SHA256 值,格式为 JSON。
- Manifest(清单):描述镜像由哪些层组成的索引文件,包含 config 文件的引用、每一层 blob 的 digest 和大小、以及媒体类型信息。
- Layers(层):以 tar.gz 格式存储的只读文件系统差异层,每一层代表一次
RUN、COPY或ADD指令的结果。
# 使用 skopeo 拉取镜像的原始 manifest 和配置,不导入本地存储
skopeo copy docker://nginx:latest dir:/tmp/nginx-oci
# 查看 manifest 文件,理解层与 config 的关联关系
cat /tmp/nginx-oci/manifest.json | jq '.'
# 查看 config 文件中的环境变量、启动命令、历史层信息
cat /tmp/nginx-oci/*.json | jq '.config.Env, .config.Cmd, .history'
在容器启动阶段,containerd 的 Snapshotter 会根据 manifest 中列出的层顺序,从上到下依次叠加,最终形成一个可写的 rootfs。overlayfs 是最常用 的 Snapshotter 实现,它将多个只读层(lowerdir)和一个可写层(upperdir)合并为一个统一的挂载视图。
查看 runc 创建的 OCI bundle 配置
# 定位到 containerd 中某个容器的运行时目录,默认 namespace 为 default
CTR_ID=$(ctr -n default containers ls -q | head -1)
cd /run/containerd/io.containerd.runtime.v2.task/default/$CTR_ID || echo "请先启动一个容器"
# 查看 config.json 中的进程启动参数和 namespace 配置
cat config.json | jq '.process.args, .linux.namespaces'
# 查看 cgroup 路径和资源配置
cat config.json | jq '.linux.cgroupsPath, .linux.resources.memory'
config.json 中的 linux.namespaces 数组定义了容器需要进入的命名空间类型,包括 pid、network、ipc、uts、mount、cgroup 等,这是容器隔离的基石。
containerd 架构核心组件
┌─────────────────────────────────────────┐
│ containerd (gRPC API) │
├─────────────────────────────────────────┤
│ Content │ Snapshotter │ Metadata │
│ (镜像层) │ (分层存储) │ (元数据) │
├─────────────────────────────────────────┤
│ Task Service │ Image Service │
│ (容器管理) │ (镜像管理) │
├─────────────────────────────────────────┤
│ Runtime Plugin (shim-v2) │
└─────────────────────────────────────────┘
containerd 采用插件化架构,各个核心组件通过内部接口解耦,用户可以按需替换或扩展特定模块。
Content Store 内容寻址存储
Content Store 是 containerd 中存储不可变内容的子系统,所有镜像层、blob 数据都以内容为 key 进行寻址,key 即为内容的 SHA256 digest。这种设计天然避免了重复存储(deduplication),同一个层被多个镜像引用时只存一份物理数据。Content Store 的存储路径通常位于 /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/ 下。
# 查看 Content Store 中存储的所有 blob(包括层和配置文件)
ctr -n default content ls
# 查看某个特定 digest 的内容类型和大小
ctr -n default content info sha256:<digest>
# 导出某个 blob 内容到文件进行人工检查
ctr -n default content get sha256:<digest> | gzip -d | tar -tv
Snapshotter 快照管理器
Snapshotter 负责管理文件系统的分层视图,是容器镜像层叠加与实际可写 rootfs 之间的桥梁。containerd 支持多种 Snapshotter 实现,用户可以根据存储后端和性能需求灵活选择:
| Snapshotter | 底层技术 | 特点 | 适用场景 |
|---|---|---|---|
overlayfs | 内核 overlayfs | 速度快、无额外空间开销 | 默认首选,大多数发行版支持 |
native | 直接复制 | 简单可靠、无内核依赖 | 测试环境、老旧内核 |
devmapper | 设备映射(thin-pool) | 支持配额限制、快照回滚 | 对磁盘 I/O 隔离有要求的场景 |
zfs | ZFS 快照 | 高效的写时复制、压缩、去重 | ZFS 文件系统环境 |
stargz | 延迟拉取(eStargz) | 按需从远程拉取层数据 | 大型镜像冷启动加速 |
# 查看当前 containerd 可用的 Snapshotter 列表
ctr -n default snapshot ls
# 查看 overlayfs snapshot 的挂载信息,理解 lowerdir/upperdir/workdir 结构
mount | grep overlay
# 查看具体某个容器的快照目录
ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/
nerdctl 是一个兼容 Docker CLI 的 containerd 客户端,它底层完全基于 containerd 的 Content Store 和 Snapshotter 工作。使用 nerdctl 可以方便地验证 containerd 的内部机制:
# 使用 nerdctl 拉取镜像,验证底层走的仍然是 containerd 的 Content Store
nerdctl pull alpine:latest
nerdctl image inspect alpine:latest --format '{{json .RootFS}}'
# 对比 Docker 与 nerdctl 的镜像存储路径差异
nerdctl image ls
事件系统与 NRI 插件接口
containerd 内置了一个发布-订阅模式的事件总线,容器生命周期中的关键节点(创建、启动、停止、删除、OOM 等)都会触发事件。外部工具可以通过 containerd 的 gRPC Event Service 订阅这些事件,实现自定义的监控、审计或自动清理逻辑。
# 使用 ctr event 实时订阅 containerd 事件流
ctr events
NRI(Node Resource Interface)是 containerd 从 v1.7 开始引入的插件接口,它允许在容器生命周期的关键钩子点(如 createRuntime、updateRuntime)注入自定义逻辑,而无需修改 containerd 源码。NRI 插件以独立进程形式运行,通过 ttrpc 与 containerd 通信,可用于资源拓扑感知调度、设备注入、安全策略增强等场景。
# /etc/containerd/config.toml 中启用 NRI
[plugins."io.containerd.nri.v1.nri"]
disable = false
plugin_config_path = "/etc/nri/conf.d"
plugin_registration_timeout = "5s"
plugin_request_timeout = "2s"
- Metadata:存储镜像和容器对象的元数据,包括名称、标签、创建时间、spec 快照等。Metadata 使用 BoltDB 存储在
/var/lib/containerd/io.containerd.metadata.v1.bolt/meta.db中。 - Task Service:管理容器的生命周期(create/start/kill/delete/pause/resume),每个运行中的容器对应一个 Task 对象。
cri-plugin 与 Kubernetes 集成
Kubernetes 1.24 之后移除了 dockershim,kubelet 直接通过 CRI(Container Runtime Interface)调用 containerd 的 cri 插件,这条路径相比过去经过 dockerd 的方案更加精简高效:
kubelet → CRI gRPC → containerd/cri-plugin → containerd → shim → runc
CRI 的两个核心接口:
| 接口 | 作用 |
|---|---|
RuntimeService | Pod/容器生命周期管理、exec/attach/logs、容器状态查询 |
ImageService | 镜像拉取、查看、删除、镜像状态查询 |
配置 containerd 支持 CRI:
# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri"]
# pause 容器镜像,用于持有 Pod 的网络 namespace
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"
pod_annotations = ["io.kubernetes.cri.*"]
# 配置镜像仓库的认证信息
[plugins."io.containerd.grpc.v1.cri".registry.configs."registry.example.com".auth]
username = "admin"
password = "password"
# 验证 containerd CRI 配置是否正确加载
systemctl restart containerd
crictl info | jq '.config'
# 使用 crictl 直接操作 containerd 的 CRI 接口,不经过 kubelet
crictl pods
crictl ps -a
shim v2 演进与 Pod 概念
shim 是 containerd 与 runc 之间的关键隔离层,它的核心职责是维护容器进程的生命周期、转发标准输入输出流、以及作为容器的父进程防止孤儿进程问题。shim-v2 相比 shim-v1 带来了一系列重大改进:
| 特性 | shim-v1 | shim-v2 |
|---|---|---|
| 每个 Pod 的 shim 数量 | 2个(containerd-shim + runc) | 1个(containerd-shim-runc-v2) |
| 内存占用 | ~30MB per Pod | ~5MB per Pod |
| 支持 cgroup v2 | 否 | 是 |
| 支持 rootless | 受限 | 完整 |
cgroup v2 是 Linux 内核下一代统一资源控制接口,与 v1 的层级控制器(memory、cpu、blkio 等分离)不同,v2 采用单一层级结构,支持更细粒度的资源管理和 eBPF 扩展。shim-v2 完整支持 cgroup v2,因此在新发行版(如 Ubuntu 22.04+、Fedora 35+)上运行时能获得更好的资源统计精度和 Rootless 容器体验。
shim-v2 通过 ttrpc(gRPC 的精简版,基于共享内存通信)与 containerd 交互,大幅降低了内存开销和通信延迟。每个 Pod 只启动一个 shim 进程,而该 shim 可以管理同一 Pod 内的多个容器(如 pause 容器和应用容器)。
任务生命周期与 pause 容器
在 Kubernetes 的 Pod 模型中,一个 Pod 包含一个或多个共享网络和存储命名空间的容器。其中 pause 容器是 Pod 中第一个被创建的容器,它的唯一作用是持有 Pod 级别的网络 namespace(netns)、IPC namespace 和 UTS namespace。后续的应用容器通过加入这些已有的 namespace 来实现网络互通和共享存储。
# 查看所有 shim 进程及其资源占用
ps aux | grep containerd-shim-runc-v2 | grep -v grep
# 查看某个 Pod 中所有容器共享的 netns
CRI_POD=$(crictl pods -q | head -1)
crictl inspectp $CRI_POD | jq '.info.runtimeSpec.linux.namespaces'
# 查看 pause 容器和实际业务容器的 cgroup 路径
crictl ps -a | grep $(crictl pods -q | head -1)
运行时类与多运行时调度
containerd 支持同时配置多种运行时类(RuntimeClass),Kubernetes 可以通过 runtimeClassName 字段为不同 Pod 选择不同的底层运行时:
# Kubernetes RuntimeClass 定义示例
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kataclass
handler: kata
---
apiVersion: v1
kind: Pod
metadata:
name: secure-pod
spec:
runtimeClassName: kataclass
containers:
- name: app
image: nginx
# containerd 中配置多种运行时
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata]
runtime_type = "io.containerd.kata.v2"
runc 源码级分析
runc 的核心容器创建逻辑位于 libcontainer 包中。当执行 runc create 时,runc 会经历以下主要步骤:
- 解析 OCI config.json:读取 bundle 中的配置,填充 spec 结构体。
- 创建 namespace:通过
syscall.Clone或unshare系统调用进入指定的 Linux namespace(pid、net、ipc、uts、mount、user、cgroup)。 - 配置 rootfs:执行 pivot_root 或 chroot,将容器的根目录切换到 OCI bundle 的 rootfs。
- 挂载文件系统:按照 config.json 中的
mounts数组逐一挂载 proc、sys、tmpfs 等。 - 设置 cgroups:在 cgroup v1 或 v2 层级中创建子组,写入 memory.limit_in_bytes、cpu.cfs_quota_us 等限制值。
- 加载 seccomp:解析 config.json 中的 seccomp 过滤器配置,通过
prctl(PR_SET_SECCOMP)或seccomp(SECCOMP_SET_MODE_FILTER)应用系统调用白名单/黑名单。 - 设置 capabilities 和 AppArmor/SELinux:根据配置调整进程能力集和安全标签。
- 执行用户指定的入口进程:最终执行 config.json 中
process.args定义的命令。
libcontainer 创建容器流程的关键代码示例
# 克隆 runc 源码进行本地分析
git clone https://github.com/opencontainers/runc.git /tmp/runc-src
cd /tmp/runc-src
# 查看 namespace 创建入口
grep -n "NewNamespace" libcontainer/*.go
# 查看 cgroup v2 资源限制实现
grep -n "fs2" libcontainer/cgroups/fs2/*.go
cgroup v1 与 v2 资源限制配置对比
# cgroup v1 路径结构(各个控制器分离)
cat /sys/fs/cgroup/memory/docker/<container-id>/memory.limit_in_bytes
cat /sys/fs/cgroup/cpu/docker/<container-id>/cpu.cfs_quota_us
# cgroup v2 统一层级结构
cat /sys/fs/cgroup/system.slice/containerd.service/<container-id>/memory.max
cat /sys/fs/cgroup/system.slice/containerd.service/<container-id>/cpu.max
# 查看容器所使用的 cgroup 版本
mount | grep cgroup2
ls /sys/fs/cgroup/memory 2>/dev/null && echo "cgroup v1" || echo "cgroup v2"
在 cgroup v2 中,原有的独立控制器被合并为统一的层级,所有资源控制文件都位于同一个目录下,路径更简洁,且引入了 memory.high(软限制)等新概念,使得资源管理更加灵活。
seccomp 配置文件加载与实践
seccomp(Secure Computing Mode)是一种内核安全机制,允许限制进程可以使用的系统调用。容器运行时可以加载自定义的 seccomp profile 来减少攻击面。Docker 和 containerd 都默认使用经过审计的 seccomp profile,仅放行容器正常工作所需的系统调用。
# 查看 runc 默认使用的 seccomp profile 路径
runc spec --seccomp
# 导出一个容器的 seccomp 状态
CTR_ID=$(ctr -n default containers ls -q | head -1)
cat /run/containerd/io.containerd.runtime.v2.task/default/$CTR_ID/config.json | jq '.linux.seccomp'
自定义 seccomp profile 示例:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86"],
"syscalls": [
{
"names": ["exit_group", "exit", "read", "write", "openat", "close"],
"action": "SCMP_ACT_ALLOW"
},
{
"names": ["execve", "execveat"],
"action": "SCMP_ACT_ALLOW",
"args": []
}
]
}
将该 profile 保存为 /etc/containerd/seccomp/custom.json,并在 OCI config 中通过 linux.seccomp 字段引用,即可实现对容器系统调用层面的强制约束。
运行时生态对比
随着容器技术的发展,除了 runc 之外,社区还涌现出了多种针对特定场景优化的容器运行时。选择合适的运行时需要在安全性、性能、隔离级别和兼容性之间做出权衡:
| 运行时 | 类型 | 隔离机制 | 启动延迟 | 内存开销 | 典型场景 |
|---|---|---|---|---|---|
runc | 原生 Linux 容器 | namespace + cgroup | 毫秒级 | ~10MB | 通用容器、Kubernetes 默认 |
crun | 原生 Linux 容器(C 语言) | namespace + cgroup | 毫秒级(比 runc 更快) | ~5MB | 资源受限边缘设备、大规模并发 |
kata-containers | 轻量虚拟化 | 独立内核虚拟机(KVM/QEMU) | 秒级 | ~128MB+ | 多租户安全隔离、不可信工作负载 |
gVisor | 用户态内核 | 系统调用拦截 + Sentry | 百毫秒级 | ~20MB | 沙箱化不可信代码、安全容器 |
WasmEdge | WebAssembly 运行时 | 沙箱 + 能力安全模型 | 毫秒级 | ~1MB | 无服务器边缘计算、轻量函数 |
crun 是用 C 语言编写的 OCI 运行时,与 runc 功能完全兼容,但在启动速度和内存占用上有明显优势,特别适合大规模节点上的高并发容器创建场景。gVisor 则采用了截然不同的设计思路:它在用户态实现了一个名为 Sentry 的内核子系统,拦截并重新实现容器的系统调用,使容器应用无法直接访问宿主内核,从而提供了额外的安全边界,代价是部分系统调用的性能开销。Kata Containers 则走轻量虚拟化路线,每个 Pod 运行在一个独立的微型虚拟机中,拥有独立的内核和 initrd,隔离级别与虚拟机相当,但启动速度和资源开销远高于传统容器。WasmEdge 基于 WebAssembly 技术,提供了极致的轻量化和启动速度,适合 FaaS 和边缘计算,但目前与 Docker/容器生态的兼容性仍在发展中。
# 安装并试用 crun 替代 runc
sudo apt-get install -y crun || sudo dnf install -y crun
# 配置 containerd 使用 crun 作为可选运行时
# 在 /etc/containerd/config.toml 中添加:
# [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun]
# runtime_type = "io.containerd.runc.v2"
# [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun.options]
# BinaryName = "crun"
# 验证 crun 版本和性能对比
crun --version
/usr/bin/time -v crun run test-container < /dev/null 2>&1 | grep "Elapsed\|Maximum resident"
自定义 Runtime 开发
基于 containerd 的插件化架构,开发者可以实现自定义的 shim 来对接非 Linux 容器运行时或特殊硬件环境。开发自定义 Runtime 的基本步骤如下:
- 实现 shim v2 接口:需要实现
api/runtime/task/v2中定义的 Create/Start/Kill/Delete/Exec 等 gRPC 方法。 - 管理容器生命周期:在 Create 中准备运行环境,在 Start 中启动实际进程,在 Delete 中清理资源。
- 注册到 containerd:将编译好的 shim 二进制文件放在
$PATH中,containerd 会根据运行时类型名(如io.containerd.myruntime.v1)自动查找并启动对应的 shim。
// 自定义 shim 的入口示例(简化版)
package main
import (
"context"
"github.com/containerd/containerd/runtime/v2/shim"
"github.com/containerd/ttrpc"
)
type service struct{}
func (s *service) Create(ctx context.Context, r *task.CreateTaskRequest) (*task.CreateTaskResponse, error) {
// 实现容器创建逻辑
return &task.CreateTaskResponse{Pid: 1234}, nil
}
func main() {
shim.Run("io.containerd.example.v1", func(ctx context.Context, id string, publisher shim.Publisher) (shim.Shim, error) {
return &service{}, nil
})
}
# 编译自定义 shim
go build -o /usr/local/bin/containerd-shim-example-v1 ./main.go
# 配置 containerd 使用新的运行时
# [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.example]
# runtime_type = "io.containerd.example.v1"
构建精简容器运行时的另一个方向是使用 ctr 或 nerdctl 直接操作 containerd,绕过 Docker 引擎的全部功能,只保留最核心的容器管理能力:
# 使用 ctr 手动创建并运行一个容器,不依赖 dockerd 或 kubelet
ctr images pull docker.io/library/alpine:latest
ctr run --rm -t docker.io/library/alpine:latest test-shell sh
# 使用 nerdctl 构建镜像(需要 buildkit)
nerdctl build -t myapp:latest .
nerdctl run -d -p 8080:80 --name myapp myapp:latest
调试 containerd 的实用技巧
生产环境中遇到容器异常时,有一套系统性的调试方法可以快速定位问题根源。
# 1. 查看 containerd 的运行日志,重点关注错误级别记录
journalctl -u containerd -f -n 200
# 2. 使用 ctr 命令行工具直接查询 containerd 内部状态,绕过 CRI
ctr -n default images ls # 列出所有镜像
ctr -n default containers ls # 列出所有容器对象
ctr -n default tasks ls # 列出运行中的任务(实际进程)
ctr -n default tasks metrics <container-id> # 查看容器的实时资源指标
# 3. 查看某个容器的 shim 进程和线程信息
ps auxf | grep -A2 containerd-shim-runc-v2
# 4. 深入分析单个容器的配置、spec 和 cgroup 路径
CTR_ID=$(ctr -n k8s.io containers ls -q | head -1)
ctr -n k8s.io container info $CTR_ID | jq '.Spec.linux.cgroupsPath'
ctr -n k8s.io container info $CTR_ID | jq '.Spec.linux.resources'
# 5. 使用 kill -SIGUSR1 触发 containerd 生成 goroutine 栈 dump
kill -SIGUSR1 $(pgrep containerd)
journalctl -u containerd | grep -A 50 "stack"
常见故障排查:当容器 stuck 在 Created 状态,通常是 shim 与 containerd 之间的 ttrpc 通信异常导致状态同步失败。此时应首先检查 /run/containerd/ 目录下对应的 socket 文件和状态文件是否存在、权限是否正确。若问题持续,可尝试在业务低峰期重启 containerd 服务(生产环境需谨慎,建议先 drain 节点上的工作负载)。
# 检查某个卡死容器的 shim socket
CTR_ID=$(ctr -n default containers ls -q | head -1)
ls -la /run/containerd/s/$CTR_ID
# 如果 socket 文件缺失或损坏,手动清理残留状态(仅用于紧急恢复)
cd /run/containerd/io.containerd.runtime.v2.task/default/
rm -rf $CTR_ID
ctr -n default container delete --force $CTR_ID 2>/dev/null
通过深入理解从 dockerd 到 containerd 再到 runc 的完整调用链,运维人员可以更从容地应对容器启动失败、资源泄漏、安全策略冲突等各类生产环境问题,也能为基于容器技术构建自定义 PaaS 平台打下坚实的基础。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。