容器运行时深度拆解:Docker、containerd与CRI-O的选型指南
2026/9/16 4:51:49 网站建设 项目流程

聊到容器运行时,很多人第一反应是Docker。这个反应很正常,毕竟大多数人第一次用docker run启动容器时,根本不会去关心后面发生了什么。直到你接手一个Kubernetes集群,登录生产节点敲了一个ps aux,发现根本没有dockerd进程,只有containerdrunc,才意识到“容器运行时”这个词在云原生语境下早就不等于Docker了。这篇文章围绕containerd、CRI-O、Docker三种运行时,从定位、底层机制、运维命令到选型建议,做一次完整的横向拆解。无论你是在学K8s、维护容器平台,还是刚从Docker与K8s分家的新闻里缓过神,都建议把这一节读完。

先说一个容易让新人陷入混乱的关系:Docker和containerd不是互斥的竞争对手,而是上下游关系。Docker现在的容器执行部分,底层就是containerd,而containerd下面又要靠runc真正去创建进程。CRI-O则是和containerd同等位置的另一种高级运行时,专为Kubernetes CRI接口设计。三者看似都是“跑容器的东西”,实际职责和设计目标差得很多,下面一层层剥开来说。

1. 先厘清一件事:“容器运行时”到底指哪一层?

1.1 从OCI规范说起,运行时不是只有一个程序

容器发展早期,Docker几乎等于容器,很多人把Docker镜像、Docker命令、Docker守护进程都混在一起理解。真正让整个生态变得规范化的,是2015年Docker公司推动成立的OCI(Open Container Initiative)。OCI发布了两个关键规范:image spec定义了镜像长什么样、怎么分层;runtime spec定义了一个容器应该如何被拉起、运行、停止。runc就是这套规范最著名的参考实现,它也是最底层的那个“容器运行时”。

runc不负责拉镜像,不负责管理镜像缓存,也不提供用户友好的命令行,它只做一件事:根据一个config.jsonrootfs,利用Linux内核的namespace、cgroup、rootfs,把一个进程放进隔离环境下跑起来。你可以把runc理解为容器世界的“汇编语言”,它足够干净,但也足够原始。如果直接用runc跑容器,你需要手工准备目录、挂载点、网络配置,这在工作量上完全不可接受。

所以就有了更上层的“高级运行时”,或者叫“容器引擎”。它们负责镜像分发、快照管理、存储和网络编排、gRPC API,以及和上层调度系统对接。containerd、CRI-O、Docker都属于这一类。这类工具才是大家平时说的“容器运行时”的常用所指,但它们最终还是会调用runc(或crun、gVisor等低级运行时)去真的执行进程。搞不清这两层,后面看进程列表、排故障都会迷糊。

1.2 低级运行时与高级运行时的分界,以及为什么容易混

我见过不少团队排查一个容器一直启动失败,直接去查containerd进程,却忽略runc的报错。其实大多数启动错误都出在低级运行时这一层:权限不足、mount失败、cgroup驱动不匹配、SELinux拦截。高级运行时更多是负责“把请求传下去”,真正的活儿是在runc或者crun里面发生的。

为了把这两层在心里分开,可以用USB-C快充来类比。OCI规范就是USB-C这个统一接口形状;runc是芯片本身,负责最底层的电流控制;containerd和CRI-O是充电头,负责协议适配、功率协商;Docker则是那个连充电头带数据线带包装盒的套装,还送你一堆App。你要给手机充电,少了充电头不行,少了芯片更不行,但如果你只需要一个充电头,Docker全家桶就显得臃肿。这个类比不一定十全十美,但在“职责分层”这件事上非常直观。

低级运行时还有crun、gVisor、Kata Containers。crun是C写的,启动速度更快、内存更小;gVisor和Kata则以更强的隔离性为代价换安全性。高级运行时一般都可以通过配置切换底层runtime,所以现实世界不是“哪个运行时最好”,而是“哪种运行方式和你的场景匹配”。

1.3 Kubernetes为什么必须介入运行时之争

