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/memory、cgroup on /sys/fs/cgroup/cpu 等。V2 只有一个 cgroup2 挂载在 /sys/fs/cgroup。
这是排障第一步。我在这上面浪费过几小时——明明系统是 V1,却按 V2 的 cpu.max 去查,当然找不到文件。
为什么需要 cgroup
在 cgroup 之前,Linux 有 nice 和 ulimit。nice 是建议不是限制——一个吃 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,只有那个线程被移走。我就见过有人把 tasks 当 cgroup.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 写进 tasks,htop 里立刻能看到只有那个线程的 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_throttled 和 throttled_usec。如果 nr_throttled 在涨,说明你的进程在撞天花板。V2 下 cpu.stat 包含 nr_periods、nr_throttled、throttled_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 的设计问题,三个规则:
- 一棵树——所有控制器在同一个层级。
- 一个位置——一个进程在树里只在一个点上。
- 叶子节点规则——进程只能在叶子 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_bytes→memory.maxmemory.soft_limit_in_bytes→memory.high(语义不同——V2 的回收更激进)blkio.throttle.*→io.max- 控制器启用/停用 →
cgroup.subtree_control
PSI(Pressure Stall Information)
V2 新增 PSI 文件:memory.pressure、cpu.pressure、io.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 的
limits和requests翻译成 kubelet 节点上的 cgroup 设置。SSH 到节点上查看/sys/fs/cgroup/kubepods/,可以从 Pod 的 PID 追溯到它的 cgroup。当 Pod 报了 OOMKilled 而你想验证实际的 cgroup 状态时很有用——有时 kubelet 的视图已经过期了。
实用调试建议
-
先看
memory.events和cpu.stat。这两个是 cgroup 级别的计数器,告诉你实际发生了什么——不是你以为发生了什么。 -
PSI 文件能在命中限制之前抓住问题。把
memory.pressure和常规监控指标一起采集。 -
V1 坑:非 root cgroup 命名空间下,
/proc/pid/cgroup的输出可能有误导性。在容器内部,路径是相对于容器 cgroup 命名空间的,不是宿主机的。 -
限制传递:V1 下父 cgroup 设了一个 CPU 限制、子 cgroup 设了一个更高的限制,子可能不遵守父的限制(如果 CPU 控制器的层级和 memory 不同)。V2 下层级强制保证子一定被父的限制约束。
-
限制别设太紧,要给正常突发留余地。推荐起点:
memory.high设memory.max的 80%,CPU 配额比预期峰值高 10-20%。监控nr_throttled和memory.events,据此调整。Cgroup 限制可以控制损害,但修不了应用层的 bug。
(全文完)