1. 从"运行时是集群的地基"说起:HA 控制面为什么绕不开 containerd 对接
做 Kubernetes HA 集群踩过几轮之后,你会发现一个事实:控制面高可用能不能撑住,很大程度不取决于 kube-apiserver 配了几个副本,而是取决于每个节点上"最底层那两三个组件"是不是真的对齐了。这里说的就是 containerd 和 kubelet 这一对组合。标题里专门把 containerd 和 kubelet 放在同一个环节,就是因为它们俩的对接质量,直接决定了一个节点能不能被 kube-apiserver 认可为 Ready,进而决定 HA 集群的数据面是否稳定。
拿一个常见的生产场景来说。控制面三节点,etcd 三副本,kube-apiserver 通过负载均衡对外提供服务,这套骨架很容易搭起来。但等你把工作节点加进去,kubelet 调用容器运行时去拉起 Pod 的时候,问题就开始冒出来了:节点反复 NotReady、Pod 一直 ContainerCreating、镜像拉取卡住不动、日志里全是连接 containerd 的 timeout。这些坑十有八九不是 Kubernetes 本身的问题,而是 kubelet 和 containerd 之间那层配置没对齐。尤其是 HA 集群,节点数量多,配置稍微不一致,故障面会被成倍放大。
这篇文章就围绕 containerd 和 kubelet 的对接展开,把 HA 场景下"运行时侧要准备什么、kubelet 侧要怎么传参、配置落地后怎么验证、坏的时候怎么查"讲清楚。适合已经装好 containerd、准备初始化 HA 集群、但不确定 kubelet 到底该怎么和 containerd 握手的人。
说句实在的,很多教程喜欢把 containerd 装完就扔在一边,直接跑 kubeadm init,然后出了问题靠搜索引擎。与其等报错再查,不如在初始化之前把两边配置统一好。接下来我把整个流程拆开讲,先从 containerd 侧开始。
2. containerd 侧的准备工作:cgroup 驱动、pause 镜像与私有仓库参数
kubelet 和 containerd 打交道,不是装完 containerd 就能直接用的。containerd 自身有几个配置项,如果没有提前调好,kubelet 起来之后会用一个错一个。这里我按实际踩坑的顺序讲。
2.1 首当其冲的 Cgroup 驱动:为什么必须和 kubelet 保持一致
cgroup 驱动是 containerd 配置里最容易埋雷的地方。containerd 默认生成的配置里,SystemdCgroup 可能是 false,也就是说它默认走 cgroupfs。而现代 Linux 发行版里,无论是 systemd 管理的系统还是 kubelet 本身,通常都推荐使用 systemd 驱动。为什么要推荐 systemd?因为操作系统里的进程是由 systemd 管理的,如果容器运行时用 cgroupfs 自己去操作 cgroup,而 systemd 同时也在管理同样的 cgroup 层级,两边就会互相踩,轻则资源统计混乱,重则节点直接 NotReady,甚至出现"kill 不掉容器、容器重启后 PID 混乱"的怪事。
所以第一件事,生成 containerd 的默认配置然后改掉这一项:
mkdir -p /etc/containerd containerd config default > /etc/containerd/config.toml然后用编辑器打开 /etc/containerd/config.toml,找到下面这一段:
[plugins."io.containerd.grpc.v1.cri"] ... [plugins."io.containerd.grpc.v1.cri".containerd] ... [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] ... [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] SystemdCgroup = true这里要特别说明,不同 containerd 版本的配置路径可能有差异,但关键字 SystemdCgroup 是不变的。你只需要确认最终配置里这一项是 true。如果你用的是较新版本,可能还需要确认非 CRI 插件的 runc 配置也保持一致,不过一般情况下把上面这一处改掉就够了。
改完之后重启 containerd:
systemctl restart containerd systemctl status containerd看到 active (running) 之后,先别急着往下走,用 crictl 看一眼运行时是否正常:
crictl version如果能正常输出版本号,说明 containerd 的 CRI 插件已经在监听了。这一步卡住的话,后面 kubelet 怎么配都是白搭。
2.2 sandbox_image 也就是 pause 镜像:HA 集群里最容易拖慢初始化速度的隐藏项
第二个容易忽略的是 sandbox_image,也就是所谓 pause 镜像。Pod 在 Kubernetes 里的模型是"一个沙箱容器加上若干业务容器",这个沙箱容器就是 pause。kubelet 每创建出一个 Pod,containerd 都要先拉取 pause 镜像,然后才能启动业务容器。
问题来了,默认配置里 sandbox_image 指向的是 registry.k8s.io 的地址。对于某些网络环境下,这个地址可能访问很慢,甚至直接超时。HA 集群初始化的时候,控制面节点要创建 etcd、apiserver、controller-manager、scheduler 这一堆静态 Pod,如果 pause 镜像拉不下来,kubelet 会一直报 SandBox 创建失败,节点状态看起来就像"卡在初始化中"。
解决办法很直接,把 sandbox_image 换成你实际可达的镜像仓库地址,或者提前把 pause 镜像 pull 到节点上并打上对应 tag。这里给一个可复现的例子,假设你的内网仓库地址是 registry.internal.example.com:
[plugins."io.containerd.grpc.v1.cri"] sandbox_image = "registry.internal.example.com/pause:3.9"如果你是单机测试,没有内网仓库,那至少要把 pause 镜像手动 pull 到本地,然后把 sandbox_image 改成和本地镜像一致的名字。不要小看这一步,我在某个模拟项目里见过整整一个下午都在处理 kubelet 反复报错,最后发现就是默认 pause 镜像地址在特定网络环境无法访问。
2.3 registry mirror 配置:多节点 HA 场景必须提前统一
第三个实战中必须处理的是镜像仓库配置。单节点集群,镜像拉不动,大不了手动 docker pull 再 import。HA 集群少说三台节点,要是每台节点拉镜像都要翻山越岭,初始化的时候你会体会到什么叫"等得花儿都谢了"。
containerd 支持在 config.toml 里配置 registry mirror,也就是镜像加速或内网仓库转发。配置路径通常长这样:
[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.internal.example.com", "https://docker.io"]这段配置意思很直白:凡是来自 docker.io 的镜像,优先走内网仓库,内网不行再走官方源。多节点 HA 场景,最稳妥的做法是:每一台节点的 containerd 配置里都写上同样的 mirror,且 endpoint 顺序保持一致。别小看"顺序一致"这件事,如果节点 A 走内网、节点 B 走公网,某些依赖镜像版本的组件在不同节点上表现不一致,后面排查起来会非常痛苦。
这里补充一个很多人忽略的细节:改完 containerd 配置后,不只要重启 containerd,还要确认 containerd 真的把新配置加载了。怎么确认?用 crictl pull 拉一个测试镜像,看日志里走的是哪个 endpoint。如果用配置里的内网地址能快速拉下来,就说明 mirror 生效了。
3. kubelet 与 containerd 的握手协议:CriSocket、超时与 node 注册参数
containerd 侧准备好之后,接下来就是 kubelet。kubelet 是一个复杂的组件,但和 containerd 对接这件事,本质上是几个参数。搞清楚这几个参数的含义,你就不会被网上各种互相矛盾的配置搞晕。
3.1 --container-runtime-endpoint:kubelet 访问 containerd 的唯一路径
kubelet 和 containerd 之间走的是 gRPC 调用,containerd 通过 CRI 插件暴露了一个 Unix Socket,默认地址是 unix:///run/containerd/containerd.sock。kubelet 要找到这个 Socket,靠的就是 --container-runtime-endpoint 参数。
如果你用 systemd 管理 kubelet,通常会在 kubelet.service 的 Environment 或 ExecStart 里看到这样一行:
--container-runtime-endpoint=unix:///run/containerd/containerd.sock这里要注意,kubelet 本身还有一个 --container-runtime 参数,老版本里常用 docker,现在几乎都固定为 remote。不要因为看到 remote 就觉得奇怪,它表示 kubelet 把运行时调用全部转发给 CRI 插件处理。
另外一个大坑是:containerd 的 Socket 文件可能因为权限问题导致 kubelet 无法访问。systemd 服务默认会有一个比较严格的安全策略,确保 kubelet 进程有权限访问 /run/containerd/containerd.sock。如果你发现 kubelet 日志里出现 "unable to connect" 或 "connection refused",先别急着看网络,先 ls -l 看一眼 Socket 文件的所有者和权限。
3.2 --runtime-request-timeout:看似不起眼的超时参数
kubelet 几乎所有的运行时操作(创建容器、拉镜像、挂载卷)都会受到 --runtime-request-timeout 的影响。这个参数默认值是 2 分钟。在 HA 集群里,很多节点初始化阶段会出现拉镜像慢、磁盘 IO 高的情况,默认超时在很多场景下其实够用,但如果你的集群使用了网络存储,或者镜像比较大,超时就可能被频繁触发。
我习惯把这个值设置成 5 分钟或更长:
--runtime-request-timeout=5m这个参数设置得长一点,并不会拖慢系统性能,它只是给 kubelet 和运行时之间的操作留出更大的余量。有人在生产环境里把这个值设得非常大,比如 15 分钟。我不建议这么做,因为超时太大会让 kubelet 对运行时故障的反应变得迟钝,节点异常了还要等很久才会开始迁移 Pod。HA 集群的初衷是故障快速转移,过长的超时反而违背了这个目标。5 分钟是一个比较平衡的取值。
3.3 --node-labels 与 --node-ip:HA 集群节点身份的关键铺垫
除了和 containerd 直接相关的参数,我还有两个建议在 kubelet 配置阶段就固定下来的参数,一个是 --node-labels,一个是 --node-ip。
先说 --node-ip。在多网卡服务器上,如果不显式指定这个参数,kubelet 可能会选错 IP,导致节点注册到集群时的地址不可达,从 apiserver 的视角看这个节点永远是 NotReady。HA 集群的节点通常有管理网和业务网,甚至还有存储网。务必明确指定:
--node-ip=10.10.10.11然后说 --node-labels。HA 集群里我们通常会给控制面节点打上 node-role.kubernetes.io/control-plane 标签,也会给某些节点打上专用标签。这个参数可以在 kubelet 启动时注入,也可以在节点加入后再用 kubectl label 补上。我的习惯是在 kubelet 启动参数里就把控制面标签写进去,这样即使后续需要重新初始化,节点角色也不会丢。
值得提醒的是,如果节点已经注册进集群,再改 --node-labels 里的某些标签,kubelet 重启后会去更新节点对象,但有些系统标签是 kube-controller-manager 管理的,手工覆盖会被回滚。所以像 node-role.kubernetes.io/control-plane 这类角色标签,最稳的方式是交给 kubeadm 处理,自己只打自定义标签。
4. 配置落到集群的完整路径:kubeadm 传参、systemd 守护与首节点初始化
当你把 containerd 的配置和 kubelet 的参数都想清楚了,接下来的问题就是:怎么把这些配置变成控制面节点上真正运行的服务。这一节是整个部署流程的骨架。
4.1 为什么强烈建议用 kubeadm 而不是手写 systemd unit
我先说结论:HA 集群初始化,建议优先使用 kubeadm 生成 kubelet 的 systemd unit 文件,而不是自己手写一份 service 文件。
原因不复杂。kubeadm init 的时候,会先通过 kubeadm init phase kubelet-start 把 kubelet 以 static pod 形式管理起来,每一个控制面组件(etcd、apiserver、controller-manager、scheduler)都是 kubelet 拉起来的一组静态 Pod。kubeadm 会生成 /etc/systemd/system/kubelet.service.d/kubeadm.conf,再叠加一个 10-kubeadm.conf,里面已经包含了和 CRI 对接的关键参数。如果你手写 kubelet.service,很容易漏掉某些 kubeadm 需要注入的参数,结果就是 kubelet 起来了但集群组件起不来。
当然,这不代表 kubeadm.conf 不能改。实际上,kubeadm 提供了一个专门的口子:/etc/default/kubelet 或 /etc/sysconfig/kubelet,里面可以放 KUBELET_EXTRA_ARGS。比如前面提到的 --node-ip、--runtime-request-timeout,都可以放在这里,避免直接修改 kubeadm 生成的 unit 文件。单位文件一旦被 kubeadm 管理,下次 kubeadm upgrade 的时候可能被覆盖,但 /etc/default/kubelet 里面的额外参数通常会被保留。
一个典型的 /etc/default/kubelet 内容长这样:
KUBELET_EXTRA_ARGS="--node-ip=10.10.10.11 --runtime-request-timeout=5m --container-runtime-endpoint=unix:///run/containerd/containerd.sock"写完之后重启 kubelet:
systemctl daemon-reload systemctl restart kubelet4.2 控制面节点的初始化动作:从 kubeadm init 到 kubeconfig 分发
控制面节点准备好之后,执行 kubeadm init 之前要确认两件事:一是 containerd 运行正常,二是 kubelet 参数已经注入。
kubeadm init 有一个参数叫 --cri-socket,如果你没有修改过 containerd 的默认 Socket 路径,通常可以省略这个参数,kubeadm 会自动探测。但如果你在自定义的目录挂载了 containerd 的 Socket,那就必须显式指定:
kubeadm init \ --control-plane-endpoint=ha-apiserver.internal.example.com:6443 \ --cri-socket=unix:///run/containerd/containerd.sock \ --pod-network-cidr=10.244.0.0/16初始化完成后,你会看到一个提示:把 /etc/kubernetes/admin.conf 拷贝到本地,用于 kubectl 访问集群。这一步大多数人不会漏,但在 HA 场景里,我建议把 admin.conf 同时复制到其他控制面节点,方便后续排查。
初始化过程会持续几分钟。有一条经验值得记住:如果 kubeadm init 长时间卡在 "waiting for the kubelet to boot" 或者 "waiting for the first static pod to be ready",绝大多数情况不是 kubeadm 卡了,而是 kubelet 和 containerd 没对上。此时不要反复重试 init,先开另一个终端去看日志:
journalctl -u kubelet -f因为 kubelet 现在由 systemd 管理,错误信息会在 journal 里滚动。你会看到类似 "Failed to connect to containerd"、"failed to determine kubelet image" 这类错误。逐个解决之后再重新 init 也不迟。
4.3 加入其余节点:HA 集群最讲究"配置一致性"
控制面首节点初始化成功之后,工作节点和其他控制面节点通过 kubeadm join 加入。这一步有一个隐藏问题:如果节点上 containerd 的配置和首节点不一致,加入之后可能出现镜像拉取慢、节点已经 Ready 但 Pod 调度不上去的情况。
我的建议是,再重复一遍那三条纪律:第一,所有节点的 containerd config.toml 中 SystemdCgroup 都是 true;第二,所有节点的 sandbox_image 指向同一个地址;第三,所有节点的 registry mirror endpoint 顺序一致。不要偷懒,逐台检查。
然后用 kubeadm token create --print-join-command 生成新的 join 命令,确保 token 没有过期。控制面节点加入时,join 命令末尾会多一个 --control-plane 参数,工作节点不需要。
加入完成后,在任意控制面节点执行:
kubectl get nodes正常情况你会看到所有节点都是 Ready 状态。如果某个节点长时间 NotReady,就按下一节的排查链路走。
5. 节点不是 Ready?这套排查链路能覆盖绝大多数场景
最后这一节是实战中最有价值的部分。我按"从底层到上层"的顺序,把 HA 集群里 kubelet 与 containerd 对接失败时最常见的几类问题列出来。每一步都对应一个可操作的验证命令。
5.1 第一层:containerd 本身还活着吗
排查的第一站永远是 runtime。别一上来就查 kubelet 或 CNI,先确认 containerd 有没有挂掉或者处于异常状态:
systemctl status containerd如果 containerd 状态不对,看日志:
journalctl -u containerd -n 100 --no-pager这里有一个真实踩过的坑:某台节点重启后,containerd 启动失败,原因是 /etc/containerd/config.toml 里写了一个不存在的插件路径。这种问题查询日志时往往不会直接报"配置文件错误",而是报某个插件无法加载。所以排查时不要只看最后几行,重点搜索 failed 或 error 关键字。
containerd 正常的情况下,用 crictl 检查运行时连通性:
crictl ps crictl images如果 crictl ps 能列出容器列表,说明 CRI 接口正常,kubelet 连接 containerd 的底层 Socket 通道大概率没问题。如果这里就报错,那就不是 kubelet 的问题,而是 containerd 的 CRI 插件没有起来。
5.2 第二层:kubelet 日志里最常见的三类报错
containerd 没问题但节点依然 NotReady 时,就要看 kubelet 日志了:
journalctl -u kubelet -n 200 --no-pager我总结下来,高频报错基本是这三类:
第一类:连接 containerd 失败。日志里会有 "Failed to connect to containerd"、“unable to connect to containerd socket” 这类字眼。原因一般是 Socket 路径不对,或者 kubelet 进程权限不够。检查 /run/containerd/containerd.sock 是否存在,权限是否为 root:root 或 kubelet 可读。
第二类:sandbox 创建失败。日志里会反复出现 "failed to create sandbox" 或者 "failed to pull pause image"。这种情况优先检查 sandbox_image 是否可达,或者本地是否已经存在 pause 镜像。用 crictl images | grep pause 看一下就知道。
第三类:PLEG 不健康。PLEG 是 kubelet 内部的一个核心模块,负责周期性地同步容器状态。日志里直接出现 "PLEG is not healthy" 的时候,说明 kubelet 无法从 containerd 获取容器状态。这种情况往往是因为 containerd 响应慢,或者事件过多导致 kubelet 卡死。大部分时候重启 kubelet 能暂缓,但治本还要检查 containerd 的资源使用和磁盘空间,特别是 containerd 的 metadta 存储目录所在分区是否被占满。
5.3 第三层:cgroup 驱动不一致的经典症状
如果前两层都正常,节点却还是 NotReady,那就值得怀疑 cgroup 驱动了。这种情况的经典症状是:节点状态显示 NotReady,kubelet 日志里有 "failed to validate cgroup v2" 或 "misconfiguration: kubelet cgroup driver: systemd, but CRI cgroup driver: cgroupfs" 之类的内容。
为什么 cgroup 驱动不一致会导致这么严重的问题?因为 kubelet 在启动时会校验自己管理的 Pod 和节点资源,需要保证自己和容器运行时的 cgroup 操作是同一套体系。systemd 驱动下,kubelet 会通过创建 systemd scope 来管理容器资源;而 cgroupfs 驱动下,kubelet 直接在 cgroup 树里创建子目录。两者混用,systemd 无法感知 kubelet 创建的 cgroup,于是系统资源会被重复统计,最终导致 Pod 里的 CPU 和内存配额失效。
排查方法很简单:分别查看 kubelet 启动参数里的 --cgroup-driver 和 containerd 配置里的 SystemdCgroup。两边必须一致。一般情况下二者都应该是 systemd。如果发现 containerd 侧是 false,改过来重启 containerd;如果 kubelet 侧是 cgroupfs,就去 /etc/default/kubelet 里的 KUBELET_EXTRA_ARGS 加上 --cgroup-driver=systemd。
补充一个细节,检查 cgroup 驱动时不要只看配置,最好实际验证。在节点上运行:
systemctl cat containerd | grep SystemdCgroup以及:
ps -ef | grep kubelet | grep cgroup用实际运行中的进程参数来验证,比看配置文件更可靠。
5.4 第四层:网络插件与镜像拉取的伪 NotReady
很多时候 kubelet 本身已经正常连接 containerd,但节点看起来就是 NotReady,原因是网络插件没有 Ready,比如 Calico 或 Cilium 的 Pod 一直起不来。这种情况下,node 状态可能不是 NotReady,而是 Ready 但带有 noSchedule 污点,或者 Ready 但网络组件异常。
检查网络插件 Pod 的日志时,需要先看它卡在什么阶段:
kubectl get pods -n kube-system -o wide如果 Pod 卡在 ContainerCreating,大概率又是 containerd 拉镜像的问题。网络上流传一句话"Kubernetes 99% 的问题都是镜像问题",虽然夸张,但在 HA 集群初始化阶段基本成立。所以镜像那块一定要舍得花时间提前准备。
还有一个容易被忽略的:Node 已经 Ready,但上面调度不了 Pod,查看 node 的状态会发现存在 disk-pressure 或 memory-pressure。这不完全是 kubelet 和 containerd 的问题,但和 containerd 的镜像垃圾堆积有一定关系。维护 HA 集群时,建议定期清理未使用的镜像,或者配置 containerd 镜像保留策略。
5.5 第五层:从本质上理解 NotReady 的恢复流程
最后说一下恢复流程。其实 Kubelet 的 NotReady 不是永久状态,等它排查到问题恢复之后,节点会重新上报心跳,自动回到 Ready。但注意,Pod 不会因为节点恢复而自动迁回,已经迁移到其他节点的 Pod 需要人工确认。
另外还有一条经验:在 HA 集群里排查 kubelet 问题时,不要把某一台节点的操作看得太轻。因为控制面三节点共同维护 etcd 的 quorum,如果你一台节点上反复重启 kubelet 导致 etcd 成员频繁失联,整个控制面可能受影响。所以建议在操作之前先规划好:一次性把配置改对,然后重启,观察,确认恢复,再动下一台。不要两台一起改。
6. 写在最后:做个能自我复检的部署习惯
到这里,containerd 和 kubelet 对接的完整链路已经走了一遍。我的感受是,真正让 HA 集群稳定跑起来的关键,不是某个命令记得多熟,而是养成一套可以复检的习惯。每次配置完 containerd,都确认 SystemdCgroup、sandbox_image、registry mirror 三个点。每次配置完 kubelet,都确认 container-runtime-endpoint、runtime-request-timeout、node-ip 三个点。然后从 crictl 到 kubelet 日志,再到 kubectl get nodes,一级一级验证。
这套流程看起来很基础,但基础的东西往往最决定成败。有一次我在模拟项目里把三节点 HA 集群从零初始化,正是按这个顺序逐台配置,初始化结束后所有节点一次 Ready,几乎没有返工。反而是那些让 kubelet 和 containerd 各跑各的节点,出了问题排查时间都是按小时算的。
最后再分享一个小技巧:初始化完毕后,把每台节点上 containerd 的 config.toml 做个备份,比如 cp 到 /root/config.toml.bak,并把机器的 /etc/default/kubelet 也备份一份。后续做升级或者二次初始化时,直接对比备份就知道哪些配置被改动过,能省下大量排查时间。