AI时代的Kubernetes教程36招-第5招-containerd、CRI、cgroup v2和Kubelet到底怎么把Pod跑起来?
从 kubelet 到 runc:Pod Sandbox 起不来的节点运行链排查
两边 cgroup driver 不一致,Pod 就永远停在半路。
[读者] 需要自己维护 K8s 节点的平台与运维工程师
[痛点] Pod 卡在 ContainerCreating,日志报错看不懂
[现在读] 节点上了 cgroup v2,旧经验开始失效
[读完] 能沿 CRI 调用链定位到具体一层并修好
[旧方案] 单机集群用默认配置,一路跑得很好
|
v
[新需求] 节点升级到 cgroup v2 统一层级,kubelet 与运行时必须对齐
|
v
[冲突] 两边各用各的 cgroup driver,Sandbox 创建对不上路径
|
v
[后果] Pod 反复重建、CNI 就绪失败、资源限制形同虚设
我是老李,做平台工程十多年,最近两年大半时间都花在节点上。集群出问题,控制面通常是无辜的:API Server 照样接请求,etcd 照样选主,真正卡住 Pod 的,是每台机器上那条不到一秒钟的调用链。
这条链是固定的:kubelet 不直接创建容器,它作为 CRI 客户端,通过 gRPC 调用 containerd;containerd 的 CRI 插件再调用 OCI 运行时(默认 runc),按 OCI Runtime Spec 起进程。链上任何一层配置对不上,Pod 就停在半路。
问题是,这条链平时太安静了:安静到你只会用 kubectl,不会用 crictl;安静到你升级完系统,才发现节点跑在 cgroup v2 上,而配置文件还是两年前抄来的那一份。
第一个问题:Pod 卡在 ContainerCreating,你该先看 kubelet 日志、containerd 日志,还是直接去看容器进程?
第二个问题:节点上了 cgroup v2 之后,是先改 kubelet 配置,还是先改 containerd 配置?
第三个问题:如果两边看起来都配好了,为什么资源限制和 kubectl top 还是空的?
01、故事
我接手的那个集群有 12 个 Linux 节点,跑的是标准的一主多从,容器运行时是 containerd。任务是给业务做一次节点镜像升级:操作系统从 cgroup v1 换到 cgroup v2 统一层级,containerd 和 kubelet 也一起升到较新的版本。
升级前,一切正常。kubelet 用 systemd 做 cgroup driver,containerd 用默认配置,Pod 起得来、日志看得到、kubectl top 也有数。
升级后,值班群里开始出现同一种现象:新建的 Pod 一直卡在 ContainerCreating,几分钟后变成 SandboxChanged,然后再来一轮。老 Pod 还活着,新 Pod 起不来。
我先在控制面看了一圈:调度正常,事件里写的是 Failed to create pod sandbox。这句话的意思是,kubelet 连容器都没来得及创建,就失败了。
变化只有一个:节点换了 cgroup 层级,配置文件没跟着换。冲突也只有一个:kubelet 和 containerd 对容器该放在哪个 cgroup 路径下的理解,不再一致。
链起一层层
错在一处处
先看再动手
证据说真话
02、问题
旧方案是跑起来就不管:配置文件一次生成,之后不再校对。这在 cgroup v1 时代能活很久,因为 v1 松散,多套层级各自为政,写错了也未必立刻报错。
到了 cgroup v2,内核只给一条统一层级,路径就是身份。kubelet 按 systemd driver 算出 pod 的 cgroup 是 kubepods.slice 下面的一串 .slice/.scope;containerd 若按 cgroupfs 语义去创建,路径就没有 .slice 后缀。两边各写各的,kubelet 在自己预期的地方找不到容器 cgroup,Sandbox 这一层就直接倒下。
业务影响很直接:扩容失败、滚动更新卡住、故障转移没地方落 Pod。
技术表现有四条:Pod 停在 ContainerCreating;kubelet 日志里出现创建 sandbox 失败;crictl ps 只看到 pause 容器或什么都看不到;/var/log/pods 下新目录迟迟不出现。
可验证的完成标准也很明确:crictl ps 里 Pod 的 sandbox 与业务容器都是 Running;crictl inspect 出来的 cgroupPath 带 .slice/.scope;/sys/fs/cgroup/kubepods.slice 下能找到对应容器 cgroup;/var/log/pods 里有日志,crictl logs 也能读到同一份内容。
旧路走不通
新规已落地
表象在应用
根因在节点
03、原理
只讲这一篇用得上的原理:CRI 是接口不是实现,Sandbox 是命名空间的壳,cgroup 是路径即身份。
kubelet 通过 CRI(Container Runtime Interface)与运行时通信,接口分两组:RuntimeService 管 Pod 和容器,ImageService 管镜像。kubelet 不关心你后面是 containerd 还是别的实现,只认 socket。containerd 内置 CRI 插件,默认监听 /run/containerd/containerd.sock,所以大多数节点上你不需要额外装任何东西。
Pod Sandbox 是这层抽象的关键。它先创建一个 pause 容器,这个容器里几乎什么都不干,只负责持有 Linux 命名空间,尤其是 network namespace。业务容器启动时,通过 OCI runtime 加入这个 network namespace,于是同一个 Pod 里的容器共享网络。网络本身由 CNI 插件负责:containerd 的 CRI 插件在 sandbox 网络命名空间就绪后,按 /etc/cni/net.d 下的配置完成 ADD,所以 CNI 没配好,sandbox 就永远是半成品。
这里有一个反直觉判断:Pod 起不来,最该怀疑的往往不是容器运行不起来,而是壳没搭好。pause 容器、网络命名空间、cgroup 路径,这三样任何一样不成立,业务容器连出场机会都没有,而它们的证据都不在 kubectl 里。
再说 cgroup v2。v1 时代,CPU 用 cpu.cfs_quota_us 和 cpu.shares,内存用 memory.limit_in_bytes;v2 收敛成 cpu.max、cpu.weight、memory.max 这几个统一文件。kubelet 按 QoS 建层级(kubepods.slice 下再分 burstable、guaranteed 等),容器 cgroup 是这套层级里的一个节点。路径不是随便写的字符串,它是 kubelet、containerd、内核三方之间的契约;契约对不上,资源统计和限制会一起失效。
至于 cgroup v2 从哪个版本开始成为更明确的默认要求,以及题面提到的具体版本行为,属于版本相关事实,需按当前官方文档核验,不要拿旧博客当依据。
接口定边界
壳子定生死
路径即身份
版本要核验
04、架构
[输入] kubelet 收到 Pod spec,需要创建一个 sandbox
|
v
[模块] kubelet 内置 CRI client → containerd CRI 插件 → runc(OCI 运行时)
|
v
[数据/状态] Pod Sandbox:pause 容器 + network namespace + 容器 cgroup 路径
|
v
[处理] CNI 插件在 netns 里配网络;runc 按 OCI spec 起业务容器并加入该 netns
|
v
[输出] 容器进程 + /var/log/pods 日志 + cgroup 资源统计回传给 kubelet
边界很清楚:kubelet 只负责决定要什么,从不算怎么做。它把 sandbox 的创建、网络、日志目录、资源统计都委托给 CRI 实现。containerd 负责怎么做,包括调用 CNI、调用 runc、把日志写到约定目录。runc 只负责起进程,按 OCI spec 设置命名空间和 cgroup。
收益是解耦:换运行时不动 kubelet,换 CNI 不动业务代码。代价是责任被切碎:一次 Pod 启动失败,可能横跨三个进程、四个目录、两个配置文件。
适用条件也要说清:这套分工只在两边配置一致时成立。cgroup driver 必须一致,socket 必须可达,CNI 配置必须存在,日志目录必须可写。少一样,链就断在那一层。
分工换灵活
也换来碎片
一次失败里
藏着三层账
05、实战一次
先跑通最小闭环,再谈优化。
环境与版本(示例环境,具体版本行为需按当前官方文档核验):
# 操作系统:Ubuntu 22.04+,systemd 已使用统一 cgroup 层级
stat -fc %T /sys/fs/cgroup
# 预期:cgroup2fs
依赖:containerd(含 CRI 插件)、kubelet、kubeadm、crictl,以及一个标准 CNI 插件。
/etc/crictl.yaml,让 crictl 找得到运行时端点:
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
debug: false
/etc/containerd/config.toml(containerd 1.x 的配置版本 2;containerd 2.x 起配置版本为 3,CRI 被拆到 io.containerd.cri.v1.* 段落,具体路径需按当前官方文档核验):
version = 2
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true
/var/lib/kubelet/config.yaml:
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
containerLogMaxSize: 10Mi
containerLogMaxFiles: 5
核心实现是一个自检脚本,把四级证据一次打出来,可以直接落到节点上运行:
#!/usr/bin/env bash
set -euo pipefail
echo "== 1. cgroup 版本 =="
stat -fc %T /sys/fs/cgroup
echo "== 2. 运行时 socket =="
ls -l /run/containerd/containerd.sock
echo "== 3. containerd 侧 cgroup driver =="
grep -n "SystemdCgroup" /etc/containerd/config.toml || echo "未显式配置 SystemdCgroup"
echo "== 4. kubelet 侧 cgroup driver =="
grep -n "cgroupDriver" /var/lib/kubelet/config.yaml || echo "未显式配置 cgroupDriver"
echo "== 5. CRI 视角(kubelet 看到的)=="
crictl -r unix:///run/containerd/containerd.sock ps
echo "== 6. containerd 视角(CRI 命名空间)=="
ctr -n k8s.io tasks ls
echo "== 7. 服务状态 =="
systemctl is-active containerd kubelet
启动与验证:
sudo systemctl restart containerd
sudo systemctl restart kubelet
systemctl status containerd --no-pager
systemctl status kubelet --no-pager
请求一次:
kubectl run chain-demo --image=nginx:alpine --restart=Never
kubectl get pod chain-demo -o wide
验证这一层是否真的立住了:
# 1. sandbox 与业务容器都应该是 Running
crictl ps
# 2. 看 sandbox 的 cgroup 路径,应该是 .slice/.scope 形态
crictl inspectp $(crictl pods -q | head -n1) | grep -i cgroupPath
# 3. 日志应该能从 CRI 读到
crictl logs <容器ID>
# 4. 落到磁盘的日志目录
ls /var/log/pods | head
ls -l /var/log/containers | head
未在当前环境实测,以下为预期结果,示例输出:
== 1. cgroup 版本 ==
cgroup2fs
== 7. 服务状态 ==
active
active
如果第 5 步 crictl ps 里只看得到 pause 容器,第 6 步 ctr -n k8s.io tasks ls 里业务容器反复出现又消失,说明问题就卡在 sandbox 与运行时之间,进入下一章。
先跑最小环
再谈优化事
每一层证据
都要落到盘
06、排查
排查第一条:Pod 卡在 ContainerCreating。
现象:kubectl get pod 显示 ContainerCreating,事件报创建 sandbox 失败,几分钟后变成 SandboxChanged。
怀疑:CNI 没装好,sandbox 的网络一直没就绪。
检查:
ls -l /etc/cni/net.d
ls -l /opt/cni/bin
journalctl -u kubelet --since "10 min ago" | grep -i -E "cni|sandbox"
journalctl -u containerd --since "10 min ago" | grep -i -E "cni|sandbox"
证据(示例输出):
/etc/cni/net.d 目录为空
kubelet 日志:failed to create pod sandbox ... network plugin not ready
根因:节点上根本没有 CNI 配置。pause 容器能起来,network namespace 也能建,但没人给它配 IP 和路由,sandbox 就一直不 Ready。
修复:按官方文档选择并部署一个 CNI 插件,确认 /etc/cni/net.d 下出现 conflist 文件,然后删掉卡住的 Pod 让它重建。
错误尝试: 先 systemctl restart kubelet containerd 来一轮。为什么错:CNI 配置文件不存在是持久状态,重启不会凭空产生配置;更糟的是,重启会把 pod sandbox 重建一遍,卡住的旧日志被新日志顶掉,本来就少的现场也没了。
排查第二条:Pod 能 Running,但资源限制不生效、kubectl top 没数据。
现象:容器在跑,crictl ps 正常,但节点上看不到容器的资源统计。
怀疑:kubelet 与 containerd 的 cgroup driver 不一致,两边算出的 cgroup 路径不是同一个。
检查:
grep -n "cgroupDriver" /var/lib/kubelet/config.yaml
grep -n "SystemdCgroup" /etc/containerd/config.toml
crictl inspect <容器ID> | grep -i cgroupPath
ctr -n k8s.io tasks ls
find /sys/fs/cgroup/kubepods.slice -maxdepth 4 -type d | head
证据(示例输出):kubelet 侧写的是 systemd,containerd 侧 SystemdCgroup 没有开启;crictl inspect 返回的 cgroupPath 是 /kubepods/... 这种没有 .slice/.scope 后缀的路径,而 kubelet 预期的是 kubepods.slice 下面的一串。
根因:配置版本 2 下 SystemdCgroup 不显式开启时,运行时会走 cgroupfs 语义(该默认值需按当前官方文档核验)。cgroup v2 只有一条统一层级,路径不一致时 kubelet 在自己算出的位置找不到容器 cgroup,资源统计与限制就一起落空。
修复:在 runtime options 里把 SystemdCgroup 设为 true,重启 containerd 与 kubelet,重建 Pod,再用上面几条命令验证路径带上了 .slice/.scope。
排查第三条:kubectl logs 为空,但 crictl logs 有内容。
现象:业务容器明明在打日志,kubectl logs 返回空。
怀疑:日志目录与软链不一致。
检查:
crictl inspect <容器ID> | grep -i logPath
ls -l /var/log/pods
ls -l /var/log/containers | grep <pod名>
证据(示例输出):CRI 报的 logPath 指向 /var/log/pods/
根因:容器日志由运行时按约定写进 /var/log/pods,再由 kubelet 维护 /var/log/containers 下的软链。软链断了或目录权限不对,kubectl logs 这条路径就断。
修复:核对 pod 目录与软链,确认 kubelet 对该目录可写,必要时重建 Pod 让 kubelet 重新生成软链;同时把 containerLogMaxSize 与 containerLogMaxFiles 显式写进 kubelet 配置,避免日志无限增长。
现象要坐实
怀疑要写清
重启最省事
也最没证据
07、优化
严格按第 06 章拿到的证据出 V2。
根因一:kubelet 与 containerd 的 cgroup driver 不一致。
修改:containerd 侧 SystemdCgroup = true,kubelet 侧 cgroupDriver: systemd。
原因:cgroup v2 只有一条统一层级,路径必须唯一,两边都要走 systemd 语义。
新行为:容器 cgroup 落在 kubepods.slice 下的一串 .slice/.scope,kubelet 能在预期位置找到它。
验证:crictl inspect 的 cgroupPath 带 .slice/.scope,且该路径在 /sys/fs/cgroup 下真实存在;已部署 metrics 组件时 kubectl top 能出数。
根因二:CNI 配置缺失,运行时却照常起 sandbox。
修改:把 CNI 插件作为节点初始化的一部分,在 kubelet 启动前完成部署。
原因:sandbox 不 Ready 不等于进程起不来,中间那段失败必须靠前置检查拦住。
新行为:节点初始化脚本在确认 /etc/cni/net.d 有 conflist 之后才允许 kubelet 起来。
验证:新 Pod 在正常时间内进入 Running,事件里不再出现 network plugin not ready。
根因三:日志目录与轮转没有显式约束。
修改:kubelet 配置里写死 containerLogMaxSize 与 containerLogMaxFiles。
原因:默认值能跑,但节点上的日志盘不会因为默认而变大。
新行为:单个容器日志有上限,轮转文件数有上限,磁盘不会被打爆。
验证:持续打日志后观察 /var/log/pods 下文件数量与大小是否被轮转限制。
再补一条工程收敛:把第 05 章那个自检脚本接进节点初始化与日常巡检。V2 的价值不只是修好了这台机器,而是下一次升级时,脚本能在 kubelet 起来之前就告诉你:cgroup 是 v2,两边 driver 都是 systemd,CNI 在,socket 通。
根因对上症
改动才有效
验证写清楚
才算做完事
08、演进
[同一输入]
|
+--[V1] 默认配置 + 人工记忆 / 代价:路径不一致,Pod 起不来,排查靠猜
|
+--[V2] 显式 driver + 前置自检 + 日志上限 / 代价:多一份配置要维护
|
[Trade-off]
得到一致性与可观测性 / 失去什么都不用配的轻松 / 适用于需要自己维护节点的场景
正确性上,V2 把两边必须一致从口头约定变成配置文件里的显式两行,不再依赖默认值。V1 在 cgroup v1 时代能活,是因为松散层级容忍不一致;v2 把容忍度收回去了。
稳定性上,V2 更稳,但稳的来源不是改对了参数,而是自检脚本把不一致提前暴露。参数会随版本变化,配置版本 2 到 3 的段落路径变化就是例子,需按当前官方文档核验;自检不会变。
复杂度上,V2 略微上升:多了一个脚本、多了几行配置、多了一次启动顺序的约束。成本也上升:节点初始化慢了一点,因为要等 CNI 就绪。
适用范围上,V2 适合所有自建节点;如果用的是托管节点与托管运行时,这些配置多半不允许你碰,你能改的是知道它在哪一层,出了事能指对方向。
遗留问题也很清楚:containerd 2.x 的配置版本 3 改了 CRI 插件段落名,手上的旧模板会失效;kubelet 从 CRI 直接询问 cgroup driver 的能力在各版本上的默认状态也不同。这两件事都得跟着版本核验,不能写成永久真理。
旧版靠默认
新版靠显式
换的是写法
争的是一致
09、洞见
9.1 先分层,再定位
节点问题最贵的不是修,是找。kubectl 只能看到控制面转述的事件,看不到 CRI 的错误原文。反直觉判断:故障现象离控制面越近,根因往往离控制面越远。所以第一动作不是重启,而是按 kubelet、containerd、runc、cgroup 与日志的顺序,逐层要证据。
9.2 路径就是契约
配置文件里的 cgroupDriver 那两行,看起来像可选项,其实是一份跨进程契约。反直觉判断:cgroup 路径不一致,最先失效的不是 Pod 启动,而是资源统计。因为统计依赖 kubelet 到容器 cgroup 的寻址,路径错了,kubelet 不是报错,而是找不到的沉默。这也是为什么很多集群的资源限制失衡很久才被发现。
9.3 重启是最贵的排查手段
重启能解决一部分问题,因为它重置了状态;但节点上的故障大多是配置态,重启不产生配置,只消灭现场。错误尝试那一条的价值不在我试错了,而在我知道为什么这是错的。
9.4 Sandbox 是所有问题的放大器
pause 容器、network namespace、CNI、cgroup,这四样都在 sandbox 阶段完成。它们的共同点是:不出问题时你完全感知不到,出问题时全都在同一个报错里。把 sandbox 阶段当成一道独立的验收工序,比事后猜测便宜得多。
分层找根因
契约看路径
重启别乱用
壳子先验收
10、系统落地
原来有什么:一个能跑的集群,一份抄来的配置文件,一条出事就重启的经验。
本篇新增什么:CRI 调用链的心智模型,kubelet 到 containerd 再到 runc;Sandbox 阶段的四个要素,pause 容器、network namespace、CNI、cgroup 路径;两个关键路径,/etc/containerd/config.toml 与 /var/lib/kubelet/config.yaml;三组命令,crictl ps 与 images 与 logs、ctr -n k8s.io、systemctl status kubelet containerd;还有一个能直接跑的自检脚本。
现在能做什么:新节点上线前先跑自检;Pod 卡住时能判断是网络层、运行时层还是 cgroup 层;能读懂 crictl inspect 里 cgroupPath 与 logPath 意味着什么。
还缺什么:没有覆盖多运行时场景下 runc 之外的沙箱运行时差异;没有覆盖 cgroup v2 下的内存回收与 OOM 细节;没有把日志采集链,也就是节点采集器到日志后端这一段接进来。
下一步如何演进:把自检脚本接进节点巡检定时任务,输出结构化结果;把 crictl 与 ctr 的输出纳入故障快照,让现场可交付给别人;在每一次运行时或节点镜像升级时,先在小规模节点上验证 driver 一致性,再放量。
模型先立住
脚本再固化
能力交出去
才算落了地
11、小结
Q1 → 先看 kubelet 日志里的 CRI 错误原文,再顺着 endpoint 找到运行时那一层
Q2 → 先确认节点 cgroup 版本,再让两边使用同一个 driver(systemd)
Q3 → 因为 driver 不一致,kubelet 在预期路径下找不到容器 cgroup
状态 → 一条可自检、可复现、可交接的节点运行链认知
三问定方向
一链见根由
能复现才真
能交接才稳
12、作业
12.1 理解题:为什么 Pod 起不来时,先怀疑 sandbox 而不是业务镜像?
参考答案:因为 sandbox 阶段承担了命名空间、网络与 cgroup 路径三件事,业务容器必须加入这个壳才能启动。镜像拉取失败会明确报在拉镜像那一步,而 sandbox 失败连创建容器都没走到,两者的日志位置和排查入口完全不同。
12.2 实战题:在一台 cgroup v2 节点上,把 kubelet 与 containerd 的 cgroup driver 对齐,并给出一条验证证据。
参考答案:在 /etc/containerd/config.toml 的 runc options 里设 SystemdCgroup = true(注意配置版本差异),在 /var/lib/kubelet/config.yaml 里设 cgroupDriver: systemd,重启 containerd 与 kubelet。验证证据用 crictl inspect 看容器 cgroupPath 是否带 .slice/.scope,并确认该路径在 /sys/fs/cgroup 下真实存在。
12.3 排障题:kubectl logs 为空,但 crictl logs 能读到日志,你按什么顺序查?
参考答案:先 crictl inspect 拿到 logPath,确认运行时写日志的位置;再看 /var/log/pods 下对应目录是否存在、是否可写;然后查 /var/log/containers 下的软链是否指向正确;最后核对 kubelet 的日志轮转配置,排除轮转导致的目录与文件变化。
12.4 架构判断题:有团队提议把 cgroup driver 写死在节点镜像里,不再随配置文件走。你怎么判断?
参考答案:方向上对,一致性应该被固化,而不是靠人记。但要注意两点:一是运行时版本升级可能改变配置段落与默认值,写死容易在升级时整体失效,需按当前官方文档核验;二是托管节点可能不让你改。更稳的做法是固化加自检:把配置进镜像,同时保留启动前自检,两者不一致时阻止 kubelet 起来。
会看一层层
会证一条条
会判一步步
才算会节点
13、思考
回到本篇那个核心冲突:一个 Pod 的启动,被切给了三个进程、两个配置文件、四类资源,分别是命名空间、网络、cgroup 与日志。切得越细,越依赖接口两边理解一致。
cgroup v1 到 v2 的变化,本质上不是文件名的变化,而是容忍度的变化:v1 允许你糊弄,v2 不允许。所以节点运维的分水岭在于,你是否从配完就不管,转向显式配置、启动前自检、出事先取证据。
下次再有 Pod 卡在 ContainerCreating,不要先重启。先问一句:这条链上,哪一层的证据我还没有?
链子切得细
一致才是骨
先取证再动
节点自会稳

浙公网安备 33010602011771号