CFN Cloud
2026-01-12

Linux CGroup 深入解析:从 V1 到 V2 的架构演进

从实战排障视角梳理 Linux cgroup——V1 多层级的问题、V2 统一模型的改进,以及 CPU 节流、OOM 行为、生产调试的真实坑点。

Control Group(cgroup)是容器资源隔离的基础。Docker、Kubernetes、systemd 全都依赖它。如果你运维线上基础设施,一定遇到过:

  • 节点内存没满,但容器被 OOM-killed(page cache 算在容器的限额里,不算在节点"空闲"里)。
  • Pod 设了 cpu: 1,平均 CPU 很低,但 P99 延迟周期性飙高(时间片节流)。
  • 主机升级切到 cgroup v2,监控脚本全挂(文件路径变了)。

本文讲的是排障时真正需要的东西——V1 哪里设计错了、V2 修了什么、还有什么坑在等。

先确认:你在哪个版本?

stat -fc %T /sys/fs/cgroup
# cgroup2fs = v2
# tmpfs     = v1

如果是 tmpfs,再看挂载了哪些控制器:

mount | grep cgroup

V1 会有多个挂载点——cgroup on /sys/fs/cgroup/memorycgroup on /sys/fs/cgroup/cpu 等。V2 只有一个 cgroup2 挂载在 /sys/fs/cgroup

这是排障第一步。我在这上面浪费过几小时——明明系统是 V1,却按 V2 的 cpu.max 去查,当然找不到文件。

为什么需要 cgroup

在 cgroup 之前,Linux 有 niceulimitnice 是建议不是限制——一个吃 CPU 的进程照样可以饿死其他进程。ulimit 是针对单个进程的,不能管一组。如果 Apache fork 了 100 个子进程,没办法说"你们加起来最多用 2 个 CPU 和 4GB 内存"。

Cgroup 让你把一组进程归到一个控制组,挂上控制器(CPU、内存、IO、PID),对整个组施加强硬限制。

V1 架构:多个层级

V1 允许每个控制器有自己的树:

/sys/fs/cgroup/cpu/demo/     ← CPU 限制
/sys/fs/cgroup/memory/demo/  ← 内存限制
/sys/fs/cgroup/blkio/demo/   ← IO 限制

看似灵活,实际带来一堆问题:

  • 协调困难:一个进程可能在 cpu/groupA 却同时在 memory/groupB。要同时对两者做限制,你得手动保持两边一致。
  • 记账开销:内核要在多个树里追踪同一个进程。
  • 线程粒度:V1 允许单独移动某个线程到不同 cgroup。这听起来有用,实际上主要是制造混乱。

线程 vs 进程的陷阱

V1 每个 cgroup 目录有两个文件:

  • tasks —— 移动单个线程(TID)
  • cgroup.procs —— 移动整个进程(PID)

如果你不小心把线程 ID 写进 tasks,只有那个线程被移走。我就见过有人把 taskscgroup.procs 用,结果进程里一个线程被限流了,其他线程没有。

#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

static void *spin(void *arg) {
  long tid = syscall(SYS_gettid);
  printf("thread tid=%ld\n", tid);
  while (1) { }
  return NULL;
}

int main() {
  printf("main pid=%d\n", getpid());
  pthread_t t1, t2;
  pthread_create(&t1, NULL, spin, NULL);
  pthread_create(&t2, NULL, spin, NULL);
  while (1) { }
  return 0;
}

gcc -O2 -pthread t.c -o t && ./t,然后用 ps -T -p <pid> 看线程。试试把一个 TID 写进 taskshtop 里立刻能看到只有那个线程的 CPU 变了。

CPU 节流:为什么 P99 会飙

最常见的 cgroup 误解:“我设了 1 CPU,平均用量才 50%,服务怎么还慢?”

答案是 CFS 配额是时间窗口而不是速率限制器。cpu.cfs_period_us 是 100ms、cpu.cfs_quota_us 是 50ms(0.5 CPU)时,你的进程每 100ms 窗口只能跑 50ms。如果它在前 20ms 就把 50ms 用完了,剩下的 80ms 被节流——即使跨几个窗口平均只有 50%。

echo 100000 > /sys/fs/cgroup/cpu/demo/cpu.cfs_period_us
echo 50000  > /sys/fs/cgroup/cpu/demo/cpu.cfs_quota_us
echo $$     > /sys/fs/cgroup/cpu/demo/cgroup.procs

查什么cpu.stat 里的 nr_throttledthrottled_usec。如果 nr_throttled 在涨,说明你的进程在撞天花板。V2 下 cpu.stat 包含 nr_periodsnr_throttledthrottled_usec

在 Kubernetes 里有个坑:默认 CFS 周期是 100ms,cpu: 0.1 的 Pod 每个周期只有 10ms。一个耗时的系统调用操作如果花了 15ms 墙钟时间,就能用掉整个配额——即使进程平均看起来很空闲。如果你的服务有定期批处理操作(日志轮转、GC、连接池刷新),预计会看到节流。

延迟敏感的服务应考虑用 cpuset 代替配额:

echo 0 > /sys/fs/cgroup/cpuset/demo/cpuset.mems
echo 2-3 > /sys/fs/cgroup/cpuset/demo/cpuset.cpus

cpuset 不存在节流问题——物理核是独占的。代价是这些核不能被其他进程用,节点利用率下降。

内存限制与 OOM