Kubernetes最初只对接Docker,Kubelet内部内嵌了一个dockershim,把CRI请求转成Docker API调用。这导致一条容器请求链路特别长:Kubelet -> dockershim -> Docker daemon -> containerd -> runc。Docker daemon本身并不是为K8s设计的,它在中间这一层除了做容器管理,还扛着镜像构建、网络、存储、日志等很多额外能力,而这些能力在K8s节点上基本用不上。

正因为这样,containerd独立出来并原生实现CRI后,Kubernetes很自然地把它拉成了默认运行时。CRI-O则从另一个方向切入:干脆不要Docker的历史包袱,做一个纯粹的K8s运行时。Kubernetes 1.24正式移除dockershim,本质不是“K8s不用Docker容器了”,而是“K8s不再内置维护Docker daemon的翻译层”。你仍然可以用cri-dockerd继续在K8s里用Docker,只不过这条路现在成了少数派。理解这段历史,才能理解为什么现在讨论运行时,主角基本都是containerd和CRI-O。到这里,至少概念上的几层分界应该清楚了。

2. 三兄弟背景与定位:Docker全家桶、containerd中间层、CRI-O轻量专用

2.1 Docker的真正价值在开发体验,而不是生产运行

Docker从2013年火起来,靠的是那句“Build, Ship, Run Anywhere”。它把一个复杂的隔离技术包装成docker run,把镜像仓库、网络、卷管理、Compose全部整合进来。开发机上装个Docker Desktop,可以构建镜像、一键起中间件、模拟多容器应用,这对本地开发效率的提升是巨大的。

从组件上看,Docker是一个典型的多进程协同架构:docker CLI负责发送命令,dockerd是个常驻daemon,负责REST API、网络、镜像构建等等;dockerd下面是containerd,负责容器生命周期与镜像分发;再往下是containerd-shim和runc。这样一层一层叠起来,功能最全,但也最重。生产K8s节点如果跑一整套Docker daemon,会出现两类问题:一是内存和磁盘占用偏高,二是链路长导致排障时要跨多个API层查状态。所以在很多优化清单里,“去掉dockershim/换containerd”都是第一步。

还有一个重点是,Docker Desktop这类工具依赖虚拟化支持。很多Windows用户遇到“Virtualization support not detected”导致Docker Desktop起不来,本质是BIOS里没开虚拟化或Hyper-V没启用,和本文讨论的运行时选型无关,但也说明Docker桌面版是个偏开发向的工具,生产服务器没必要背这个包袱。

2.2 containerd:从Docker核心剥离出的标准引擎

containerd的历史就是Docker拆分史。Docker把最核心的容器执行能力抽出来,捐赠给CNCF,然后containerd逐步长成一个生产级容器引擎。它提供API操作镜像拉取、挂载层、创建和停止容器,又内置了CRI插件,所以Kubelet可以直接通过gRPC和它通信,不再经过任何Docker翻译层。

containerd在K8s节点上的角色很纯粹:管镜像、管容器生命周期、管沙箱网络。不提供docker build,不提供Compose,也不提供像docker CLI那样对用户友好的前端。但它胜在稳定、干净、依赖少。Kubernetes从1.24之后默认使用containerd,各大云厂商托管集群也基本默认它,说明生态成熟度已经非常高。

使用containerd时需要注意它有两个不同的客户端入口:ctrcrictlctr走的是containerd自身的API,适合做镜像导入导出、检查状态,但它默认操作的是default命名空间,看不到K8s放在k8s.io命名空间里的容器。如果直接用ctr images list发现什么都看不到,不用慌,加上-n k8s.io重试就对了。另外,如果你真的怀念Docker CLI的手感,可以装nerdctl,它对containerd的体验做了很好的兼容,只是生产环境里未必需要多装一个工具。

2.3 CRI-O:为Kubernetes CRI而生的极简选手

CRI-O是完全围绕Kubernetes CRI标准来设计的高级运行时。它没有历史包袱,不提供Docker Compose那种开发工具,也不提供类似ctr的通用容器管理API,就是一门心思做Kubelet和OCI runtime之间的桥梁。Red Hat主导开发,OpenShift默认用它,Podman生态也和它共享大量组件。

