Linux 基础体系 · 第 35/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux cgroups v2 深入:CPU、Memory、IO、PID、Pressure 和委派

所属系列:Linux 基础体系
所属模块:七、内核深入
标签:Linux、cgroups、容器、性能优化

1. cgroups v2 解决什么问题

cgroup(control group) 是 Linux 内核对一组进程进行资源核算和资源控制的机制。它可以限制、保护或统计:

  • CPU 调度时间;
  • 内存和 swap 使用;
  • 块设备 I/O;
  • 进程数量;
  • 资源压力(Pressure Stall Information,PSI)。

cgroup 只负责“资源归属和资源控制”,不负责完整的进程视图隔离。例如:

  • cgroups 可以限制一个服务最多使用多少 CPU;
  • PID namespace 可以让进程看到不同的 PID 视图;
  • mount namespace 可以隔离挂载点;
  • network namespace 可以隔离网络设备和路由表。

容器通常同时使用 cgroups 和 namespaces。cgroups 解决“最多使用多少资源”,namespaces 解决“能看到哪些对象”。

cgroups v2 与 v1 的核心差异是:

  1. v2 使用一个统一层级(unified hierarchy);
  2. 一个 cgroup 目录同时承载所有启用的控制器;
  3. 控制器通过父 cgroup 的 cgroup.subtree_control 向子树传播;
  4. 资源控制更强调层级语义和委派安全;
  5. v2 不再使用 v1 中的 release_agent 等机制;
  6. 多数现代 systemd 发行版已经默认使用 v2。

检查当前系统:

stat -fc %T /sys/fs/cgroup

如果输出类似:

cgroup2fs

说明 /sys/fs/cgroup 是 cgroups v2。

进一步查看挂载和控制器:

mount | grep cgroup
cat /sys/fs/cgroup/cgroup.controllers
cat /sys/fs/cgroup/cgroup.subtree_control

典型输出可能是:

cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)
cpuset cpu io memory hugetlb pids rdma misc

cgroup.controllers 表示当前 cgroup 可以向子 cgroup 开放的控制器
cgroup.subtree_control 表示当前 cgroup 已经向直接子 cgroup 开放的控制器

这两个文件不是同一个概念:

  • 父目录有 memory,不代表子目录已经能使用 memory.max
  • 只有父目录写入 +memorycgroup.subtree_control 后,子目录才会获得 memory 控制器接口。

2. cgroups v2 的层级模型

cgroup 层级是目录树。目录本身表示一个 cgroup,目录中的特殊文件表示控制器接口和状态。

例如:

/sys/fs/cgroup/
├── cgroup.controllers
├── cgroup.procs
├── system.slice/
│   ├── cgroup.procs
│   └── app.service/
│       ├── memory.max
│       └── cpu.max
└── demo/
    └── worker/

进程通过写入 cgroup.procs 加入 cgroup:

echo "$PID" > /sys/fs/cgroup/demo/cgroup.procs

写入后,进程及其线程组归属于目标 cgroup。一个进程在同一时刻只能属于一个 cgroup,但不同控制器在 v2 中不再分别挂载到不同层级。

2.1 进程归属和线程归属

cgroup.procs 以进程组为单位移动进程。对多线程程序而言,写入一个线程组中的 PID,通常会移动整个线程组。

cgroup.threads 则允许在线程级别移动线程,但这需要满足 cgroup 的线程模式规则,普通服务管理一般不应随意使用它。

查看当前 cgroup:

cat /proc/self/cgroup

在 cgroups v2 中常见结果为:

0::/user.slice/user-1000.slice/session-2.scope

格式是:

hierarchy-ID::cgroup-path

v2 的 hierarchy ID 通常为 0,因为所有控制器位于统一层级。

2.2 控制器的层级传播

假设根 cgroup 中存在 cpumemory 控制器:

cat /sys/fs/cgroup/cgroup.controllers
# cpu memory ...

先创建子 cgroup:

mkdir /sys/fs/cgroup/demo

此时 /sys/fs/cgroup/demo 未必有 cpu.maxmemory.max。父 cgroup 需要显式开放控制器:

echo "+cpu +memory" > /sys/fs/cgroup/cgroup.subtree_control

然后查看:

ls /sys/fs/cgroup/demo/cpu.max
ls /sys/fs/cgroup/demo/memory.max

控制器是由父级决定是否可用于直接子级的。子级也可以继续向孙级传播:

mkdir /sys/fs/cgroup/demo/worker
echo "+cpu +memory" > /sys/fs/cgroup/demo/cgroup.subtree_control

这形成了一个重要的因果关系:

父级 cgroup.subtree_control
        │
        ▼
子级获得控制器接口
        │
        ▼
子级可配置 cpu.max、memory.max 等

因此,写入 demo/cpu.max 失败,并不一定表示内核不支持 CPU 控制器,也可能只是 demo 的父级没有开启 +cpu


3. 一个可验证的 cgroups v2 实验环境

