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 镜像与仓库。