5.3 cgroups v2
cgroup v1 为什么被推翻,v2 的「统一层级」到底统一了什么?为什么在父目录写 memory.max 没有用,非得先写 cgroup.subtree_control?cpu.max 的两个数字怎么换成核数,memory.high 与 memory.max 在 OOM 上差在哪?
手工建一棵 cgroup 树并给进程加上 CPU/内存/PID/IO 限额;读懂 memory.events 与 cpu.stat 判断「被限流」还是「被 OOM」;把 Docker 与 systemd 的参数映射到具体 cgroup 文件。
namespace 让进程「看不见」,但看不见不等于「拿不到」。一个只有 namespace 的容器,仍然可以用 fork 炸弹耗尽宿主 PID,用 mmap 吃光内存,用 dd 打满磁盘带宽。cgroups(control groups)就是内核对「一组进程能用多少资源」的记账与限流机制。
它经历过一次大改。v1 给每类资源各建一棵树,导致「同一个进程在 CPU 树里属于 A、在内存树里属于 B」这种无法自洽的状态;v2 把全部控制器收进一棵统一树,并规定控制器只能从父向子逐级启用。Ubuntu 24.04、systemd 255、Docker 26 默认都用 v2,所以你遇到的绝大多数「容器内存限制不生效」问题,都能在 v2 的父子规则里找到原因。
这一章按「层级规则 → 手工建树 → 四个控制器 → systemd 映射 → 排障」的顺序展开。所有写文件的命令都需要 root;每段实验后面都给了清理步骤。阅读时请对照 5.1.5 里 systemd-run 生成的那三个文件。
5.3.1 为什么要有 v2:v1 的四个结构性问题
cgroup v1 在 2008 年进入主线(2.6.24),设计上允许每个控制器挂一棵独立的层级。这带来了灵活性,也带来了四个难以修补的问题:
| 问题 | v1 的表现 | v2 的修正 |
|---|---|---|
| 分组不一致 | CPU 树里进程在 /A,内存树里在 /B,无法回答「这个容器属于谁」 | 一棵树,一个进程在任意时刻只属于一个 cgroup |
| 控制器冲突 | 同一个进程被不同层级的 memory 限制反复覆盖 | 控制器沿树自上而下生效,子层只能更严 |
| 内部进程 | 父 cgroup 可以既有子 cgroup 又有进程,子层与父层直接竞争 | no-internal-process:启用域控制器的非根 cgroup 不能放进程 |
| 接口割裂 | cpu.shares、cpu.cfs_quota_us、memory.limit_in_bytes 命名与语义各不相同 | 统一为 cpu.weight、cpu.max、memory.max |
v1 还有一个被容器安全关注的点:release_agent。它允许在 cgroup 空掉时由内核执行一个宿主路径上的程序,早期配合 notify_on_release 被用于容器逃逸。v2 没有这个接口,这也是发行版推动 v2 的动力之一。
检查你的系统用的是哪一套:
stat -fc '%T' /sys/fs/cgroup
# cgroup2fs
mount | grep cgroup
# cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)
cat /proc/self/cgroup
# 0::/user.slice/user-1000.slice/session-2.scope
0::/... 是 v2 的格式:0 是 hierarchy ID(v2 固定为 0),中间为空(v1 这里是控制器列表),最后是路径。如果看到多行 12:memory:/...、4:cpu:/...,说明系统还在混合模式,v2 控制器不全。
- 「v2 只是 v1 换了个文件名」——层级规则、控制器启用方式、内部进程约束都变了;把 v1 的
tasks写法照搬到 v2 会直接失败。 - 「Ubuntu 24.04 还默认 v1」——24.04 默认 unified;只有显式加
systemd.unified_cgroup_hierarchy=0才回到 hybrid。 - 「cgroup v2 不支持 blkio」——支持,控制器名从
blkio改为io,接口是io.max/io.weight/io.stat。 - 「cgroup 能隔离 CPU」——它做的是配额与权重分配,不是「把某些核划给容器」;核级隔离要用
cpuset.cpus或isolcpus。
5.3.2 统一层级的三条规则
v2 只有一棵树,根是 /sys/fs/cgroup。每个 cgroup 是一个目录,目录里的文件分两类:
- 核心文件(
cgroup.前缀):由 cgroup 核心维护,所有 cgroup 都有; - 控制器文件(
cpu.、memory.、io.、pids.等前缀):由父 cgroup 的cgroup.subtree_control决定是否出现在子目录里。
理解 v2 只要抓住三条规则:
规则一:控制器属于父,不属于自己。 在 A/cgroup.subtree_control 里写 +memory,效果是「A 的子 cgroup 出现 memory.* 文件」,而不是 A 自己获得内存控制。所以想给 A/B 设 memory.max,必须先在 A 的 subtree_control 里启用 memory。
规则二:只能自顶向下启用。 非根 cgroup 的 subtree_control 只能包含父 cgroup 已经启用的控制器;父没启用,子写了也会 ENODEV/EINVAL。这保证了「限制只能越往下越紧」,不会出现子层绕过父层。
规则三:启用了域控制器的非根 cgroup 不能有进程。 这就是 no-internal-process constraint。原因很直接:如果父 cgroup 既有自己的进程、又有子 cgroup,父层进程就会和子层争抢同一份配额,记账无法公平。根 cgroup 豁免。
/sys/fs/cgroup ← root(豁免 no-internal-process)
├── cgroup.controllers cpuset cpu io memory hugetlb pids rdma misc
├── cgroup.subtree_control cpuset cpu io memory pids
├── system.slice/ ← systemd 的系统服务
│ ├── ssh.service/
│ └── docker-xxxx.scope/ ← 一个容器(systemd cgroup driver)
├── user.slice/
│ └── user-1000.slice/session-2.scope/
└── demo/ ← 手工创建
├── cgroup.subtree_control +cpu +memory +pids
└── a/ ← 真正放进程的叶子
├── cpu.max 50000 100000
├── memory.max 67108864
└── pids.max 32
读这棵树要注意方向:demo 只是「分配者」,它自己不跑进程;真正被限额的是 demo/a 里的进程。demo 的 subtree_control 决定 a 里出现哪些控制器文件;a 里的 cpu.max 等文件由 demo 拥有。这就是为什么「在 demo 里写 memory.max」往往找不到文件——demo 自己没有被父层启用 memory 控制器。
核心文件速查:
| 文件 | 读/写 | 作用 |
|---|---|---|
cgroup.controllers | 只读 | 本 cgroup 可用的控制器 |
cgroup.subtree_control | 读写 | 对子 cgroup启用/禁用控制器(+cpu -memory) |
cgroup.procs | 读写 | 属于本 cgroup 的进程 PID 列表;写 PID 迁移整个进程 |
cgroup.threads | 读写 | 线程级迁移,只在线程模式子树里有效 |
cgroup.type | 读写 | domain / domain threaded / threaded |
cgroup.events | 只读 | populated(是否有活进程)、frozen |
cgroup.stat | 只读 | nr_descendants、nr_dying_descendants |
cgroup.freeze | 读写 | 写 1 冻结整棵子树(Linux 5.2+) |
cgroup.kill | 只写 | 写 1 杀死整棵子树(Linux 5.14+) |
cgroup.max.depth / .descendants | 读写 | 限制子树深度/数量,防 namespace 式泄漏 |
5.3.3 从零建一棵树(root,手工写文件)
下面这套操作在 Ubuntu 24.04 的 /sys/fs/cgroup 上可直接执行。它会在根下建一个 demo 目录,启用 cpu/memory/pids 三个控制器,再在 demo/a 里给一个 sleep 进程设限额。
# 需要 root
sudo -i
# 0. 确认是 v2,并看根层已启用哪些控制器
stat -fc '%T' /sys/fs/cgroup
cat /sys/fs/cgroup/cgroup.controllers
cat /sys/fs/cgroup/cgroup.subtree_control
# 1. 建父 cgroup,并为其子层启用控制器
mkdir /sys/fs/cgroup/demo
echo "+cpu +memory +pids" > /sys/fs/cgroup/demo/cgroup.subtree_control
cat /sys/fs/cgroup/demo/cgroup.subtree_control
# cpu memory pids
# 2. 建叶子 cgroup:控制器文件这时才出现
mkdir /sys/fs/cgroup/demo/a
ls /sys/fs/cgroup/demo/a | grep -E '^(cpu|memory|pids)\.'
# cpu.max cpu.stat memory.max memory.current pids.max ...
# 3. 写限额
echo "50000 100000" > /sys/fs/cgroup/demo/a/cpu.max
echo $((64 * 1024 * 1024)) > /sys/fs/cgroup/demo/a/memory.max
echo 32 > /sys/fs/cgroup/demo/a/pids.max
# 4. 把一个进程放进去(写 cgroup.procs 会迁移整个进程)
sh -c 'echo $$ > /sys/fs/cgroup/demo/a/cgroup.procs; exec sleep 300' &
sleep 0.2
# 5. 验证
cat /sys/fs/cgroup/demo/a/cgroup.procs
cat /proc/$(cat /sys/fs/cgroup/demo/a/cgroup.procs)/cgroup
# 0::/demo/a
cat /sys/fs/cgroup/demo/a/cpu.max
cat /sys/fs/cgroup/demo/a/memory.max
第 4 步的写法值得解释:sh -c 'echo $$ > cgroup.procs; exec sleep 300' 让 shell 先把自己的 PID 写进 cgroup,再 exec 成 sleep。这样 sleep 继承同一个 PID 与 cgroup 成员身份,不会出现「shell 在 cgroup 里、sleep 在外面」的错位。如果你先起 sleep 再写它的 PID,效果一样;但写 shell 的 PID 更省一次查找。
清理:
# 先杀进程,再删目录(非空目录删不掉)
kill %1 2>/dev/null || true
sleep 0.2
rmdir /sys/fs/cgroup/demo/a
rmdir /sys/fs/cgroup/demo
验证 no-internal-process
demo 启用了域控制器,所以它自己不能有进程。试着把当前 shell 放进去:
# 需要 root;预期失败
echo $$ > /sys/fs/cgroup/demo/cgroup.procs
# bash: echo: write error: Device or resource busy
EBUSY 不是权限问题,而是规则三。正确做法是把进程放进叶子 demo/a,或者先清空 demo/cgroup.subtree_control 再放进程——但那样它的子层就失去控制器了。
- 「
mkdir后就能写memory.max」——不能。控制器文件只在父层启用后出现在子层;demo自己没有memory.max。 - 「往
cgroup.procs写 PID 只迁移一个线程」——写任一线程的 TID 会迁移整个进程;线程级迁移要用cgroup.threads,且只在 threaded 子树里可用。 - 「删掉目录,进程就被杀了」——
rmdir只对空 cgroup 成功;有进程时返回EBUSY,要先杀进程或迁移。 - 「
cpu.max的50000 100000是 50%」——对单核是 50%;对多核,配额是「每个周期内整个 cgroup 可用的 CPU 时间总和」,200000 100000才是 2 个核。 - 「systemd 会自动接管我建的目录」——不会。
/sys/fs/cgroup/demo这种手建目录不在 systemd 的 unit 树里;生产环境应通过systemd-run/slice 让 systemd 管理,否则 cgroup 与 unit 生命周期不同步。
5.3.4 cpu 控制器:配额、权重与限流证据
CPU 控制器有两个维度:绝对配额(cpu.max)与相对权重(cpu.weight)。前者是硬上限,后者只在兄弟 cgroup 竞争时决定分配比例。
| 文件 | 格式 | 默认 | 说明 |
|---|---|---|---|
cpu.max | $MAX $PERIOD | max 100000 | 每 PERIOD 微秒最多用 MAX 微秒 CPU;max 表示不限 |
cpu.weight | 1–10000 | 100 | 兄弟间按权重分配空闲 CPU |
cpu.weight.nice | -20–19 | 0 | 用 nice 值表达权重,便于和旧工具兼容 |
cpu.max.burst | 微秒 | 0 | 允许在空闲后突发使用的额度 |
cpu.stat | 键值 | — | usage_usec、nr_periods、nr_throttled、throttled_usec |
cpu.max 的两个数字换成「核数」的公式是:
可用核数 = MAX / PERIOD
50000 100000 = 0.5 核;100000 100000 = 1 核;200000 100000 = 2 核。PERIOD 的合法范围是 1000–1000000 微秒,默认 100000(100 ms)。MAX 不能小于 1000,也不应大于 PERIOD × 核数——超过部分不会生效。
验证限流是否真的发生,看 cpu.stat 的 nr_throttled 与 throttled_usec:
# 需要 root;在 demo/a 里跑一个死循环,观察 throttling
mkdir -p /sys/fs/cgroup/demo/a
echo "25000 100000" > /sys/fs/cgroup/demo/a/cpu.max
sh -c 'echo $$ > /sys/fs/cgroup/demo/a/cgroup.procs; exec yes > /dev/null' &
sleep 5
cat /sys/fs/cgroup/demo/a/cpu.stat
# usage_usec 1240000
# nr_periods 50
# nr_throttled 48
# throttled_usec 3600000
kill %1
nr_throttled 接近 nr_periods,说明这个 cgroup 几乎每个周期都撞上了配额上限——这正是「CPU 被限住」的直接证据。只看 usage_usec 不够:它只说明用了多少,不说明是否被压住。
权重的作用方式不同。假设两个 cgroup 都跑满,cpu.weight 分别是 100 和 300,那么空闲 CPU 按 1:3 分配;但如果系统没有竞争,权重不会限制任何一个。权重适合「按重要性分配」,配额适合「按上限隔离」。
Docker 的 --cpu-shares 默认 1024,取值范围 2–262144。runc 会把它换算成 v2 的 cpu.weight,换算公式大致是 1 + (shares - 2) * 9999 / 262142。所以默认的 1024 shares 在 v2 里对应 cpu.weight 约 39,而不是 100。两套刻度不同,迁移时不要直接比数字。
5.3.5 memory 控制器:max、high、low 与 OOM
memory 控制器是容器最常用的限额,也是误解最多的一组文件。
| 文件 | 语义 | 超限后果 |
|---|---|---|
memory.max | 硬上限(字节或 max) | 回收失败后触发 cgroup OOM,杀进程 |
memory.high | 软上限/节流阈值 | 超过后强制回收并节流,通常不杀进程 |
memory.low | 尽力保护 | 回收时尽量不碰,但不保证 |
memory.min | 硬保护 | 只要未超父层限制,就不回收 |
memory.swap.max | swap 上限 | 超过后不再换出匿名页 |
memory.oom.group | 1 = 整组同生共死 | OOM 时杀掉整个 cgroup |
memory.current | 当前用量(含 page cache) | 只读 |
memory.events | low/high/max/oom/oom_kill/... 计数 | 只读,判断 OOM 的证据 |
memory.stat | anon、file、slab 等细分 | 只读,判断内存花在哪 |
memory.max 与 memory.high 的区别,用一句话说:max 是「不许超过」,high 是「超过就让你慢下来」。max 触发的是 OOM killer;high 触发的是直接回收 + 节流,进程还活着,只是分配变慢。生产上给在线服务设 high、给批处理设 max 是常见组合。
下面这个程序用来制造可控的内存压力:
/* hog.c — 逐 MiB 分配并触页,观察 memory.max 与 OOM;编译:cc -O2 -Wall -Wextra -Werror hog.c -o hog */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define MAX_BLOCKS 8192
static char *blocks[MAX_BLOCKS];
int main(int argc, char **argv)
{
long mib = argc > 1 ? atol(argv[1]) : 128;
if (mib <= 0 || mib > MAX_BLOCKS)
mib = MAX_BLOCKS;
for (long i = 0; i < mib; i++) {
char *p = malloc(1024 * 1024);
if (p == NULL) {
perror("malloc");
return 1;
}
memset(p, (int)(i & 0xff), 1024 * 1024); /* 触页,计入 memory.current */
blocks[i] = p; /* 防止被优化掉 */
if (i % 16 == 0) {
printf("allocated %ld MiB\n", i);
fflush(stdout);
}
}
printf("done\n");
sleep(60);
return 0;
}
把 memory.max 设为 64 MiB、memory.swap.max 设为 0,再跑 256 MiB:
# 需要 root
cc -std=c17 -Wall -Wextra -Werror -O2 -g hog.c -o hog
mkdir -p /sys/fs/cgroup/demo/a
echo $((64 * 1024 * 1024)) > /sys/fs/cgroup/demo/a/memory.max
echo 0 > /sys/fs/cgroup/demo/a/memory.swap.max
sh -c 'echo $$ > /sys/fs/cgroup/demo/a/cgroup.procs; exec ./hog 256'
# 另一个终端观察
cat /sys/fs/cgroup/demo/a/memory.events
cat /sys/fs/cgroup/demo/a/memory.current
sudo dmesg | tail -5
预期看到 memory.events 里 oom_kill 1,dmesg 里出现 Memory cgroup out of memory: Killed process ... (hog)。注意是 cgroup OOM,不是全局 OOM:被杀进程属于 demo/a,宿主其他进程不受影响。
如果改成 memory.high=32M、memory.max=128M,同样的 hog 64 不会死,但会变慢:
echo $((32 * 1024 * 1024)) > /sys/fs/cgroup/demo/a/memory.high
echo $((128 * 1024 * 1024)) > /sys/fs/cgroup/demo/a/memory.max
time sh -c 'echo $$ > /sys/fs/cgroup/demo/a/cgroup.procs; exec ./hog 64'
cat /sys/fs/cgroup/demo/a/memory.events
# high 若干次,oom_kill 0
memory.events 的 high 计数会增加,oom_kill 保持 0。这就是「节流而非杀死」的证据。
清理:
sudo kill %1 2>/dev/null || true
sleep 0.2
sudo rmdir /sys/fs/cgroup/demo/a 2>/dev/null || true
- 「
memory.current就是进程 RSS」——它包含 page cache、slab、socket 缓冲等。一个只读文件的服务也可能因为缓存把current顶到上限,但那些页是可回收的。 - 「设了
memory.max就一定不会 OOM」——超过后先回收,回收不动就 OOM;memory.oom.group=0时只杀一个进程,可能杀错。 - 「
memory.high是软限制,可以随便超」——超了会被节流到远低于预期的吞吐,表现为「CPU 不高但程序很慢」。 - 「swap 关闭时
memory.swap.max无所谓」——容器里通常把memory.swap.max设为 0,避免匿名页换出后延迟飙升;不设则可能用宿主 swap。 - 「cgroup OOM 会杀整个容器」——默认只杀触发者;要整组杀,必须设
memory.oom.group=1(Kubernetes 的 Pod 级 OOM 就依赖这个)。
5.3.6 io 与 pids:块设备限速与 fork 炸弹
io 控制器
io 控制器有两个维度:io.max 是绝对带宽/IOPS 上限,io.weight 是相对权重。io.max 按块设备号设置,格式是 MAJ:MIN key=value ...:
# 查看设备号(NVMe 通常是 259:x,SATA 是 8:x)
lsblk -ndo NAME,MAJ:MIN
# 需要 root;给 8:0 设 1 MiB/s 读、512 KiB/s 写
echo "8:0 rbps=1048576 wbps=524288" > /sys/fs/cgroup/demo/a/io.max
cat /sys/fs/cgroup/demo/a/io.max
# 8:0 rbps=1048576 wbps=524288 riops=max wiops=max
# 权重与统计
echo "8:0 200" > /sys/fs/cgroup/demo/a/io.weight
cat /sys/fs/cgroup/demo/a/io.stat
# 8:0 rbytes=... wbytes=... rios=... wios=...
四个键都可以取 max:rbps、wbps、riops、wiops。限制由 blk-throttle 实现,作用在块层,因此对 O_DIRECT、buffered write、文件系统元数据都生效。对 overlayfs 上的容器,io.stat 显示的是底层块设备的号,不是 overlay 的虚拟设备。
io.weight 的范围是 1–10000,默认 100。它只在兄弟 cgroup 竞争同一设备时生效;没有竞争时,权重不限制任何东西。另有 io.latency 用于「按延迟目标保护」,属于更高级的用法,配置复杂,生产上通常交给 cgroup v2 感知的运行时或 systemd-oomd。
pids 控制器
pids.max 限制 cgroup 内任务(线程)总数,是防 fork 炸弹最直接的手段:
# 需要 root;限制 8 个任务,然后尝试 fork 32 个
mkdir -p /sys/fs/cgroup/demo/fork
echo 8 > /sys/fs/cgroup/demo/fork/pids.max
sh -c 'echo $$ > /sys/fs/cgroup/demo/fork/cgroup.procs
for i in $(seq 1 32); do sleep 5 & done
wait' 2>&1 | tail -5
# sh: fork: retry: Resource temporarily unavailable
# sh: fork: retry: Resource temporarily unavailable
cat /sys/fs/cgroup/demo/fork/pids.events
# max 24
cat /sys/fs/cgroup/demo/fork/pids.current
每次 fork 失败会计入 pids.events 的 max。注意 pids.current 统计的是任务数:一个多线程进程的每个线程都占一个名额,所以给 Java 服务设 pids.max 要把线程数算进去。
清理:
# 杀掉 cgroup 里剩余进程,再删目录
sudo sh -c 'xargs -r kill < /sys/fs/cgroup/demo/fork/cgroup.procs' 2>/dev/null || true
sleep 0.2
sudo rmdir /sys/fs/cgroup/demo/fork 2>/dev/null || true
sudo rmdir /sys/fs/cgroup/demo 2>/dev/null || true
- 确认在 v2:
stat -fc %T /sys/fs/cgroup应为cgroup2fs;/proc/PID/cgroup应为0::/...。 - 确认控制器已启用:从根往下逐级
cat cgroup.subtree_control,看目标 cgroup 的父层有没有启用对应控制器。 - 确认文件存在:
ls /sys/fs/cgroup/<path>/ | grep memory;文件不存在就是父层没启用。 - 确认进程在目标 cgroup:
cat /proc/<pid>/cgroup与cat .../cgroup.procs对照;systemd 可能把它移到别的 scope。 - 确认写入成功:
echo的返回码;EBUSY是内部进程限制,EINVAL是格式或范围错。 - 确认限流证据:CPU 看
cpu.stat的nr_throttled,内存看memory.events,IO 看io.stat与io.pressure。
5.3.7 systemd 的 slice/scope/service 映射
在 systemd 系统上,你几乎不需要手工 mkdir /sys/fs/cgroup。systemd 把每个 unit 映射成一个 cgroup,并用 slice 组织层级:
| systemd 概念 | cgroup 路径 | 说明 |
|---|---|---|
-.slice | / | 根 slice |
system.slice | /system.slice | 系统服务 |
user.slice | /user.slice | 用户会话 |
machine.slice | /machine.slice | VM/容器(systemd-nspawn、部分 Podman) |
foo.service | /system.slice/foo.service | 服务进程的主 cgroup |
foo.scope | /system.slice/foo.scope | 外部创建、由 systemd 接管的进程组 |
foo.slice | /foo.slice | 自定义层级 |
常用命令:
# 看某个服务的 cgroup 路径与资源属性
systemctl show ssh.service -p ControlGroup -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax
# 树状查看
systemd-cgls /system.slice
systemd-cgtop -m
# 起一个带限额的临时 scope
sudo systemd-run --scope --unit=demo-limits \
-p MemoryMax=64M -p MemoryHigh=48M -p CPUQuota=25% -p TasksMax=32 \
sleep 300
cat /sys/fs/cgroup/system.slice/demo-limits.scope/memory.max
cat /sys/fs/cgroup/system.slice/demo-limits.scope/cpu.max
Docker 默认使用 systemd cgroup driver,容器出现在 /sys/fs/cgroup/system.slice/docker-<full-id>.scope(若用 cgroupfs driver,则是 /sys/fs/cgroup/docker/<id>)。检查方式:
docker info --format '{{.CgroupDriver}} {{.CgroupVersion}}'
# systemd 2
docker run -d --name cgdemo --memory=64m --cpus=0.5 --pids-limit=64 busybox sleep 300
cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max 2>/dev/null | tail -1
docker inspect cgdemo --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.PidsLimit}}'
docker rm -f cgdemo
Docker/systemd 参数到 cgroup 文件的映射:
| 用户参数 | systemd 属性 | cgroup 文件 | 换算 |
|---|---|---|---|
--cpus=1.5 | CPUQuota=150% | cpu.max | 150000 100000 |
--cpu-shares=1024 | CPUWeight= | cpu.weight | 约 39(见 5.3.4) |
--memory=64m | MemoryMax=64M | memory.max | 67108864 |
--memory-reservation=32m | MemoryLow=32M | memory.low | 33554432 |
--pids-limit=64 | TasksMax=64 | pids.max | 64 |
--blkio-weight=200 | IOWeight= | io.weight | 由运行时按 v1→v2 刻度换算 |
cgroupfs driver 直接 mkdir,简单但有一个竞态:容器创建与 systemd 的 cgroup 清理可能同时发生,导致「刚建的 cgroup 被 systemd 删掉」。systemd driver 把容器注册成 .scope,由 systemd 统一记账与回收,冲突就消失了。代价是容器必须在 systemd 的树里,且 Delegate=yes 的 slice 才能让容器内再分 cgroup。Kubernetes 的 kubepods.slice 就是同一套思路。
5.3.8 观测、回收与排障
cgroup v2 自带一组观测文件,不需要额外工具就能回答「谁被限流、谁在 OOM、谁在等 IO」:
# 子树规模与状态
cat /sys/fs/cgroup/demo/cgroup.stat
# nr_descendants 2
# nr_dying_descendants 0
cat /sys/fs/cgroup/demo/cgroup.events
# populated 1
# frozen 0
# PSI:some=至少一个任务被阻塞的时间占比
cat /sys/fs/cgroup/demo/a/cpu.pressure
cat /sys/fs/cgroup/demo/a/memory.pressure
cat /sys/fs/cgroup/demo/a/io.pressure
# some avg10=12.34 avg60=8.90 avg300=4.56 total=1234567
PSI(pressure stall information)的 some 行表示「至少一个任务因为该资源而停顿」的百分比。它比利用率更能说明问题:CPU 利用率 100% 不一定慢,但 cpu.pressure 的 some 很高通常意味着排队。
两个「批量操作」文件在排障时很有用:
# 冻结整棵子树(做快照/迁移前)
echo 1 > /sys/fs/cgroup/demo/cgroup.freeze
# 杀死整棵子树(比逐个 kill 更可靠,Linux 5.14+)
echo 1 > /sys/fs/cgroup/demo/cgroup.kill
# 主动回收内存(Linux 5.19+ 的 memory.reclaim)
echo 64M > /sys/fs/cgroup/demo/a/memory.reclaim
cgroup.kill 的价值在于原子性:它一次性给子树内所有进程发 SIGKILL,不会出现「边杀边 fork」的漏网之鱼。容器运行时删除容器时优先用它。
- 「
memory.current降不下来就是泄漏」——先看memory.stat的file;page cache 占大头时,echo 1G > memory.reclaim或posix_fadvise(DONTNEED)就能降。 - 「
nr_descendants是进程数」——是子 cgroup 数量;进程数看cgroup.procs的行数。 - 「
cgroup.freeze会释放资源」——冻结只是停止调度,内存与 cgroup 结构都还在。 - 「
cgroup.kill在旧内核也能用」——5.14 才有;旧内核要用cgroup.procs逐个 kill,并配合cgroup.freeze防 fork。
5.3.9 结构体、控制器与接口文件全表
这一节回答查文档时最费时间的问题:/sys/fs/cgroup 下的每个文件,在内核里对应哪个结构体的哪个字段,写下去会触发谁。把这张表记住,排障时就不用靠猜。
五个核心结构体
cgroup v2 的实现围绕五个结构体展开。它们的分工是:cgroup 描述「一个层级节点」,cgroup_subsys_state 描述「某控制器在这个节点上的状态」,css_set 描述「一组进程共同引用的控制器状态组合」,cgroup_subsys 描述「控制器类型本身」,kernfs_node 描述「这个节点在 sysfs 里的文件」。
| 结构体 | 定义位置(Linux 6.8) | 关键字段 | 谁维护 | 何时变 |
|---|---|---|---|---|
struct cgroup | kernel/cgroup/cgroup-internal.h | self、level、max_depth、nr_descendants、subtree_control、subtree_ss_mask、parent、children、kn、dom_cgrp | cgroup 核心 | mkdir/rmdir、写 subtree_control |
struct cgroup_subsys_state | include/linux/cgroup-defs.h | cgroup、ss、refcnt、sibling、children、id、flags | 各控制器 + 核心 | 建/删 cgroup、启用/禁用控制器 |
struct css_set | include/linux/cgroup-defs.h | refcount、dom_cgrp、dfl_cgrp、subsys[]、tasks、e_csets | cgroup 核心 | 进程迁移、控制器启用组合变化 |
struct cgroup_subsys | include/linux/cgroup-defs.h | css_alloc、css_online、css_offline、css_free、can_attach、attach、can_fork、fork、exit、dfl_cftypes | 控制器作者 | 编译期注册,运行期只读 |
struct kernfs_node | include/linux/kernfs.h | parent、name、flags、mode、ino、priv、ns | kernfs | 建/删文件、改权限 |
subtree_control 与 subtree_ss_mask 的区别值得单独说:前者是用户写进去的意愿(+memory -cpu 这种字符串解析后的位图),后者是核心过滤后实际生效的位图。普通域 cgroup 上两者通常一致;在线程子树里,不支持线程模式的控制器会被从 subtree_ss_mask 里剔除,于是出现「subtree_control 有 memory、子层却没有 memory.*」的现象。判断「为什么子层没有某个文件」时,先看父层的 cgroup.controllers(可用集合),再看 cgroup.subtree_control(已启用集合)。
控制器是一组可被层级启用/禁用的资源管理回调集合,由 struct cgroup_subsys 描述。启用某个控制器,意味着该 cgroup 的子层会获得这个控制器的接口文件,并且挂载到这个 cgroup 的进程在 fork/charge 时会走该控制器的钩子。控制器不属于某个 cgroup,而属于「cgroup 与子系统」的交叉点——这就是 cgroup_subsys_state 存在的理由。
控制器清单
v2 把 v1 的十几个子系统收敛成九个控制器。每个控制器只在父层启用后才在子层出现文件:
| 控制器 | 主要接口文件 | 内核实现 | 备注 |
|---|---|---|---|
cpu | cpu.max、cpu.weight、cpu.stat、cpu.pressure、cpu.uclamp.* | kernel/sched/core.c、kernel/sched/fair.c | 配额走 CFS bandwidth,权重走 task_group |
cpuset | cpuset.cpus、cpuset.mems、cpuset.cpus.effective、cpuset.cpus.partition | kernel/cgroup/cpuset.c | 绑核与 NUMA 节点;partition 用于 cpuset 隔离 |
memory | memory.max/high/low/min、memory.current、memory.stat、memory.events、memory.swap.*、memory.oom.group、memory.reclaim | mm/memcontrol.c | 唯一能触发 cgroup OOM 的控制器 |
io | io.max、io.weight、io.stat、io.latency、io.cost.* | block/blk-throttle.c、block/blk-iolatency.c | 按 MAJ:MIN 设限;缓冲写也受限 |
pids | pids.max、pids.current、pids.events | kernel/cgroup/pids.c | 统计任务(线程)数,fork 时 charge |
hugetlb | hugetlb.<size>.max/current/events/rsvd.max | mm/hugetlb_cgroup.c | 按 hugepage 尺寸分文件 |
rdma | rdma.max、rdma.current | kernel/cgroup/rdma.c | 按 RDMA 设备设限 |
misc | misc.max、misc.current | kernel/cgroup/misc.c | 5.13+,给无专用控制器的资源用 |
freezer | cgroup.freeze(核心文件) | kernel/cgroup/cgroup.c | v2 里不再算独立控制器 |
freezer 在 v2 里被提升为核心功能,因此没有 freezer.max 之类的文件,只有每个 cgroup 都有的 cgroup.freeze。这也解释了为什么 cgroup.freeze 对任何 cgroup 都可用,而 memory.max 必须先启用 memory。
接口文件的四类
| 类别 | 例子 | 语义 | 写入后果 |
|---|---|---|---|
| 层级/核心 | cgroup.procs、cgroup.threads、cgroup.subtree_control、cgroup.type、cgroup.freeze、cgroup.kill | 管理 cgroup 自身 | 迁移进程、启停控制器、冻结/杀死 |
| 控制器旋钮 | cpu.max、memory.high、pids.max、io.max | 设限额 | 立即生效,已在其中的进程也受影响 |
| 只读统计 | cpu.stat、memory.stat、memory.events、*.pressure | 观测 | 写入返回 EPERM/EINVAL |
| 键值型 | io.max、io.stat、io.weight、rdma.max、hugetlb.<size>.* | 第一列是设备/尺寸键 | 按行解析,格式错返回 EINVAL |
键值型文件的读写格式不统一,是脚本出错的重灾区:io.max 写 8:0 rbps=1048576,io.weight 写 8:0 200,hugetlb.2MB.max 只写一个数字。判断办法是看内核文档的「Interface Files」小节,不要凭直觉套格式。
cgroup.type 的四个状态
| 值 | 含义 | 如何进入 | 限制 |
|---|---|---|---|
domain | 普通域 cgroup | 默认 | 启用控制器后受 no-internal-process 约束 |
domain threaded | 线程子树的根 | 有子层写成 threaded 后自动出现 | 自身可有进程,子层按线程粒度 |
domain invalid | 线程子树被清空后的过渡态 | 删除全部 threaded 子层 | 不能挂进程、不能建子层,直到重新启用 |
threaded | 线程粒度 cgroup | 向 domain threaded 的子层写 threaded | 只能容纳线程;并非所有控制器支持 |
写 threaded 不是万能开关:如果这个 cgroup 启用了不支持线程模式的控制器(典型是 memory、io),内核返回 EOPNOTSUPP。这也是「为什么我的 Java 容器不能按线程限内存」的答案——内存控制器根本不支持线程粒度。
命令表:从观测到修改
| 目的 | 命令 | 读/改了什么 |
|---|---|---|
| 看进程属于哪个 cgroup | cat /proc/<pid>/cgroup | 0::/path 这一行 |
| 看整棵树 | systemd-cgls、systemd-cgtop | unit 与 cgroup 的映射 |
| 看 unit 的资源属性 | systemctl show ssh.service -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax -p Delegate | systemd 侧配置,不一定等于内核文件当前值 |
| 起临时 scope | systemd-run --scope -p MemoryMax=64M -p CPUQuota=25% cmd | 建 unit + 写 cgroup 文件 |
| 改运行中 unit | systemctl set-property --runtime ssh.service MemoryMax=128M | 立即写内核文件;不带 --runtime 会持久化到磁盘 |
| 改容器 | docker update --memory 128m --cpus 1.5 <id> | 运行时改写容器 cgroup |
| 看容器限额 | docker inspect <id> --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}' | 记录的是配置值,不是当前用量 |
| 直接写文件 | echo 64M > /sys/fs/cgroup/<path>/memory.max | 绕过 systemd,注意生命周期不同步 |
systemctl show 的 MemoryMax 与 cat memory.max 可能不一致:前者是 unit 配置(可能是 infinity),后者是内核当前值(可能被 set-property 改过)。排障时以内核文件为准,配置漂移以 systemctl show 为准。
cgroup 没有发明新的系统调用,而是把控制面做成了一个伪文件系统。好处是任何语言、任何脚本都能操作,权限模型复用 VFS,挂载命名空间还能让容器只看到自己那棵子树。代价是「写文件」不是原子事务:一次 write 可能部分生效,错误只能用 errno 表达,复杂配置要写多次。
5.3.10 内核路径:从 mount 到限额生效
这一节回答「一次 echo 50000 100000 > cpu.max 在内核里经过哪些函数,失败时错误码从哪里来」。知道路径,才能把「写不进去」定位到具体一层。
挂载与建树
mount -t cgroup2 none /sys/fs/cgroup 走的是 cgroup2 的文件系统类型,魔数 0x63677270(CGROUP2_SUPER_MAGIC):
mount(2)
→ cgroup2_fill_super() kernel/cgroup/cgroup.c
→ cgroup_setup_root() 建根 cgroup、初始化 css_set
→ kernfs_mount() 把 cgroup 树映射成 sysfs 目录
根 cgroup 的 cgroup.controllers 内容由 cgroup_ss_mask(root) 决定,即编译进内核且未在启动参数里禁用的控制器。cgroup.subtree_control 初始为空,所以刚挂载时根下没有任何控制器文件——这是第一次接触 v2 的人最常卡住的地方。
建子层走 VFS 的 mkdir,最终落到 cgroup_mkdir:
/* kernel/cgroup/cgroup.c(Linux 6.8,节选) */
static int cgroup_mkdir(struct kernfs_node *parent_kn, const char *name,
umode_t mode)
{
struct cgroup *parent = cgroup_kn_lock_live(parent_kn, false);
struct cgroup *cgrp;
int ret;
cgrp = cgroup_create(parent, name, mode);
if (IS_ERR(cgrp))
return PTR_ERR(cgrp);
ret = cgroup_apply_control_enable(cgrp);
...
}
cgroup_create 分配 struct cgroup 并把它挂到父节点的 children 链表;cgroup_apply_control_enable 负责为这个新节点按父层的 subtree_ss_mask 创建各个控制器的 cgroup_subsys_state,再通过 cgroup_add_cftypes 把接口文件建出来。所以「控制器文件属于父层的启用决定,出现在子层」这句话,在代码里就是这一行调用。
rmdir 的条件也在这一层:cgroup_rmdir 检查 cgroup_has_tasks() 与子节点。有进程返回 EBUSY,有子 cgroup 返回 ENOTEMPTY。这两个错误码经常被混为一谈。
进程迁移
写 cgroup.procs 的路径是:
write(cgroup.procs)
→ cgroup_procs_write() kernel/cgroup/cgroup.c
→ cgroup_attach_task() threadgroup = true
→ cgroup_migrate()
→ cgroup_migrate_execute() 逐个控制器调 can_attach / attach
cgroup_attach_task 的 threadgroup 参数决定迁移粒度:cgroup.procs 传 true,写任意线程的 TID 都会把整个线程组搬过去;cgroup.threads 传 false,只搬一个线程。迁移不是「改一个指针」:它要为进程找到或新建一个 css_set(控制器状态组合),把 task_struct->cgroups 指向它,并让各控制器在 attach 回调里更新自己的记账(例如 cpu_cgroup_attach 会把任务挂到新 task_group 的 cfs_rq)。
cpu.max:配额如何变成限流
write(cpu.max)
→ cpu_max_write() kernel/sched/core.c
→ tg_set_cfs_bandwidth() 设置 task_group 的 quota/period
→ 每个 CPU 的 cfs_rq->runtime_enabled = 1
→ sched_cfs_period_timer() 每个 period 补充 runtime_remaining
限流发生时,check_cfs_rq_runtime 发现 runtime_remaining <= 0,调用 throttle_cfs_rq 把整个 cfs_rq 从运行队列摘下来,等下一个 period 的 timer 再放回。cpu.stat 里的 nr_throttled 就是这条路径的计数器,throttled_usec 是被摘下的总时长。配额粒度受 PERIOD 限制:即使把 MAX 设成 1000 微秒,一个 period 内也只能跑 1 毫秒,所以 v2 的 CPU 限额天然是「毫秒级」的,不适合做微秒级硬实时。
memory.max:回收、节流与 OOM
write(memory.max)
→ memory_max_write() mm/memcontrol.c
→ mem_cgroup_resize_max()
→ try_to_free_mem_cgroup_pages() 循环回收,直到低于新上限或放弃
→ 回收失败 → mem_cgroup_oom()
→ memory.oom.group == 1 ? oom_group_kill() : 选一个进程杀
memory.high 不走这条路:memory_high_write 只改 memcg->high,真正的节流发生在每次 charge 时。try_charge_memcg 发现用量超过 high,调用 mem_cgroup_handle_over_high,按超出比例让当前任务睡眠一小段时间,同时触发直接回收。因此 high 的表现是「分配变慢」,而不是「分配失败」;memory.events 的 high 计数就是每次进入这条路径的次数。
pids.max:fork 时 charge
fork/clone
→ copy_process() kernel/fork.c
→ pids_can_fork() kernel/cgroup/pids.c
→ pids_try_charge() 超过 pids.max → -EAGAIN
pids 的检查点在 fork 路径上,不在写文件时。所以 pids.max 可以从 100 改成 10,已有的 100 个任务不会被杀,但下一次 fork 会失败。-EAGAIN 传到用户态就是 Resource temporarily unavailable;bash 收到后会打印 fork: retry: 并重试几次,这就是 5.3.6 里那两行输出的来源。
io.max 与 PSI
io.max 的写入落到块层的 blk-throttle:tg_set_limit 解析 MAJ:MIN key=value,把限额存进 throtl_grp;真正的限流点在 blk_throtl_bio,每个 bio 提交前按 token bucket 判断是否需要排队。因为作用在块层,文件系统缓存回写、O_DIRECT、元数据写都会经过它。
PSI 的记账点分散在调度与内存路径里:任务进出运行队列时 psi_task_change,内存 stall 时 psi_memstall_enter/exit,最终汇总到每个 cgroup 的 struct psi_group。cpu.pressure 的 some 就是「至少一个任务在等 CPU」的时长占比,由 psi_show 按 avg10/avg60/avg300 指数衰减算出。PSI 需要 CONFIG_PSI=y,且部分发行版用 psi=1 启动参数才开启。
# 需要 root;确认 tracefs 挂载
mount | grep tracefs || mount -t tracefs none /sys/kernel/tracing
ls /sys/kernel/tracing/events/cgroup/
# cgroup_attach_task cgroup_mkdir cgroup_rmdir cgroup_release cgroup_transfer_tasks
# 只抓一次 mkdir 与 attach
echo 1 > /sys/kernel/tracing/events/cgroup/cgroup_mkdir/enable
echo 1 > /sys/kernel/tracing/events/cgroup/cgroup_attach_task/enable
mkdir /sys/fs/cgroup/trace-demo
sh -c 'echo $$ > /sys/fs/cgroup/trace-demo/cgroup.procs'
cat /sys/kernel/tracing/trace | tail -20
# 清理
rmdir /sys/fs/cgroup/trace-demo
echo 0 > /sys/kernel/tracing/events/cgroup/enable
trace 里能看到 cgroup_mkdir 的 name=trace-demo 与 cgroup_attach_task 的 pid=,比读源码更快确认「谁在什么时候迁移了进程」。
5.3.11 两个实验:配额曲线与内存节流
前面几节给的是结论,这一节给可复现的证据。两个实验都不依赖 Docker,只需要 root 与一台 cgroup v2 的 Linux(Ubuntu 24.04 / Debian 12 / Fedora 39 均可)。
实验一:cpu.max 的实际核数,cpu.weight 的分配比例
先写一个「烧 CPU 并报告自己用了多少 CPU 时间」的小程序,用 CLOCK_PROCESS_CPUTIME_ID 读数,避免依赖外部计时工具:
/* burn.c — 烧 CPU 指定秒数,报告实际 CPU 时间与比值;编译:cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror burn.c -o burn */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
static double ts(const struct timespec *t)
{
return (double)t->tv_sec + (double)t->tv_nsec / 1e9;
}
int main(int argc, char **argv)
{
double dur = argc > 1 ? atof(argv[1]) : 3.0;
struct timespec w0, w1, c0, c1;
volatile unsigned long x = 0;
clock_gettime(CLOCK_MONOTONIC, &w0);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &c0);
do {
for (int i = 0; i < 100000; i++)
x += (unsigned long)i;
clock_gettime(CLOCK_MONOTONIC, &w1);
} while (ts(&w1) - ts(&w0) < dur);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &c1);
printf("wall=%.2fs cpu=%.2fs ratio=%.2f\n",
ts(&w1) - ts(&w0), ts(&c1) - ts(&c0),
(ts(&c1) - ts(&c0)) / (ts(&w1) - ts(&w0)));
return 0;
}
脚本先测三档配额,再用 taskset -c 0 把两个 cgroup 钉在同一个 CPU 上测权重:
#!/usr/bin/env bash
# exp-cpu.sh — 需要 root;在 cgroup v2 上测 cpu.max 与 cpu.weight
set -euo pipefail
cd "$(dirname "$0")"
CG=/sys/fs/cgroup/exp-cpu
cleanup() {
for d in a b; do
[ -d "$CG/$d" ] && xargs -r kill < "$CG/$d/cgroup.procs" 2>/dev/null || true
done
sleep 0.2
rmdir "$CG/a" "$CG/b" "$CG" 2>/dev/null || true
}
trap cleanup EXIT
rm -rf "$CG"
mkdir -p "$CG"
echo "+cpu" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
echo "+cpu" > "$CG/cgroup.subtree_control"
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror burn.c -o burn
quota_run() { # $1 = quota 微秒,或 max
mkdir -p "$CG/a"
echo "$1 100000" > "$CG/a/cpu.max"
sh -c "echo \$\$ > $CG/a/cgroup.procs; exec ./burn 3"
grep -E 'nr_periods|nr_throttled|throttled_usec' "$CG/a/cpu.stat" | tr '\n' ' '
echo
rmdir "$CG/a"
}
echo "== 配额:wall 固定 3s,看 cpu 时间 =="
quota_run 25000 # 0.25 核
quota_run 100000 # 1 核
quota_run max # 不限
echo "== 权重:两个 cgroup 抢 CPU 0 =="
mkdir -p "$CG/a" "$CG/b"
echo max > "$CG/a/cpu.max"; echo 100 > "$CG/a/cpu.weight"
echo max > "$CG/b/cpu.max"; echo 300 > "$CG/b/cpu.weight"
taskset -c 0 sh -c "echo \$\$ > $CG/a/cgroup.procs; exec ./burn 4" &
pa=$!
taskset -c 0 sh -c "echo \$\$ > $CG/b/cgroup.procs; exec ./burn 4" &
pb=$!
wait "$pa" "$pb"
预期输出(4 核以上机器):
== 配额:wall 固定 3s,看 cpu 时间 ==
wall=3.00s cpu=0.75s ratio=0.25
nr_periods 30 nr_throttled 29 throttled_usec 2250000
wall=3.00s cpu=3.00s ratio=1.00
nr_periods 30 nr_throttled 0 throttled_usec 0
wall=3.00s cpu=3.00s ratio=1.00
nr_periods 30 nr_throttled 0 throttled_usec 0
== 权重:两个 cgroup 抢 CPU 0 ==
wall=4.00s cpu=0.98s ratio=0.25
wall=4.00s cpu=3.02s ratio=0.75
读法:第一档的 ratio≈0.25 与 nr_throttled≈nr_periods 互相印证——不是程序跑得慢,而是每个周期都被配额掐住。权重实验里 a、b 的 CPU 时间约为 1:3,正好等于 cpu.weight 的 100:300;如果两个进程都放在同一个 cgroup,权重不起作用,因为它们共享同一个 task_group。
如果机器只有 1 个 CPU,权重实验仍然是 1:3,但配额实验的 max 档 ratio 会接近 1.00 且 wall 可能略大于 3 秒,这是正常的调度抖动。
实验二:memory.high 的节流证据链
复用 5.3.5 的 hog.c(逐 MiB 分配并触页)。把 memory.high 设成远低于总分配量,观察三件事:进程没死、memory.events 的 high 在涨、memory.pressure 的 full 不为零。
#!/usr/bin/env bash
# exp-mem.sh — 需要 root;观察 memory.high 节流与 PSI
set -euo pipefail
cd "$(dirname "$0")"
CG=/sys/fs/cgroup/exp-mem
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror hog.c -o hog
cleanup() {
[ -d "$CG/a" ] && xargs -r kill < "$CG/a/cgroup.procs" 2>/dev/null || true
sleep 0.2
rmdir "$CG/a" "$CG" 2>/dev/null || true
}
trap cleanup EXIT
rm -rf "$CG"
mkdir -p "$CG"
echo "+memory" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
echo "+memory" > "$CG/cgroup.subtree_control"
mkdir -p "$CG/a"
echo 32M > "$CG/a/memory.high"
echo 256M > "$CG/a/memory.max"
echo 0 > "$CG/a/memory.swap.max"
sh -c "echo \$\$ > $CG/a/cgroup.procs; exec ./hog 160" &
pid=$!
sleep 0.3
for i in $(seq 1 8); do
printf 't=%ss current=%s high=%s full=%s\n' \
"$i" \
"$(cat "$CG/a/memory.current")" \
"$(awk '/^high /{print $2}' "$CG/a/memory.events")" \
"$(awk '/^full /{print $0}' "$CG/a/memory.pressure" | cut -d' ' -f1-4)"
sleep 0.5
done
echo "== memory.stat 的主要项 =="
awk '/^(anon|file|inactive_file|active_file|slab) /' "$CG/a/memory.stat"
echo "== OOM 计数 =="
grep -E 'oom|oom_kill' "$CG/a/memory.events"
kill "$pid" 2>/dev/null || true
预期看到的现象:
t=1s current=33554432 high=0 full=full avg10=0.00 avg60=0.00 avg300=0.00
t=2s current=34603008 high=118 full=full avg10=12.40 avg60=3.10 avg300=0.80
...
== OOM 计数 ==
oom 0
oom_kill 0
若内核没有开启 CONFIG_PSI,memory.pressure 不存在,把 full 那一列去掉即可,high 计数与 memory.stat 的结论不变。
current 停在 32 MiB 附近而不是一路涨到 160 MiB,说明 high 在每次 charge 时把分配压住了;high 计数在涨说明确实进入了节流路径;oom_kill 0 说明进程没有被杀。memory.stat 里 anon 是主体、file 很小,说明这个负载几乎没有可回收的 page cache,节流只能靠「让分配变慢」实现——这正是 high 与 max 的行为差异。
反过来,如果把 memory.high 删掉(写 max)只留 memory.max=64M,hog 160 会在几秒内触发 cgroup OOM,memory.events 出现 oom_kill 1,dmesg 出现 Memory cgroup out of memory。两个实验对照,就能把「节流」与「杀死」分开。
memory.high 的检查发生在每次 charge,而不是每个页面。一次 memset 1 MiB 会触发多次 charge(页表、folio、可能的分批),每次超过 high 都记一次。因此 high 计数是「进入节流路径的次数」,不是「超限的字节数」。判断严重程度要看 memory.pressure 的 full avg10 与 memory.stat 的构成,而不是数 high。
5.3.12 委托:把一棵子树交给非 root 进程
rootless 容器、systemd --user、Kubernetes 的 pod 级 cgroup 都有一个共同需求:非 root 进程要在自己的 cgroup 下建子层、写限额、迁移进程。内核没有 delegate(2) 这样的系统调用;委托完全由「文件属主 + cgroup.procs 的写权限检查 + 两条层级规则」拼出来。理解这三样,才能解释「我给目录 chmod 777 了,为什么还是 EACCES」。
| 要做的动作 | 需要什么 | 内核检查点 | 不满足时 |
|---|---|---|---|
子层出现 memory.max | 父层 cgroup.subtree_control 含 +memory,且祖父层已启用 | cgroup_controllers_show / cgroup_subtree_control_write | 子层没有该文件,写入 ENOENT |
在委托层 mkdir | 目录对委托人可写可执行 | VFS inode_permission | EACCES |
给子层写 memory.max | 子层文件对委托人可写 | VFS | EACCES |
| 把进程迁进子层 | 目标 cgroup.procs 可写,且源/目标公共祖先的 cgroup.procs 可写 | cgroup_procs_write_permission | EACCES |
| 把进程迁出委托子树 | 同上(公共祖先通常是宿主的根) | 同上 | EACCES |
| 委托层有进程时再启用子层控制器 | 委托层内没有进程(no-internal-process) | cgroup_subtree_control_write | EBUSY |
| systemd 不清理/搬动子树 | unit 设 Delegate=yes | systemd 侧 | 子层被 systemd 回收 |
| 非 root 服务拿到目录属主 | Delegate=yes 且 User= 非 root | systemd 侧 chown | 目录仍属 root,EACCES |
第四、五行是委托围栏(delegation containment):委托人可以在子树内自由搬进程,但不能把外面的进程拉进来,也不能把里面的进程推出去。判断依据不是「源 cgroup 或目标 cgroup 是否可写」,而是「两者的公共祖先是否可写」。一个常见误解是「我有 box/cgroup.procs 的写权限,就能把任意 PID 写进去」——如果那个 PID 原本在 user.slice 下,公共祖先是 /,普通用户不可写,内核返回 EACCES。
systemd 的 user@1000.service 就是按这个模型配置的:它带 Delegate=yes,systemd 把该 unit 的 cgroup 交给用户,用户侧的 systemd 再在 app.slice、session.slice 下建子层。宿主的 systemd 承诺不再插手该 unit 以下的层级,这就是「两层 systemd 不打架」的原因。
内核路径:公共祖先检查与打开时的凭证
/* kernel/cgroup/cgroup.c(Linux 6.8,节选) */
static int cgroup_procs_write_permission(struct cgroup *src_cgrp,
struct cgroup *dst_cgrp,
struct super_block *sb,
struct cgroup_namespace *ns)
{
struct cgroup *com_cgrp = src_cgrp;
int ret;
while (!cgroup_is_descendant(dst_cgrp, com_cgrp))
com_cgrp = cgroup_parent(com_cgrp);
ret = cgroup_may_write(com_cgrp, sb);
if (ret)
return ret;
if ((cgrp_dfl_root.flags & CGRP_ROOT_NS_DELEGATE) &&
(!cgroup_is_descendant(src_cgrp, ns->root_cset->dfl_cgrp) ||
!cgroup_is_descendant(dst_cgrp, ns->root_cset->dfl_cgrp)))
return -ENOENT;
return 0;
}
com_cgrp 从源 cgroup 开始,沿父链上移到「同时是目标祖先」的那个节点。委托子树内的迁移,公共祖先就是委托根(委托人可写);跨子树迁移,公共祖先会一路上到 /(普通用户不可写)。NS_DELEGATE 分支是 cgroup namespace 的委托模式:源或目标不在 namespace 根之下时返回 ENOENT,而不是 EACCES。
还有一个容易忽略的细节:__cgroup_procs_write 用 of->file->f_cred(打开文件时的凭证)做检查,而不是 current_cred()。这防的是「root 打开 cgroup.procs 后把 fd 传给非特权进程」——继承 fd 不能继承权限。
5.3.13 两个补充实验:手工委托围栏与 systemd 委托
实验一:手工委托,观察 EACCES 与公共祖先
需要 root、cgroup v2,以及一个普通用户 alice(uid 1000)。这个实验不使用 systemd,直接验证内核的三条规则。
# 需要 root;建委托层,启用控制器并交给 alice
sudo mkdir /sys/fs/cgroup/deleg
echo "+memory +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
sudo chown alice:alice /sys/fs/cgroup/deleg
sudo chown alice:alice /sys/fs/cgroup/deleg/cgroup.procs \
/sys/fs/cgroup/deleg/cgroup.threads /sys/fs/cgroup/deleg/cgroup.subtree_control
# alice 建子层、写限额
sudo -u alice bash -c '
cd /sys/fs/cgroup/deleg
echo "+memory +pids" > cgroup.subtree_control
mkdir box
echo 33554432 > box/memory.max'
# alice 启动一个进程(此时它在 user slice,不在委托子树里)
sudo -u alice sleep 300 &
p=$!
# 尝试从委托外拉进 box:公共祖先是 /,alice 不可写
sudo -u alice sh -c "echo $p > /sys/fs/cgroup/deleg/box/cgroup.procs"
# 预期:sh: echo: write error: Permission denied
# root 可以把它直接放进叶子(root 对公共祖先可写)
echo "$p" | sudo tee /sys/fs/cgroup/deleg/box/cgroup.procs
cat "/proc/$p/cgroup"
# 0::/deleg/box
# 现在 alice 可以在子树内迁移:box → box2,公共祖先是 deleg
sudo -u alice bash -c "
mkdir /sys/fs/cgroup/deleg/box2
echo $p > /sys/fs/cgroup/deleg/box2/cgroup.procs"
cat "/proc/$p/cgroup"
# 0::/deleg/box2
预期关键行:
sh: 1: echo: write error: Permission denied
0::/deleg/box
0::/deleg/box2
三次迁移的结果正好对应围栏规则:第一次是「外 → 内」,公共祖先为 /,EACCES;第二次由 root 执行,公共祖先可写,成功;第三次是「内 → 内」,公共祖先为 deleg,alice 有写权限,成功。注意 root 必须把进程写进 box 而不是 deleg 本身——deleg 已为子层启用控制器,再有进程就违反 no-internal-process,写入会返回 EBUSY。
实验二:systemd 的 Delegate=yes + User=
手工 chown 在生产上不可维护。systemd 的写法是给 unit 两个属性:Delegate=yes 打开委托,User= 让 systemd 把 unit 的 cgroup 交给该用户。
# 需要 root;起一个以 alice 身份运行、带限额并委托的服务
sudo systemd-run --unit=deleg-svc \
--property=Delegate=yes --property=User=alice \
--property=MemoryMax=64M --property=CPUQuota=25% \
sleep 600
systemctl show deleg-svc -p ControlGroup -p Delegate -p User -p MemoryMax
# ControlGroup=/system.slice/deleg-svc.service
# Delegate=yes
# MemoryMax=67108864
# alice 在 unit 下再分一层,并设自己的限额
sudo -u alice bash -c '
cd /sys/fs/cgroup/system.slice/deleg-svc.service
echo "+memory +pids" > cgroup.subtree_control
mkdir child
echo 33554432 > child/memory.max
cat child/memory.max'
sudo systemctl stop deleg-svc
预期 child/memory.max 是 33554432。这 32 MiB 不是独立的预算,而是 64 MiB 之下的子配额:child 最多用 32 MiB,整个 unit 仍受 64 MiB 与 25% CPU 约束。委托分配的是父层给它的额度,不是新的资源。
两个实验的差别在「谁 chown」:实验一手工 chown,实验二由 systemd 在启动时完成。共同点是内核只看文件属主与公共祖先,不看「这个用户是不是容器的所有者」。
- 只 chown 目录,不 chown
cgroup.procs——目录可写只能mkdir;迁移进程还要写cgroup.procs,否则EACCES。 - 把
cgroup.subtree_control的写权限也给出去,却保留memory.max可写——委托人能改自己的上限,虽然仍受祖先限额约束,但会破坏「父层分配、子层使用」的模型;资源旋钮文件应保持只读。 - 委托层里留进程——为子层启用控制器时返回
EBUSY;委托根只做容器,进程放叶子。 - 从委托外直接把 PID 写进子层——公共祖先不可写,
EACCES;必须由 root(或公共祖先的属主)先放入。 Delegate=yes但没设User=——systemd 只停止干预,不会把目录交给非 root;非特权服务仍写不了。- 以为
Delegate=yes能阻止 stop 杀进程——委托只影响 cgroup 管理,KillMode=仍决定停止时杀谁。 - 用长期持有的
cgroup.procsfd 做迁移——权限按open时的凭证检查,fd 被继承后仍带着原权限;不要把它传给不可信进程。 - 在 cgroup namespace 里写资源文件——
nsdelegate模式下,namespace 根只允许写cgroup.procs、cgroup.threads与cgroup.subtree_control,写memory.max会被拒绝;资源分配必须在宿主侧完成。
5.3.14 本章陷阱清单
下面 14 条覆盖了从写文件到读统计的常见误判。每条都按「现象 → 原因 → 修复」写,遇到问题时按顺序对照。
| # | 现象 | 原因 | 修复/验证 |
|---|---|---|---|
| 1 | 子层没有 memory.max | 父层 cgroup.subtree_control 没启用 memory | 在父层写 +memory,再在子层写限额 |
| 2 | 写 cgroup.procs 报 EBUSY | 该 cgroup 已启用域控制器且有子层,违反 no-internal-process | 把进程放进叶子,或清空 subtree_control |
| 3 | cpu.max 写 max 100000 后仍被限 | max 是「无限」,不是「1 核」;检查是否还有父层配额 | 逐级 cat cpu.max,父层配额也会约束子层 |
| 4 | cpu.weight 设成 10000 仍然慢 | 权重只在兄弟竞争时生效,且受父层配额约束 | 先确认同一父层下有无竞争者 |
| 5 | memory.current 远大于进程 RSS | 计入 page cache、slab、socket 缓冲 | 看 memory.stat 的 anon 与 file 分解 |
| 6 | memory.max 超限后进程收到 SIGSEGV | 实际是 OOM killer 发的 SIGKILL;SIGSEGV 来自程序自身 | 看 memory.events 的 oom_kill 与 dmesg |
| 7 | pids.max=100 的容器起不来 Java | 线程也计数,200 线程超过 100 | 按线程峰值设置,或调大 pids.max |
| 8 | io.max 写了但限不住 | 设备号写错,或写的是 overlay 的虚拟设备号 | lsblk -ndo NAME,MAJ:MIN 查底层块设备 |
| 9 | io.weight 与 io.max 同时设,weight 无效 | max 是硬上限,weight 只在未超 max 时按比例分配 | 二选一,或把 max 设得足够宽 |
| 10 | rmdir 报 ENOTEMPTY 而不是 EBUSY | 还有子 cgroup,不是还有进程 | 先删子层;cgroup.procs 为空不代表能删 |
| 11 | cgroup.freeze 后内存没降 | 冻结只停止调度,内存与结构仍在 | 需要释放内存用 memory.reclaim 或删除 cgroup |
| 12 | cgroup.kill 在 CentOS 7 上不存在 | 该文件 Linux 5.14 才有,且 v1 没有 | 旧内核用 cgroup.freeze + 逐个 kill |
| 13 | systemd 服务里再建 cgroup 失败 | unit 没有 Delegate=yes,cgroup 目录属 root 且不可写 | 在 unit 里加 Delegate=yes,或使用 --slice |
| 14 | memory.stat 的 file 很大就以为泄漏 | page cache 可回收,inactive_file 是主要可回收部分 | 用 memory.current - inactive_file 估算工作集 |
- v1 与 v2 并存:
stat -fc %T /sys/fs/cgroup返回tmpfs说明是 v1 或 hybrid;此时/sys/fs/cgroup/memory/下才是 v1 文件,不要按 v2 路径写。 memory.reclaim需要 5.19+:旧内核只能在压力下被动回收,或写memory.force_empty(v1 遗留语义,v2 已移除)。cgroup.kill与cgroup.freeze的版本:freeze 是 5.2,kill 是 5.14;容器运行时会在启动时探测这些文件是否存在,缺失时降级为逐个kill。
5.3.15 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出 v1 的四个结构性问题 | 分组不一致、控制器冲突、内部进程、接口割裂 | 5.3.1 |
| 解释「控制器属于父」 | 能说清 subtree_control 的作用对象 | 5.3.2 |
| 手工建树并写出三个限额 | 目录、启用、写值三步不缺 | 5.3.3 |
把 cpu.max 换算成核数 | MAX/PERIOD,会判断 throttling | 5.3.4 |
区分 memory.max 与 memory.high | 一个 OOM,一个节流 | 5.3.5 |
用 io.max 限速并解释设备号 | 会查 lsblk -o MAJ:MIN | 5.3.6 |
| 解释 Docker 参数到 cgroup 文件的映射 | 至少四项 | 5.3.7 |
用 cpu.stat/memory.events/PSI 找证据 | 不只看利用率 | 5.3.8 |
| 指出五个核心结构体与控制器归属 | 至少 cgroup、cgroup_subsys_state、css_set | 5.3.9 |
说出 cpu.max 写下去经过的函数 | cpu_max_write → tg_set_cfs_bandwidth | 5.3.10 |
| 用两个实验给出配额与节流证据 | 有 nr_throttled 或 high+PSI 数据 | 5.3.11 |
| 遇到限额不生效能按清单定位 | 能区分 EBUSY/ENOTEMPTY/EINVAL | 5.3.12 |
Q1 在 /sys/fs/cgroup/demo 里执行 echo 64M > memory.max 报「No such file or directory」。最可能的原因是什么?怎么修?
Q2 cpu.max 写成 200000 100000,这个 cgroup 在 4 核机器上最多能用多少 CPU?如果宿主只有 1 核呢?
Q3 一个容器 memory.max=256M,memory.current 一直在 250M 左右,memory.events 的 high 很大、oom_kill=0。说明什么?该调哪个参数?
Q4 pids.max=100 的容器里跑一个 200 线程的 Java 程序,会发生什么?为什么不是「100 个进程」?
Q5 Docker 用 systemd cgroup driver 时,容器进程在 /proc/PID/cgroup 里显示什么?如果显示 /docker/<id>,说明什么?
Q6 你在一个 cgroup 的父层写了 +memory,但子层 ls 仍然没有 memory.max;cat 父层/cgroup.controllers 也没有 memory。问题出在哪一层?
Q7 为什么 memory.high 超限时进程只是变慢,而 memory.max 超限会导致 OOM?两者在 charge 路径上的差别是什么?
答案
A1 demo 自己没有被父层启用 memory 控制器,所以目录里没有 memory.max。正确做法是在 demo 的父层(这里是 root)的 cgroup.subtree_control 里已有 memory 的前提下,先 echo "+memory" > /sys/fs/cgroup/demo/cgroup.subtree_control,再在子目录 demo/a 里写 memory.max。注意控制器文件属于父、作用于子。
A2 配额是每 100 ms 最多 200 ms CPU 时间,因此最多相当于 2 个核。4 核机器上它只能用 2 核;1 核机器上它最多用 1 核(受物理核数限制),配额不会凭空造出 CPU。
A3 说明内存用量被 memory.high 顶住,内核在持续直接回收与节流,但没有触发 OOM。应看 memory.high 的值(可能设得偏低),结合 memory.stat 判断是 file 还是 anon 占大头;若是缓存,可适当提高 high 或主动 memory.reclaim。
A4 线程也会被计入 pids.current,200 个线程超过 pids.max=100,后续 clone/pthread_create 会失败(EAGAIN),程序可能启动失败或运行中创建线程失败。pids 控制器统计的是任务数,不是进程数;给多线程服务设限要按线程峰值留余量。
A5 应显示 0::/system.slice/docker-<full-id>.scope。若显示 0::/docker/<id>,说明 Docker 用的是 cgroupfs driver,而不是 systemd driver;两种都能工作,但前者缺少 systemd 的统一记账与 Delegate 语义。
A6 父层的父层(祖父)没有启用 memory,所以父层的 cgroup.controllers 里没有 memory,echo +memory 会失败(内核返回 ENOENT;若控制器根本没编译进内核则是 ENODEV)。控制器必须从根往下逐层启用:根 cgroup.subtree_control 有 memory → 父层 cgroup.controllers 才会出现 memory → 父层 subtree_control 启用后,子层才有 memory.max。
A7 超 high 时,try_charge_memcg 仍然允许分配成功,只是调用 mem_cgroup_handle_over_high 让任务睡眠并触发直接回收;超 max 时,mem_cgroup_resize_max/charge 路径在回收失败后调用 mem_cgroup_oom,走到 OOM killer。一个是「限速」,一个是「拒绝并清理」。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 内核文档 | Documentation/admin-guide/cgroup-v2.rst | 5.3.1–5.3.13 | 完整(接口语义以该文档与 Linux 6.8 为准) |
| N | 第 10 章 容器网络虚拟化 | 5.3.7、5.6 | 背景(容器 cgroup 路径与网络设备) |
| R | 第 2 章 内存管理(2.13 回收页面、2.17 KSM) | 5.3.5、5.3.10 | 背景(memory.high 触发的直接回收与 try_to_free_mem_cgroup_pages) |
| R | 第 3 章 进程管理(3.2 CFS 调度器、3.2.5 组调度) | 5.3.4、5.3.9、5.3.10 | 背景(task_group、cfs_bandwidth 与 cpu.max 的对应) |
| R | 第 3 章 进程管理(3.1.2 fork、clone flags) | 5.3.10 | 背景(pids_can_fork 在 copy_process 中的位置) |
| R | 第 5 章 中断管理 | 5.3.6 | 背景(块层限流与 IO 完成路径) |
| O | 第 7–10 章 调度 | 5.3.4 | 背景(权重与配额的区别) |
| systemd | systemd.resource-control(5)、systemd-run(1) | 5.3.7、5.3.9、5.3.12 | 完整(unit 属性到 cgroup 文件的映射) |
| man-pages | cgroups(7)、psi(7) | 5.3.2–5.3.13 | 精选(v2 文件、迁移注意事项与 PSI 语义) |
下一站:5.4 overlayfs 与镜像分层 讲第三件套:lower/upper/work/merged 四个目录、copy-up 的触发条件、whiteout 如何表达删除,以及
docker save出来的层 tar 与/var/lib/docker/overlay2的对应关系。