cgroup v2 控制器全集:memory、io 与 systemd 资源管理

从 v1 的三棵树到 v2 的一棵树

cgroup v2 控制器全集

cgroup v1 的结构性问题出在「每个控制器一棵树」:cpu、memory、blkio 各自挂载在独立目录,同一进程在 cpu 树、memory 树里可以挂在完全不同的位置——「这批进程算一个资源组吗」在 v1 里没有唯一答案,各控制器之间靠内部状态同步,语义打架与边界模糊是常态;容器运行时为了对齐三棵树写过大量补丁式代码。v2 只保留一棵统一树:/sys/fs/cgroup 下所有控制器共享同一层级,「进程组」第一次成为唯一的一等公民。配套的是两条新规则:no-internal-process 约束——一个 cgroup 要么装进程、要么向下分发控制器属性,两者不可兼得,往已开启 subtree_control 的组里塞进程会收到 EBUSY;显式分发——父节点用 cgroup.subtree_control 决定向子树开放哪些控制器,控制器属性逐层显式授权,而不是 v1 那样整树隐式生效。线程型 cgroup(cgroup.type = threaded)是这条规则的例外通道,用于把进程与它的线程组分开管理。判断一台机器是不是 v2 只需一条命令:

flowchart LR
    subgraph V1["cgroup v1:控制器各自成树"]
        direction TB
        T1["cpu 树<br/>/sys/fs/cgroup/cpu"] --- X1["同一批进程<br/>在三棵树里可能挂在不同位置"]
        T2["memory 树<br/>/sys/fs/cgroup/memory"] --- X1
        T3["blkio 树<br/>/sys/fs/cgroup/blkio"] --- X1
    end
    subgraph V2["cgroup v2:一棵统一树"]
        direction TB
        R["/sys/fs/cgroup<br/>cgroup.controllers 列出可用控制器"] --> S["system.slice<br/>cgroup.subtree_control = +cpu +io +memory"]
        S --> A1["myapp.service<br/>cpu.max · memory.max · io.weight"]
        S --> A2["backup.service<br/>cpu.max · memory.max · io.weight"]
    end
$ stat -fc %T /sys/fs/cgroup
cgroup2fs
$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc
$ cat /sys/fs/cgroup/cgroup.subtree_control
memory pids
# no-internal-process 约束的现场:组里已有进程再开控制器会失败
$ echo +io > /sys/fs/cgroup/demo/cgroup.subtree_control
bash: echo: write error: Device or resource busy

下面按 memory、io、cpu 三个主力控制器过全集——每个文件先讲语义,再给可直接复现的操作。

memory 控制器:从限到保的全套旋钮

memory 控制器是容器资源管理的核心,v2 把 v1 分散的文件重排成一套语义清晰的旋钮。memory.max 是硬顶:超出后内核先回收该组内存,回收到位仍然不够就在组内触发 OOM 选择牺牲者——注意是组内 OOM,只杀这个 cgroup 里的进程,不波及全系统。memory.high 是节流线:超出后内核对该组施加高强度回收与分配节流(任务被反复拉入回收路径、速度变慢),让「超支」先表现为变慢而不是死亡——这是 v2 相对 v1 最重要的行为升级,v1 只有 limit 一条线,要么活着要么被杀。memory.min 是硬保护:保护量内的内存即使全系统告急也不回收;memory.low 是软保护:全系统回收时优先回收别的组、最后才动这里。memory.current 是当前用量(含页缓存),memory.swap.max 限制该组可用 swap 上限,memory.events 用计数器记录边界事件,memory.oom.group 打开后组内 OOM 从「杀一个任务」变成「杀整组」,避免杀一半留下状态残缺的应用,memory.reclaim(内核 5.14+)是主动回收接口,写入字节数即触发该组回收。跑一遍真实流程:

$ mkdir -p /sys/fs/cgroup/demo
$ echo +memory > /sys/fs/cgroup/cgroup.subtree_control   # 先向子树开放控制器
$ echo 536870912 > /sys/fs/cgroup/demo/memory.max        # 硬顶 512MiB
$ echo 419430400 > /sys/fs/cgroup/demo/memory.high       # 节流线 400MiB
$ cat /sys/fs/cgroup/demo/memory.current
198324224
$ cat /sys/fs/cgroup/demo/memory.events
high 37
max 0
oom 0
oom_kill 0

memory.events 这份输出读出来就是一份健康报告:high 37 表示越过节流线 37 次(任务被回收节流过,业务可能已经变慢);max 0 与 oom_kill 0 说明还没碰硬顶、没发生过组内 OOM。监控告警直接建在这四个计数器的增长速率上,比事后翻日志灵敏得多。两个容易踩的配套行为也要点名。memory.swap.max 默认 max(不限):只设 memory.max 不设 swap 上限的容器,超支时会把页换到 swap 里「慢慢地痛」,memory.current 看不出异常,要配合 swap.current 与第 17 篇的 cgroup 内存压力文件(memory.pressure)一起看。memory.min/low 保护的是「回收顺序」不是「配额」:它们不阻止其他组超支,只决定全系统回收时先牺牲谁——给延迟敏感服务的热数据(数据库缓存页)配 low,是「邻居超支不连坐」的关键手段;给绝不能换出的页配 min,则要克制,min 总量过大等于把回收压力全转嫁给无保护的组。