下面的实验直接操作 cgroupfs,适合临时测试。生产环境中,如果系统由 systemd 管理,应该优先使用 systemd unit、scope 或 transient unit,而不是绕过 systemd 直接修改其目录。

假设当前 shell 具有 root 权限,并且系统确实使用 cgroups v2:

test "$(stat -fc %T /sys/fs/cgroup)" = cgroup2fs

创建测试层级:

CG=/sys/fs/cgroup/wr-demo

mkdir "$CG"
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control

这里有两个风险:

  1. 根 cgroup 可能已经由 systemd 管理,直接改动可能与 systemd 的资源配置产生竞争;
  2. 某些控制器可能受内核配置、启动参数或虚拟化环境限制,写入不一定成功。

查看结果:

cat "$CG/cpu.max"
cat "$CG/memory.max"
cat "$CG/pids.max"

典型默认值可能是:

max 100000
max
max

max 表示不设置该项的有限上限;第二列 100000 是 CPU quota 的默认周期,单位为微秒。

清理前先确保没有进程仍在测试 cgroup 中:

rmdir "$CG"

如果目录非空,或者仍有子 cgroup、进程或内核对象关联,删除可能失败。不要在不了解进程归属的情况下直接删除生产 cgroup。


4. CPU:权重、配额和实际调度

CPU 控制器主要提供三类语义:

  • cpu.weight:竞争时的相对权重;
  • cpu.max:一个周期内允许使用的 CPU 时间上限;
  • cpu.stat:CPU 使用和限流统计。

4.1 cpu.weight:相对分配,不是百分比

查看和设置:

cat /sys/fs/cgroup/wr-demo/cpu.weight
echo 200 > /sys/fs/cgroup/wr-demo/cpu.weight

v2 中 cpu.weight 的常见有效范围是 110000,默认值通常为 100。它不是百分比,也不是“允许使用 200% CPU”。

假设同一个父 cgroup 下有两个可运行子 cgroup:

A.cpu.weight = 100
B.cpu.weight = 300

如果两者持续竞争同一个 CPU 资源,并且都没有 quota 限制,那么理想化分配比例为:

A=100100+300=25%A = \frac{100}{100+300}=25\%

B=300100+300=75%B = \frac{300}{100+300}=75\%

这只是竞争时的相对比例。如果机器有 8 个逻辑 CPU,而且只有 A 在运行,A 仍可能使用多个 CPU;如果 CPU 没有竞争,权重不会主动限制它。

因此,下列理解是错误的:

cpu.weight = 200  => 只能使用 2 个 CPU
cpu.weight = 50   => 只能使用 50% CPU

正确理解是:它影响同一资源层级中可运行任务之间的调度份额。

权重具有层级性。例如:

root
├── batch  weight=100
│   ├── A weight=100
│   └── B weight=300
└── service weight=300

batchservice 同时竞争,第一层先按 100:300 分配;进入 batch 后,A 和 B 再按 100:300 分配。资源分配不是把所有叶子节点的权重简单相加。

4.2 cpu.max:硬配额

cpu.max 的格式是:

quota period

单位是微秒。设置:

echo "50000 100000" > /sys/fs/cgroup/wr-demo/cpu.max

含义是:

  • 每个 100000 微秒,也就是 100 毫秒的周期;
  • 最多使用 50000 微秒 CPU 时间;
  • 在单核等效意义上相当于最多 50% 的一个 CPU;
  • 配额是 cgroup 聚合值,而不是每个进程各自 50%。

如果 cgroup 中有 4 个线程,它们共享这 50000 微秒。四个线程同时运行时,可能很快耗尽配额,之后被限流到下一个周期。

取消 quota:

echo "max 100000" > /sys/fs/cgroup/wr-demo/cpu.max

如果 quota 大于 period,例如:

200000 100000

表示最多使用两个 CPU 的时间总量。它不保证一定能同时获得两个 CPU,因为实际并行度还受 CPU 数量、亲和性、调度和父级限制影响。

CPU 限流的过程是:

  1. cgroup 中的任务运行;
  2. 聚合使用时间达到当前周期的 quota;
  3. 内核将该 cgroup 标记为 throttled;
  4. 任务暂时不能继续获得 CPU;
  5. 周期结束后恢复可运行资格。

查看统计:

cat /sys/fs/cgroup/wr-demo/cpu.stat

可能得到:

usage_usec 812345
user_usec 700000
system_usec 112345
nr_periods 120
nr_throttled 45
throttled_usec 2300000

其中:

  • usage_usec:总 CPU 使用时间;
  • user_usec:用户态时间;
  • system_usec:内核态时间;
  • nr_periods:经历的 quota 周期数;
  • nr_throttled:发生限流的周期数;
  • throttled_usec:累计被限流的时间。

nr_throttled 大不一定说明服务有问题;需要结合延迟、吞吐和 throttled_usec 判断。一个批处理任务被限流可能只是预期行为,而一个低延迟服务频繁被限流则可能导致尾延迟上升。

