1. 从“黄金搭档”到“分道扬镳”:一个必然的技术演进
如果你在2020年底到2021年初关注过Kubernetes社区,可能会被一条新闻刷屏:Kubernetes宣布将在后续版本中弃用对Docker作为容器运行时的直接支持。当时,这个消息在开发者圈子里激起了不小的波澜,很多人第一反应是困惑和担忧:“我用了这么多年的docker run和docker build,难道以后在K8s里都不能用了吗?”“是不是K8s要和Docker彻底决裂了?”一时间,各种猜测和误解四起。
事实上,这个决定并非一时兴起,更不是两个明星开源项目之间的“宫斗”。它背后是一个清晰的技术演进逻辑,是Kubernetes为了追求更高效、更稳定、更符合云原生标准的架构所做出的必然选择。要理解这一点,我们不能只看“弃用”这个结果,而必须回到容器技术栈的底层,看看Docker在K8s集群中究竟扮演了什么角色,以及为什么这个角色会变得不再合适。
简单来说,你可以把早期的K8s和Docker的关系,想象成一家餐厅(K8s)和一位全能大厨(Docker)。餐厅需要出餐,这位大厨不仅会炒菜(运行容器),还自带全套厨房设备(镜像构建、存储、网络),甚至包揽了采购和洗碗(日志、监控)。一开始餐厅规模小,有这么一位全能大厨确实省心。但随着餐厅发展成为连锁集团,管理成千上万家分店,问题就来了:每家分店的后厨都被这位大厨的“全家桶”塞得满满当当,架构臃肿,升级维护困难,而且大厨的很多独家手艺(比如Docker特有的守护进程通信方式)和集团标准的中央厨房流程(K8s定义的CRI接口)对接不畅,效率低下。
K8s放弃的,并不是“容器”这个概念,也不是我们熟悉的Docker镜像(OCI标准镜像依然完全兼容),它放弃的仅仅是Docker这个“全能大厨”作为在K8s节点上直接管理容器生命周期的那个组件。餐厅(K8s)决定,以后所有分店(节点)的后厨,只聘用符合集团标准接口(CRI)的、职责单一的“炒菜师傅”(如containerd或CRI-O),至于镜像构建、仓库管理这些工作,交给更专业的中央厨房(CI/CD流水线)和供应链(镜像仓库)去完成。这个转变,是为了让整个系统更专注、更高效、也更易于维护。
所以,当你再看到“K8s弃用Docker”时,不必恐慌。你的Dockerfile依然有效,你的docker build出来的镜像在K8s里照样跑得欢。变的只是K8s集群内部,容器被创建和运行的那最后一环的实现方式。接下来,我们就深入后厨,拆解这个转变背后的每一个技术细节和考量。
2. 拆解Docker:它远不止是“运行容器”
在深入讨论K8s的抉择之前,我们必须先彻底搞清楚Docker到底是什么。很多人的误解源于将“Docker”等同于“容器运行时”,但实际上,Docker是一个包含了大量组件的完整平台。当你在一个K8s节点上安装Docker时,你安装的是一整套东西,而K8s真正用到的只是其中很小一部分。
2.1 Docker的“全家桶”架构
一个完整的Docker引擎(Docker Engine)安装后,主要包含以下核心组件:
Docker Daemon (
dockerd): 这是一个常驻后台的守护进程,它是Docker的核心大脑。所有Docker命令(如docker run,docker ps)实际上都是通过REST API与这个守护进程通信,由它来执行具体的操作。dockerd本身非常庞大,它要处理镜像管理、容器生命周期管理、网络、存储卷、日志等几乎所有事情。containerd: 这是一个专注于容器生命周期管理的守护进程。事实上,从Docker 1.11版本开始,Docker引擎的架构进行了重大调整,将实际的容器运行时功能剥离到了
containerd这个独立的项目中。dockerd作为上层管理者,将大多数容器操作(如创建、启动、停止容器)委托给containerd去执行。containerd再通过一个名为runc的工具来真正创建容器。runc: 这是一个符合OCI(Open Container Initiative)运行时标准的轻量级命令行工具。它的功能非常单一:根据一个容器运行时规范(bundle,里面包含了config.json和根文件系统)来启动一个容器。
containerd会调用runc来完成容器的创建。Docker CLI (
docker): 我们最熟悉的命令行工具,它是用户与dockerd交互的客户端。其他组件: 如用于构建镜像的
docker build系统、管理镜像层的overlay2存储驱动、网络驱动(如bridge)等。
所以,一个简单的docker run nginx命令,背后的调用链大致是:docker cli->dockerd->containerd->runc-> 启动容器。
2.2 K8s真正需要的是什么:CRI与容器运行时
Kubernetes的设计哲学是“控制平面”与“数据平面”分离。控制平面(如kube-apiserver, kube-scheduler)负责决策,数据平面(即各个节点)负责执行。在节点上,负责与容器打交道的组件是kubelet。
kubelet的核心任务之一就是管理Pod和其中的容器。但它不应该、也不可能去直接操作Docker Daemon这样复杂的“全家桶”。为了定义一个标准的交互方式,Kubernetes提出了容器运行时接口。
CRI是一个gRPC协议,它定义了一组标准API。kubelet通过这些API来发出指令,例如:“请在这个Pod里创建一个容器,使用这个镜像,设置这些环境变量...” 而实现这些API的服务端,就是容器运行时。
在早期,Kubernetes社区维护了一个名为dockershim的适配器代码。这个代码直接内置在kubelet中。它的作用就是将kubelet通过CRI发出的请求,翻译成Docker Daemon能理解的API(Docker Engine API)。
所以,在“K8s使用Docker”的时代,实际的架构是:kubelet-> (dockershim适配器) ->dockerd->containerd->runc
发现问题了吗?K8s并没有直接使用Docker来运行容器,它使用的是Docker引擎中的containerd组件。但为了用到containerd,它不得不先经过dockershim和整个dockerd。dockerd提供了大量K8s根本不需要的功能(比如独立的镜像构建、Docker Swarm集群管理等),却引入了额外的复杂性和性能开销。
这就好比,你想用一把锋利的菜刀(containerd),但菜刀被装在一个功能繁多、体积庞大的多功能工具箱(Docker Engine)里。K8s每次用刀,都得先打开这个工具箱,拿出刀,用完再放回去。而这个工具箱本身还有自己的一套开锁方式(Docker Engine API),需要专门的翻译(dockershim)才能听懂K8s的指令。
3. “弃用”的导火索:Dockershim的维护之痛
理解了上述架构,就能明白“弃用”的直接原因:维护dockershim这块“翻译层”代码,成了Kubernetes社区一个越来越沉重的负担。
3.1 一个不被祝福的“适配层”
dockershim从一开始就是一个妥协的产物。在Kubernetes项目早期,Docker是市场上唯一成熟且被广泛接受的容器解决方案。为了降低用户的使用门槛,快速推广K8s,集成Docker是最务实的选择。但Docker的API并非为K8s而生,两者在模型和特性上并不完全匹配,因此需要dockershim这个适配层来弥合差异。
随着时间推移,这个适配层的问题日益凸显:
测试与维护成本高昂:Docker Engine的每一个新版本发布,Kubernetes社区都需要测试
dockershim是否还能正常工作。Docker API的任何变动(即使是细微的)都可能导致dockershim出问题。这意味着K8s核心团队需要投入大量精力去维护一个并非核心功能的、针对第三方项目的适配代码。问题排查链路冗长:当用户在K8s集群中遇到容器相关的问题时,排查链路变得异常复杂。问题可能出在kubelet、
dockershim、dockerd、containerd或runc的任何一个环节。社区支持人员需要精通整个调用栈,这极大地增加了故障诊断的难度。阻碍创新与优化:
dockershim的存在,使得K8s无法直接利用更底层的容器运行时(如containerd)的新特性。任何优化都需要经过Docker Engine API和dockershim这两层转换,效率低下,且可能无法实现。
3.2 一个关键的认知转变:Docker != 容器标准
在容器生态发展的早期,Docker几乎成了容器的代名词。但社区逐渐意识到,这种绑定对生态的健康不利。为了推动容器技术的标准化和互操作性,Linux基金会牵头成立了OCI。
OCI制定了两个核心规范:
- 镜像规范:定义了容器镜像的格式(如层、配置清单)。Docker镜像格式后来捐赠并演化成了OCI镜像标准。
- 运行时规范:定义了容器运行时的标准,
runc是其参考实现。
这意味着,只要符合OCI标准,任何容器运行时都可以运行任何OCI镜像。Docker只是第一个成功实现了这套标准的平台,但它并不是标准本身。
Kubernetes通过CRI接口,希望能够与任何符合OCI标准的容器运行时对接,而不是绑定在Docker这一家实现上。dockershim的存在,使得K8s在事实上与Docker引擎强耦合,这与K8s追求开放、可插拔的架构目标背道而驰。
3.3 更优的替代方案已经成熟
当dockershim的维护成本越来越高时,它的替代品已经变得非常成熟和稳定。
containerd:这原本就是Docker引擎内部使用的容器运行时。它本身就是一个实现了CRI接口的、功能完备的容器运行时。相比于完整的Docker引擎,
containerd更加轻量、专注(只管理容器生命周期和镜像),并且由CNCF托管,与Kubernetes同属一个基金会,目标和路线图更容易对齐。CRI-O:这是Red Hat主导的、专门为Kubernetes设计的容器运行时。它的设计目标非常纯粹:以最少的代码、最高的效率实现CRI接口。它直接使用
runc运行容器,使用buildah构建镜像,整个栈都非常精简。
使用containerd或CRI-O,kubelet与容器运行时的交互链路变得非常简洁:kubelet-> (通过CRI gRPC接口) ->containerd/CRI-O->runc
移除了dockershim和dockerd这两个中间层,架构更清晰,依赖更少,性能更优,稳定性也更好。对于Kubernetes项目来说,放弃一个沉重的历史包袱,拥抱更简洁、更标准的架构,是一个再自然不过的技术决策。
4. 迁移实战:从Docker到Containerd的平滑过渡
理论讲清楚了,我们落到实操上。如果你正在管理一个使用Docker作为运行时的K8s集群,该如何安全、平滑地迁移到containerd呢?这个过程需要谨慎操作,但步骤是清晰的。下面我以一个典型的、使用kubeadm搭建的集群为例,拆解迁移的全过程。
重要提示:生产环境迁移前,务必在测试环境充分验证,并制定详细的回滚方案。确保你对整个集群有完整的备份(包括etcd数据)。
4.1 迁移前的准备工作与检查
迁移不是简单地卸载Docker安装containerd,首先要确保集群和节点处于一个可迁移的状态。
检查Kubernetes版本:确保你的Kubernetes版本在1.20以上(1.20版本开始默认不再内置
dockershim,1.24版本正式移除)。使用kubectl version确认。检查节点状态:确保所有节点状态都是
Ready,并且没有异常的Pod。使用kubectl get nodes和kubectl get pods -A查看。识别有状态工作负载:特别关注使用
hostPath卷、DaemonSet或者对节点文件系统有特殊依赖的Pod(例如某些监控Agent、日志收集器如Fluentd、存储插件如Ceph RBD)。这些Pod在容器运行时变更时最容易出问题,可能需要特殊的处理或重启顺序。备份关键配置:备份每个节点上的Kubelet服务配置文件(通常是
/var/lib/kubelet/kubeadm-flags.env或/etc/systemd/system/kubelet.service.d/10-kubeadm.conf),以及/etc/containerd/config.toml(如果存在)。
4.2 关键一步:配置Containerd
在安装containerd之前,我们先准备好它的配置文件。这是迁移成功的关键,很多坑都出在这里。
安装containerd:通过系统包管理器安装。例如在Ubuntu上:
sudo apt-get update sudo apt-get install -y containerd生成默认配置:
containerd安装后默认没有配置文件,我们需要生成一个。sudo mkdir -p /etc/containerd sudo containerd config default | sudo tee /etc/containerd/config.toml修改关键配置:编辑
/etc/containerd/config.toml,有几个地方必须调整。- 配置Systemd Cgroup驱动:Kubernetes推荐使用
systemd作为cgroup驱动,与kubelet保持一致。找到[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]部分,确保SystemdCgroup = true。[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] SystemdCgroup = true - 配置私有镜像仓库:如果你的集群使用私有镜像仓库(如Harbor, Quay),需要在这里配置
auth。找到[plugins."io.containerd.grpc.v1.cri".registry]部分,进行配置。[plugins."io.containerd.grpc.v1.cri".registry] [plugins."io.containerd.grpc.v1.cri".registry.mirrors] [plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"] endpoint = ["https://registry-1.docker.io"] [plugins."io.containerd.grpc.v1.cri".registry.mirrors."harbor.mycompany.com"] endpoint = ["https://harbor.mycompany.com"] [plugins."io.containerd.grpc.v1.cri".registry.configs] [plugins."io.containerd.grpc.v1.cri".registry.configs."harbor.mycompany.com".auth] username = "your-username" password = "your-password" - 配置Sandbox镜像:Pod的基础设施容器(Pause容器)镜像需要指定。通常kubelet会配置,但这里也可以设置。确保镜像地址可拉取。
- 配置Systemd Cgroup驱动:Kubernetes推荐使用
重启containerd:应用配置。
sudo systemctl daemon-reload sudo systemctl restart containerd sudo systemctl enable containerd
4.3 切换Kubelet的容器运行时
现在告诉kubelet,不要再通过dockershim找Docker了,直接使用containerd的CRI接口。
修改kubelet配置:编辑kubelet的启动参数文件。对于
kubeadm管理的集群,通常是/var/lib/kubelet/kubeadm-flags.env。sudo vi /var/lib/kubelet/kubeadm-flags.env找到
KUBELET_KUBEADM_ARGS这一行,修改--container-runtime和--container-runtime-endpoint参数。注意:不同K8s版本参数可能不同。- 对于1.23及以下版本:可能需要移除
--container-runtime=docker,并添加--container-runtime=remote和--container-runtime-endpoint=unix:///run/containerd/containerd.sock。 - 对于1.24及以上版本:
--container-runtime参数已被移除,只需确保--container-runtime-endpoint=unix:///run/containerd/containerd.sock存在。
一个修改后的示例可能看起来像这样:
KUBELET_KUBEADM_ARGS="--container-runtime-endpoint=unix:///run/containerd/containerd.sock ...其他原有参数..."- 对于1.23及以下版本:可能需要移除
重启kubelet:
sudo systemctl daemon-reload sudo systemctl restart kubelet验证切换:在节点上执行
sudo crictl ps,如果能看到当前运行的容器列表(主要是pause容器和你的业务容器),说明crictl(CRI兼容的CLI工具)已经能通过CRI接口与containerd通信了。同时,在Master节点上,该节点的状态应很快恢复为Ready。
4.4 处理Docker与镜像迁移
现在容器运行时已经切换,但节点上还装着Docker,之前用Docker拉取的镜像也在Docker的存储目录里。
镜像导出与导入(可选但推荐):
containerd和Docker默认使用不同的存储目录。为了不让Pod因镜像不存在而重新拉取(尤其对于大镜像或私有仓库),可以手动迁移镜像。- 使用
docker save导出镜像为tar包,然后用ctr(containerd的CLI)或crictl导入。但更通用的做法是让Pod自己触发拉取,或者使用ctr images pull直接从仓库拉取。
- 使用
卸载Docker(谨慎):确认所有业务Pod运行正常,且新创建的Pod也能成功调度到该节点后,可以考虑卸载Docker。但建议观察一段时间(如24小时)后再操作。
sudo apt-get purge -y docker-ce docker-ce-cli注意:卸载Docker不会删除
/var/lib/docker下的镜像和容器数据,这些数据已经没用了,可以手动清理以释放空间。
4.5 迁移后的验证与常见问题排查
迁移完成后,必须进行全面的验证。
基础功能验证:
kubectl get nodes:节点状态应为Ready。kubectl get pods -A:所有Pod应处于Running或Completed状态,无异常重启。- 部署一个简单的测试Pod(如
kubectl run test --image=nginx:alpine),确认能正常创建和运行。
网络与存储验证:测试那些依赖网络和存储的Pod,确保Service发现、Ingress、PVC挂载等功能正常。
常见问题与排查命令:
- Pod卡在
ContainerCreating:使用kubectl describe pod <pod-name>查看事件。常见原因是镜像拉取失败(检查containerd的registry配置)或containerd的CRI插件未正确加载(检查/etc/containerd/config.toml和containerd日志sudo journalctl -u containerd)。 - 节点状态为
NotReady:检查kubelet日志sudo journalctl -u kubelet -f,常见错误是--container-runtime-endpoint路径不正确或containerd.sock权限问题。 - 使用
crictl调试:crictl是你的新朋友。常用命令:sudo crictl ps # 查看容器 sudo crictl images # 查看镜像 sudo crictl logs <容器ID> # 查看容器日志 sudo crictl inspect <容器ID或Pod沙箱ID> # 查看详情
- Pod卡在
5. 深入对比:Containerd vs Docker在K8s下的真实差异
迁移之后,作为集群管理员,你会感受到哪些具体的变化?下面我们从运维和开发的不同视角,来对比一下使用containerd和之前使用Docker的差异。
5.1 运维视角:更轻量、更稳定、更“K8s原生”
资源占用显著降低:这是最直观的感受。
containerd的二进制文件更小,内存占用更少,因为它剥离了Docker Daemon的众多非核心功能(如API Server、构建引擎、Swarm模式等)。这意味着节点可以将更多资源留给业务Pod。进程树更简洁:在节点上运行
ps aux | grep -E \"(docker|containerd)\",你会看到dockerd这个庞然大物消失了,只剩下containerd进程。少了一个守护进程,就意味着少了一个潜在的故障点和攻击面。启动速度更快:
containerd的启动速度比dockerd快,这对于节点重启或故障恢复的场景有益。日志与监控的变更:这是需要适应的地方。之前我们习惯用
docker logs命令查看容器日志,现在则需要通过kubectl logs,或者到节点上使用crictl logs。容器日志的存储位置也从/var/lib/docker/containers/...变为了containerd管理的区域(通常可通过crictl inspect找到路径)。监控方面,之前针对Docker Daemon的监控指标(如engine_daemon_container_actions_seconds_sum)不再可用,需要转向监控CRI接口暴露的指标或使用cAdvisor(它现在直接通过CRI收集容器数据)。调试命令的变化:运维人员需要熟悉一套新的CLI工具链:
crictl:替代大部分docker命令,用于调试容器和Pod。但它不是完全一一对应,功能更聚焦于CRI。ctr:containerd的底层管理工具,功能强大但更偏底层,一般用于管理命名空间、镜像等,日常运维较少直接使用。nerdctl:一个兼容Docker CLI语法的containerd客户端,对于从Docker迁移过来的用户非常友好,几乎可以无缝切换命令习惯(如nerdctl ps,nerdctl images)。
5.2 开发者视角:几乎无感,但需了解底层变化
对于大多数应用开发者而言,这次迁移几乎是透明的,这恰恰证明了K8s和CRI设计的成功。
Dockerfile与镜像完全兼容:你之前写的所有Dockerfile,构建出来的镜像,在
containerd运行时上完全无需修改即可运行。因为两者都遵循OCI标准。你的CI/CD流水线中docker build和docker push的步骤可以原封不动。docker命令的替代:在开发环境中,你当然可以继续使用Docker Desktop或Docker Engine来构建和测试镜像。只是在连接到K8s集群进行一些底层调试时,需要从docker命令切换到kubectl或crictl。例如,以前在节点上排查问题可能会用docker exec进入容器,现在更标准的做法是kubectl exec。镜像构建的分离:K8s的这次变更,进一步明确了“构建”和“运行”的边界。Kubernetes是一个容器编排和运行平台,它不关心镜像如何构建。镜像构建是CI/CD工具(如Jenkins、GitLab CI、Tekton等)和镜像构建工具(如Docker、Buildah、Kaniko等)的职责。这种分离让系统架构更清晰。
5.3 性能与稳定性提升:理论上的优势如何体现?
理论上,移除dockershim层可以减少一次网络序列化/反序列化(gRPC -> Docker API)和进程间通信的开销,从而降低延迟。但在大多数常规负载下,这种性能提升可能微乎其微,不易被直接感知。
真正的稳定性提升体现在:
- 依赖简化:链路更短,组件更少,出错的概率自然降低。
- 问题隔离:当出现容器运行时问题时,排查范围更聚焦于
containerd和CRI,无需再考虑Docker Daemon的复杂状态。 - 社区支持:
containerd是CNCF毕业项目,与Kubernetes由同一个社区紧密协作,问题修复和特性迭代的协同性更好。
6. 不止于Containerd:CRI生态与未来展望
K8s放弃Docker运行时,不仅仅是换了一个默认选项,更是拥抱了一个基于CRI的、充满活力的容器运行时生态。
6.1 CRI的“参考实现”:CRI-O
如果说containerd是出身于Docker世家、功能全面的“优等生”,那么CRI-O就是专为Kubernetes而生的“特长生”。它的设计哲学是“仅实现CRI,别无其他”。因此,它比containerd更加轻量,组件更少(直接使用runc和conmon),安全边界可能更清晰(遵循最小权限原则)。Red Hat的OpenShift容器平台就默认使用CRI-O。对于追求极致精简和与K8s版本严格锁定的环境,CRI-O是一个非常好的选择。
6.2 安全容器的崛起:Kata Containers与gVisor
CRI标准化的另一个巨大好处是,为安全容器这类特殊的运行时打开了大门。传统容器(如runc创建)与宿主机共享内核,存在潜在的安全风险。安全容器通过不同的技术路径(如轻量级虚拟机、系统调用拦截)来提供更强的隔离。
- Kata Containers:通过轻量级虚拟机(MicroVM)来隔离每个Pod,每个Pod拥有独立的内核。它通过
containerd的shimv2接口与K8s集成,对用户而言,只需在Pod的runtimeClassName中指定kata,即可使用。 - gVisor:Google推出的容器沙箱,它通过一个用Go语言实现的、模拟Linux内核的“哨兵”(Sentry)来拦截容器的系统调用,提供一种折中的安全与性能方案。它同样实现了CRI。
这些运行时可以无缝接入Kubernetes,为不同安全等级的工作负载提供灵活的选择。如果没有标准的CRI接口,这种集成将异常困难。
6.3 对云原生生态的深远影响
这次变革巩固了Kubernetes作为容器编排领域事实标准的地位。它向生态传递了一个明确信号:K8s定义接口(CRI),社区提供实现。这鼓励了更多创新在运行时层面发生,而不会威胁到上层编排的稳定性。
对于开发者而言,这意味着我们应该更多地关注开放标准(如OCI镜像、CRI),而不是某个特定的厂商实现。你的技能投资应该放在如何编写高效的Dockerfile、如何设计云原生应用、如何用好Kubernetes API上,而不是深究某个容器运行时CLI工具的冷门参数。
最后,回顾整个历程,K8s放弃Docker运行时,不是一个时代的结束,而是一个更成熟、更开放时代的开始。它剥离了历史的包袱,让架构回归简洁和专注。作为从业者,理解其背后的技术逻辑,掌握迁移和运维的新工具,我们就能更好地驾驭这片云原生的浪潮,而不是被它所困扰。当你下次再遇到类似“K8s为什么用containerd而不用Docker”的疑问时,你可以从容地从CRI接口、架构解耦和生态演进的角度,给出一个清晰的解答。