如果你自己搭过 k8s 集群,一定对“容器运行时”这个词不陌生。我最早搭测试集群时,kubelet 配置里填的还是 docker,后来一升级,节点直接 NotReady,查了半天日志才明白,k8s 在 v1.24 之后把 dockershim 这条路拆了,这背后就是“容器运行时之争”这场持续多年的技术路线选择。很多人一听到“之争”就觉得是在争论 Docker 和 containerd 谁好谁坏,其实没这么简单。这里争的是 Kubernetes 节点应该以什么方式执行容器进程:是继续兼容一整套 Docker 工具链,还是把接口收敛成一种可替换的标准协议。如果想把 k8s 的排障能力提上去,这几条运行时背后的逻辑是绕不过去的。这篇文章作为 k8s 系列的第一篇,我就把运行时演进、接口设计、迁移踩坑一次说透。
1. 容器运行时到底是干嘛的:先搞清楚“争”在哪里
1.1 镜像只是安装包,运行时才是真正干活的人
容器概念被大多数人接受,是从docker run这种命令行开始的。但命令只是表面。一个容器真正跑起来,需要 Linux 内核提供几样东西:独立的文件系统视图(chroot/挂载命名空间)、隔离的进程和网络视图(PID/网络/UTS 等 Namespace)、资源上限(cgroups)、可写层(UnionFS 之类)。而“容器运行时”这个词,指的就是负责完成这些内核操作的执行者。
我后来理解运行时,喜欢把它类比成“镜像是一台电脑的装机盘,运行时是真正把系统装进硬件并启动的那个人”。没有装机的人,装完盘的镜像只是文件;没有运行时,pull 下来的镜像只是躺在磁盘上的一堆层。runc 是最典型的低级运行时,它调用内核的 clone、setns、mount 等系统调用,在 Linux 系统里把 namespaces 和 cgroups 组合起来,最终创建出容器进程。k8s 里几乎每个普通容器最后都是 runc 拉起来,只不过在它前面还隔着 containerd 或 CRI-O 这层高级运行时。
理解了这层,就明白所谓“容器运行时之争”,争的不是“谁会用 docker ps”,而是“kubelet 究竟通过哪条路径把这些底层能力暴露给业务”。路径不同,可维护性、安全边界、排障接口就完全不同。
1.2 kubelet 要的是一个标准插头:CRI 出现之前的世界
Kubernetes 早期有个很特殊的现实:节点上的 kubelet 想跑容器,最现成的方式是调用 Docker 的 API。于是 k8s 在自己的代码里做了一个叫 dockershim 的适配层,把 kubelet 需要的能力翻译成 Docker API 调用,再由 Docker 里的 containerd 和 runc 把容器拉起来。这套方案在当时理所当然,毕竟 Docker 就是事实标准,镜像生态也围着它转。
问题在于 dockershim 不是 Docker 官方组件,而是 Kubernetes 内部的一个模块。这就意味着 k8s 想加一个新能力,得先看 Docker API 是否跟得上;Docker 想加一个新特性,又要考虑会不会破坏 k8s 的兼容性。两边发展方向并不完全一致,维护成本越来越高。为了让 k8s 不再绑定某一家技术栈,社区在 2016 年前后开始推动 CRI(Container Runtime Interface,容器运行时接口)标准。
CRI 的思路其实很简单:kubelet 不再关心你叫 Docker 还是别的,只要你的运行时能实现一套固定的 gRPC 协议,就能被 Kubernetes 管理。协议里主要分两块,一块管镜像,叫 ImageService;一块管容器生命周期和 Pod 沙箱,叫 RuntimeService。从这一刻起,运行时的“身份”开始从品牌转向接口。这场“争”,说到底就是:k8s 的节点逻辑应该围绕哪个抽象来构建。围绕 Docker API,还是围绕 CRI,决定了未来所有节点的行为。
2. 几个主要玩家逐个数:containerd、CRI-O、安全运行时
2.1 从 Docker 生态里走出来的 containerd,为什么能成为默认
containerd 出身很有意思,它本身是从 Docker 项目里拆分出来的高级运行时,负责镜像管理、容器执行、快照、事件、度量这些能力。人们经常把它说成“没有 Docker CLI 的 Docker”,这个说法虽然粗糙但形象:它保留了你熟悉的镜像层管理能力,也负责把容器生命周期跑完,但没有 docker build、docker compose、docker network 这些面向开发者的外围功能。
对 k8s 来说,这些外围功能本来就不需要。kubelet 要的是:你帮我取镜像、创建 sandbox、启动容器、收集退出状态、清理资源。containerd 把这些能力做成内置 CRI plugin,于是 kubelet 可以直接通过 unix socket 和它通信。这也是为什么现在用 kubeadm 初始化集群时,默认运行时就是 containerd,不需要额外装 Docker。
从运维角度讲,containerd 最大的优势是“镜像兼容”。你之前用 Docker 制作和拉取的镜像,几乎都能直接交给 containerd 运行,迁移成本非常低。而且新的镜像压缩、延迟拉取、内容寻址存储都在 containerd 生态里推进,它已经不只是“替代品”,而是很多新特性的主阵地。日常管理 containerd 可以用 crictl 或 nerdctl,前者是从 kubelet 视角看容器,后者则尽量还原 docker CLI 的体验,甚至能跑 docker compose 的编排文件。
2.2 CRI-O:不为 Docker 历史买单的轻量路线
CRI-O 的思路和 containerd 不太一样。它从诞生那天起就是奔着“只做 k8s 运行时”去的,力求组件最小化,不做任何 docker CLI 意义上的事情,也不提供 build、compose 之类的能力,只实现 k8s 需要的 CRI 服务。
CRI-O 内部也用 runc 等低级运行时执行容器,但它用来“看护”容器的是一套叫 conmon 的机制,职责和 containerd 的 shim 类似。它很适合那种对节点组件数量敏感、希望攻击面尽量小的环境。如果你是一个追求极简的团队,想把节点上“因为历史原因而存在”的组件降到最低,CRI-O 会是很有吸引力的选项。
但我得说句实在话:CRI-O 的社区影响力和周边工具链没有 containerd 那么丰富。容器出问题时,containerd 生态里有 ctr、crictl、nerdctl 可以排查,而 CRI-O 更多依赖 crictl。镜像构建、推送这些流程基本靠外部工具完成。所以选 CRI-O,往往是在“最小化节点依赖”和“生态便利性”之间做取舍,而不是简单看它是不是比 containerd 更轻。
2.3 安全运行时:共享内核不够,那就上更强的隔离
如果业务场景是“多个互不信任的租户跑在同一台机器上”,runc 这种共享内核的隔离模型就不够安全了。普通容器逃逸一旦发生,攻击面直接面向宿主机内核。这个背景下,gVisor 和 Kata Containers 这类安全运行时应运而生。
gVisor 的做法是在用户态实现了一个“内核层”,容器进程发出的系统调用会被这个用户态内核拦截,它先用自己的策略判断能不能执行,然后再转发给宿主机内核。好处是逃逸风险大幅降低;坏处是 syscall 转发有性能损耗,网络和磁盘密集场景会有明显开销。
Kata Containers 走的是另一条路:它把每个容器放进一个轻量虚拟机里,有自己的独立内核,隔离边界接近虚拟机,同时又尽量复用 OCI 镜像和容器接口,让上层看起来还是在跑容器。代价是启动时间更重、内存占用更高。
这两个运行时都可以通过 CRI 接入 k8s,通常配合 RuntimeClass 给特定 Pod 指定运行时,而不是全节点全局替换。真实的落地方案往往是“普通业务用 containerd,高安全需求业务用 gVisor 或 Kata”,这也是我认为运行时之争后半场的主旋律:不是选出一个赢家,而是让接口标准允许不同运行时按需共存。
3. 标准接口、shim、pause 容器:运行时设计的三个底层机关
3.1 OCI 规范:让所有容器镜像变成“普通话”
前面提到,containerd、CRI-O、runc、gVisor 能互相组合,背后靠的是 OCI 规范。它主要包含两部分:镜像规范定义镜像怎么打包、怎么分层、怎么表示元信息;运行时规范定义容器进程应该在一个什么样的文件系统、命名空间、资源限制下启动。一个运行时只要实现运行时规范,就能运行符合 OCI 镜像规范的镜像。
这就是为什么 Docker 时代的镜像可以在 containerd 下继续跑,也是为什么 gVisor 可以“假装自己是 runc”接入现有流程。规范把底层实现和上层工具解耦了:你拉的是一个镜像,运行它的却可能是普通进程、用户态内核或者轻量虚拟机。理解这一点,是理解“既可以换运行时,又不用重做镜像”的关键。
3.2 CRI 里的沙箱:Pod 不是一堆进程,而是一个 shared namespace 集体
Kubernetes 里 Pod 是调度的基本单位,但内核里并没有“Pod”这个概念。运行时为了满足 kubelet 对 Pod 的语义,需要创建一个 PodSandbox,也就是一个由 pause 容器持有的沙箱。pause 容器通常只有一个非常小的进程,它不运行业务,只负责率先创建并持有网络 Namespace、IPC、UTS 这些资源。Pod 里后续创建的普通业务容器,都通过 setns 加入到这个沙箱里,于是它们共享网络栈和进程命名空间。
如果没有 pause 容器,你很难解释“Pod 里网络为什么是共享的”以及“为什么 Pod 内所有容器能通过 localhost 互相访问”。从 CRI 协议的角度看,RuntimeService 提供了创建和删除 PodSandbox 的操作,镜像服务则负责提前把 pause 镜像放到节点上。很多节点 NotReady 的问题,最后都落在“pause 镜像没拉下来”这一条上,所以这个概念很重要。
3.3 shim:为什么容器不能直接挂在 kubelet 下面
以前我有个困惑:containerd 都启动容器了,为什么还要一个 containerd-shim 这样的中间层?后来排障明白了。低级运行时 runc 创建完容器进程后会立刻退出,它不适合长期担任父进程。如果没有接盘者,容器的标准输入输出就断了,退出状态也没人收集,容器进程可能直接变孤儿。
containerd-shim 就是来接盘的。它成为容器进程的父进程,负责维持 stdio 通道、保存容器退出码,还能在 containerd 短暂重启时让容器进程继续存活。CRI-O 的 conmon 干的也是类似的事。这个设计带来的一个直观现象:你重启 containerd 服务,节点上已经运行的容器进程不一定会被杀掉;但 kubelet 和 containerd 之间的会话状态可能已经乱了,所以重启运行时之后经常要同步重启 kubelet,才能让状态重新对齐。理解 shim 之后,这类“容器还在,k8s 却说没了”的诡异问题就有了解释。
4. 实操实录:集群从 Docker 切到 containerd,我踩过的坑
4.1 迁移前先对好三样东西:运行时间端点、Cgroup Driver、沙箱镜像
切运行时最忌讳“上来就换”。我在准备迁移前会先检查节点上的三处配置。
第一处是 kubelet 当前的容器运行时端点,确认它到底在走 /var/run/dockershim.sock 还是别的 unix socket。第二处是 cgroup 驱动。我的经验里,这是最容易导致节点 NotReady 的原因。kubelet 启动参数里通常有--cgroup-driver=systemd,而 containerd 默认配置不一定开着 SystemdCgroup。两者必须一致,否则 kubelet 启动后会一直报错,说 cgroup 驱动配置冲突。
第三处是沙箱镜像。containerd 的 CRI 插件配置里有一个 sandbox_image 字段,默认是一个公网地址下的 pause 镜像。如果你的节点无法直接访问公网,哪怕其他镜像都正常,Pod 也会一直停在 ContainerCreating。迁移前必须确认这个镜像已被预置或替换成可达的镜像地址,最好在离线环境里提前导入。
我用的配置片段大概是这样的(注意这是示例,具体镜像地址请替换成你环境里可达的地址):
version = 2 [plugins."io.containerd.grpc.v1.cri"] sandbox_image = "<your-registry>/pause:3.9" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] SystemdCgroup = true这里SystemdCgroup = true必须和 kubelet 的 cgroup driver 一致。至于为什么建议选 systemd:在主流 Linux 发行版上,systemd 是初始化进程,它自己就是 cgroup 的管理者;如果容器运行时和 kubelet 各管一套,容易造成资源统计错乱,cgroup v2 下冲突更明显。
4.2 一次干净的节点替换流程
我在实操里会按这个顺序走,每一步都有它存在的理由:
kubectl drain <node> --ignore-daemonsets:先把业务 Pod 平滑驱逐到其他节点。如果不带--ignore-daemonsets,DaemonSet 控制的 Pod 会因为无法调度而卡住整个流程。- 停掉 kubelet:
systemctl stop kubelet。这时候不会再调度新 Pod 到本节点。 - 停掉 Docker 以及它的 socket 自动激活:
systemctl stop docker docker.socket。如果不关 socket,docker 命令可能又把它拉起来。 - 安装并初始化 containerd,生成默认配置并修改上面提到的关键项。生成默认配置的命令通常是
containerd config default > /etc/containerd/config.toml,然后编辑。 - 把 kubelet 的启动参数或 kubeadm 配置里的运行时端点改成
unix:///run/containerd/containerd.sock。 - 启动 containerd,再启动 kubelet。
kubectl uncordon <node>,让节点重新接受调度。
如果使用 kubeadm,最好直接改 kubeadm 配置里的criSocket字段,而不是只改 systemd 文件,否则kubeadm upgrade时配置会被覆盖。这个细节我踩过一次,改完 systemd 之后下个版本 upgrade,运行时端点又被拉回旧值,节点直接 NotReady,你现在看到这段文字就当提前避坑了。
4.3 日常命令对照表:没有 docker ps 的日子
切到 containerd 后,最直接的痛感来自命令。我整理了一份对照表,平时排查问题照着用就行:
| 原来的习惯 | containerd 生态里的等价命令 |
|---|---|
| docker ps | crictl ps |
| docker logs | crictl logs |
| docker exec -it | crictl exec -it |
| docker inspect | crictl inspect |
| docker images | crictl images |
| docker pull | crictl pull |
有一点必须注意:crictl 默认连接的是哪个 socket,靠参数-r unix:///run/containerd/containerd.sock或配置文件决定。如果你有两个运行时并存,不指定端点就可能连到别的地方。
而ctr是 containerd 自己的调试工具,用起来要小心 namespace。containerd 里不同 namespace 的数据是隔离的,k8s 的容器默认在k8s.io这个 namespace 下,所以需要ctr -n k8s.io container ls这类命令才能看到部分信息。日常我更推荐 nerdctl,它把 namespace 的概念封装得接近 docker CLI,又能直接解析 compose 文件,很适合团队过渡期使用。镜像构建方面,containerd 生态一般搭配 buildkit 来做,构建出的镜像同样是 OCI 格式,可以直接被运行时使用。
4.4 几个真实场景里的问题速查
我把自己碰到过的几类典型问题整理成一张速查表,都是切运行时后常见的情况:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 节点 NotReady,kubelet 报 sandbox image 拉取失败 | sandbox_image 默认指向公网,节点拉不到 | 替换成内网可达源,或离线导入 pause 镜像 |
| 容器进程还活着,kubelet 却说容器不存在 | containerd 重启后状态与会话错位 | 重启 kubelet,让它重新同步 |
| crictl 命令连接失败 | 端点指向错误,或 containerd 没起来 | 检查 /run/containerd/containerd.sock,指定 -r |
| Pod 一直 ContainerCreating,网络相关报错 | CNI 插件安装顺序不对,或 runtime 重启导致网络 namespace 状态混乱 | 按文档重装 CNI,必要时重建节点上的 Pod |
| 节点 Ready,但业务容器初始化和启动明显变慢 | 镜像拉取路径变化、本地缓存未预热 | 提前批量拉镜像,检查并发拉取配置 |
排查这类问题,我的顺序始终是先看 kubelet 日志,再看 crictl ps 和 crictl inspect,最后才看具体业务日志。因为运行时层的问题往往表现为“Pod 没起来”,但真正的原因可能藏在 pause 镜像或 cgroup 配置里,从上层往下看很容易误判成应用异常。
5. 这场“争”给选型留下的启示
5.1 选运行时的真正标准不是“哪个更好”,而是边界是否清晰
回头看容器运行时之争,核心收获不是“containerd 打败了 Docker”这种简单叙事,而是接口标准化带来的选择自由。kubelet 只面向 CRI 编程,容器运行时可以有多种实现;镜像生态统一在 OCI 下,换运行时不用换镜像;隔离需求可以按 Pod 差异化配置,而不是改全节点。这套设计让 Kubernetes 更像一个“调度和编排控制器”,而不是某个容器工具的附属品。
如果让我给选型建议,普通生产集群默认 containerd 基本不会错。它生态完整、故障案例多、文档好找。某个安全相关集群需要更强隔离,可以保留 containerd 作为默认运行时,再通过 RuntimeClass 给高风险负载挂 gVisor 或 Kata。CRI-O 更适合追求节点最小化、且团队愿意承担周边工具链差异的场景。
5.2 一个让你少走弯路的判断框架
我现在判断一个运行时能不能用,会看四件事:它是否完整实现 CRI;它是否兼容 OCI 镜像;它和 kubelet 的 cgroup 模型是否一致;它的排障工具链是否足够成熟。这四件事比“性能有多好”“启动有多快”更优先。因为性能可以通过节点扩容补,接口不兼容和工具链缺失会让你在真实故障里寸步难行。
另外,不建议把多个运行时混在同一个默认配置里玩花活。RuntimeClass 是很好的抽象,但没有统一监控和排障手册前,花活就是雷区。我在内部测试环境先引入过安全运行时,又因为没提前收集节点日志,结果负载调度过去后只能靠猜,这种教训一次就够了。
最后说点个人习惯。切到 containerd 之后,我很少再回头用 docker 命令看节点,不是它不好,而是排查路径必须和当前架构对齐。每次遇到节点不对,先看 kubelet:journalctl -u kubelet -n 200,再crictl ps -a,最后才是业务容器日志。这套顺序帮我解决过太多“看起来像应用问题,实际上是运行时问题”的故障。容器运行时之争真正的赢家,是那个把所有选项都变成接口的抽象层;而你要做的,是把底层逻辑搞清楚,然后让选型服从你的场景。