部分较新的内核还会提供 cpu.max.burst 等 burst 相关接口,但其可用性和语义依赖内核版本。部署脚本不应假定该文件永远存在,应先检查:

test -e /sys/fs/cgroup/wr-demo/cpu.max.burst

4.3 CPU 亲和性与 cgroup quota 不是一回事

CPU affinity 决定任务可以在哪些 CPU 上运行;cpu.max 决定 cgroup 总共能获得多少 CPU 时间。

例如:

  • 允许任务运行在 CPU 0、1 上;
  • cpu.max=50000 100000

这并不等于“固定占用 CPU 0 的一半和 CPU 1 的一半”。它只是限制整个 cgroup 每个周期获得的总 CPU 时间。

反过来,仅设置 cpu.max=200000 100000 也不等价于绑定两个固定 CPU。需要固定 CPU 时,应结合 cpuset 控制器和 cpuset.cpus,但 cpuset 受 NUMA、热插拔和父级 CPU 集合约束。


5. Memory:使用量、回收压力和 OOM

memory 控制器比“设置一个内存上限”复杂,因为 Linux 内存既包括匿名页,也包括文件页、内核内存、socket 等不同类型。

常见接口:

memory.current
memory.stat
memory.events
memory.min
memory.low
memory.high
memory.max
memory.oom.group
memory.swap.max

5.1 memory.currentmemory.stat

cat /sys/fs/cgroup/wr-demo/memory.current
cat /sys/fs/cgroup/wr-demo/memory.stat

memory.current 给出当前 cgroup 的内存使用量,单位为字节。memory.stat 会拆分匿名内存、文件缓存、内核内存等类别。

不要把进程 RSS 简单相加后当成 cgroup 内存使用量。原因包括:

  • 多个进程可能共享同一页;
  • 文件页由 cgroup 记账,但其回收特性不同;
  • 内核内存可能被纳入 cgroup 记账;
  • 进程 RSS 与 cgroup 统计的边界并不完全相同。

因此,诊断容器内存时,应优先观察 memory.currentmemory.statmemory.events,再结合进程级指标。

5.2 memory.minmemory.low:保护,不是额度

memory.min 表示硬保护。父级发生内存回收时,低于该保护范围的内存通常不会被回收;如果多个子 cgroup 的 memory.min 总和超过父级可用内存,内核会按比例分配保护,而不是凭空创造内存。

memory.low 是最佳努力保护。内存紧张时,内核会尽量避免回收低于该值的内存,但在更严重的压力下仍可能回收。

例如:

echo 512M > /sys/fs/cgroup/wr-demo/memory.min
echo 1G   > /sys/fs/cgroup/wr-demo/memory.low

这不表示 cgroup 必定拥有 1.5 GiB 内存,也不表示它可以使用 1 GiB。它表达的是回收优先级:

memory.min:更强的不可回收保护
memory.low:较弱的回收保护

若所有服务都设置过高的 memory.min,保护总量超过物理内存,系统可能把压力转移到未保护的 cgroup,甚至更快触发 OOM。

5.3 memory.high:回收和限速阈值

echo 1G > /sys/fs/cgroup/wr-demo/memory.high

当使用量超过 memory.high 时,内核会对该 cgroup 施加内存回收压力,必要时让相关任务在内存回收路径上花费更多时间。它不是立即杀进程的硬上限,但可能显著增加延迟。

这使得 memory.high 适合渐进式退化:

  1. 正常使用低于 high;
  2. 超过 high 后,任务受到回收压力;
  3. 应用可能变慢、分配阻塞;
  4. 如果仍继续增长,最终可能触及 memory.max

memory.high 不是应用级限流器。它不能保证应用会平滑释放内存,也不能代替应用自身的缓存上限。

5.4 memory.max:硬上限和 OOM 路径

echo 2G > /sys/fs/cgroup/wr-demo/memory.max

当 cgroup 内存使用接近或超过 memory.max 时,内核会尝试回收。无法回收且分配仍然需要内存时,会在该 cgroup 内触发 cgroup OOM。

查看事件:

cat /sys/fs/cgroup/wr-demo/memory.events

可能输出:

low 0
high 12
max 3
oom 1
oom_kill 1
oom_group_kill 0

这些是累计计数:

  • low:触及 low 保护相关事件;
  • high:触及 high 的事件;
  • max:分配受 memory.max 影响的次数;
  • oom:发生 OOM 条件的次数;
  • oom_kill:因 cgroup OOM 杀死进程的次数;
  • oom_group_kill:以组为单位杀死的次数。

设置:

echo 1 > /sys/fs/cgroup/wr-demo/memory.oom.group

表示发生 cgroup OOM 时,尽量把整个 cgroup 作为一个不可分割的任务组处理,而不是只杀死一个进程。它适合进程之间强耦合的服务,但会扩大故障影响面;一个辅助进程触发 OOM,也可能导致整个服务组退出。

