如果你的服务跑在 Kubernetes 或者 Docker 里,最近两三年大概率被提示过要检查节点是否启用了 cgroup v2。很多人的第一反应是“内核换了个东西,反正容器照跑”,但等到真的把系统切到 v2 后,才发现某些监控数据不对、某些调整参数的方式变了、甚至某些 Java 应用在容器里看到的 CPU 配额跟以前不一样。这些并不是小问题,而是 cgroup v2 在架构上动了不少底层逻辑。

cgroup v1 从 2008 年左右进入内核,到如今已经服役十几年。它确实解决了进程组资源限制的基本问题,但随着容器、混部、在线离线业务隔离等场景越来越复杂,v1 的很多设计缺陷开始变成实际故障。cgroup v2 不是简单把 v1 改一改,而是重新定义了层级结构、进程归属和控制器协同方式。这篇文章不打算逐条念内核文档,而是想从工程实际遇到的问题出发,聊聊 v2 到底解决了什么,以及切换时你真正需要留意的地方。
v1 时代最让人头疼的几个问题
先回顾一下 v1 的模型。每个控制器(cpu、memory、io、net_prio 等)都各自挂载成一个子系统,每个子系统挂载点下面是一棵独立的树。也就是说,同一个进程可以同时出现在 cpu 子系统的层级里,也可以出现在 memory 子系统的另一个层级里,而这两棵树的组织方式彼此无关。
这个设计初衷是灵活,但实际使用中给系统组件带来了很大复杂性。最典型的场景是 systemd 和容器运行时:它们想统一管理整个会话的所有资源,就得去操作多个挂载点,而且很难保证不同控制器之间的层级拓扑一致。你可能会在一个控制器下把进程 A 和 B 放在同一组,在另一个控制器下又把它们拆开,这种不一致导致资源统计很难对账。
另一个经典问题是线程和进程的关系。v1 允许同一个线程组内的不同线程被分到不同的 cgroup,这听起来更细粒度,但大多数应用并不希望线程被拆散。容器场景里,进程组才是有意义的资源单元,按线程拆分除了增加混乱,还会让 runtime 在迁移进程时遇到很多 edge case。
还有一个隐患是控制器之间缺乏协同。比如 memory 控制器回收页面时,并不知道 io 控制器如何加权。v1 里每个控制器独立运作,导致在内存压力下,不同 cgroup 的 io 优先级可能完全失控。这类问题在混部场景里非常致命。
v2 的核心变化:统一层级与进程模型
v2 最根本的改变是把所有控制器放在同一个层级树里。每个 cgroup 都是一个节点,所有控制器共享这棵树的拓扑。也就是说,一个 cgroup 里可以同时配置 cpu、memory、io 等资源限制,而不会出现不同控制器之间层级不一致的问题。
同时,v2 明确规定 cgroup 只控制进程,不再控制单个线程。线程组中的所有线程必须属于同一个 cgroup。这个变化对容器 runtime 来说其实是简化:它们只需要管理 pid 和 cgroup 路径的映射,不用再担心某个线程意外脱离控制。
v2 还引入了“内部进程约束”(no internal processes)。也就是说,非根 cgroup 不能同时拥有自己的进程和子 cgroup。你必须把进程放到叶子节点,否则就会报错。这个规则初看有点怪,但它保证了资源统计的清晰:每个 cgroup 的资源消耗只来自其子 cgroup,不会出现“父节点统计了一半,子节点又统计一半”的含糊情况。Kubernetes 的 kubelet 和容器运行时在创建 pod 沙箱时,其实已经自动遵循了这种结构。
另外,v2 默认启用了 cgroup namespace 的隔离,这让容器内看到的 cgroup 路径更接近容器视角,而不是宿主的完整路径。很多监控 agent 不再需要费力去解析容器外的路径前缀。
控制器合并带来的实际收益
v1 里 cpu 和 cpuacct 是两个控制器,一个管配额,一个管统计。v2 把它们合并成了 cpu 控制器,同时在 cpu.stat 里同时提供 usage、per-core 统计和负载平均值。io 控制器也合并了 blkio 和 io 的职责,并且在 io.stat 里给出了更统一的读写统计。
对运维来说,最直观的变化是以前查一个容器资源使用要读好几个文件,现在 /sys/fs/cgroup/cpu.stat、memory.stat、io.stat 各司其职,并且路径统一在同一个挂载点下。比如你想看一个 systemd service 的资源使用,直接进对应目录就能看到所有控制器文件。
更重要的是,v2 加入了压力感知(pressure stall information, PSI)。虽然 PSI 本身不依赖 cgroup v2,但 v2 的层级结构让 PSI 数据能按 cgroup 准确归因。这直接催生了 cpu.pressure、memory.pressure、io.pressure 这些文件,让开发者可以实时知道某个容器是否在等内存、等 io,而不是靠猜。
一个实际的例子:以前在 v1 下定位一个容器“偶尔变慢”的问题,你只能看 cpu 使用率、内存占用,很难判断是不是因为和其他容器争抢 io 导致的。有了 PSI 后,直接看 memory.pressure 和 io.pressure 就能发现是不是有持续的回收或磁盘排队。这类诊断能力是 v1 时代很难具备的。
内存控制器:回收和统计的改进
v1 的内存控制器统计基于整页(page),并且没有很好地区分匿名页和文件页的回收优先级。v2 对内存管理做了不少改动,但最值得关注的是它支持了回收参数更细粒度的配置。例如,memory.high 是 v2 引入的软限制,可以用来让 cgroup 在达到硬限制前就开始回收,避免直接触发 OOM。v1 里虽然也有 memory.high 类似的机制,但实际行为不太一样,导致很多应用在容器里被莫名其妙杀掉。
v2 还引入了 memory.reclaim 接口,允许外部主动触发某个 cgroup 的内存回收。这在内存超卖场景下很有用:当宿主机内存紧张时,系统可以先扫描可回收的 cgroup,而不是立刻走全局 OOM。
另外,v2 里 swap 统计更清晰,可以分别查看匿名页和文件页的使用量,并针对 swap 设置独立的最大值。在 v1 中,memory.memsw.limit_in_bytes 和 memory.limit_in_bytes 的关系经常让人困惑,很多团队因为配置不一致导致 swap 异常。v2 直接拆成 memory.swap.max,语义清晰了很多。
设备控制器与 BPF 结合
v1 的设备控制器基于设备号白名单,配置起来很麻烦,而且对动态设备支持不好。v2 弃用了旧的 devices 控制器,改由 BPF 来实现设备访问控制。内核推荐使用 cgroup/bpf 程序来过滤设备访问,这带来了更强的表达力。比如你可以基于设备类型、读写操作、甚至协议层信息来写策略,而不是只能列几个设备号。
这也意味着,依赖 v1 devices 控制器的老工具需要适配。好在 Docker 和 containerd 等主流 runtime 已经完成了迁移。如果你维护的是自研 runtime,或者还在用比较老的 systemd 版本,这块需要额外关注。
一个简单的 v1/v2 对比
| 特性 | cgroup v1 | cgroup v2 |
|---|---|---|
| 层级结构 | 每个控制器独立挂载树 | 统一层级,所有控制器共享一棵树 |
| 进程模型 | 线程可被拆分到不同 cgroup | 只控制进程,线程组不可拆分 |
| 内部进程 | 允许父节点包含进程和子节点 | 非根 cgroup 不能同时拥有进程和子节点 |
| CPU 控制器 | cpu 和 cpuacct 分离 | 合并为 cpu,统一统计 |
| IO 控制器 | blkio 独立,且和 io 分离 | 合并为 io,更完整的统计 |
| 内存回收 | 缺少主动回收接口 | 支持 memory.reclaim 主动回收 |
| 压力信息 | 无 | 支持 PSI(cpu/memory/io 压力) |
| 设备控制 | devices 控制器基于设备号 | 通过 BPF 程序实现,更灵活 |
这个表格并不是说 v1 一无是处,只是它是在一个相对简单的年代设计的。今天容器、微服务、混部这些场景,对资源控制的精确性和可观测性要求远高于十年前。
迁移到 v2 时的几个典型坑
很多系统在切换 cgroup v2 后,最先遇到的不是功能问题,而是兼容性。下面这三个坑比较有代表性。
1. Java 应用看到的 CPU 配额不对
Java 10 之前没有很好的 cgroup 感知,JVM 在容器里常常拿到宿主的 CPU 核数。后来 Java 增加了 UseContainerSupport,但它需要正确读取 cgroup 文件。cgroup v2 的路径是 /sys/fs/cgroup/cpu.max,而 v1 是 /sys/fs/cgroup/cpu/cpu.cfs_quota_us。很多老版本的 JDK 还不支持 v2 路径,导致 JVM 以为自己是无限 CPU,结果容器被限流后 GC 线程数过多,性能急剧下降。升级 JDK 到较新版本是必须的。
2. systemd 和 runtime 版本过旧
cgroup v2 在 systemd 里需要 244 以上版本才能原生支持,更老的版本需要加 systemd.unified_cgroup_hierarchy=1 并可能遇到兼容性问题。containerd 需要 1.5+,Docker 需要 20.10+。很多生产环境还停留在 CentOS 7(内核 3.10),它根本不支持 v2,所以如果你想用 v2,首先要升级内核和发行版。这不是改个参数就行的事。
3. 监控 agent 读取的文件路径变了
以前监控 agent 会去 /sys/fs/cgroup/cpu/… 和 /sys/fs/cgroup/memory/… 分别读数据,在 v2 下这些路径都不存在了,只有 /sys/fs/cgroup/cpu.stat 和 memory.stat。很多 agent 没适配,导致指标缺失。如果你的监控体系依赖 node_exporter 或者自研采集器,迁移前一定要确认版本是否支持 cgroup v2。不然你看到的内存使用率可能一直是 0。
如何快速判断和验证当前 cgroup 版本
在开始迁移之前,先确认你的系统已经启用了 v2。下面几个命令可以快速判断。
# 查看 cgroup 挂载情况
mount | grep cgroup
# 如果看到 cgroup2 挂载在 /sys/fs/cgroup,说明 v2 已启用
# 如果看到多个 cgroup 挂载点(如 /sys/fs/cgroup/cpu、/sys/fs/cgroup/memory),说明还在用 v1
# 检查内核启动参数
cat /proc/cmdline | grep systemd.unified_cgroup_hierarchy
如果你看到 cgroup2 类型,并且 /sys/fs/cgroup 下直接就是 cgroup.controllers 等文件,那就是 v2。反之,如果 /sys/fs/cgroup 下面有 cpu、memory 等子目录,就是 v1。
对于容器内,可以这样看:
cat /sys/fs/cgroup/cgroup.controllers
如果文件存在,说明当前进程运行在 cgroup v2 环境中。如果报错,那基本还是 v1 的布局。
落地建议:不是所有场景都必须立即切换
cgroup v2 是内核发展的方向,但要不要马上切换取决于你的系统现状。这里给几条务实的判断标准。
- 如果你的内核是 5.8+,并且 systemd、containerd 等核心组件版本较新,那直接启用 v2 是顺理成章的。
- 如果你的系统还在用老内核,或者依赖一些没有跟上 v2 的第三方监控 agent、运行时,建议先评估兼容性再迁移。
- 如果你主要是做离线任务隔离,v2 的 io 控制器和 PSI 能明显改善混部场景的可控性,值得优先尝试。
- 如果只是跑几个普通容器,对资源统计精度要求不高,继续用 v1 也不会出大问题,但要注意发行版迟早会默认切到 v2。
迁移时建议先在测试环境完整跑一遍现有 CI 和关键应用,重点看 JVM 参数、监控指标、容器启停脚本。一旦生产环境切换,基本无法平滑回退到 v1(因为需要重启系统)。所以验证越充分,后面越省心。
写在最后
cgroup v2 的改动并非为了炫技,而是把资源管理从“多个各自为政的树”收敛成“一棵清晰统一的树”。它让系统组件更容易维护,也让运行时和容器编排工具能够用一致的方式控制资源。理解 v2 的关键,不是背一堆文件路径,而是理解它为什么非要“统一”和“无内部进程”。有了这两点,很多实际问题的答案自己就能浮现出来。
如果你的工作涉及容器性能调优或者混部资源管理,花点时间把现有系统迁移到 v2 是值得的。别等到发行版默认切换时你才发现监控是瞎的、应用被限流。提前适应这个模型,后面会顺畅很多。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/504/