从Kubernetes 1.20宣布废弃dockershim开始,社区里就一直有个争论:K8s以后到底还能不能用Docker?到了1.27版本,答案其实已经很明确——能,但前提是把路子走对。kubelet不再直接认识Docker的socket,想让集群继续用Docker作为运行时,中间必须加一个适配层。这篇笔记记录的是我当时把一套1.27集群从默认的containerd切到Docker runtime的完整过程,包括为什么要加cri-dockerd、kubeadm init的cri-socket应该怎么写,以及配完之后怎么验证它真的生效了。如果你正好在纠结新集群用哪种运行时,或者想把老集群迁到1.27继续保留docker命令习惯,这篇应该对你很有用。
1. Kubernetes 1.27的docker runtime链路到底长什么样
1.1 从dockershim移除说起
很多人一听到"Kubernetes移除dockershim"就以为"K8s不再支持Docker",这个理解是偏的。准确说法是:kubelet不再内置那一段专门对接Docker的桥接代码。
早期Kubernetes为了跑起来,在kubelet里直接嵌了一块叫dockershim的转换模块,它把kubelet发出的CRI请求翻译成Docker API,这样K8s就能用Docker来创建容器。这个设计解决了当时容器运行时生态贫瘠的问题,但代价是kubelet和Docker的版本耦合太紧。后来CNCF生态里出现了更纯粹的容器运行时,CRI规范也成熟了,Kubernetes从1.20宣布废弃dockershim,1.24正式删除,到1.27这个版本,kubelet已经完全不再内置它对Docker的兼容层。
那1.27想继续用Docker怎么办?答案是cri-dockerd。这是一个独立进程,专门把CRI请求翻译成Docker API,它替代了当年dockershim的位置,只不过从"内嵌代码"变成了"外部服务"。理解这件事的关键在于:CRI是一个接口规范,它不是某个具体软件,任何进程只要实现了这套接口,kubelet就能让它来管容器。cri-dockerd就是这样一个外部实现。
拿生活中的情况类比,CRI就像墙上的标准插座,kubelet只会插标准插头;dockershim是当年为了把一个不符合标准的旧插头强行插进去而做的转接器,cri-dockerd则是后来重新做的、更规范的新转接器。你最终还是能点亮"Docker"这盏灯,只不过中间多了一段转接。
1.2 Docker和containerd的从属关系
还有一个很常见的误解,就是把Docker runtime和containerd当成完全对立的两条路线。实际上Docker Engine本身是分层的:最上面的docker CLI是给人敲命令用的;dockerd负责管理镜像、网络、卷、构建这些上层能力;再往下,dockerd也依赖containerd来真正管理容器生命周期;最底层是runc这类OCI运行时去启动进程。
所以在"用Docker runtime"的架构里,完整链路是这样的:
kubelet <-> (CRI) <-> cri-dockerd <-> (Docker API) <-> dockerd <-> containerd <-> runc也就是说,即使你走Docker这条路,底层最终还是会落到containerd和runc。这一点想通了,很多后续选型就不会纠结了——用Docker runtime多出来的开销主要是cri-dockerd和dockerd这两个进程的常驻资源,容器本身执行效率并不会因为多包了一层而明显下降,毕竟最终干活的还是同一套底层。
网上那些"K8s不支持Docker了"的说法,准确讲应该是"Kubernetes不再内置dockershim",而Docker作为CRI运行时在1.27里完全还能用,只是需要把cri-dockerd这个适配层部署好。这也是我写这篇笔记的核心前提。
2. 环境准备:内核、cgroup驱动和cri-dockerd部署
2.1 先把节点的底子打好
不管你的节点以后跑containerd还是Docker,有些系统层面的准备是绕不开的。我这边统一用Debian系的节点做演示,RHEL系的思路一样,就是包管理器命令不同。
先确认几件事:CPU至少2核、内存至少2GB,磁盘留20GB以上。主机名、MAC地址、product_uuid都要保证唯一,尤其是多节点实验环境,直接克隆虚拟机出来的节点很容易踩这个坑。swap必须关掉,kubeadm在初始化时会检查,只要swapon开着就直接报错中断。
内核模块和网络转发参数也需要提前配好。容器网络和iptables转发都依赖这些内核特性,尤其是Calico、Flannel这类网络插件,如果br_netfilter没加载,Pod之间通信经常表现为时好时坏,非常难查。建议直接这样配:
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf overlay br_netfilter EOF sudo modprobe overlay sudo modprobe br_netfiltercat <<EOF | sudo tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF sudo sysctl --system这些参数不是玄学,它们决定了Kubernetes节点上的Pod流量能不能被iptables规则正确处理。漏掉br_netfilter的话,桥接流量会绕过netfilter,网络策略和Service转发就可能失效。
2.2 Docker的cgroup驱动必须和Kubelet对齐
节点基础环境准备好之后,安装Docker。这里有一个特别容易被忽略的点:cgroup驱动。
Docker的默认cgroup驱动是cgroupfs,而Kubernetes从1.24开始默认推荐、并且kubelet几乎总是以systemd驱动运行。两者不一致时,kubelet启动日志里会明确报错,连节点注册都完不成。这个坑我见过太多次了,而且报错信息写得非常直白:
kubelet cgroup driver: "systemd" is different from docker cgroup driver: "cgroupfs"解决方式是在安装完Docker后,立刻写一个daemon.json并重启dockerd:
{ "exec-opts": ["native.cgroupdriver=systemd"], "log-driver": "json-file", "log-opts": { "max-size": "100m" } }sudo systemctl daemon-reload sudo systemctl restart docker docker info | grep -i cgroup看到Systemd就说明改好了。另外容器日志建议加上max-size限制,不然后台跑几个打印狂魔容器,能把你磁盘写满。这一步不提前做,后面初始化集群必然要折返。
2.3 下载并部署cri-dockerd
cri-dockerd作为适配层,是整条链路里的关键部件。它的release包里自带二进制和systemd配置,下载时要注意版本和Kubernetes 1.27配套,一般选0.3.x的最新发行版即可,太老的版本不支持CRI v1协议,后面会踩到"CRI v1 runtime API is not implemented"这类错。
解压后把cri-dockerd二进制放到/usr/local/bin,然后准备systemd service。release包里自带的unit配置可以直接用,但为了讲清楚进程参数,我这里给一个精简版本:
[Unit] Description=CRI Docker After=docker.service [Service] ExecStart=/usr/local/bin/cri-dockerd \ --network-plugin=cni \ --pod-infra-container-image=registry.k8s.io/pause:3.9 Restart=always RestartSec=10 [Install] WantedBy=multi-user.target有两个参数值得单独说明。
--pod-infra-container-image指定的是每个Pod里那个pause沙箱容器的镜像。如果节点所在网络访问官方镜像仓库不顺畅,这里可以改成你们内部仓库里的pause:3.9路径,不然Pod会一直卡在ContainerCreating。--network-plugin=cni则让cri-dockerd把网络配置交给CNI插件去处理,这和Kubernetes节点上需要先装好CNI二进制是一个道理。
配置好后启动并确认socket文件存在:
sudo systemctl daemon-reload sudo systemctl enable --now cri-docker ls -l /var/run/cri-dockerd.sock看到socket文件生成,适配层就算起来了。后面kubelet、crictl都是通过这个socket和cri-dockerd通信。
3. kubeadm init与节点加入:cri-socket参数不能偷懒
3.1 控制平面初始化
一切就绪后,用kubeadm初始化控制平面。这里最核心的一个参数就是--cri-socket,它直接告诉kubeadm"你该用哪个运行时接口"。
sudo kubeadm init \ --kubernetes-version=v1.27.x \ --cri-socket=unix:///var/run/cri-dockerd.sock \ --pod-network-cidr=10.244.0.0/16为什么我不依赖kubeadm的自动探测?因为如果节点上同时存在containerd.sock和cri-dockerd.sock,自动探测的结果不确定,可能选到默认的containerd路径,那你这套Docker runtime就白配了。显式指定,让结果可控。
这里补充一句:--pod-network-cidr要和你后面选的网络插件匹配,比如Flannel对应10.244.0.0/16,Calico则根据它的配置来决定。这个CIDR不直接影响runtime,但写错了网络插件会起不来。
init成功后会输出一串重要信息,包括kubeconfig路径、join命令和token。把admin.conf拷到本地,让kubectl可以管理集群:
mkdir -p $HOME/.kube sudo cp /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config然后安装网络插件。我习惯用Calico,直接apply它对应的manifest即可。很多新手在这时候发现master节点一直NotReady,就去查kubelet日志,查半天发现其实是网络插件没装。节点状态要从NotReady变Ready,必须等CNI就绪。
3.2 worker节点加入时也别忘了cri-socket
控制平面起来后,worker节点加入时有个非常容易犯的低级错误:join命令里不带--cri-socket。
sudo kubeadm join 192.168.x.x:6443 \ --token <token> \ --discovery-token-ca-cert-hash sha256:<hash> \ --cri-socket unix:///var/run/cri-dockerd.sock如果不带这个参数,kubeadm会用默认的containerd socket去探测runtime。结果就是:节点确实join进去了,但状态一直NotReady,kubelet日志里反复报连不上containerd。我自己调试时遇到过这个问题,第一反应是去查kubelet配置,后来才发现是join命令少了个参数,属于配置层面的粗心,但排查起来极其耗费时间。
3.3 kubelet的兜底配置
虽说init和join时指定了cri-socket,但有些场景下你还得手动确认kubelet的启动参数。kubeadm生成的kubelet配置主要在/var/lib/kubelet/kubeadm-flags.env里:
KUBELET_KUBEADM_ARGS="--container-runtime-endpoint=unix:///var/run/cri-dockerd.sock --container-runtime=remote"如果这里不是cri-dockerd的socket路径,就会出现节点注册了但runtime状态对不上的情况。改完这个文件后,一定要执行:
sudo systemctl daemon-reload sudo systemctl restart kubeletkubeadm升级版本时经常会重写这个文件,所以升级完集群最好顺手检查一遍,这是我从一次升级事故里总结出的经验。
4. 验证runtime生效:从kubectl到crictl到真实故障表
4.1 kubectl视角第一层确认
配置完所有节点后,先别急着跑应用,先确认runtime到底有没有生效。最直接的就是看节点信息:
kubectl get nodes -o wide输出结果里有一列CONTAINER-RUNTIME,如果你的链路没问题,这里显示的是类似docker://24.0.7的信息,前缀是docker就说明kubelet确实通过cri-dockerd在跟Docker通信。如果想看更详细的信息,可以describe节点:
kubectl describe node <node-name> | grep -i "Container Runtime Version"看到docker版本号,说明节点上报给控制面的runtime元数据也是对的。
这里还要解释一个细节:kubectl层面看到的是kubelet上报的runtime字符串,它不一定代表所有容器都在用Docker管理,但至少说明CRI链路里cri-dockerd是通的。真正要验证容器层,还得看下一层工具。
4.2 crictl和docker的对照二层确认
crictl是CRI标准的命令行工具,它不关心底层是containerd还是Docker,只关心你配置的runtime-endpoint。在这个架构里,crictl应该指向cri-dockerd的socket:
sudo crictl config --set \ runtime-endpoint=unix:///var/run/cri-dockerd.sock \ --set image-endpoint=unix:///var/run/cri-dockerd.sock配置完后跑一下crictl info,能看到runtime类型和版本。再随手拉一个测试镜像,分别用crictl和docker看容器:
sudo crictl ps sudo docker ps你会发现crictl列出的容器在docker ps里也能看到,只是名字不太一样。这是因为cri-dockerd把CRI容器翻译成了Docker容器,底层是同一批进程。我自己做这个验证时,同时跑这两个命令的感觉就像在对照同一份数据的两种展示形式。反过来,如果crictl配置错了地址,比如指向docker.sock,就会报"CRI runtime is not supported",因为Docker本身并不直接对外提供CRI接口。
4.3 实测踩过的坑:一张故障排查对照表
这一路下来,我把几个高频故障整理成了一张表,后面遇到类似报错可以直接对照:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| kubelet启动报cgroup driver不一致 | Docker的cgroup驱动还是cgroupfs | 改daemon.json设为systemd并重启dockerd |
| CRI v1 runtime API is not implemented | cri-dockerd版本太老 | 升级到支持CRI v1的0.3.x版本 |
| Pod一直ContainerCreating,日志报拉取pause镜像失败 | pause:3.9镜像源不可达 | 将cri-dockerd的--pod-infra-container-image指向内部仓库 |
| 节点NotReady,kubelet连不上/var/run/containerd/containerd.sock | join或kubelet配置未指定cri-dockerd的socket | 在kubeadm-flags.env里改endpoint并重启kubelet |
| docker ps能看到容器,kubectl查不到Pod | cri-dockerd进程没被systemd托管后自启 | 检查cri-docker.service状态并enable |
这张表看起来很简单,但每一条背后都对应着至少半小时的排查时间。尤其是第一条cgroup驱动不一致,报错信息很吓人,实际改动却很小,就一行JSON配置的事。
5. 1.27下继续用还是换containerd?聊聊我的选型参考
5.1 两种方案的真实差异
整套Docker runtime配好之后,我用了一段时间,也拿它和containerd做过实际对比。这里不吹不黑,只说客观差异:
| 维度 | Docker runtime(cri-dockerd) | containerd(原生CRI) |
|---|---|---|
| 组件数量 | kubelet + cri-dockerd + dockerd + containerd | kubelet + containerd |
| 常驻内存占用 | 多约200-300MB | 相对更省 |
| 命令行习惯 | 可继续用docker exec/logs/ps | 需用crictl/ctr,命令风格不同 |
| 镜像管理方式 | dockerd负责镜像层,docker images查看 | crictl images,工具链更朴素 |
| 排查日志便利性 | 所有容器都能用docker logs,直观 | crictl logs也能用,但输出格式更克制 |
从性能上看,两者容器吞吐差距很小,因为Docker底层同样依赖containerd。真正拉开差距的是运维习惯和组件数量。如果你所在团队的脚本里全是docker命令,迁移到containerd意味着所有自动化工具都要重写一遍,那用cri-dockerd过渡是完全合理的选择。
5.2 我的建议与一个小技巧
落到具体场景,我的建议是这样的:
如果你是要新建一个干净集群,没有历史包袱,直接上containerd。它是Kubernetes的默认路径,文档、CNI插件、监控组件默认适配都是往这条路靠的,少一层适配器就少一个排查维度。
如果是为了升级老集群到1.27,而节点上跑了一堆依赖docker命令的运维脚本,那保Docker runtime比较省事,关键是把cri-dockerd的systemd服务设好,别让它在节点重启后掉链子。
如果只是测试学习环境,我反而推荐用cri-dockerd。因为docker ps看到的东西更直观,初学者对着docker命令行理解K8s的容器模型会容易很多,跑完想知道Pod里的容器在宿主机上长什么样,一条docker ps就看见了。
最后分享一个我实际用下来的小技巧:不管你选哪种runtime,都建议在节点上装好crictl并把runtime-endpoint指向正在使用的socket。因为crictl看的是CRI真实状态,它的视角和kubelet几乎一致。而docker命令行看到的部分信息,比如容器名和暂停容器之间的关系,有时候反而会让人误解CRI层的逻辑。遇到调度异常需要定位时,crictl ps -a的输出往往比docker ps更有参考价值。