取消内存上限:

echo max > /sys/fs/cgroup/wr-demo/memory.max

这里的 OOM 与全局 OOM 不完全相同。cgroup OOM 首先约束该 cgroup 的故障范围;如果系统整体也缺内存,仍可能出现全局回收和全局 OOM。

5.5 swap 是独立控制项

现代 cgroups v2 通常使用:

cat /sys/fs/cgroup/wr-demo/memory.swap.max

设置:

echo 0 > /sys/fs/cgroup/wr-demo/memory.swap.max

表示该 cgroup 不允许新增 swap 使用,但不一定自动把已经存在的 swap 使用量立刻换回内存。

如果设置:

echo 1G > /sys/fs/cgroup/wr-demo/memory.swap.max

它限制的是 swap 使用量,而不是“内存加 swap 的总和”。总资源边界需要同时考虑:

memory.max + memory.swap.max

实际可用接口还受系统是否启用 swap、内核版本和发行版配置影响。不要仅凭容器运行时的“内存限制”名称推断 swap 行为,应直接检查对应 cgroup 文件。


6. IO:设备、权重、带宽和延迟

cgroups v2 的 IO 控制器针对块设备资源。常见接口:

io.stat
io.weight
io.max
io.latency

首先确定设备的 major:minor:

lsblk -o NAME,MAJ:MIN,TYPE,MOUNTPOINTS

假设目标设备是 8:0,但这个值只是示例,不能直接复制到所有机器。

6.1 io.stat:实际记账

cat /sys/fs/cgroup/wr-demo/io.stat

典型格式类似:

8:0 rbytes=1048576 wbytes=2097152 rios=128 wios=256 dbytes=0 dios=0

这些字段表示该设备上的读写字节数和 I/O 操作数。不同内核版本还可能显示更多字段。

IO 统计并不等价于“应用调用了多少次 read(2)”:

  • buffered I/O 可能先命中 page cache;
  • 写入可能异步下刷;
  • 多个进程的 I/O 可能合并;
  • 文件系统、块层和设备队列会改变最终请求形态。

因此,应用层吞吐、page cache 行为和 io.stat 需要结合分析。

6.2 io.weight:竞争时的相对权重

设置:

echo "8:0 200" > /sys/fs/cgroup/wr-demo/io.weight

常见有效范围为 110000,默认通常为 100。它与 CPU weight 类似,是竞争时的相对权重,不是固定 MB/s。

如果两个 cgroup 在同一块设备上同时产生可调度的 I/O:

A.io.weight = 100
B.io.weight = 300

理想化情况下,设备调度资源大致按 1:3 分配。但实际结果还受到:

  • I/O 调度器;
  • 请求大小;
  • 顺序或随机访问;
  • 文件系统;
  • 设备自身队列;
  • 读写混合;
  • SSD、NVMe 或网络块设备实现;

等因素影响。

6.3 io.max:按设备设置硬限制

格式通常为:

major:minor rbps=... wbps=... riops=... wiops=...

例如:

echo "8:0 rbps=10485760 wbps=5242880" \
  > /sys/fs/cgroup/wr-demo/io.max

含义是:

  • 对设备 8:0 限制读取速率为 10 MiB/s;
  • 写入速率为 5 MiB/s;
  • 未指定的 riopswiops 不改变原有设置。

也可以限制 IOPS:

echo "8:0 riops=100 wiops=50" > /sys/fs/cgroup/wr-demo/io.max

这里的风险是设备标识和挂载路径不是同一个概念。一个路径可能位于:

  • LVM logical volume;
  • device mapper;
  • md RAID;
  • overlayfs;
  • 网络存储;
  • 虚拟块设备。

限制上层设备不一定得到你期望的下层物理设备效果。应先确认应用 I/O 实际经过哪个块设备,并验证限制是否反映在应用延迟和 io.stat 中。

取消某一项限制:

echo "8:0 rbps=max wbps=max" > /sys/fs/cgroup/wr-demo/io.max

完整清除某设备设置的方式依赖当前文件内容和内核实现,生产脚本应先读取现值再修改,不要假设所有字段都可以省略。

6.4 io.latency 的边界

io.latency 用于表达目标延迟,例如:

echo "8:0 target=50000" > /sys/fs/cgroup/wr-demo/io.latency

50000 通常表示 50000 微秒,即 50 毫秒。

它不是“保证每次 I/O 都不超过 50 ms”的硬实时承诺,而是让内核在支持的块层和调度条件下,通过影响其他 cgroup 的调度来尽量保护目标 cgroup 的延迟。

因此:

  • 底层设备已经饱和时,目标可能无法实现;
  • 某些 I/O 调度器或设备路径可能不支持预期语义;
  • 文件系统缓存和异步写回会使应用感知延迟与块设备完成延迟不同;
  • 目标延迟设置过低可能把压力转移给其他 cgroup。

io.latency 属于依赖内核和设备调度能力的接口,使用前必须检查文件是否存在并做实际验证。


