6.5 容器运行时 (containerd / runc)

一句话

容器不是 “一个程序”,而是一条链:kubelet → CRI → containerd → shim → runc → 进程;镜像格式由 OCI image-spec 定,容器行为由 OCI runtime-spec 定,runc 只是其中一个实现——换运行时换的是隔离强度与开销,不换镜像。

一条链,五段职责

kubelet ──CRI──► containerd ──► containerd-shim ──► runc ──► 容器进程
                     │                                │
                拉镜像/建快照                    读 config.json
                管理生命周期                 clone + namespace + cgroup
环节 负责什么 关键点
kubelet 通过 CRI(gRPC)下指令:拉镜像、起停容器、查状态 只认 CRI,不认 Docker CLI
containerd 镜像拉取与解压、内容存储、快照、容器元数据、事件 常驻守护进程,重启不影响已跑容器
shim 每个 Pod 一个(containerd-shim-runc-v2),托管进程、日志、退出码 让容器脱离 containerd 的生命周期
runc 按 OCI runtime-spec 创建容器:clone 出 namespace、写 cgroup、pivot_root、exec 短命进程:容器起来后它就退出
容器进程 业务本身 PID 1 的信号语义见 6.3 namespace (隔离) 与 cgroups (限制)

🔑 shim 的存在解释了一个常见疑问:为什么 systemctl restart containerd 不会杀掉业务容器——容器的父进程是 shim,不是 containerd。

两个 OCI 规范:镜像与运行时是分开的

规范 定义什么 谁实现
image-spec manifest / config / layers 的格式与 digest 计算 所有构建工具与镜像仓库
runtime-spec config.json:process、root、mounts、linux.namespaces、cgroups、hooks runc、crun、runsc、Kata 等

config.json 是这条链的 “接口契约”:里面的 linux.namespaces 决定隔离哪几维,linux.cgroupsPath 决定挂在哪个 cgroup。换运行时 = 换一个能读同一份 config.json 的执行器,这就是 “不换镜像” 的原因。

运行时选型:隔离强度换开销

运行时 隔离 启动开销 兼容性 适合
runc namespace + cgroups(共享内核) 最低(毫秒级) 完整 默认选择,绝大多数业务
crun 同 runc(C 实现) 更低、内存更省 完整 高密度节点、启动敏感场景
runsc(gVisor) 用户态内核拦截系统调用 中(系统调用有翻译开销) 部分不兼容(io_uring、部分 syscall) 多租户、不可信代码
Kata Containers 轻量 VM(硬件虚拟化) 高(百毫秒级) 接近完整 强合规、需独立内核
WASM(如 wasmtime) 沙箱,非 Linux ABI 低 需编译为 WASM 插件、边缘、极短任务

Kubernetes 用 RuntimeClass 把 “哪些 Pod 用强隔离运行时” 表达出来;这层的编排细节见 7.x(待补)。

containerd 与 Docker 的关系

  • Docker = containerd + dockerd + CLI + 构建(BuildKit)。
  • Kubernetes 在 1.24 移除了 dockershim,直接用 CRI 对接 containerd 或 CRI-O。
  • 因此 “生产 K8s 上跑 docker” 这句话在今天通常是错的:跑的是 containerd,docker ps 看不到容器是正常现象(用 crictl)。

排错:按链路找,别猜

# 1) K8s 视角:容器在不在、状态是什么
crictl ps -a | head -5
# 示例输出(字段:容器ID 镜像 创建 状态 名称 尝试 所属Pod)
# 3f9a1c2d8e7b  registry/app:1.4.2  2m ago  Running  app  0  a1b2c3

# 2) 运行时视角:这个容器用了哪个运行时、config 长什么样
crictl inspect 3f9a1c2d8e7b | jq '.info.runtimeSpec.linux.namespaces'
# 3) containerd 视角:镜像与快照
ctr -n k8s.io images ls | grep app

排错顺序与链路一致:kubelet 事件 → CRI 状态 → containerd 日志 → shim 日志 → runc 报错。最常见的三类失败:镜像拉取失败(凭证/网络)、config.json 与内核能力不匹配(如 cgroup v2 上的旧配置)、runc 起进程失败(权限、seccomp、no_new_privs)。

