先说明一个现实:如果你还在用 cgroup v1 的节点跑 Kubernetes,当集群升级到 v1.35+ 时,节点会直接起不来,而且报错信息非常容易让人误判成“kubelet 崩溃”或者“容器运行时挂了”。这个坑我最近帮朋友排查时踩了个遍,折腾了快一天才定位到根因。这篇文章我不打算给你复述官方文档,而是把问题背后的机制、排查思路、以及最稳妥的修复路径讲清楚。无论你是集群管理员、SRE,还是刚接手自建集群的运维,这篇内容都能帮你节省大量排查时间,少走弯路。
1. 问题背景:cgroup 版本演进与 v1.35 的“硬门槛”
1.1 Kubernetes 为什么要管 cgroup 版本
cgroup 是 Linux 内核用来限制、隔离进程资源(CPU、内存、IO、PID 等)的核心机制。Kubernetes 的 Pod 资源请求(requests)和限制(limits),最终都是靠 kubelet 调用容器运行时,再由运行时通过 cgroup 落实到内核里的。可以说,没有 cgroup,Kubernetes 的“资源管理”就是纸面承诺。
cgroup 有两个大的版本:v1 和 v2。v1 年代久远,每个资源类型(cpu、memory、blkio、pids 等)都挂在独立的层级目录里,管理分散、路径冗长;v2 则是把统一的层级结构建立起来,所有资源控制都在同一个 cgroup 树下面,通过不同的接口文件(cpu.max、memory.max 等)分别控制,设计更整洁,也更符合现代容器的资源管理需求。
Kubernetes 官方从很早开始就在推动 cgroup v2 的支持,但真正把“v2 作为默认”写进逻辑,是在一系列版本迭代中逐步完成的。到了 v1.35+,这个变化已经变成硬性要求:默认情况下,kubelet 假定节点已经运行 cgroup v2,如果你还在用 cgroup v1,节点启动流程直接失败。
1.2 为什么 v1.35+ 不再容忍 cgroup v1
有朋友可能会问:“既然 v1 还能用,为什么不继续兼容?”这事得从 Kubernetes 的维护策略说起。Kubernetes 对底层依赖的清理,通常采取“先警告,后移除”的节奏。官方很早就宣布 cgroup v1 支持进入弃用(deprecated)状态,然后在后续大版本里开始逐步收紧:
- 早期版本:cgroup v1 和 v2 都能正常工作,kubelet 自动探测。
- 中期版本:启动时会打印警告,提醒你尽快迁移到 v2。
- v1.31 前后:官方在 kubelet 中开始明确警告未来将移除 v1 支持。
- v1.32 左右:kubelet 代码中已经移除了部分 cgroup v1 路径,继续用 v1 即使能跑,也会丢失部分资源统计或管理能力。
- v1.35+:行为收紧,默认配置下,节点在 cgroup v1 环境里直接拒绝启动。
这个演进的核心逻辑是:维护两套 cgroup 代码路径的成本太高。尤其是 kubelet 的资源管理模块(如 kubepods 的 cgroup 管理、QoS 分类),很多底层调用已经深度依赖 v2 的目录结构和接口语义,再为 v1 做兼容,会让核心控制器代码越来越大,也越来越难以维护。而且主流发行版(Ubuntu 22.04+、Debian 11+、RHEL 9+、openEuler 22.03+)早已默认使用 cgroup v2,继续保留 v1 作为一等公民,投入产出比已经非常低。
1.3 影响范围:谁会被这个变化击中
下面这几类环境最容易踩中这个雷,建议先对号入座:
- 使用 CentOS 7、RHEL 7、Ubuntu 18.04 等老系统跑 Kubernetes 的集群(这些系统默认或常见配置就是 cgroup v1)。
- 云厂商提供的托管节点镜像,如果没有同步升级内核参数,大概率还是 v1。
- 自建集群中,之前手工在 GRUB 里加过 systemd.unified_cgroup_hierarchy=0 来强制使用 v1 的节点。
- 容器运行时配置中显式指定了 cgroup 驱动为 cgroupfs,且节点本身跑的是 v1 的老环境。
如果你的集群符合上面任意一条,并且在 v1.35+ 版本升级中出现了节点无法加入集群、kubelet 反复重启、或者启动日志里出现 “cgroup v1 is not supported” 之类的内容,那么这篇文章就是写给你的。
2. 核心细节解析:从启动报错到根因定位
2.1 启动失败时你会看到的典型报错
先给你看几个实际环境中会出现的典型症状,方便你快速确认是不是这个问题:
- kubelet 服务状态是 active (running),但节点始终处于 NotReady。
- kubelet 日志反复打印类似 “Failed to run kubelet: failed to validate kubelet flags” 或 “unsupported cgroup driver: cgroupfs”。
- containerd 日志中出现 “failed to load cgroup” 相关错误。
- 更隐晦的:kubelet 能启动,但 Pod 创建后一直处于 ContainerCreating,事件中提示 “failed to create task: cgroup v1 is not supported”。
这些报错单独看,很容易被误判成“CRI 配置错误”“kubelet 参数写错”“目录权限不对”。但实际上,根子都在 cgroup 版本上。
2.2 根因:kubelet 与容器运行时的 cgroup 驱动必须匹配,且版本必须为 v2
这里要展开讲一下 Kubernetes 和 cgroup 之间的两条关键链路:
第一条是kubelet 与容器运行时之间的 cgroup 驱动协商。kubelet 启动时会读取配置,确定自己使用哪种 cgroup 驱动(systemd 或 cgroupfs),然后通过 CRI 调用容器运行时(如 containerd、CRI-O),要求运行时也使用一致的驱动。如果两者不一致,kubelet 会和运行时互相推卸责任,最终表现为节点无法正常工作。
第二条是kubelet 对节点 cgroup 版本的探测。在 v1.35+ 中,kubelet 启动时会检查 /sys/fs/cgroup 是否挂载为 v2,如果检测到不是 v2,就直接拒绝启动。这一步是一个硬性检查,不再像旧版本那样“给个警告然后继续跑”。
之所以必须一致,是因为 cgroup v1 和 v2 的文件系统布局完全不同。kubelet 在 v2 环境下会向 /sys/fs/cgroup/kubepods.slice 写入资源控制参数;如果在 v1 环境下,则需要操作 /sys/fs/cgroup/cpu/kubepods 之类的路径。当 kubelet、运行时、内核三者之间对“当前该用哪套布局”的理解不一致时,资源管理就彻底错乱,最轻的表现是 QoS 失效,最重的表现就是节点直接不可用。
2.3 如何确认节点当前的 cgroup 版本
在动手修复之前,先确认你的节点到底跑的是 v1 还是 v2。这里有一套快速的检查方法,直接在节点上执行即可:
# 方法一:查看 cgroup 挂载点 stat -fc %T /sys/fs/cgroup/ # 输出 tmpfs 表示 cgroup v2 # 输出 cgroup 表示 cgroup v1 # 方法二:检查是否存在 cgroup.controllers 文件 ls /sys/fs/cgroup/cgroup.controllers 2>/dev/null && echo "cgroup v2" || echo "cgroup v1" # 方法三:通过系统参数确认 grep -i cgroup /proc/cmdline # 如果有 systemd.unified_cgroup_hierarchy=0,说明被强制指定为 v1实际操作中,我建议三种方法都跑一遍,因为它们判断的维度不太一样。方法一确认挂载文件系统类型,方法二确认是否存在 v2 独有的统一控制文件,方法三确认是不是有人在内核启动参数里做过强制指定。如果三者结论不一致(比如方法一是 v2 但方法三显示强制指定了 v1),多半是挂载状态比较混乱,需要重启节点让参数生效。
另外,容器运行时也可能有自己的配置。以 containerd 为例,可以执行:
containerd config dump | grep -i cgroup # 查看 SystemdCgroup 是否为 true,以及是否有 cgroup v1 相关的兼容配置如果 containerd 没找到 SystemdCgroup 配置,或者显式配置为 false,它就会在 v1 环境下使用 cgroupfs 驱动。在 v2 环境下则默认走 v2 的 cgroup 管理路径。这里必须和 kubelet 的配置保持一致。
2.4 排查三连:内核、运行时、kubelet 逐层排除问题
当你发现节点起不来,不要急着改配置,按下面的顺序做一次快速体检:
- 先看内核:uname -r 确认内核版本。cgroup v2 从内核 4.5 开始支持,5.2 之后才比较稳定。如果你还在用 3.10 之类的老内核(CentOS 7 默认内核),那不用看别的,直接升级系统或者换发行版,因为老内核根本没法正常工作在 v2 模式。
- 再看运行时:systemctl status containerd 确认运行时是否健康。如果 containerd 正常,再看 kubelet;如果 containerd 也不正常,优先排查运行时日志。
- 最后看 kubelet:journalctl -u kubelet -f 实时跟踪启动日志,重点看前 20 行左右的错误输出。
这套排查顺序的核心逻辑是:从底层到上层,逐层排除。内核出问题,上层再折腾也没用;运行时不健康,kubelet 自然无法注册节点。定位问题就像剥洋葱,一层一层来,效率最高。
3. 实操过程与核心环节实现:把节点迁移到 cgroup v2
3.1 迁移前的准备:备份与影响评估
切换到 cgroup v2 不是改个配置文件那么简单,它意味着节点上的所有进程都要切换到新的资源管理机制下。因此,迁移前必须做好两件事:
第一,确认节点上的应用兼容性。绝大多数容器化应用不受影响,因为容器内看到的 cgroup 视图由运行时处理,应用本身很少直接操作 cgroup。但如果你在宿主机上跑了一些需要直接读写 /sys/fs/cgroup 的监控组件或调优工具(比如老版本的 cadvisor、netdata、某些 Java 版本对 cgroup 内存感知),就需要检查它们是否有 v2 兼容版本。
第二,确认容器运行时支持 v2。这里分两类:
- containerd:1.5+ 版本官方支持 v2,建议直接用 1.7+,很稳定。
- CRI-O:1.20+ 版本支持 v2,建议用 1.29+。
- Docker(如果还有节点直接跑 docker):20.10+ 才开始良好支持 v2,建议直接用最新稳定版。
对于 Kubernetes 节点来说,kubelet 的版本和运行时版本还有配套关系。如果 kubelet 已经升级到 v1.35+,但运行时还是老版本,同样可能出现 cgroup 驱动或探测逻辑的兼容问题。我的建议是:在 cgroup 版本切换这件事上,先把运行时升级到至少一年内的稳定版,再做 v2 切换,能避免很多莫名其妙的兼容性问题。
另外一个容易忽略的点是节点上的GPU、SR-IOV、DPDK 等特殊硬件设备插件。这些组件有时会依赖 cgroup v1 的特定路径来设置设备限制或大页内存分配。如果你在集群里用到了这类设备,迁移前务必联系设备插件厂商或查看官方文档,确认 cgroup v2 兼容性。否则节点虽然起来了,但设备资源分配可能悄悄失效。
3.2 修改内核启动参数:systemd.unified_cgroup_hierarchy
要让节点从 v1 切到 v2,最核心的操作是修改 GRUB 内核启动参数。这里以最常见的 GRUB2 引导为例,手把手演示整个过程。
先编辑 /etc/default/grub:
vim /etc/default/grub找到 GRUB_CMDLINE_LINUX 这一行,检查里面是否有以下参数:
- systemd.unified_cgroup_hierarchy=0:强制使用 v1,必须移除。
- systemd.unified_cgroup_hierarchy=1:可选,显式启用 v2,可以保留。
- cgroup_no_v1=all:可选,禁用所有 v1 控制器,只保留 v2,强烈建议加上。
- systemd.legacy_systemd_cgroup_controller=yes:老系统才有的参数,必须移除。
典型修改前后的对比:
# 修改前(v1 强制模式) GRUB_CMDLINE_LINUX="rhgb quiet systemd.unified_cgroup_hierarchy=0" # 修改后(切换为 v2 模式) GRUB_CMDLINE_LINUX="rhgb quiet systemd.unified_cgroup_hierarchy=1 cgroup_no_v1=all"cgroup_no_v1=all 这个参数非常重要,它的作用是禁用所有 v1 控制器。如果不加这个参数,系统在切换后可能仍会挂载一部分 v1 控制器(比如保留老的 blkio 或 pids 控制器),造成“混合模式”(cgroup v2 和 v1 共存)。虽然混合模式在大多数情况下也能工作,但会带来不确定的行为,特别是在资源统计和 OOM 判定上。既然我们下决心要切换到 v2,就一步到位。
修改完成后,更新 GRUB 配置:
# BIOS 系统 grub2-mkconfig -o /boot/grub2/grub.cfg # UEFI 系统(根据实际挂载点调整) grub2-mkconfig -o /boot/efi/EFI/centos/grub.cfg # Debian/Ubuntu 系 update-grub如果节点上有多个内核版本,建议在重启前确认当前使用的内核是否支持 v2。下面的命令可以查看已安装内核:
rpm -qa | grep kernel # 或 dpkg -l | grep linux-image我建议选择一个支持 cgroup v2 的双内核(至少内核版本 5.8+),不要用旧内核碰运气。
3.3 修改容器运行时配置
内核参数改好后,重启前还有一个必做动作:调整容器运行时的 cgroup 驱动配置,确保它和 kubelet 一致,并且使用 systemd 驱动。
以 containerd 为例,修改 /etc/containerd/config.toml:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] base_runtime_spec = "" cni_conf_dir = "" cni_max_conf_num = 0 cni_network_plugin_dir = "" container_annotations = [] pod_annotations = [] privileged_without_host_devices = false runtime_engine = "" runtime_path = "" runtime_root = "" runtime_type = "io.containerd.runc.v2" sandbox_mode = "podsandbox" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] BinaryName = "runc" CriuImagePath = "" CriuPath = "" CriuWorkPath = "" IoGid = 0 IoUid = 0 NoNewKeyring = false NoPivotRoot = false Root = "" ShimCgroup = "" SystemdCgroup = true关键点就在 SystemdCgroup = true。在 v2 环境下,这个选项必须为 true,否则 containerd 会尝试自己挂载 cgroup 并管理 cgroupfs,与 systemd 的管理职责冲突,最终造成资源管理混乱。
修改完成后:
systemctl restart containerdCRI-O 的配置在 /etc/crio/crio.conf 中:
[crio.runtime] conmon_cgroup = "pod" cgroup_manager = "systemd"cgroup_manager = "systemd" 是 CRI-O 在 v2 环境下的正确姿势。
3.4 修改 kubelet 配置
kubelet 端同样需要确认配置。在 v1.35+ 中,kubelet 默认使用 systemd cgroup 驱动,如果你的 kubelet 配置了 cgroupDriver: cgroupfs,需要改成 systemd:
# /var/lib/kubelet/config.yaml apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration cgroupDriver: systemd同时,确认 kubelet 的 systemd unit 中没有强制指定 --cgroup-driver=cgroupfs 的参数。很多发行版的 kubelet 是通过 EnvironmentFile 加载参数的,检查一下 /etc/systemd/system/kubelet.service.d/ 目录下的 drop-in 文件:
grep -r "cgroup" /etc/systemd/system/kubelet.service.d/如果找到了 --cgroup-driver=cgroupfs,直接删掉这一行,让 kubelet 用默认值(v1.35+ 默认就是 systemd 驱动)。
3.5 节点重启与验证
确认所有配置修改完毕后,重启节点:
systemctl reboot重启完成后,按下面的顺序验证:
- 确认 cgroup 版本:
stat -fc %T /sys/fs/cgroup/ # 必须输出 tmpfs- 确认没有残留的 v1 控制器:
ls /sys/fs/cgroup # 应该看到 cgroup.controllers、cgroup.procs、init.scope 等 v2 特征文件 # 不应该看到 cpu、memory、blkio 等 v1 目录- 确认 containerd 正常:
systemctl status containerd ctr version- 确认 kubelet 能正常注册节点:
systemctl status kubelet kubectl get node <节点名> # 状态应该是 Ready- 在节点上跑一个测试 Pod,确认资源限制生效:
kubectl run test-cgroup --image=busybox --restart=Never --limits='cpu=200m,memory=256Mi' -- sh -c "sleep 3600" kubectl exec -it test-cgroup -- cat /sys/fs/cgroup/cpu.max # 输出应该是类似 "20000 100000" 的值,表示 200m CPU 限制 kubectl exec -it test-cgroup -- cat /sys/fs/cgroup/memory.max # 输出应该是 268435456,对应 256Mi最后一步非常重要。很多节点迁移后表面上 Ready,但资源限制实际没生效。通过进入 Pod 查看 v2 的控制文件内容,才能确认整条链路是通的。
| 检查项 | 期望值 | 说明 |
|---|---|---|
| /sys/fs/cgroup 文件系统类型 | tmpfs | 说明挂载的是 cgroup v2 的简化虚拟文件系统 |
| /sys/fs/cgroup/cgroup.controllers | 存在 | v2 的控制器列表文件 |
| kubelet cgroupDriver | systemd | systemd 与 containerd 一致 |
| containerd SystemdCgroup | true | CRI 与 kubelet 协商无误 |
| kubectl get node | Ready | 节点成功加入集群 |
| Pod 内 /sys/fs/cgroup/cpu.max | 如 "20000 100000" | CPU 限制真正生效 |
4. 常见问题与排查技巧实录:我在迁移中踩过的坑
4.1 坑一:升级 kubelet 后,containerd 还在用 v1 配置
这是我最常遇到的情况。很多集群的 kubelet 已经由安装脚本升级到了 v1.35+,但 containerd 的配置还是几年前的初始配置,SystemdCgroup 为 false。结果节点启动后,kubelet 报错与 containerd 的 cgroup 驱动不一致,或者 containerd 静默地尝试使用 v1 路径,导致节点无法注册。
排查方法很简单:
journalctl -u containerd -n 100 | grep -i cgroup如果看到类似 “cgroup v1 detected” 的日志,说明 containerd 检测到了 v1,但 kubelet 又要求 v2,两者必然打架。解决办法就是把 SystemdCgroup 改为 true,并重启 containerd。
4.2 坑二:GRUB 更新命令用错,重启后“戛然而止”
有些朋友在生成 GRUB 配置时,没搞清楚自己系统是 BIOS 还是 UEFI,把配置文件写错了位置。最常见的错误是在 UEFI 系统上执行 grub2-mkconfig -o /boot/grub2/grub.cfg,结果系统重启后直接进不了操作系统,卡在 GRUB 界面。
为了避免这种情况,我每次都先确认系统引导模式:
[ -d /sys/firmware/efi ] && echo "UEFI" || echo "BIOS"然后再选择正确的写盘路径。如果是云服务器(尤其是云厂商的镜像),可能还需要使用 grubby 工具来修改内核参数,而不是手工编辑 /etc/default/grub:
# 查看当前内核参数 grubby --info=ALL | grep args # 修改默认内核的启动参数 grubby --update-kernel=DEFAULT --args="systemd.unified_cgroup_hierarchy=1 cgroup_no_v1=all"在迁移前,建议先把关键配置备份到 /backup 目录,宁可多花两分钟备份,也不要在生产环境里赌一次。
4.3 坑三:忽略节点上的自定义 systemd 服务
如果你在节点上跑了一些自定义的 systemd 服务(比如自研的 agent、日志采集器),它们可能显式依赖 cgroup v1 的路径。迁移到 v2 后,这些服务可能起不来,或者行为异常。
典型的例子是旧版 node-exporter、telegraf,它们在采集 cgroup 统计信息时如果硬编码了 v1 路径,v2 环境下会直接采集不到数据。解决办法就是升级到最新版本,或者调整配置,让它们自动探测 cgroup 版本。这一项要提前加进迁移 checklist,否则节点切完第二天才被监控告警叫醒,那体验就太酸爽了。
4.4 常见问题速查表
| 异常现象 | 可能原因 | 快速排查/解决 |
|---|---|---|
| kubelet 启动报错 “failed to validate kubelet flags” | kubelet 探测到 cgroup v1 | 检查 /sys/fs/cgroup 是否为 v2;检查 GRUB 参数 |
| containerd 报 “cgroup v1 is not supported” | containerd 不支持 v1,或节点仍为 v1 | 升级 containerd 到 1.7+;切换内核参数为 v2 |
| 节点 Ready 但 Pod 卡在 ContainerCreating | 运行时和 kubelet 的 cgroup 驱动不一致 | 对比 kubelet 和 containerd 配置,统一为 systemd |
| Pod 内看到的内存限制和 limits 不匹配 | cgroup v1/v2 路径混用导致读取错误 | 进入 Pod 查看 /sys/fs/cgroup/memory.max,对比限制值 |
| 迁移后监控组件采集不到容器指标 | 监控组件硬编码 v1 路径 | 升级采集器,或开启兼容模式 |
| 重启后系统起不来 | GRUB 参数错误或写错引导文件 | 通过云控制台/救援模式进入系统,修正配置 |
4.5 独家避坑技巧:如何把迁移风险降到最低
最后分享两个我个人的习惯,平常可能不会写在公开文档里:
第一,迁移前先用临时节点验证。如果你的集群有多个节点,一定先挑一个非关键节点做迁移,确认完整流程没问题,再推广到其他节点。不要图省事一次性批量切,万一哪个节点的硬件或驱动有特殊依赖,批量操作会放大问题。
第二,准备一个回滚预案。切换到 v2 后如果出现极端问题(比如新的内核和 runc 配合不好,导致容器无法启动),你需要在 10 分钟内恢复节点。我的做法是保留一个 GRUB 菜单项,里面有一份旧的 v1 内核参数配置。万一 v2 出问题,重启时选旧项,节点就回到 v1 模式,先把业务恢复,再慢慢排查。这个兜底操作在真实运维中救了我好几次。
5. 迁移后的长期运维观察点
5.1 资源统计口径变化对监控的影响
从 v1 切到 v2 后,监控数据的采集口径会发生变化。最典型的是内存使用量:
- v1 里,容器内存使用量通常看 memory.usage_in_bytes,包含 page cache。
- v2 里,对应的是 memory.current,其语义与 v1 略有差异,且更接近真实内存压力。
如果你在监控面板上发现切换后 Pod 内存使用率突然“涨”了十几个百分点,先不要慌,很可能是采集口径变了,而不是业务真实的内存暴涨。建议在迁移前就记录好切换前的监控基线,切换后做一次对比,确认业务负载稳定的情况下,内存曲线的基线平移是正常的。
CPU 使用率的统计也有差异。v1 的 cpuacct.usage 和 v2 的 cpu.stat 在统计单位、是否包含 hyper-threading 的核数计算上都有细微差别。如果占用率监控出现 1%-2% 的偏移,不必大惊小怪,但要记录下来,方便后续告警阈值的微调。
5.2 OOM 行为的差异
cgroup v2 对 OOM 的处理也与 v1 不同。v2 在 OOM 时是“按 cgroup 范围”选择的,杀掉某个 cgroup 内最占内存的进程,而不是像 v1 那样有时直接杀掉整个 cgroup 中的任意进程。如果你是跑 Java 应用的集群,需要留意这个差异:Java 进程可能在 OOM 时被“精准清除”,而不是整个 Pod 被杀死。从稳定性角度看,这其实是更好的行为,但应用如果有状态,仍然可能产生脏数据。建议在迁移后做一次故障演练或压力测试,明确你集群里 OOM 时的真实表现。
5.3 不要迷信“切换一次,永远安逸”
cgroup 版本只是 Kubernetes 节点底层环境的一部分。v2 切换完成后,我建议顺手检查一下节点的 systemd 版本。cgroup v2 是 systemd 230+ 才逐步支持的,太老的 systemd 版本即使在 v2 内核上也会出现部分功能异常。如果节点 systemd 版本太老,下一步就该考虑系统升级了。
另外,Kubernetes 未来对 cgroup v2 的依赖只会加深,不太可能回头兼容 v1。早切早安心,拖着不切,只会让后续的集群升级越来越难。如果你所在团队对迁移风险有顾虑,可以分批推进:先灰度一个节点池,跑一两周观察稳定性,再逐步扩大到全量节点。这个过程我经历过好几轮,整体风险是可控的,只要按照上面的操作步骤一步步来,不会出大问题。
就我个人体会而言,这次 v1.35 的强制切换虽然是“靴子落地”,但对于老集群来说也算是一次变相的技术债清洗。借着这次机会,把系统版本、内核参数、运行时配置、监控口径全部梳理一遍,后面维护起来反而更省心了。