容器运行时深度解析:dockerd 到 containerd 再到 runc 的完整调用链

深入理解 Docker 容器运行时架构,从 dockerd 到 containerd 再到 runc/shim 的完整调用链与调试技巧。

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 格式存储的只读文件系统差异层,每一层代表一次 RUNCOPYADD 指令的结果。
# 使用 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 数组定义了容器需要进入的命名空间类型,包括 pidnetworkipcutsmountcgroup 等,这是容器隔离的基石。

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 隔离有要求的场景
zfsZFS 快照高效的写时复制、压缩、去重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 的两个核心接口:

接口作用
RuntimeServicePod/容器生命周期管理、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-v1shim-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 会经历以下主要步骤:

  1. 解析 OCI config.json:读取 bundle 中的配置,填充 spec 结构体。
  2. 创建 namespace:通过 syscall.Cloneunshare 系统调用进入指定的 Linux namespace(pid、net、ipc、uts、mount、user、cgroup)。
  3. 配置 rootfs:执行 pivot_root 或 chroot,将容器的根目录切换到 OCI bundle 的 rootfs。
  4. 挂载文件系统:按照 config.json 中的 mounts 数组逐一挂载 proc、sys、tmpfs 等。
  5. 设置 cgroups:在 cgroup v1 或 v2 层级中创建子组,写入 memory.limit_in_bytes、cpu.cfs_quota_us 等限制值。
  6. 加载 seccomp:解析 config.json 中的 seccomp 过滤器配置,通过 prctl(PR_SET_SECCOMP)seccomp(SECCOMP_SET_MODE_FILTER) 应用系统调用白名单/黑名单。
  7. 设置 capabilities 和 AppArmor/SELinux:根据配置调整进程能力集和安全标签。
  8. 执行用户指定的入口进程:最终执行 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沙箱化不可信代码、安全容器
WasmEdgeWebAssembly 运行时沙箱 + 能力安全模型毫秒级~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 的基本步骤如下:

  1. 实现 shim v2 接口:需要实现 api/runtime/task/v2 中定义的 Create/Start/Kill/Delete/Exec 等 gRPC 方法。
  2. 管理容器生命周期:在 Create 中准备运行环境,在 Start 中启动实际进程,在 Delete 中清理资源。
  3. 注册到 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"

构建精简容器运行时的另一个方向是使用 ctrnerdctl 直接操作 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 平台打下坚实的基础。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. Harbor 私有镜像仓库深度实践:企业级容器镜像管理
  2. Docker 镜像大小优化与层缓存策略深度指南
  3. Docker Rootless 模式与容器安全深度剖析