容器(Docker/K8s)的资源隔离、systemd 的服务限额、多租户机器上的公平调度,底座都是 cgroup。cgroup v2 在 4.5 内核后逐步取代 v1,用单一层级树取代了 v1 混乱的多层级设计。本文用一个 Python 模拟器量化演示 cpu.weight 份额分配、memory.max 硬限制、io.max 限速与层级继承的全部数值行为,并剖析 v1 到 v2 的设计演进。
一、从 v1 到 v2:为什么要重做
cgroup v1 的每个控制器(cpu、memory、blkio、devices…)可以各自挂载一棵独立的层级树。听上去灵活,实际是灾难:同一进程在 cpu 树、memory 树、blkio 树上可能处于完全不同位置,资源策略互相打架;控制器间的协调(比如 io 要参考 memory 的回写状态)没有统一视图几乎不可能。
v2 的核心决策就一条:整个系统只有一棵 cgroup 树,所有控制器挂在这同一棵树上。一个 cgroup 目录同时配置 cpu.weight、memory.max、io.max,进程归属一目了然。这条决策代价是失去了多树的灵活性,换来的是语义清晰和控制器协作的可能——事后被证明是压倒性的正确。
二、三大核心控制器的语义
- cpu.weight(份额,软限制):默认值 100,范围 1~10000。它不是固定百分比,而是相对权重:CPU 紧张时各 cgroup 按权重比例瓜分;CPU 空闲时任何组都可以用满整机。这与 CFS 的 nice/weight 一脉相承——本文第一篇调度文章里的权重公式在此原样复用。
- memory.max(硬限制):组内内存用量触及上限后,新的分配请求直接失败,cgroup OOM killer 介入选杀进程回收内存。这是硬墙,不会像 cpu.weight 那样『空闲时可以多用』。另有 memory.high 是软上限(超限会被压制回收但不直接杀)、memory.low 是保护下限。
- io.max(限速):按设备(如 8:0)设 rbps/wbps(读写字节/秒)或 riops/wiops。超出部分被限流(throttled),进程阻塞等待而非失败——I/O 限速是排队而非拒绝。
三、完整可运行模拟器(Python 3,约 90 行)
#!/usr/bin/env python3
"""cgroup v2 模拟:cpu.weight 份额、memory.max 硬限制、
io.max 限速、层级继承"""
# ---------- cpu.weight: 按权重瓜分 CPU ----------
def cpu_share(cgroups):
"""模拟一个 CPU 在多个 cgroup 间的份额分配"""
total_w = sum(w for _, w in cgroups)
return {name: 100.0 * w / total_w for name, w in cgroups}
print('=== cgroup v2 模拟 ===')
print('--- cpu.weight 份额分配 ---')
top = [('system.slice', 100), ('user.slice', 100),
('myapp.slice', 100)]
for n, s in cpu_share(top).items():
print(' %s: weight=100 -> CPU 份额 %.1f%%' % (n, s))
print('调整 myapp.slice weight=200 后:')
top2 = [('system.slice', 100), ('user.slice', 100),
('myapp.slice', 200)]
for n, s in cpu_share(top2).items():
print(' %s: CPU 份额 %.1f%%' % (n, s))
print()
print('--- 层级继承:myapp.slice 内两个子组 ---')
sub = [('db.service', 100), ('web.service', 300)]
sub_shares = cpu_share(sub)
myapp_total = cpu_share(top2)['myapp.slice']
for n, s in sub_shares.items():
print(' myapp.slice/%s: 组内份额 %.0f%% -> 实际占整机 %.1f%%'
% (n, s, myapp_total * s / 100))
# ---------- memory.max: 硬限制触发 OOM ----------
print()
print('--- memory.max 硬限制模拟(限额 64MB) ---')
LIMIT = 64
used = 0
for i in range(10):
if used + 8 > LIMIT:
print(' 第 %d 次申请 8MB: 超出 memory.max=64MB'
' -> 分配失败, cgroup OOM-killer 介入' % (i + 1))
used -= 24
print(' OOM killer 选中占用最高的进程,'
'回收 24MB,当前用量 %dMB' % used)
else:
used += 8
print(' 第 %d 次申请 8MB: 成功, 累计 %dMB' % (i + 1, used))
# ---------- io.max: 限速 ----------
print()
print('--- io.max 限速模拟(8:0 设备 rbps=100MB/s) ---')
rleft = 100
requests = [30, 50, 80, 120, 150]
print(' 需求: %s MB/s, 上限 100MB/s' % requests)
for i, want in enumerate(requests):
got = min(want, rleft)
rleft -= got
status = '全额满足' if got == want else '被限流(throttled)'
print(' 进程%d 需求 %3dMB/s -> 实际 %3dMB/s %s'
% (i + 1, want, got, status))
print()
print('--- 关键设计:v2 统一层级 ---')
print('v1: cpu、memory、io 各自独立层级,'
'同一进程可能在三棵树的不同位置')
print('v2: 单一层级树,一个 cgroup 同时挂所有控制器,'
'挂载点统一 /sys/fs/cgroup')
四、真实运行输出与解读
以上代码在 Python 3 下原样运行,输出如下:
=== cgroup v2 模拟 ===
--- cpu.weight 份额分配 ---
system.slice: weight=100 -> CPU 份额 33.3%
user.slice: weight=100 -> CPU 份额 33.3%
myapp.slice: weight=100 -> CPU 份额 33.3%
调整 myapp.slice weight=200 后:
system.slice: CPU 份额 25.0%
user.slice: CPU 份额 25.0%
myapp.slice: CPU 份额 50.0%
--- 层级继承:myapp.slice 内两个子组 ---
myapp.slice/db.service: 组内份额 25% -> 实际占整机 12.5%
myapp.slice/web.service: 组内份额 75% -> 实际占整机 37.5%
--- memory.max 硬限制模拟(限额 64MB) ---
第 1 次申请 8MB: 成功, 累计 8MB
...(第 2~8 次各 +8MB,累计到 64MB)...
第 8 次申请 8MB: 成功, 累计 64MB
第 9 次申请 8MB: 超出 memory.max=64MB -> 分配失败, cgroup OOM-killer 介入
OOM killer 选中占用最高的进程,回收 24MB,当前用量 40MB
第 10 次申请 8MB: 成功, 累计 48MB
--- io.max 限速模拟(8:0 设备 rbps=100MB/s) ---
需求: [30, 50, 80, 120, 150] MB/s, 上限 100MB/s
进程1 需求 30MB/s -> 实际 30MB/s 全额满足
进程2 需求 50MB/s -> 实际 50MB/s 全额满足
进程3 需求 80MB/s -> 实际 20MB/s 被限流(throttled)
进程4 需求 120MB/s -> 实际 0MB/s 被限流(throttled)
进程5 需求 150MB/s -> 实际 0MB/s 被限流(throttled)
逐条解读:
- 权重是相对值:三个组都是 100 时各 33.3%;把 myapp.slice 调到 200,它拿到 50%,其余两个被挤到 25%。weight 只有在 CPU 争用时才生效——空闲时任一进程都能跑满整机,这是份额制与固定配额(cpu.max)的本质区别。
- 层级继承是乘法:myapp.slice 占整机 50%,它内部的 db.service(组内 25%)和 web.service(组内 75%)实际占整机 12.5% 和 37.5%。这正是 systemd 的 system.slice / user.slice / 自定义 app.slice 层层套娃的数学本质,K8s 的 Pod → 容器两级限制同理。
- memory.max 是硬墙:用量爬满 64MB 后第 9 次申请直接失败,OOM killer 选杀占用最高的进程回收 24MB。真实内核里 oom_badness 启发式打分(占用 + 可牺牲性)决定牺牲者,日志可在 dmesg 里看到『Memory cgroup out of memory』。
- io.max 是排队不是拒绝:100MB/s 限额下,前两个进程全额满足,第三个 80MB/s 的需求只拿到剩余 20MB/s,第四五个直接排队等待——限流让进程变慢而非报错。这就是为什么磁盘限流的服务表现为延迟飙升而非 I/O 报错,排查时要看 io.stat 的 throttling 计数而非错误日志。
五、理论分析
5.1 份额制 vs 配额制
cpu.weight(份额)与 cpu.max(配额,带宽控制 cfs_quota 的 v2 化)是两种不同的公平:份额制在资源富余时不设限,争用时按比例分;配额制是绝对的周期上限(如每 100ms 最多 50ms)。份额制利用率更高,配额制隔离性更强。生产实践:K8s 的 requests 映射到 weight(份额)、limits 映射到 quota(配额),二者配合才能既高利用又防 noisy neighbor。
5.2 no internal process 约束
v2 有一条容易踩坑的规则:非叶子 cgroup 里不能有进程(开启了控制器的中间节点必须为空)。因为控制器需要明确资源竞争的双方是『子 cgroup 之间』,混入本级进程会让份额语义混乱。这解释了为什么 systemd 会给每个服务单独建叶子 slice,根 slice 只挂控制器不跑进程。
5.3 内存限制的层次语义
memory.max(硬墙)、memory.high(软压制)、memory.low(保护)三层设计对应三种 QoS 意图:max 是『绝对不能超』,high 是『超了就回收施压但不杀』,low 是『别人抢内存时保住我』。Meta 的内存调优实践(oomd、senpai)就是围绕 high/low 的动态调节,目标是把整机内存利用率推到 90% 以上而不触发硬 OOM。
六、工程应用
- 手工操作:v2 挂载在 /sys/fs/cgroup,mkdir 即建组,echo 写文件即配置:
echo 200 > /sys/fs/cgroup/myapp/cpu.weight、echo 512M > .../memory.max、echo 8:0 rbps=104857600 > .../io.max,把 PID 写入 cgroup.procs 即纳入管理。整个接口是文件系统,这是 cgroup 设计的精妙之处。 - systemd 集成:systemd 是 v2 的最大用户,systemd-run --slice=myapp --property CPUWeight=200 ./app 一条命令完成建组、配置、纳管;/etc/systemd/system/ 里给服务配 [Service] MemoryMax= 即落地为 memory.max。
- K8s 映射:Pod 的 resources.requests/limits 最终落到 cgroup 文件。排查 Pod CPU 被限流(cpu.stat 的 throttled 字段)或容器被 OOMKilled(memory.events 的 oom_kill 字段)都要回到这层看原始数据,kubectl describe 只是它的包装。
- 观测:cpu.stat(usage/throttled)、memory.stat / memory.events(oom、oom_kill、high 触发次数)、io.stat 是三大控制器的原始计数器,比 top/free 等聚合工具更能定位隔离层问题。
七、常见误区
- 误区 1:『设了 cpu.weight=200 就能保证 50% CPU』。错。weight 只在争用时生效,空闲时单组可用满整机;且 200 是相对值,实际份额取决于同层其他组的权重总和。要绝对保证用 cpu.max 配额。
- 误区 2:『memory.max 设大一点就不会 OOM』。应用存在内存泄漏时,任何上限都只是推迟 OOM;且 page cache 也计入用量,读文件多的服务实际可用堆内存比 max 小得多。正确姿势是 memory.high 提前施压 + 监控 memory.events。
- 误区 3:『I/O 被限流会在日志里报错』。io.max 的限流是进程阻塞排队,不产生错误,只表现为延迟上涨。排查慢盘先查 io.stat 的 throttled 计数,别一上来怀疑硬件。
- 误区 4:『v1 和 v2 可以混用无碍』。混挂在迁移期可行,但同一控制器不能同时挂两棵树,且两套语义并存会让排查心智负担翻倍。新系统应一步到位纯 v2(systemd 默认 unified 模式)。
- 误区 5:『把进程加进中间节点就行』。违反 no internal process 约束会写入失败或行为异常。进程只能落在叶子 cgroup,中间节点只挂控制器做资源分发。
八、参考文献
- Linux kernel documentation: Documentation/admin-guide/cgroup-v2.rst(v2 的权威设计文档,含 no internal process 等全部规则)。
- T. Heo (2015). Cgroup v2: unified hierarchy. LWN.net 及内核 cgroup-v2 主线系列补丁。
- systemd 文档: systemd.resource-control(5)(CPUWeight/MemoryMax/IOWeight 与 cgroup v2 的完整映射)。
- Meta Engineering (2019-2023). oomd、senpai 与内存调优实践 系列博客(engineering.fb.com)。
- W. Mauerer 著,郭旭译. 专业 Linux 内核架构 相关章节:控制组与资源管理。