7. PID:限制进程数量,而不是限制 PID 数值

PID 控制器的主要接口:

pids.current
pids.max
pids.events

设置:

echo 100 > /sys/fs/cgroup/wr-demo/pids.max

表示该 cgroup 及其受控层级内最多允许创建 100 个任务。这里的“任务”在内核层面包含线程,因此一个创建大量线程的程序也可能耗尽 PID 配额。

查看:

cat /sys/fs/cgroup/wr-demo/pids.current
cat /sys/fs/cgroup/wr-demo/pids.events

可能输出:

max 2

pids.events 中的 max 表示因为达到 pids.max,创建任务失败的累计次数。

当进程调用 fork()clone() 或创建线程时,内核会检查目标 cgroup 的 PID 配额。超过限制后,创建操作通常失败并返回 EAGAIN。这与内存 OOM 不同:

  • PID 限制通常不会杀死已有进程;
  • 它使后续创建失败;
  • 应用可能把 EAGAIN 错误误报成“系统 fork 失败”;
  • 线程池、子进程管理器和日志组件都可能受影响。

测试代码或命令可以观察这种故障,但不应在生产服务上随意设置过低值。设置为无限制:

echo max > /sys/fs/cgroup/wr-demo/pids.max

PID namespace 与 pids.max 也不同:

  • PID namespace 改变进程看到的 PID 层级;
  • pids.max 限制内核允许创建多少任务;
  • 容器可以拥有独立 PID namespace,但仍受宿主机 cgroup 的 PID 限制。

8. Pressure:资源压力不是资源使用率

PSI(Pressure Stall Information)描述的是任务因为资源不足而无法前进的时间比例。它回答的问题不是:

CPU 使用了多少?

而是:

有多少任务、在多长时间内因为 CPU 不够而无法运行?

cgroups v2 中常见文件:

cpu.pressure
memory.pressure
io.pressure

读取:

cat /sys/fs/cgroup/wr-demo/cpu.pressure
cat /sys/fs/cgroup/wr-demo/memory.pressure
cat /sys/fs/cgroup/wr-demo/io.pressure

典型输出:

some avg10=12.34 avg60=5.67 avg300=1.23 total=987654
full avg10=1.20 avg60=0.50 avg300=0.10 total=12345

8.1 somefull

some 表示在某段时间内,至少有一个相关任务处于资源 stall 状态。

full 表示在某段时间内,相关任务全部处于资源 stall 状态。对于 CPU,full 的含义尤其需要谨慎:它不是“CPU 利用率为 100%”,而是 cgroup 中没有任务能够有效推进,所有非空闲任务都在等待 CPU 或其他条件。

可以用集合表示:

  • TT:cgroup 中当前有工作的任务集合;
  • S(t)S(t):时刻 tt 处于该资源 stall 的任务集合。

则:

