ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

容器运行时深度拆解:Docker、containerd与CRI-O的选型指南

容器运行时深度拆解:Docker、containerd与CRI-O的选型指南 聊到容器运行时很多人第一反应是Docker。这个反应很正常毕竟大多数人第一次用docker run启动容器时根本不会去关心后面发生了什么。直到你接手一个Kubernetes集群登录生产节点敲了一个ps aux发现根本没有dockerd进程只有containerd和runc才意识到“容器运行时”这个词在云原生语境下早就不等于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公司推动成立的OCIOpen Container Initiative。OCI发布了两个关键规范image spec定义了镜像长什么样、怎么分层runtime spec定义了一个容器应该如何被拉起、运行、停止。runc就是这套规范最著名的参考实现它也是最底层的那个“容器运行时”。runc不负责拉镜像不负责管理镜像缓存也不提供用户友好的命令行它只做一件事根据一个config.json和rootfs利用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最初只对接DockerKubelet内部内嵌了一个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时需要注意它有两个不同的客户端入口ctr和crictl。ctr走的是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再发送CreateContainer、StartContainer请求容器运行时才真正把业务容器塞进这个沙箱里跑起来。当容器运行时收到启动请求时它会基于镜像的rootfs工作调用快照器把镜像层挂载成容器可写的文件系统然后生成符合OCI runtime spec的config.json交给runc。runc执行一系列系统调用创建namespaces、cgroup、挂载proc/sys等最后启动容器里的init进程。这个init进程出现后runc自身就退出不再占用一个线程这正是Linux容器和虚拟机最大的区别之一进程级隔离没有中间人或接管者。3.2 shim/conmon存在的意义守护状态、断连保护很多人会问runc启动完就退出了那容器如果还在跑谁来负责监控它答案是shimcontainerd场景下是containerd-shim-v2CRI-O场景下则是conmon。它们像容器的“监护人”在runc返回后继续充当容器init进程的父进程收集退出码、转发标准输出、保存状态文件。shim还有一个关键价值让容器运行时主进程的重启不影响正在跑的容器。比如containerd要做版本升级、或者突然崩溃如果没有shim容器init进程会变成孤儿状态管理就失控了。有了shim之后即使上层的containerd或CRI-O暂时不可用已经启动的容器还能继续运行等上层恢复后再重新接管。这种“随时可以热升级daemon”的设计在生产环境里价值极高也是我判断一个运行时是否适合生产的关键指标之一。3.3 containerd的内部模块Content Store、Snapshotter、CRI Plugincontainerd不是一股脑把所有东西塞在一起。它内部有清晰的模块分工Content Store负责保存镜像层打包的blob数据Image Store维护已经解析过的镜像元数据Snapshotter负责把镜像层和读写层组装成容器rootfsMetadata 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使用containerdkubelet - containerd CRI Plugin - containerd - containerd-shim - runc使用CRI-Okubelet - 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 --tail50 container_id # 看容器日志 crictl inspect container_id # 查看容器详细配置 crictl rmi --prune # 清理未被使用的镜像如果节点上用的是containerd你还会用到ctr。ctr不走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-mirrorscontainerd则要修改/etc/containerd/config.toml在CRI Plugin的registry配置里加mirrors。CRI-O则需要改/etc/crio/crio.conf.d目录下的配置文件。同样是挂国内镜像加速三个运行时完全是三套语法迁移时最容易漏。清理镜像的姿势也不一样。Docker用docker image prunecontainerd用crictl rmi --prune但crictl只清理当前CRI命名空间里的镜像引用如果镜像还有多余的blob你可能还要用ctr -n k8s.io clean。像我这种懒人一般直接配合kubelet的imageGC策略来兜底避免手动清理误伤。4.4 我在排障时遇到过的一个实际案例有一次节点报镜像拉取失败我照旧在机器上敲crictl images发现nginx镜像明明在列表里但Kubelet一直说拉不到。排查到最后发现真正的镜像缓存是containerd的Content Store里那一堆blobcrictl images显示的是CRI层的引用。因为某些镜像tag覆盖了CRI层引用和Content Store里的摘要对不上导致运行时认为镜像不存在。这种情况的通用处理办法是重新拉一次镜像或者把containerd的metadata目录备份后重建。它很好地说明了一个问题现代容器运行时内部的组件分工太细了光会敲命令不够得了解每个命令查的是哪一层。这也是我为什么坚持所有K8s工程师都应该把containerd的CTR和CRI两套入口分清楚而不是只会用docker。5. 选型不是选“最好”而是选“最少维护成本”5.1 不同场景的实用建议先给结论再解释。我的推荐顺序是开发机选DockerK8s生产节点默认containerdOpenShift和RHEL体系优先CRI-O边缘/资源受限环境可以试CRI-O或K3s内置的containerd。开发机选Docker没有悬念因为docker build、docker 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 ps、docker logs之类的依赖甚至是crontab里的清理脚本迁移前一定要全局搜索替换。还有一点不要在K8s节点上为了“兼容”继续用cri-dockerd把Docker daemon接回去。除非你有很强的理由否则等于把K8s刚拆掉的冗余又装回来稳定性和资源占用都会倒退。5.3 我现在的个人习惯开发用Docker、集群用containerd、CRI-O保持关注接触容器这几年我自己把它做了拆分。日常写代码、起中间件、做镜像构建我离不开Docker。但每次摸生产K8s集群我会第一时间确认crictl的runtime endpoint指向方式并用crictl ps -a看一遍所有沙箱。如果集群用的是containerdctr -n k8s.io这套查看镜像的命令我也会顺手用起来如果是OpenShiftCRI-O的表现我再观察几次就会放心交给团队。前两年我把很多精力花在对比“哪个运行时性能高”上结果发现收益其实很有限。真正帮我在线上故障里节省时间的是我清楚知道每一条日志应该去哪个目录找每个crictl/ctr命令看到的是哪一层状态。容器运行时的世界一直在变Docker更新、containerd发版、CRI-O继续演进都不稀奇。只要把这些底层逻辑吃透换什么引擎都不慌。如果以后再有同事问“Docker到底还能不能用”我大概会把这个话题压缩成一句开发继续用DockerK8s生产用containerdOpenShift考虑CRI-O其他的试过才知道。
返回列表