V1 的 memory.limit_in_bytes 是一堵硬墙。超过它就 OOM-kill——没有警告、没有渐进式降速。内核从 cgroup 里选一个 victim,不一定是导致超限的进程(它选组里最大的内存消耗者)。

echo 200M > /sys/fs/cgroup/memory/demo/memory.limit_in_bytes
echo $$ > /sys/fs/cgroup/memory/demo/cgroup.procs
./memhog 500

memory.failcnt 显示触发了多少次限制。dmesg 里有最近的 OOM 事件。

V2 改进——memory.high:增加了一个软阈值。超过 memory.high 时内核施加回收压力(节流分配、换出),给你时间反应,而不是直接杀死进程。线上建议 memory.high 设为 memory.max 的 80%,对回收活动做告警。

常见坑:page cache 计入 cgroup 内存限制。你的应用可能只用了 200MB RSS,但它读过的文件留下的 page cache 可能把总用量推过限制。这是容器化环境里"神秘的 OOM"的常见原因。如果你的服务启动时读了大量文件,这些缓存页在内存压力到来之前不会被回收——并且它们算在你的限额里。

IO 和 PID 控制器

blkio 按设备主次设备号限制磁盘 IO:

echo "8:0 10485760" > /sys/fs/cgroup/blkio/demo/blkio.throttle.write_bps_device

限制写入 /dev/sda 最高 10MB/s。注意它作用于直接写入,并且(取决于内核版本)和 page cache 的交互可能很微妙。进程快速脏页时不会立刻撞限——写入先到 cache,刷盘时才被节流。

pids 限制 fork 数量。超限时 fork() 返回 EAGAIN。大多数 fork bomb 会被内存限制先抓住,但 PID 限制可以防止容器运行时把宿主机的 PID 空间耗尽。

V2:统一层级

V2 修复了 V1 的设计问题,三个规则:

  1. 一棵树——所有控制器在同一个层级。
  2. 一个位置——一个进程在树里只在一个点上。
  3. 叶子节点规则——进程只能在叶子 cgroup。内部节点配置控制器和委派资源给子节点。
mount -t cgroup2 none /sys/fs/cgroup
echo "+cpu +memory" > /sys/fs/cgroup/cgroup.subtree_control
mkdir /sys/fs/cgroup/demo
echo "100000 100000" > /sys/fs/cgroup/demo/cpu.max
echo "300M" > /sys/fs/cgroup/demo/memory.max
echo $$ > /sys/fs/cgroup/demo/cgroup.procs

叶子节点坑:试图把进程放进非叶子 cgroup 时内核会拒绝,报"Device or resource busy"——一点也不直观。每个人至少被坑一次。

V2 关键文件变化:

  • cpu.cfs_*cpu.max(格式:max quota period,如 50000 100000
  • memory.limit_in_bytesmemory.max
  • memory.soft_limit_in_bytesmemory.high(语义不同——V2 的回收更激进)
  • blkio.throttle.*io.max
  • 控制器启用/停用 → cgroup.subtree_control

PSI(Pressure Stall Information)

V2 新增 PSI 文件:memory.pressurecpu.pressureio.pressure。用百分比显示资源压力导致的时间损失:

some avg10=2.34 avg60=1.87 avg300=0.98 total=492817349
full avg10=1.12 avg60=0.89 avg300=0.45 total=238475281

some 表示至少一个任务被阻塞。full 表示所有任务都被阻塞。avg10/avg60/avg300 是 10s/60s/300s 窗口的衰减平均值。

PSI 是第一个能在问题变成 OOM 或节流之前发现它的信号。memory.pressure 持续 some avg10 > 5 说明回收太频繁——提高 memory.high

生态如何使用 cgroup

线上很少直接操作 cgroup 文件。交互通过以下方式:

  • systemd:通过 service unit 管理 cgroup。systemd-run --scope -p MemoryMax=200M -p CPUQuota=50% bash 创建一个带限制的临时 scope。systemd 的 cgroup 管理和手动操作有时不兼容——我见过 systemd 在 unit 重启时重置 cgroup 限制,盖过了监控脚本的修改。

  • Docker / Kubernetes:Pod 的 limitsrequests 翻译成 kubelet 节点上的 cgroup 设置。SSH 到节点上查看 /sys/fs/cgroup/kubepods/,可以从 Pod 的 PID 追溯到它的 cgroup。当 Pod 报了 OOMKilled 而你想验证实际的 cgroup 状态时很有用——有时 kubelet 的视图已经过期了。

实用调试建议

  1. 先看 memory.eventscpu.stat。这两个是 cgroup 级别的计数器,告诉你实际发生了什么——不是你以为发生了什么。

  2. PSI 文件能在命中限制之前抓住问题。把 memory.pressure 和常规监控指标一起采集。

  3. V1 坑:非 root cgroup 命名空间下,/proc/pid/cgroup 的输出可能有误导性。在容器内部,路径是相对于容器 cgroup 命名空间的,不是宿主机的。

  4. 限制传递:V1 下父 cgroup 设了一个 CPU 限制、子 cgroup 设了一个更高的限制,子可能不遵守父的限制(如果 CPU 控制器的层级和 memory 不同)。V2 下层级强制保证子一定被父的限制约束。

  5. 限制别设太紧,要给正常突发留余地。推荐起点:memory.highmemory.max 的 80%,CPU 配额比预期峰值高 10-20%。监控 nr_throttledmemory.events,据此调整。Cgroup 限制可以控制损害,但修不了应用层的 bug。

(全文完)