Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 控制器不全。

关于 v1/v2 的四个误解
  1. 「v2 只是 v1 换了个文件名」——层级规则、控制器启用方式、内部进程约束都变了;把 v1 的 tasks 写法照搬到 v2 会直接失败。
  2. 「Ubuntu 24.04 还默认 v1」——24.04 默认 unified;只有显式加 systemd.unified_cgroup_hierarchy=0 才回到 hybrid。
  3. 「cgroup v2 不支持 blkio」——支持,控制器名从 blkio 改为 io,接口是 io.max/io.weight/io.stat。
  4. 「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 再放进程——但那样它的子层就失去控制器了。

手工建 cgroup 的五个坑
  1. 「mkdir 后就能写 memory.max」——不能。控制器文件只在父层启用后出现在子层;demo 自己没有 memory.max。
  2. 「往 cgroup.procs 写 PID 只迁移一个线程」——写任一线程的 TID 会迁移整个进程;线程级迁移要用 cgroup.threads,且只在 threaded 子树里可用。
  3. 「删掉目录,进程就被杀了」——rmdir 只对空 cgroup 成功;有进程时返回 EBUSY,要先杀进程或迁移。
  4. 「cpu.max 的 50000 100000 是 50%」——对单核是 50%;对多核,配额是「每个周期内整个 cgroup 可用的 CPU 时间总和」,200000 100000 才是 2 个核。
  5. 「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 $PERIODmax 100000每 PERIOD 微秒最多用 MAX 微秒 CPU;max 表示不限
