容器运行时:Docker 与 containerd
Docker vs containerd · CRI / OCI 两套标准 · dockershim 移除 · runc/gVisor/Kata 隔离谱系
一句话结论
K8s 只认 CRI,Docker 那套 CLI/构建/API 对它是冗余;1.24 删掉 dockershim 直连 containerd,镜像格式(OCI)不变、构建与运行彻底解耦。
场景问题
面试高频题:「K8s 1.24 之后不支持 Docker 了,我的镜像还能用吗?」——一句话答案是镜像照用,变的只是运行时那一层。但要讲清楚为什么,得先分清一堆容易混的名词:Docker、dockerd、containerd、containerd-shim、runc、CRI、OCI。
问题的根子是分层与标准化:容器技术早期 Docker 一家全包(构建镜像、拉镜像、管网络卷、起容器、CLI、daemon 全都在 dockerd 里)。K8s 要调度容器,早期只能通过一个叫 dockershim 的适配层去喊 Docker。可 Docker 这一大坨里,K8s 真正需要的只有"按 OCI 规范把一个容器跑起来"这一小块,其余构建/CLI/网络对 kubelet 全是冗余负担,还得 K8s 自己维护 shim 去适配 Docker 的私有 API。于是社区把职责逐层拆开、定标准,最后 kubelet 直连更轻的 containerd,把 Docker 从生产运行链路里请了出去。
打个比方:Docker 早期像一家「前店后厂」的餐馆——点菜(CLI)、收银(API)、备料(构建镜像)、后厨炒菜(跑容器)全捏在一个老板手里。K8s 这个大客户其实只要「后厨炒菜」这一项服务,却被迫走前台点菜的流程,还得雇个翻译(dockershim)把自己的话转成这家店的黑话。后来行业出了统一菜谱格式(OCI 镜像/运行时规范)和统一点单协议(CRI),K8s 就能绕开前台,直接对着标准化的后厨(containerd → runc)下单。类比失效边界:请走的是「前台点菜那套 Docker 专属流程」,不是「后厨」——containerd 本就是 Docker 自己拆出来捐给 CNCF 的后厨,runc 也还是原来那口锅;菜谱(镜像)是 OCI 标准格式,换不换前台都能照做。所以"不支持 Docker"≠"容器换了内核",只是 kubelet 不再绕 Docker 的前台而已;你本地用
docker build打的镜像,集群照样跑。
实现方案
调用链:从 kubelet 到容器进程
一个容器被拉起来,是一条逐层下探、职责递减的调用链:
- kubelet:K8s 节点代理,通过 CRI(gRPC)向运行时下"起 Pod/起容器/拉镜像"的指令。
- containerd:高层运行时(daemon),管镜像拉取、快照(rootfs)、容器生命周期,暴露 CRI 服务(
cri插件)。CRI-O 是同层的另一选择。 - containerd-shim:每个容器一个 shim 进程,作为容器的"父进程守护"——即使 containerd 重启,容器也不受影响(解耦 daemon 与容器生命周期)。
- runc:低层运行时(OCI runtime),真正调用内核的 namespace + cgroup + rootfs pivot 把进程"关进"容器,然后自己退出(容器进程由 shim 托管)。
Docker 的
dockerd如今只是 containerd 之上再包一层:加了构建(BuildKit)、Compose、Swarm、dockerCLI、镜像管理 API。这些对开发本地很方便,但对 kubelet 是纯冗余。
两套标准:CRI 管上、OCI 管下
容器生态能"换零件不换整机",靠两套正交的标准把接口钉死:
| 标准 | 全称 | 谁和谁之间 | 约定什么 |
|---|---|---|---|
| CRI | Container Runtime Interface | kubelet ↔ 高层运行时 | gRPC 接口:RunPodSandbox / CreateContainer / PullImage… K8s 只认这个 |
| OCI runtime-spec | Open Container Initiative | 高层运行时 ↔ 低层运行时 | 一个 rootfs + config.json,runc/crun/kata-runtime 都按它跑 |
| OCI image-spec | Open Container Initiative | 镜像的存储/分发格式 | 分层 tar + manifest + config,docker build 产物即 OCI 镜像 |
一句话:CRI 让 K8s 不绑定某个运行时,OCI 让镜像和低层运行时可互换。 正因为镜像是 OCI 标准格式,"去 Docker"才不影响镜像可用性。
K8s 1.24 移除 dockershim:来龙去脉
- 为什么删:dockershim 是 kubelet 里专门适配 Docker 私有 API 的一段代码,由 K8s 团队维护。Docker 本身不实现 CRI,而 containerd/CRI-O 原生支持 CRI,留着 shim 等于为一个"外壳"长期背维护包袱。1.20 起标记弃用,1.24 正式移除。
- 影响面(关键结论):
- 镜像不受影响:OCI 镜像格式不变,
docker build的镜像照常运行、docker push照常分发。 - 构建与运行解耦:本地开发照用 Docker/BuildKit 构建;生产集群节点的运行时换成 containerd 或 CRI-O。
- 要改的是节点运维:排障命令从
docker ps换成crictl ps;日志/监控里对dockershim的假设要清理。 - Docker 镜像仓库/Registry 不受影响:那是分发层,与运行时无关。
- 镜像不受影响:OCI 镜像格式不变,
- 一句避坑:面试别答"镜像不能用了"——变的是运行时组件,不是镜像格式。
为什么这么做
- 分层解耦:把"构建 / 分发 / 运行"拆成独立组件,各自演进。kubelet 只依赖 CRI,运行时可插拔。
- containerd/shim 解耦生命周期:shim 让容器脱离 daemon 独立存活,containerd 升级/重启不杀容器——这是生产稳定性的关键设计。
- 去掉冗余:生产节点不需要 Docker 的 CLI/构建/Swarm,containerd 更轻、攻击面更小、资源占用更低。
镜像分层与 snapshotter
- 镜像 = 只读层叠加:每条 Dockerfile 指令生成一层(OCI layer,本质是 tar diff),运行时用 overlayfs 把多只读层 + 一层可写层联合挂载成容器 rootfs。
- snapshotter:containerd 用 snapshotter 插件管理这套层叠快照,默认
overlayfs,也有native/btrfs/stargz(懒加载、边拉边跑,加速冷启动)。 - 相同底层被多镜像共享,省磁盘省拉取——这也是"改一行代码只重传顶层"的原因(层缓存)。
runc / gVisor / Kata:隔离谱系
低层运行时按隔离强度 × 性能开销排成一条谱系,通过 K8s 的 RuntimeClass 为不同 Pod 选择:
| 运行时 | 隔离方式 | 隔离强度 | 开销 | 适用 |
|---|---|---|---|---|
| runc | 共享宿主内核 + namespace/cgroup | 弱(共享内核,一个内核漏洞全穿) | 近乎零 | 默认,可信负载 |
| gVisor | 用户态内核(Sentry 拦截 syscall) | 中(syscall 不直达宿主内核) | syscall 变慢 | 多租户、跑不可信代码 |
| Kata | 轻量虚拟机(每容器一个微型 VM) | 强(独立内核 + 硬件虚拟化) | 内存/启动开销 | 强隔离、混部不可信租户 |
与
k8s-network里提到的"多 Runtime +RuntimeClass"是同一件事:网络侧关注选哪个 CNI,运行时侧关注选哪种隔离。两者由RuntimeClass/CNI 各管一段,互不重复。
为什么别的选择不行
常见误区
| 说法 / 做法 | 为什么不对 |
|---|---|
| "K8s 1.24 后 Docker 镜像不能用了" | 混淆运行时与镜像格式。镜像是 OCI 标准,照用;变的只是节点运行时组件 |
| 生产节点继续留着完整 Docker | CLI/构建/Swarm 对 kubelet 是冗余,徒增资源占用与攻击面;containerd 更轻 |
| 不可信代码直接用 runc 跑 | runc 共享宿主内核,一个内核提权漏洞即逃逸;应上 gVisor/Kata |
用 docker ps 排查 K8s 容器 | 节点已是 containerd,Docker 看不到 CRI 容器,要用 crictl |
- 仍用 Docker 作 K8s 运行时(Mirantis cri-dockerd):技术上可行(把 shim 外置成独立组件),但等于自己扛维护,收益是零——不如直接上 containerd。
- CRI-O 替代 containerd:CRI-O 更"K8s 专用"(只做 CRI,不做通用容器管理),OpenShift 默认;containerd 生态更广、也能被 Docker/nerdctl 复用。两者都对,按发行版/生态选。
排障:从 docker 到 crictl / nerdctl
crictl:面向 CRI 的排障工具,直接问 kubelet 用的运行时。crictl ps/crictl images/crictl logs <id>/crictl inspect——K8s 节点上取代docker ps。nerdctl:containerd 的 Docker 兼容 CLI(命令几乎和docker一样,还支持 BuildKit、Compose、懒加载镜像),适合在 containerd 节点上"像用 Docker 一样"操作。ctr:containerd 自带的底层调试 CLI(按 namespace,如k8s.io),偏底层、不适合日常。- 一句话:K8s 容器用
crictl,想要 Docker 手感用nerdctl,底层 debug 才用ctr。
沉淀结论
结论
- 调用链:kubelet →(CRI)→ containerd →(shim)→ runc →(namespace/cgroup)→ 容器进程;越往下职责越少。
- 两标准:CRI 管 kubelet↔运行时,OCI 管运行时↔内核与镜像格式。K8s 只认 CRI,镜像认 OCI。
- 1.24 删 dockershim:去掉 K8s 自维护的 Docker 适配层,直连 containerd/CRI-O;镜像格式不变、构建与运行解耦,改的是节点运行时与排障命令。
- 隔离谱系:runc(共享内核·快)→ gVisor(用户态内核)→ Kata(轻量 VM·强隔离),按
RuntimeClass选。
相关专题:K8s 异构 & 网络 · CNI 网络插件 · 秒杀场景承载(网络相关内容见前两篇,本篇不复述)
记忆口诀
- 调用链:kubelet →CRI→ containerd →shim→ runc →内核(namespace/cgroup)
- 两标准:CRI 管上(kubelet↔运行时)/ OCI 管下(运行时↔内核 + 镜像格式)
- 删 shim:Docker 不实现 CRI / shim 是 K8s 包袱 / 1.24 移除 / 镜像照用不变
- 隔离谱系:runc 共享内核快 / gVisor 用户态内核 / Kata 轻量 VM 最强
- 排障:K8s 用 crictl / Docker 手感用 nerdctl / 底层 debug 用 ctr
内容来源
综合整理。参考方向:OCI runtime-spec / image-spec,Kubernetes CRI 设计文档与 dockershim 移除公告(1.20 弃用、1.24 移除),containerd / CRI-O / runc / gVisor / Kata Containers 官方文档,overlayfs 与 containerd snapshotter 文档,crictl/nerdctl/ctr 使用文档。
扩展阅读
- server-daily-ops —— 容器运行时排障作为日常 SOP 的排障入口:本篇讲运行时原理(CRI/OCI 分层、containerd 调用链、隔离谱系),server-daily-ops 讲日常巡检和值班响应中的排障动作。
自测:合上资料能说清楚吗?
- K8s 1.24 移除 dockershim 后,我用
docker build打的镜像还能在集群跑吗?为什么?
参考答案
能。变的是运行时组件(kubelet 从经 dockershim 调 Docker,改为经 CRI 直连 containerd/CRI-O),不是镜像格式。镜像是 OCI 标准格式,docker build/docker push 产物照常运行与分发。要改的是节点运行时与排障命令(docker ps→crictl ps),镜像仓库不受影响。
- 讲清 kubelet 到容器进程的调用链,以及 CRI 和 OCI 各管哪一段。
参考答案
kubelet →(CRI gRPC)→ containerd →(每容器一个 containerd-shim)→ runc →(namespace/cgroup/rootfs)→ 容器进程。CRI 约定 kubelet↔高层运行时的接口(K8s 只认它,故可换 containerd/CRI-O);OCI 约定高层↔低层运行时(runtime-spec,runc/crun/kata 通用)以及镜像格式(image-spec)。shim 让容器脱离 daemon 独立存活。
- dockershim 为什么被删?留着 Docker 当运行时有什么坏处?
参考答案
dockershim 是 kubelet 里适配 Docker 私有 API 的一段代码,由 K8s 团队维护;Docker 不实现 CRI,而 containerd/CRI-O 原生支持 CRI,留着 shim 等于为一个"外壳"长期背维护包袱(1.20 弃用、1.24 移除)。生产节点留完整 Docker,其 CLI/构建/Swarm 对 kubelet 全是冗余,徒增资源与攻击面;containerd 更轻。
- runc、gVisor、Kata 的隔离方式和取舍是什么?跑不可信代码该选哪个?
参考答案
runc 共享宿主内核 + namespace/cgroup,开销近零但隔离最弱(内核漏洞即逃逸)。gVisor 用用户态内核(Sentry)拦截 syscall,不直达宿主内核,隔离更强但 syscall 变慢。Kata 每容器一个轻量 VM(独立内核 + 硬件虚拟化),隔离最强、有内存/启动开销。跑不可信代码/多租户选 gVisor 或 Kata,通过 RuntimeClass 指定。
- 在 containerd 节点上排查一个跑挂的 K8s 容器,你用什么命令?为什么不是
docker?
参考答案
用 crictl(面向 CRI):crictl ps 看容器、crictl logs <id> 看日志、crictl inspect 看详情。节点运行时是 containerd,docker 命令看不到 CRI 管理的容器。想要 Docker 手感可用 nerdctl(containerd 的 Docker 兼容 CLI),底层调试才用 ctr(注意 -n k8s.io namespace)。