CRI-O的启动链路比containerd还要简洁:Kubelet -> CRI-O -> conmon -> runc。conmon相当于containerd-shim的角色,负责在容器1号进程退出后回收状态、转发日志。因为组件少,CRI-O在内存占用上通常比Docker方案低不少,也常被选作边缘节点、资源受限环境的运行时。

但“极简”的另一面是功能上的克制:在非Kubernetes场景下,你很难单独用CRI-O管理一个容器。它的调试入口基本就是crictl,而crictl又是面向CRI抽象层的,和docker命令的体验差异较大。如果你要搭建一个单机容器平台,CRI-O不太合适;如果你的目标就是K8s集群,CRI-O是一个值得认真考虑的选项。

2.4 三者的关键差异一次看明白

为了快速建立印象,我做了一个对比表。这里没有列“谁比谁更好”,只列事实:

维度DockercontainerdCRI-O
定位开发、CI、单机容器平台通用容器引擎Kubernetes专用运行时
是否支持构建镜像原生支持(BuildKit)不支持,需配nerdctl等不支持
是否有Compose级编排Docker Compose
原生CRI支持不原生,需cri-dockerd内置内置
常用CLIdockerctr / crictl / nerdctlcrictl
命名空间机制有,但用户通常无感有,ctr需要显式指定不强调
底层runtimeruncrunc/crun等可切runc/crun等可切
典型场景开发机、CI、桌面搭配K8s开发K8s生产默认OpenShift/RHEL系集群

这个表格解决的是定位问题。再往下,实际操作中它们跑容器的方式也不完全相同,尤其是启动链路和状态维护逻辑差异很大,我把底层协作机制拆开来继续聊。

3. 底层机制拆解:CRI、OCI、shim、runc是怎样协作的

3.1 一个Pod从请求到进程启动,经过了哪几步

以Kubernetes使用containerd的场景为例,Kubelet收到要创建一个Pod的指令后,会通过CRI接口发送RunPodSandbox请求。这里“PodSandbox”说的就是那个pause容器,先起一个占位容器,把网络命名空间和Pod内共享的各个namespace建好。随后Kubelet再发送CreateContainerStartContainer请求,容器运行时才真正把业务容器塞进这个沙箱里跑起来。

当容器运行时收到启动请求时,它会基于镜像的rootfs工作,调用快照器把镜像层挂载成容器可写的文件系统,然后生成符合OCI runtime spec的config.json,交给runc。runc执行一系列系统调用,创建namespaces、cgroup、挂载proc/sys等,最后启动容器里的init进程。这个init进程出现后,runc自身就退出,不再占用一个线程,这正是Linux容器和虚拟机最大的区别之一:进程级隔离,没有中间人或接管者。

3.2 shim/conmon存在的意义:守护状态、断连保护

很多人会问:runc启动完就退出了,那容器如果还在跑,谁来负责监控它?答案是shim,containerd场景下是containerd-shim-v2,CRI-O场景下则是conmon。它们像容器的“监护人”,在runc返回后继续充当容器init进程的父进程,收集退出码、转发标准输出、保存状态文件。

shim还有一个关键价值:让容器运行时主进程的重启不影响正在跑的容器。比如containerd要做版本升级、或者突然崩溃,如果没有shim,容器init进程会变成孤儿,状态管理就失控了。有了shim之后,即使上层的containerd或CRI-O暂时不可用,已经启动的容器还能继续运行,等上层恢复后再重新接管。这种“随时可以热升级daemon”的设计,在生产环境里价值极高,也是我判断一个运行时是否适合生产的关键指标之一。

3.3 containerd的内部模块:Content Store、Snapshotter、CRI Plugin

containerd不是一股脑把所有东西塞在一起。它内部有清晰的模块分工:Content Store负责保存镜像层打包的blob数据,Image Store维护已经解析过的镜像元数据,Snapshotter负责把镜像层和读写层组装成容器rootfs,Metadata Database保存容器、命名空间、快照等关系,最外层再包一层gRPC API和CRI Plugin。

镜像拉取的过程也可以按这个模块拆解:首先从Registry下载各层blob到Content Store,校验摘要;然后通过Image Store建立镜像索引;创建容器时,Snapshotter基于镜像层创建快照链,用overlayfs或者native驱动生成一个可写的upperdir,最后把挂载点放到容器命名空间。这个过程看似复杂,但好处是每个环节都可以独立观测、独立扩展。比如想给containerd配镜像加速器,本质上就是修改CRI Plugin里的registry mirror配置;想换底层文件系统驱动,只需要调整snapshotter。