io 控制器:按设备限速与按权重分配

io 控制器管块设备,所有文件都以设备号为主键(lsblk 查主次设备号)。io.max 是硬限流,四个旋钮各管一路:rbps/wbps 限每秒读写字节数,riops/wiops 限每秒读写次数,不需要的旋钮写 max 表示不限:

$ lsblk -o NAME,MAJ:MIN | grep -w nvme0n1
nvme0n1    259:0
$ echo "259:0 rbps=104857600 wiops=2000" > /sys/fs/cgroup/demo/io.max
$ cat /sys/fs/cgroup/demo/io.stat
259:0 rbytes=38797312 wbytes=0 rios=947 wios=0 dbytes=0 dios=0

io.max 的关键局限必须写进部署文档:它只对直接 IO 生效。Buffered 写的真正落盘发生在内核回写线程里,不经过调用进程的 IO 路径,io.max 拦不住它——限不住应用写脏页的速度,是「io.max 配了等于没配」这类事故的根源。回写流量的治理交给另外两个机制:io.weight(1~10000,默认 100)按权重在竞争时分配带宽,借 cgroup writeback 机制对回写同样生效;io.cost(内核 5.0+)是成本模型,以 io.cost.qos 声明延迟目标与预算,按设备实测成本给各组发放 IO 路径的使用权,是比硬限流精细得多的共享方案。io.latency 是 io.cost 的简化版,按 echo "259:0 target=10000" 的方式声明毫秒级延迟目标,实现简单但只有「达标 / 惩罚」两种状态。监控读 io.stat:rbytes/wbytes/rios/wios 是流量计数,dbytes/dios 是 discard 量。选型口诀:限单个应用的绝对上限用 io.max(记得应用要走 direct IO,数据库类天然满足);多个应用分一块盘用 io.weight;有延迟 SLA 的存储服务上 io.cost。

cpu 控制器:配额、权重与被掐掉的时间

cpu.max 是配额文件,格式是「配额微秒 周期微秒」:200000 100000 表示每 100ms 周期内最多用 200ms CPU 时间——即 2 个核;默认 max 100000 不限。内核按周期记账,周期内配额用尽就把该组任务掐住直到下个周期,这就是 CFS 带宽控制,也是容器 CPU limit 的底层实现。cpu.weight(1~10000,默认 100)在 CPU 竞争时按权重分时,替代 v1 的 cpu.shares;cpu.stat 是监控的核心,nr_throttled 与 throttled_usec 直接量化「被限流」:

$ echo "200000 100000" > /sys/fs/cgroup/demo/cpu.max
$ cat /sys/fs/cgroup/demo/cpu.stat
usage_usec 61287491
user_usec 52431002
system_usec 8856489
nr_periods 615
nr_throttled 184
throttled_usec 43199202

这份输出翻译成人话:615 个调度周期里 184 个撞了配额(撞线率 30%),任务累计被掐住 43.2 秒。撞线率持续偏高有两种成因:配额确实不够(扩容或调大 cpu.max),或负载是突发型而周期太短(短周期内突刺直接吃满配额,长周期可以摊平——v2 还提供 cpu.max.burst 给可预测的突发留余量)。应用侧的症状是「周期性延迟毛刺」:每 100ms 一个可能的停顿尖峰,P99 被拉高而平均无感——排查这类毛刺时先看 throttled_usec,再决定是加配额还是调周期。

weight 与 quota 的组合套路

cpu.max 与 cpu.weight 各管一头,生产上通常组合使用:quota 定「最多能用多少」(上限语义),weight 定「抢不过别人时能分到多少」(竞争语义)。一个典型部署是给延迟敏感服务配高 weight(如 500)但不设 quota(满核可用),给跑批配低 weight(如 50)加 quota(如 4 核)——跑批吃饱自己配额的同时,高峰期自然让位给在线服务,而不是靠 kill 或硬限流切换。验证组合效果用两个命令即可:压测时 systemd-cgtop 看按 weight 折算的份额是否生效;在线服务的 cpu.stat 里 nr_throttled 是否保持 0。cpuset 的核绑定与内存节点限制(cpuset.cpus / cpuset.mems)第 16 篇已专文讲过,这里补一句 v2 的差异:cpuset 同样走统一树,且生效规则变了——父组的 cpuset.cpus 是子组的硬边界,子组只能在父层已分配的 CPU 范围内取子集;容器配 --cpuset-cpus 若超出父层范围会被按交集静默收窄,排查「配了核绑定却不生效」时先看父层的 cpuset.cpus.effective。顺带把剩余控制器一句话带过:pids 控制器只有 pids.max 一个文件(防 fork 炸弹,systemd 的 TasksMax 是它的接口);hugetlb 限大页用量;misc 限 SEV-SNP 这类特殊资源;rdma 管设备委托——认识名字即可,日常主力始终是 memory、io、cpu 三个。