rootless:把守护进程也降权

做法 效果 代价
rootless containerd / podman 守护进程本身以普通用户运行,配合 user namespace 绑定低端口需 net.ipv4.ip_unprivileged_port_start 或端口转发
只做 “容器内非 root” 缩小应用权限 守护进程仍是 root,逃逸后仍可能拿到宿主 root

两者的差别是 “谁在守门”:前者把守护进程也变成非特权,后者只改应用身份。强隔离仍要靠 gVisor / Kata,不要指望 rootless。

反模式

反模式 后果
在 K8s 节点上用 docker ps 排错 看不到容器,误判 “容器没起来”
以为重启 containerd 会重启业务 误操作前不敢重启,错过了恢复窗口
为 “更安全” 全局切 gVisor,不做兼容性验证 部分系统调用被拦,应用随机失败
把 runc 当常驻进程管理 它本来就该在容器起来后退出
多租户场景只用 runc + namespace namespace 不是安全边界,逃逸即出局
混用 Docker 与 containerd 的镜像存储 拉了两份、占双倍磁盘,排查时对不上

三条铁律

🔑 镜像与运行时解耦:image-spec 管 “装什么”,runtime-spec 管 “怎么跑”,换运行时不用重做镜像。

🔑 shim 是容器存活的关键:容器的父进程是 shim,不是 containerd。

🔑 强隔离只能换边界(用户态内核 / 轻量 VM),调 namespace 与 cgroup 参数换不来安全。

自检

Q1 重启 containerd 后,业务容器还在跑,但 crictl ps 短时间查不到,为什么?

▸ 答案:因为容器由 shim 托管,containerd 重启不影响已运行进程;但 containerd 需要重新从磁盘恢复容器元数据(bolt 数据库)并与 shim 重新建立连接,这个过程有短暂窗口。若元数据损坏,才会出现 “进程在跑但平台认为它不存在”——这类容器需要手工 kill 后重建。

Q2 同一个镜像,能不能既用 runc 跑又用 Kata 跑?为什么?

▸ 答案:能。镜像遵循 image-spec,只描述文件系统与环境;怎么隔离由 runtime-spec 的 config.json 决定,Kata 只是把这个 config 交给一个轻量 VM 去执行。前提是镜像里的应用不依赖 “特定内核版本或模块”,否则换 Kata 可能需要调整。

Q3 为什么 runc 在容器启动后就退出了,而不是一直守着?

▸ 答案:runc 的职责是 “按 spec 把进程创建出来”:设置 namespace、cgroup、挂载、执行 exec。进程一旦成为容器的 1 号进程,就不需要 runc 了;保活与回收由 shim 负责。这也是容器 “启动快、开销小” 的一部分——没有常驻的运行时进程。

Q4 一个团队为了多租户安全,把所有 Pod 切到 gVisor,结果部分服务随机失败。你会怎么定位?

▸ 答案:先确认失败是否与系统调用相关:gVisor 用用户态内核拦截 syscall,对 io_uring、部分 mmap/ptrace、某些 socket 选项支持不全。定位方法是把同一个镜像用 runc 跑一遍对比,或用 gVisor 的 --strace 看被拒绝的调用。结论往往是 “核心链路用 Kata 或 runc + 额外隔离,只有不可信代码用 gVisor”,而不是全局切换。

Q5 K8s 节点上 docker ps 显示没有容器,是不是节点坏了?

▸ 答案:不是。K8s 1.24 起移除 dockershim,容器由 containerd(或 CRI-O)管理,Docker CLI 看不到它们。正确命令是 crictl ps(需要配置 --runtime-endpoint)或 ctr -n k8s.io containers ls。把 “docker ps 为空” 当故障,会导致误重启服务、误删节点。

跨篇相关:namespace 与 cgroups 的隔离/限制语义见 6.3 namespace (隔离) 与 cgroups (限制);共享内核的边界与 VM 的差别见 6.1 容器与虚拟机的本质差异;只读层与可写层的落盘见 6.2 镜像与分层、写时复制;镜像的来源与漏洞见 6.6 镜像安全与扫描;Pod 网络与卷的挂载见 6.7 容器存储与网络;镜像分发与仓库信任边界见 54.2 镜像与仓库。