3.4 三种运行时在K8s中的链路对比

如果非要用一条链来表示,三种方案的区别非常直观:

  • 使用Docker daemon + dockershim(旧方案):kubelet -> dockershim -> Docker API -> dockerd -> containerd -> containerd-shim -> runc
  • 使用containerd:kubelet -> containerd CRI Plugin -> containerd -> containerd-shim -> runc
  • 使用CRI-O:kubelet -> CRI-O -> conmon -> runc

链路越长,接口越多,意味着每个环节都可能成为故障点,也意味着同样的请求要消耗更多CPU和内存来传递状态。K8s移除dockershim的价值,不是否定Docker整个生态,而是把这条链路的冗余跳数砍掉了。对生产节点来说,每减少一跳,都是实打实的稳定性收益。

4. 生产环境实测对比:资源占用、运维命令和排障手感

4.1 资源占用:Docker daemon是隐藏的“内存大户”

我们在线上的K8s节点做过一次测试:同样的硬件、同样的工作负载,把引擎从docker + dockershim切换到containerd后,节点内存占用立刻下来一截。原因不复杂,dockerd里承载了太多和K8s运行无关的能力:镜像构建缓存、Docker网络管理、日志驱动、插件系统、REST API。这些在开发机上很实用,在生产节点上就是纯消耗。

CRI-O的内存占用通常又比containerd再低一些,这符合它极简的定位。不过在同等负载下,这个差距不会大到让业务有体感。容器启动延迟的瓶颈更多在runc创建进程、网络插件配置、镜像拉取和rootfs展开,而不是上层daemon的处理速度。所以选型时如果把重点放在“性能谁更高”上,大概率得不出一个让你惊喜的结论,更多是比谁的稳定性和生态更省心。

4.2 把脑子里那套docker命令翻译过来:crictl和ctr的用法

在containerd或CRI-O节点上,docker ps不可用,这会让很多习惯用Docker的人一时手足无措。其实命令映射很简单:docker ps对应crictl psdocker images对应crictl imagesdocker logs对应crictl logsdocker inspect对应crictl inspect。注意crictl还有一层pod的概念,crictl pods可以看Pod沙箱,crictl ps默认看普通容器。

下面这几条是我在K8s节点上最常用的crictl命令,可以直接抄作业:

crictl ps -a # 查看所有容器,包括已退出容器 crictl pods # 查看Pod沙箱 crictl images # 查看节点上已有镜像 crictl logs --tail=50 <container_id> # 看容器日志 crictl inspect <container_id> # 查看容器详细配置 crictl rmi --prune # 清理未被使用的镜像

如果节点上用的是containerd,你还会用到ctrctr不走CRI接口,而是直接操纵containerd,因此需要关注命名空间。K8s管理的容器在k8s.io命名空间里,想看节点上已缓存镜像,用ctr -n k8s.io images list。如果不带-n参数,你会发现列表是空的,这几乎是每个新接触containerd的人都会踩的坑。

4.3 日志、镜像清理和加速器配置的关键差异

从Docker切到containerd,很多运维脚本要跟着改。日志路径变了:Docker把json日志存在/var/lib/docker/containers下面,而K8s的日志一般在/var/log/pods/var/log/containers下面,这部分即便用Docker也由kubelet维护。所以日志采集器如果还盯着docker目录,切换后就会丢数据。

镜像加速器配置也是重灾区。Docker是在/etc/docker/daemon.json里写registry-mirrors,containerd则要修改/etc/containerd/config.toml,在CRI Plugin的registry配置里加mirrors。CRI-O则需要改/etc/crio/crio.conf.d目录下的配置文件。同样是挂国内镜像加速,三个运行时完全是三套语法,迁移时最容易漏。

清理镜像的姿势也不一样。Docker用docker image prune,containerd用crictl rmi --prune,但crictl只清理当前CRI命名空间里的镜像引用;如果镜像还有多余的blob,你可能还要用ctr -n k8s.io clean。像我这种懒人,一般直接配合kubelet的imageGC策略来兜底,避免手动清理误伤。