systemd:资源管理的日常入口

绝大多数场景不应该手写 /sys/fs/cgroup——systemd 是 cgroup v2 的官方管理层,每个 service、scope、slice 就是一个 cgroup,属性名与控制器文件一一对应:CPUQuota 写进 cpu.max、MemoryMax 写进 memory.max、MemoryHigh 对应 memory.high、MemoryMin/MemoryLow 对应保护旋钮、IOWeight 对应 io.weight、IOReadBandwidthMax/IOWriteBandwidthMax 对应 io.max、TasksMax 对应 pids.max。运行时调整一条命令,改动落到 systemd 的 drop-in 里持久保存:

$ systemctl set-property myapp.service MemoryMax=1G CPUQuota=200% IOWeight=50
$ systemctl show myapp.service -p MemoryMax -p CPUQuotaPerSecUSec
MemoryMax=1073741824
CPUQuotaPerSecUSec=2s
$ cat /sys/fs/cgroup/system.slice/myapp.service/memory.max
1073741824

临时限制用 systemd-run,适合跑批、压测这类一次性负载:

$ systemd-run --scope -p MemoryMax=512M -p CPUQuota=100% ./loadtest
Running scope as unit: run-r68b3c.scope

层级侧,system.slice、user-1000.slice、machine.slice 构成默认的资源分区。slice 本身也是一个 cgroup,可以像服务一样配资源属性,给一组服务统一设限;容器运行时则通过 Delegate=true 拿到子树的自主权(Docker/Kubernetes 的 cgroup 驱动正是这么工作的)。systemd-cgtop 提供按 cgroup 的实时资源视图。自建分区与归组各两条命令:

# /etc/systemd/system/app.slice —— 自建资源分区:一组服务共享 8G 上限
[Slice]
CPUWeight=200
MemoryMax=8G
$ systemctl daemon-reload
$ systemd-run --slice=app.slice -p MemoryMax=2G ./worker   # 成员归入分区,自身再限 2G
$ systemctl status app.slice --no-pager | head -5

这套接口的价值是把内核文件语义包装成运维语言:写 MemoryMax=1G 的人不需要知道 memory.max 的字节语义,但排查问题时两侧要能对上——systemd show 的值与内核文件里的值应严格一致,不一致就是配置被手改过的信号。

容器运行时怎么用这套旋钮

最后把 docker 与 Kubernetes 的参数翻译回控制器文件——排查容器资源问题时,两侧能互相对账才算真正理解。Docker 侧的映射:--memory 写进 memory.max,--memory-reservation 写进 memory.low,--cpus 折算成 cpu.max 的配额(NanoCpus ÷ 1000 = 每周期微秒),--cpu-shares 折算成 cpu.weight,--pids-limit 对应 pids.max:

$ docker run -d --memory=512m --memory-reservation=400m --cpus=1.5 --cpu-shares=300 myapp
$ cat /sys/fs/cgroup/system.slice/docker-4f2a.../memory.max
536870912
$ cat /sys/fs/cgroup/system.slice/docker-4f2a.../cpu.max
150000 100000

Kubernetes 侧:resources.limits.memory 写进 memory.max(kubelet 另把 memory.high 设在 limit 之下一点,让超支先节流后 OOM);limits.cpu 写进 cpu.max;requests.cpu 折算成 cpu.weight;Guaranteed/Burstable/BestEffort 三个 QoS 等级落到 kubepods.slice 下的三个子 slice,每个容器在对应 pod slice 里有独立的容器 scope。排查口径因此明确:kubectl describe 看 QoS 与 limit,进 cgroup 目录核对 events 计数——K8s 显示的 OOMKilled 状态与 memory.events 的 oom_kill 增长是同一件事的两个视图;性能毛刺则对应 cpu.stat 的 throttled_usec。容器引擎的参数只是表象,行为永远发生在内核的控制器文件里。

为什么你的应用会受这个影响

四个高频故障模式对应四个排查入口。容器被 OOM 杀:先看对应 cgroup 的 memory.events 里 oom_kill 是否增长——组内 OOM 只杀组内进程,应用日志里的 Killed 与宿主机 OOM Killer 无关,加内存不如调大 MemoryMax 或排查泄漏。周期性延迟毛刺:先看 cpu.stat 的 throttled_usec 增长速率,再怀疑网络与存储。配了 IO 限流但磁盘仍被打满:检查负载是不是 buffered 写——io.max 管不了回写,改用 io.weight 或让应用走 direct IO。v1 迁移 v2 后限制「失效」:先确认 stat -fc %T 是 cgroup2fs,再检查每层的 cgroup.subtree_control——v2 的控制器要逐层显式分发,父层没开放,子层文件根本不会出现。一句话总结:v2 的每个限制行为都在 memory.events / cpu.stat / io.stat 里留了计数器,限流体系的所有异常都应该是监控告警,而不是用户投诉。

小结

cgroup v2 不是 v1 的简单升级——是一次资源管理的重新设计——统一层级 + no-internal-process + 系统化接口——三个核心变化——理解 v2 的设计哲学比记住参数更重要。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?