cpu.weight1–10000100兄弟间按权重分配空闲 CPU
cpu.weight.nice-20–190用 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` 与 `cpu.weight`

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.maxswap 上限超过后不再换出匿名页
memory.oom.group1 = 整组同生共死OOM 时杀掉整个 cgroup
memory.current当前用量(含 page cache)只读
memory.eventslow/high/max/oom/oom_kill/... 计数只读,判断 OOM 的证据
memory.statanon、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
内存限额的五个坑
  1. 「memory.current 就是进程 RSS」——它包含 page cache、slab、socket 缓冲等。一个只读文件的服务也可能因为缓存把 current 顶到上限,但那些页是可回收的。
  2. 「设了 memory.max 就一定不会 OOM」——超过后先回收,回收不动就 OOM;memory.oom.group=0 时只杀一个进程,可能杀错。
  3. 「memory.high 是软限制,可以随便超」——超了会被节流到远低于预期的吞吐,表现为「CPU 不高但程序很慢」。
  4. 「swap 关闭时 memory.swap.max 无所谓」——容器里通常把 memory.swap.max 设为 0,避免匿名页换出后延迟飙升;不设则可能用宿主 swap。
  5. 「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
限额不生效时的排查顺序
  1. 确认在 v2:stat -fc %T /sys/fs/cgroup 应为 cgroup2fs;/proc/PID/cgroup 应为 0::/...。
  2. 确认控制器已启用:从根往下逐级 cat cgroup.subtree_control,看目标 cgroup 的父层有没有启用对应控制器。
  3. 确认文件存在:ls /sys/fs/cgroup/<path>/ | grep memory;文件不存在就是父层没启用。
  4. 确认进程在目标 cgroup:cat /proc/<pid>/cgroup 与 cat .../cgroup.procs 对照;systemd 可能把它移到别的 scope。
  5. 确认写入成功:echo 的返回码;EBUSY 是内部进程限制,EINVAL 是格式或范围错。
  6. 确认限流证据: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.sliceVM/容器(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.5CPUQuota=150%cpu.max150000 100000
--cpu-shares=1024CPUWeight=cpu.weight约 39(见 5.3.4)
--memory=64mMemoryMax=64Mmemory.max67108864
--memory-reservation=32mMemoryLow=32Mmemory.low33554432
--pids-limit=64TasksMax=64pids.max64
--blkio-weight=200IOWeight=io.weight由运行时按 v1→v2 刻度换算
为什么容器运行时偏爱 systemd driver

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」的漏网之鱼。容器运行时删除容器时优先用它。

观测与回收的四个坑
  1. 「memory.current 降不下来就是泄漏」——先看 memory.stat 的 file;page cache 占大头时,echo 1G > memory.reclaim 或 posix_fadvise(DONTNEED) 就能降。
  2. 「nr_descendants 是进程数」——是子 cgroup 数量;进程数看 cgroup.procs 的行数。
  3. 「cgroup.freeze 会释放资源」——冻结只是停止调度,内存与 cgroup 结构都还在。
  4. 「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 cgroupkernel/cgroup/cgroup-internal.hself、level、max_depth、nr_descendants、subtree_control、subtree_ss_mask、parent、children、kn、dom_cgrpcgroup 核心mkdir/rmdir、写 subtree_control
struct cgroup_subsys_stateinclude/linux/cgroup-defs.hcgroup、ss、refcnt、sibling、children、id、flags各控制器 + 核心建/删 cgroup、启用/禁用控制器
struct css_setinclude/linux/cgroup-defs.hrefcount、dom_cgrp、dfl_cgrp、subsys[]、tasks、e_csetscgroup 核心进程迁移、控制器启用组合变化
struct cgroup_subsysinclude/linux/cgroup-defs.hcss_alloc、css_online、css_offline、css_free、can_attach、attach、can_fork、fork、exit、dfl_cftypes控制器作者编译期注册,运行期只读
struct kernfs_nodeinclude/linux/kernfs.hparent、name、flags、mode、ino、priv、nskernfs建/删文件、改权限

subtree_control 与 subtree_ss_mask 的区别值得单独说:前者是用户写进去的意愿(+memory -cpu 这种字符串解析后的位图),后者是核心过滤后实际生效的位图。普通域 cgroup 上两者通常一致;在线程子树里,不支持线程模式的控制器会被从 subtree_ss_mask 里剔除,于是出现「subtree_control 有 memory、子层却没有 memory.*」的现象。判断「为什么子层没有某个文件」时,先看父层的 cgroup.controllers(可用集合),再看 cgroup.subtree_control(已启用集合)。

定义 5.3.1 · 控制器(controller)

控制器是一组可被层级启用/禁用的资源管理回调集合,由 struct cgroup_subsys 描述。启用某个控制器,意味着该 cgroup 的子层会获得这个控制器的接口文件,并且挂载到这个 cgroup 的进程在 fork/charge 时会走该控制器的钩子。控制器不属于某个 cgroup,而属于「cgroup 与子系统」的交叉点——这就是 cgroup_subsys_state 存在的理由。

控制器清单

v2 把 v1 的十几个子系统收敛成九个控制器。每个控制器只在父层启用后才在子层出现文件:

控制器主要接口文件内核实现备注
cpucpu.max、cpu.weight、cpu.stat、cpu.pressure、cpu.uclamp.*kernel/sched/core.c、kernel/sched/fair.c配额走 CFS bandwidth,权重走 task_group
cpusetcpuset.cpus、cpuset.mems、cpuset.cpus.effective、cpuset.cpus.partitionkernel/cgroup/cpuset.c绑核与 NUMA 节点;partition 用于 cpuset 隔离
memorymemory.max/high/low/min、memory.current、memory.stat、memory.events、memory.swap.*、memory.oom.group、memory.reclaimmm/memcontrol.c唯一能触发 cgroup OOM 的控制器
ioio.max、io.weight、io.stat、io.latency、io.cost.*block/blk-throttle.c、block/blk-iolatency.c按 MAJ:MIN 设限;缓冲写也受限
pidspids.max、pids.current、pids.eventskernel/cgroup/pids.c统计任务(线程)数,fork 时 charge
hugetlbhugetlb.<size>.max/current/events/rsvd.maxmm/hugetlb_cgroup.c按 hugepage 尺寸分文件
rdmardma.max、rdma.currentkernel/cgroup/rdma.c按 RDMA 设备设限
miscmisc.max、misc.currentkernel/cgroup/misc.c5.13+,给无专用控制器的资源用
freezercgroup.freeze(核心文件)kernel/cgroup/cgroup.cv2 里不再算独立控制器

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 容器不能按线程限内存」的答案——内存控制器根本不支持线程粒度。

命令表:从观测到修改

目的命令读/改了什么
看进程属于哪个 cgroupcat /proc/<pid>/cgroup0::/path 这一行
看整棵树systemd-cgls、systemd-cgtopunit 与 cgroup 的映射
看 unit 的资源属性systemctl show ssh.service -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax -p Delegatesystemd 侧配置,不一定等于内核文件当前值
起临时 scopesystemd-run --scope -p MemoryMax=64M -p CPUQuota=25% cmd建 unit + 写 cgroup 文件
改运行中 unitsystemctl 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 启动参数才开启。

用 tracefs 验证 cgroup 路径
# 需要 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。两个实验对照,就能把「节流」与「杀死」分开。

例 5.3.1 · 为什么 high 计数涨得比预期快

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_permissionEACCES
给子层写 memory.max子层文件对委托人可写VFSEACCES
把进程迁进子层目标 cgroup.procs 可写,且源/目标公共祖先的 cgroup.procs 可写cgroup_procs_write_permissionEACCES
把进程迁出委托子树同上(公共祖先通常是宿主的根)同上EACCES
委托层有进程时再启用子层控制器委托层内没有进程(no-internal-process)cgroup_subtree_control_writeEBUSY
systemd 不清理/搬动子树unit 设 Delegate=yessystemd 侧子层被 systemd 回收
非 root 服务拿到目录属主Delegate=yes 且 User= 非 rootsystemd 侧 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 在启动时完成。共同点是内核只看文件属主与公共祖先,不看「这个用户是不是容器的所有者」。

委托的八个坑
  1. 只 chown 目录,不 chown cgroup.procs——目录可写只能 mkdir;迁移进程还要写 cgroup.procs,否则 EACCES。
  2. 把 cgroup.subtree_control 的写权限也给出去,却保留 memory.max 可写——委托人能改自己的上限,虽然仍受祖先限额约束,但会破坏「父层分配、子层使用」的模型;资源旋钮文件应保持只读。
  3. 委托层里留进程——为子层启用控制器时返回 EBUSY;委托根只做容器,进程放叶子。
  4. 从委托外直接把 PID 写进子层——公共祖先不可写,EACCES;必须由 root(或公共祖先的属主)先放入。
  5. Delegate=yes 但没设 User=——systemd 只停止干预,不会把目录交给非 root;非特权服务仍写不了。
  6. 以为 Delegate=yes 能阻止 stop 杀进程——委托只影响 cgroup 管理,KillMode= 仍决定停止时杀谁。
  7. 用长期持有的 cgroup.procs fd 做迁移——权限按 open 时的凭证检查,fd 被继承后仍带着原权限;不要把它传给不可信进程。
  8. 在 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
3cpu.max 写 max 100000 后仍被限max 是「无限」,不是「1 核」;检查是否还有父层配额逐级 cat cpu.max,父层配额也会约束子层
4cpu.weight 设成 10000 仍然慢权重只在兄弟竞争时生效,且受父层配额约束先确认同一父层下有无竞争者
5memory.current 远大于进程 RSS计入 page cache、slab、socket 缓冲看 memory.stat 的 anon 与 file 分解
6memory.max 超限后进程收到 SIGSEGV实际是 OOM killer 发的 SIGKILL;SIGSEGV 来自程序自身看 memory.events 的 oom_kill 与 dmesg
7pids.max=100 的容器起不来 Java线程也计数,200 线程超过 100按线程峰值设置,或调大 pids.max
8io.max 写了但限不住设备号写错,或写的是 overlay 的虚拟设备号lsblk -ndo NAME,MAJ:MIN 查底层块设备
9io.weight 与 io.max 同时设,weight 无效max 是硬上限,weight 只在未超 max 时按比例分配二选一,或把 max 设得足够宽
10rmdir 报 ENOTEMPTY 而不是 EBUSY还有子 cgroup,不是还有进程先删子层;cgroup.procs 为空不代表能删
11cgroup.freeze 后内存没降冻结只停止调度,内存与结构仍在需要释放内存用 memory.reclaim 或删除 cgroup
12cgroup.kill 在 CentOS 7 上不存在该文件 Linux 5.14 才有,且 v1 没有旧内核用 cgroup.freeze + 逐个 kill
13systemd 服务里再建 cgroup 失败unit 没有 Delegate=yes,cgroup 目录属 root 且不可写在 unit 里加 Delegate=yes,或使用 --slice
14memory.stat 的 file 很大就以为泄漏page cache 可回收,inactive_file 是主要可回收部分用 memory.current - inactive_file 估算工作集
三个版本差异
  1. v1 与 v2 并存:stat -fc %T /sys/fs/cgroup 返回 tmpfs 说明是 v1 或 hybrid;此时 /sys/fs/cgroup/memory/ 下才是 v1 文件,不要按 v2 路径写。
  2. memory.reclaim 需要 5.19+:旧内核只能在压力下被动回收,或写 memory.force_empty(v1 遗留语义,v2 已移除)。
  3. 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,会判断 throttling5.3.4
区分 memory.max 与 memory.high一个 OOM,一个节流5.3.5
用 io.max 限速并解释设备号会查 lsblk -o MAJ:MIN5.3.6
解释 Docker 参数到 cgroup 文件的映射至少四项5.3.7
用 cpu.stat/memory.events/PSI 找证据不只看利用率5.3.8
指出五个核心结构体与控制器归属至少 cgroup、cgroup_subsys_state、css_set5.3.9
说出 cpu.max 写下去经过的函数cpu_max_write → tg_set_cfs_bandwidth5.3.10
用两个实验给出配额与节流证据有 nr_throttled 或 high+PSI 数据5.3.11
遇到限额不生效能按清单定位能区分 EBUSY/ENOTEMPTY/EINVAL5.3.12
即时自测 · 5.3

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.rst5.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背景(权重与配额的区别)
systemdsystemd.resource-control(5)、systemd-run(1)5.3.7、5.3.9、5.3.12完整(unit 属性到 cgroup 文件的映射)
man-pagescgroups(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 的对应关系。