4.4 我在排障时遇到过的一个实际案例

有一次节点报镜像拉取失败,我照旧在机器上敲crictl images,发现nginx镜像明明在列表里,但Kubelet一直说拉不到。排查到最后发现,真正的镜像缓存是containerd的Content Store里那一堆blob,crictl images显示的是CRI层的引用。因为某些镜像tag覆盖了,CRI层引用和Content Store里的摘要对不上,导致运行时认为镜像不存在。

这种情况的通用处理办法是重新拉一次镜像,或者把containerd的metadata目录备份后重建。它很好地说明了一个问题:现代容器运行时内部的组件分工太细了,光会敲命令不够,得了解每个命令查的是哪一层。这也是我为什么坚持所有K8s工程师都应该把containerd的CTR和CRI两套入口分清楚,而不是只会用docker。

5. 选型不是选“最好”,而是选“最少维护成本”

5.1 不同场景的实用建议

先给结论,再解释。我的推荐顺序是:开发机选Docker,K8s生产节点默认containerd,OpenShift和RHEL体系优先CRI-O,边缘/资源受限环境可以试CRI-O或K3s内置的containerd。

开发机选Docker没有悬念,因为docker builddocker compose、Docker Desktop的图形界面都是工作效率放大器。生产节点选containerd则是因为它在K8s生态里最成熟,遇到问题时能找到的资料最多,各种监控、日志、网络插件对它的适配也最完整。CRI-O不是不好,但它更适合有Red Hat体系背景的团队,否则一个小问题都要自己翻issue,维护成本会高一些。

如果是CI构建节点,我反而推荐保留Docker,因为构建镜像这件事Docker生态仍然是体验最好的。即便你用containerd默认运行的K8s集群,也完全可以安排一台专属的构建机跑Docker,构建完推到Registry,再由集群里的containerd拉取运行,不被单一运行时绑架。

5.2 从Docker迁移到containerd最容易踩的几个坑

在生产环境做运行时切换,不是改一行配置那么简单。首先要迁移的是镜像加速器配置,把daemon.json里的registry-mirrors搬到containerd的config.toml里。其次是关于crictl的runtime endpoint,通常要指向/run/containerd/containerd.sock;如果用CRI-O则指向/var/run/crio/crio.sock。这个不配好,crictl命令会连到旧地址上。

第二个容易踩的坑是权限和SELinux。Docker默认的环境有时会掩盖SELinux问题,切到containerd后,某些目录的标签不对就会导致挂载失败,红帽系系统上尤其明显。第三个坑是脚本里还留着docker psdocker logs之类的依赖,甚至是crontab里的清理脚本,迁移前一定要全局搜索替换。

还有一点:不要在K8s节点上为了“兼容”继续用cri-dockerd把Docker daemon接回去。除非你有很强的理由,否则等于把K8s刚拆掉的冗余又装回来,稳定性和资源占用都会倒退。

5.3 我现在的个人习惯:开发用Docker、集群用containerd、CRI-O保持关注

接触容器这几年,我自己把它做了拆分。日常写代码、起中间件、做镜像构建,我离不开Docker。但每次摸生产K8s集群,我会第一时间确认crictl的runtime endpoint指向方式,并用crictl ps -a看一遍所有沙箱。如果集群用的是containerd,ctr -n k8s.io这套查看镜像的命令我也会顺手用起来;如果是OpenShift,CRI-O的表现我再观察几次就会放心交给团队。

前两年我把很多精力花在对比“哪个运行时性能高”上,结果发现收益其实很有限。真正帮我在线上故障里节省时间的,是我清楚知道每一条日志应该去哪个目录找,每个crictl/ctr命令看到的是哪一层状态。容器运行时的世界一直在变,Docker更新、containerd发版、CRI-O继续演进,都不稀奇。只要把这些底层逻辑吃透,换什么引擎都不慌。

如果以后再有同事问“Docker到底还能不能用”,我大概会把这个话题压缩成一句:开发继续用Docker,K8s生产用containerd,OpenShift考虑CRI-O,其他的,试过才知道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询