some(t)={1,S(t)>00,S(t)=0some(t)= \begin{cases} 1, & |S(t)|>0 \\ 0, & |S(t)|=0 \end{cases}

full(t)={1,S(t)=TT>00,其他情况full(t)= \begin{cases} 1, & |S(t)|=|T| \land |T|>0 \\ 0, & \text{其他情况} \end{cases}

avg10avg60avg300 是指数衰减的时间窗口平均值,表示近似的 stall 时间占比,单位为百分比。它们不是简单的最近 10、60、300 秒算术平均。

total 是累计 stall 时间,单位为微秒。读取两次后可计算区间压力:

pressuretotal2total1106×(t2t1)×100%pressure \approx \frac{total_2-total_1}{10^6 \times (t_2-t_1)} \times 100\%

其中:

  • total_2-total_1 是累计 stall 微秒增量;
  • t2t1t_2-t_1 是两次采样间隔,单位为秒;
  • 结果是该区间内的累计 stall 时间占墙上时间的比例。

但并发任务可能同时 stall,因此 PSI 的累计时间不应直接解释为“机器只剩下多少 CPU”。它是任务等待压力的聚合指标。

8.2 CPU PSI 与 CPU 使用率的反例

考虑两个 CPU 密集型任务运行在一台 2 CPU 机器上:

  • 两个任务都持续运行;
  • 两个 CPU 都被占满;
  • 没有任务因为 CPU 调度而长时间等待。

此时 CPU 使用率接近 100%,但 CPU PSI 可能很低。

再考虑一台 2 CPU 机器上有 20 个可运行任务:

  • CPU 使用率仍然接近 100%;
  • 大量任务在运行队列等待;
  • CPU some 会升高;
  • 如果所有任务在某些时刻都处于等待状态,full 也可能升高。

所以“CPU 使用率高”与“CPU 压力高”不是同义词。

8.3 Memory PSI 和 IO PSI

memory PSI 反映任务因内存回收、缺页处理或内存不足路径而无法推进的时间。它可能在内存还没有达到 memory.max 时就升高。

io PSI 反映任务等待 I/O 完成的时间。设备吞吐率并不直接决定 IO PSI:

  • 大量顺序读可能吞吐很高但等待压力不大;
  • 少量随机同步 I/O 可能吞吐很低但延迟很高,IO PSI 很高。

PSI 适合与控制器状态联合判断:

cpu.pressure + cpu.stat 的 throttled_usec
memory.pressure + memory.events 的 high/max/oom
io.pressure + io.stat 的 rbytes/wbytes/ios

例如:

  • CPU PSI 高、nr_throttledthrottled_usec 同时增长:可能是 quota 过紧;
  • memory PSI 高、memory.eventshigh 增长:可能是 high 触发了回收压力;
  • IO PSI 高、io.stat 速率不高:可能是设备延迟、队列或同步 I/O 问题,而不只是带宽不足。

9. 完整示例:创建受限 worker 并验证故障路径

下面创建一个同时具备 CPU、内存和 PID 限制的 cgroup。假设控制器已经挂载且当前 shell 具备 root 权限。

CG=/sys/fs/cgroup/wr-demo
mkdir -p "$CG"
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control

设置参数:

# 每 100 ms 最多使用 50 ms CPU 时间
echo "50000 100000" > "$CG/cpu.max"

# 内存硬上限 256 MiB
echo 256M > "$CG/memory.max"

# swap 上限 0;需要系统支持该接口
if [ -e "$CG/memory.swap.max" ]; then
    echo 0 > "$CG/memory.swap.max"
fi

# 最多 64 个任务/线程
echo 64 > "$CG/pids.max"

# OOM 时尽量整体处理
echo 1 > "$CG/memory.oom.group"

启动一个进程并移动到 cgroup:

sleep 1000 &
PID=$!
echo "$PID" > "$CG/cgroup.procs"
cat "$CG/cgroup.procs"

为什么先启动再移动?因为示例需要一个已存在的进程作为测试对象。生产服务更适合:

  1. 先创建并配置 cgroup;
  2. 再启动服务,使其从一开始就在目标 cgroup 中;
  3. 或通过 systemd 直接创建 scope。

读取配置:

cat "$CG/cpu.max"
cat "$CG/memory.max"
cat "$CG/pids.max"
cat "$CG/memory.events"

如果写入 cgroup.procs 失败,常见原因包括:

  • 当前用户没有权限;
  • PID 已经退出;
  • 目标 cgroup 不存在;
  • 进程属于不允许跨越的线程模式;
  • systemd 或其他管理器正在重排进程归属。

测试结束:

kill "$PID"
wait "$PID" 2>/dev/null || true
rmdir "$CG"

删除失败时,检查:

cat "$CG/cgroup.procs"
find "$CG" -mindepth 1 -maxdepth 1 -type d -print

10. cgroup 的状态、迁移和故障路径

cgroup 不是静态标签,而是内核持续维护的状态对象。常见状态文件包括:

cgroup.procs
cgroup.threads
cgroup.events
cgroup.stat

cgroup.events 可能包含:

populated 1
frozen 0

populated=1 表示该 cgroup 或其后代仍有任务。只有在没有子 cgroup、没有任务且没有其他引用时,删除才更可能成功。

进程迁移的基本步骤是:

  1. 写入目标 cgroup 的 cgroup.procs
  2. 内核检查权限和层级约束;
  3. 更新进程的 cgroup 归属;
  4. 后续资源记账和控制作用于新层级;
  5. 已经产生的统计通常不会自动“倒流”到新 cgroup。

这意味着迁移不是时间旅行。进程过去已经消耗的 CPU、内存和 I/O 不会因为移动而从旧统计中消失。

移动一个正在执行的服务还有竞态风险:

  • 进程可能在移动前已经创建子进程;
  • 服务管理器可能立即把进程移回原 cgroup;
  • 新线程可能在不同时间加入;
  • 资源限制可能在业务高峰中突然生效。

生产切换通常应通过服务管理器或容器运行时完成,以便定义清晰的生命周期。


11. 委派:让非 root 管理子 cgroup

委派(delegation) 是把某个 cgroup 子树的管理权交给另一个用户或服务,使其可以在该子树内创建 cgroup、移动自己的进程并设置被允许的控制参数。

委派不是简单地执行:

chown -R alice:alice /sys/fs/cgroup/project

这样做可能暴露超出预期的能力,甚至破坏宿主机资源管理。

11.1 委派需要同时考虑三个边界

文件系统权限边界

cgroupfs 中的目录和特殊文件遵循权限检查。被委派用户至少需要能够:

  • 在委派目录下创建和删除子 cgroup;
  • 将自己启动的进程写入目标 cgroup.procs
  • 设置允许委派的控制文件。

通常不应把父级的 cgroup.procs 写权限交给不可信用户,因为移动进程的能力本身可能改变其他服务的资源归属。

控制器边界

控制器需要由父级通过 cgroup.subtree_control 开放。委派者只应获得明确允许的控制器,例如只允许 pidsmemory,不一定允许 cpuiocpuset

控制器配置必须满足层级约束:

父级已开放控制器
        ↓
子级获得控制接口
        ↓
委派者在子树内配置限制

如果父级未开放控制器,委派者不能通过修改子目录文件“凭空启用”它。

进程归属边界

cgroup v2 的设计要求避免一个 cgroup 一边直接承载业务进程,一边把域控制器资源分配给子 cgroup。这个规则通常称为 no internal process constraint

直观地说:

错误或受限结构:

parent
├── 自己直接运行的进程
└── child 使用 cpu、memory 等域控制器

因为父级自己的进程会和 child 争夺同一层级资源,资源分配边界不清晰。更合理的结构是:

parent
├── service-a
├── service-b
└── delegated-subtree
    ├── worker-a
    └── worker-b

父级只做资源分配,实际业务进程放入专门的子 cgroup。某些线程模式可以改变规则,但线程 cgroup 的创建和迁移约束更复杂,不应把它当作普通进程 cgroup 的替代品。

11.2 委派不等于获得 root 权限

cgroup 委派用户通常可以:

  • 在被委派的子树中创建 cgroup;
  • 管理自己启动的进程;
  • 修改被开放的控制参数。

但这不意味着该用户可以:

  • 修改父级或兄弟 cgroup;
  • 移动任意系统进程;
  • 改变整个系统的 CPU、内存或 I/O 上限;
  • 绕过 namespace、LSM 或 capability 检查。

还要注意,cgroup namespace 只改变进程看到的 cgroup 路径视图,不自动授予 cgroupfs 写权限,也不自动提供资源管理能力。权限仍由文件系统权限、用户命名空间、capability 和内核检查共同决定。

11.3 一个委派的安全思路

可采用如下结构:

/sys/fs/cgroup/
└── delegated/
    └── alice/
        ├── cgroup.procs
        ├── cgroup.subtree_control
        ├── memory.max
        ├── pids.max
        └── jobs/

管理员负责:

  1. 创建 alice 子树;
  2. 只向其子树开放指定控制器;
  3. 设置父级硬上限;
  4. 以最小权限调整目录和必要文件;
  5. 不授予修改宿主级控制器和其他服务 cgroup 的能力。

委派者负责:

  1. 在自己的子树中创建任务 cgroup;
  2. 先设置限制,再启动或移动自己的进程;
  3. 监控 memory.eventspids.events 和 PSI;
  4. 不依赖删除 cgroup 来终止进程;
  5. 遇到权限或层级错误时检查父级传播状态,而不是反复重试写入。

生产系统通常更适合让 systemd、容器运行时或专门的资源管理器完成委派,因为它们能够同时处理服务生命周期、权限、日志和回收。


12. 与 systemd 和容器运行时的关系

在 systemd 系统中,常见 cgroup 层级包括:

system.slice
user.slice
user-UID.slice
session-*.scope

一个 systemd service 通常对应一个 cgroup。使用 systemd 创建临时 scope:

systemd-run --scope \
  -p CPUQuota=50% \
  -p MemoryMax=256M \
  -p TasksMax=64 \
  sleep 1000

这些参数最终会映射到 cgroups v2 的相关接口,但 systemd 可能同时设置其他属性。实际生效值应检查 unit 的 cgroup:

systemctl status <unit>
systemctl show <unit> -p ControlGroup

容器运行时也通常创建自己的 cgroup 层级。直接修改容器 cgroup 可能被运行时覆盖,或者与编排系统期望的资源状态不一致。尤其在 Kubernetes 等环境中,CPU request/limit、memory limit、QoS 类别和节点级 cgroup 层级之间存在多层映射,不能仅看容器内部一个 cpu.max 就推断整个调度结果。


13. 诊断:把控制参数和压力证据连起来

只看一个指标很容易误判。更可靠的诊断顺序是:

CPU

cat cpu.max
cat cpu.weight
cat cpu.stat
cat cpu.pressure

推导:

  • cpu.max 受限且 nr_throttledthrottled_usec 增长:存在 quota 限流;
  • cpu.max 未受限但 PSI 高:更可能是兄弟 cgroup 竞争、CPU 数不足或调度延迟;
  • 权重低但无竞争:权重不会导致任务主动变慢;
  • quota 很高但 cpuset 只有一个 CPU:实际并行度仍可能只有一个 CPU。

Memory

cat memory.current
cat memory.max
cat memory.high
cat memory.events
cat memory.pressure
cat memory.stat

推导:

  • memory.current 接近 memory.maxmaxoom 增长:硬上限正在影响分配;
  • high 增长而 oom_kill 不增长:可能是回收压力和延迟问题,而非已经杀进程;
  • memory PSI 高但 memory.max 很宽松:可能是宿主机或父 cgroup 内存压力;
  • RSS 下降不明显但 memory.current 变化:需要区分匿名页、文件页和内核记账。

IO

cat io.stat
cat io.max
cat io.weight
cat io.pressure

推导:

  • IO PSI 高而吞吐不高:优先检查设备延迟、队列、同步访问和底层存储;
  • io.max 生效时应用写入变慢,但 page cache 可能暂时掩盖限制;
  • 数据库直接 I/O 与普通 buffered I/O 的观察结果可能不同;
  • 只限制某一设备,而实际数据位于另一设备时,配置不会产生预期效果。

PID

cat pids.current
cat pids.max
cat pids.events

推导:

  • pids.current 接近 pids.max:检查线程数、worker 数和子进程泄漏;
  • pids.eventsmax 增长:创建任务已经失败过;
  • 即使已有进程健康运行,后续 fork 或线程创建也可能持续失败。

14. 常见失败表现及其原因

No such file or directory

常见原因:

  • cgroup v1 而不是 v2;
  • 控制器未由父级开放;
  • 内核或发行版没有提供该可选接口;
  • 拼写错误;
  • 设备的 major:minor 不存在。

先检查:

stat -fc %T /sys/fs/cgroup
cat /sys/fs/cgroup/cgroup.controllers
cat /sys/fs/cgroup/cgroup.subtree_control

Invalid argument

常见原因:

  • cpu.max 的 quota 大于允许范围或格式错误;
  • memory.max 小于当前无法回收的使用量;
  • io.max 的设备号或字段格式错误;
  • cpuset 设置超出父级允许的 CPU 集合;
  • 控制器不支持当前配置。

Device or resource busy

常见原因:

  • 尝试启用控制器时违反层级约束;
  • cgroup 仍有进程或子 cgroup;
  • 正在使用不兼容的 threaded/domain 模式;
  • systemd 或其他管理器持有并发状态。

权限错误

cgroupfs 不是普通配置目录。即使用户能读取 memory.current,也不表示能写入 memory.max。权限还可能受:

  • rootless 用户命名空间;
  • cgroup namespace;
  • systemd 的委派策略;
  • LSM;
  • capability;
  • 容器运行时限制;

影响。

“设置了 memory.max,但应用没有立即退出”

这是正常现象。memory.max 首先触发回收;只有无法继续满足分配时才进入 cgroup OOM。文件缓存可能被回收,应用也可能释放内存,因此设置上限不是定时器式的“到值立即 kill”。

“设置了 io.max,但应用刚开始仍然很快”

普通 buffered 写入可能先进入 page cache,应用的 write() 调用很快返回,真正的块设备写回发生在之后。需要观察一段时间,并结合 io.stat、设备指标和应用提交/落盘语义判断。需要持久化确认的应用还应区分 write()fsync() 和实际设备完成延迟。


15. 生产取舍

cgroups 的参数应根据故障目标选择:

  • 想让低优先级任务在竞争时少获得 CPU:使用 cpu.weight
  • 想限制绝对 CPU 消耗:使用 cpu.max
  • 想在内存紧张时优先保护关键服务:使用 memory.minmemory.low
  • 想让服务逐渐退化而不是立刻 OOM:使用 memory.high
  • 想建立明确的内存故障边界:使用 memory.max
  • 想限制设备吞吐或 IOPS:使用 io.max
  • 想在竞争时调整 I/O 份额:使用 io.weight
  • 想防止 fork/线程爆炸:使用 pids.max
  • 想判断资源是否已经影响业务进展:使用 PSI,而不是只看利用率。

这些控制项可以组合,但组合后因果关系会变复杂。例如:

cpu.max 太低
    → CPU throttling
    → CPU PSI 上升
    → 请求排队和尾延迟上升

或者:

memory.high 太低
    → 频繁回收
    → memory PSI 上升
    → 应用分配和访问延迟增加
    → 可能进一步触发 CPU 消耗

因此,限额不是越小越好。每次修改都应同时记录:

  1. 生效配置;
  2. cpu.statmemory.eventsio.statpids.events
  3. 对应 PSI;
  4. 应用吞吐、错误率和尾延迟;
  5. 回滚方式。

回滚本身也要考虑状态:

echo "max 100000" > /sys/fs/cgroup/wr-demo/cpu.max
echo max > /sys/fs/cgroup/wr-demo/memory.max
echo max > /sys/fs/cgroup/wr-demo/pids.max

解除限制不会撤销已经发生的 OOM、I/O 排队或应用级超时;它只改变后续内核资源控制行为。若进程已经被 OOM 杀死,还需要由服务管理器重新启动。

cgroups v2 的核心不是几个限制文件,而是一套有层级、有记账、有故障路径的资源管理模型:

进程归属
  → 控制器层级
  → 资源分配或限制
  → 内核回收、调度、排队或拒绝
  → 统计事件与 PSI
  → 服务侧诊断和恢复

只有把配置、层级、权限和观测结果放在同一条因果链上,CPU、内存、I/O、PID 限制和资源压力指标才不会被误解为彼此独立的开关。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。