mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4335 字
12 分钟
Cgroup v2 深入
2021-09-07

当你执行 docker run --memory=512m --cpus=2 nginx 时,Docker 是怎么限制容器只能使用 512MB 内存和 2 个 CPU 的?答案是 Cgroup,Linux 内核的**控制组(Control Group)**机制。Cgroup 让你将进程分组,并对每个组施加资源限制、优先级分配和审计统计。

Cgroup 经历了从 v1 到 v2 的重大架构演进。v1 的每个控制器有独立的层级树,进程可能同时属于不同控制器的不同 cgroup,管理混乱;v2 采用统一层级,所有控制器共享同一棵 cgroup 树,一个进程只属于一个 cgroup。

Cgroup 的历史可以追溯到 2006 年,Google 工程师 Paul Menage 和 Rohit Seth 向内核提交了“Process Containers”补丁,2007 年合入主线(随 2.6.24 于 2008 年初发布),后更名为 Control Groups。v1 允许每个控制器(cpu、memory、blkio 等)拥有独立层级树,进程可以同时属于不同控制器的不同 cgroup,这带来了管理混乱:进程在 cpu cgroup 属于 /A,在 memory cgroup 却属于 /B,资源归属难以追踪。2016 年 Tejun Heo 主导 v2 重构,采用统一层级,一个进程只属于一个 cgroup。这一“看似更不灵活”的设计反而解决了 v1 的核心痛点。

前置知识#

Important
  • Ch02 Linux Namespace 深入:Namespace 提供”视图隔离”,Cgroup 提供”资源限制”

  • Linux 文件系统操作:Cgroup v2 通过 /sys/fs/cgroup/ 文件系统暴露接口,所有操作都是文件读写

  • Linux 进程管理基础:进程树、信号、资源统计

Note

Cgroup 和 Namespace 经常被混淆:Namespace 决定进程”能看到什么”,Cgroup 决定进程”能用多少”。

下面看 Cgroup v2 的架构设计、CPU/内存/IO 三大控制器,以及容器运行时的使用方式。

一、Cgroup v2 架构#

1.1 从 Cgroup v1 到 v2#

graph TB subgraph CgroupV1["Cgroup v1:多层级"] V1CPU["cpu 控制器<br/>/sys/fs/cgroup/cpu/"] V1MEM["memory 控制器<br/>/sys/fs/cgroup/memory/"] V1BLK["blkio 控制器<br/>/sys/fs/cgroup/blkio/"] V1PID["pids 控制器<br/>/sys/fs/cgroup/pids/"] end subgraph CgroupV2["Cgroup v2:统一层级"] V2ROOT["/sys/fs/cgroup/<br/>统一 cgroup 树"] V2CPU["cpu.max + cpu.weight"] V2MEM["memory.max + memory.min"] V2IO["io.max + io.weight"] V2PID["pids.max"] end V1CPU -.->|"问题:进程可属于<br/>不同层级的不同 cgroup"| V2ROOT V1MEM -.-> V2ROOT V1BLK -.-> V2ROOT V1PID -.-> V2ROOT V2ROOT --> V2CPU V2ROOT --> V2MEM V2ROOT --> V2IO V2ROOT --> V2PID style CgroupV1 fill:#ffcdd2,stroke:#c62828 style CgroupV2 fill:#c8e6c9,stroke:#2e7d32

1.2 Cgroup v1 vs v2 对比#

维度Cgroup v1Cgroup v2
层级结构每个控制器独立层级统一层级
进程归属可属于不同控制器的不同 cgroup只能属于一个 cgroup
控制器挂载各自挂载到 /sys/fs/cgroup/控制器名统一挂载到 /sys/fs/cgroup/
内存控制器memory + memswmemory(含 swap)
IO 控制器blkioio(基于 cgroup 写回)
压力通知per-cgroup PSI(系统级 PSI 4.20 起独立存在)
eBPF 扩展有限完整支持
内核版本2.6.24+4.5+(推荐 5.4+)

1.3 Cgroup v2 的主要文件#

# 查看 Cgroup v2 的根目录
ls /sys/fs/cgroup/
# 核心文件
cgroup.controllers # 当前 cgroup 可用的控制器
cgroup.subtree_control # 子 cgroup 启用的控制器
cgroup.procs # 属于当前 cgroup 的进程 PID
cgroup.type # cgroup 类型(domain/threaded)
cgroup.max.depth # 最大嵌套深度
cgroup.max.descendants # 最大后代数量
cgroup.stat # 统计信息
# PSI(Pressure Stall Information)
cpu.pressure # CPU 压力
memory.pressure # 内存压力
io.pressure # IO 压力

二、CPU 控制器#

2.1 cpu.max:硬限制#

cpu.max 设置 CPU 时间的硬限制,格式为 quota period(微秒):

# 限制容器最多使用 2 个 CPU 核心
# quota = 200000μs, period = 100000μs
echo "200000 100000" > /sys/fs/cgroup/docker/container1/cpu.max
# 查看当前设置
cat /sys/fs/cgroup/docker/container1/cpu.max
# 200000 100000
# 不限制(默认)
echo "max 100000" > /sys/fs/cgroup/docker/container1/cpu.max

CPU 配额计算公式:

可用 CPU 核数 = quota / period
例如:200000 / 100000 = 2 核

2.2 cpu.weight:软限制(权重)#

cpu.weight 设置 CPU 时间的权重分配(1-10000,默认 100):

# 设置权重为 200(相对于默认 100,获得 2 倍 CPU 时间)
echo "200" > /sys/fs/cgroup/docker/container1/cpu.weight
# 两个容器的 CPU 时间分配比例
# container1 (weight=200) : container2 (weight=100) = 2:1

2.3 cpu.max vs cpu.weight#

特性cpu.maxcpu.weight
类型硬限制软限制(权重)
超限行为进程被限流(throttled)按权重分配空闲 CPU
适用场景严格限制 CPU 使用相对优先级分配
Docker 参数--cpus=2--cpu-shares=2048
空闲 CPU不可使用空闲 CPU可以使用空闲 CPU

2.4 CPU 限流机制#

sequenceDiagram participant App as 容器进程 participant CFS as CFS 调度器 participant Cgroup as Cgroup CPU 控制器 Note over App,Cgroup: 一个调度周期(period = 100ms) App->>CFS: 请求 CPU 时间 CFS->>Cgroup: 检查剩余 quota Cgroup-->>CFS: quota 剩余 50ms App->>CFS: 继续执行 CFS->>Cgroup: 检查剩余 quota Cgroup-->>CFS: quota 用完! Note over App: 进程被限流(throttled)<br/>等待下一个周期 Note over App,Cgroup: 下一个周期开始 Cgroup->>CFS: quota 重置为 200ms App->>CFS: 恢复执行

2.5 cpuset:CPU 亲和性#

# 限制容器只能使用 CPU 0 和 CPU 2
echo "0,2" > /sys/fs/cgroup/docker/container1/cpuset.cpus
# 限制 NUMA 节点
echo "0" > /sys/fs/cgroup/docker/container1/cpuset.mems
# Docker 等价参数
docker run --cpuset-cpus=0,2 nginx

三、内存控制器#

3.1 memory.max:内存硬限制#

# 限制容器最多使用 512MB 内存
echo "536870912" > /sys/fs/cgroup/docker/container1/memory.max # 512 * 1024 * 1024
# 查看当前内存使用
cat /sys/fs/cgroup/docker/container1/memory.current
# 134217728 (128MB)
# 查看内存限制
cat /sys/fs/cgroup/docker/container1/memory.max
# 536870912
# 不限制
echo "max" > /sys/fs/cgroup/docker/container1/memory.max

3.2 memory.min / memory.low:内存保护#

Cgroup v2 引入了内存保护机制,防止重要容器的内存被 OOM 回收:

文件含义OOM 行为
memory.min最小内存保证(硬保护)低于此值绝不回收
memory.low最佳内存保证(软保护)低于此值尽量不回收
memory.max最大内存限制超过此值触发 OOM
# 设置内存保护
echo "134217728" > memory.min # 保证至少 128MB
echo "268435456" > memory.low # 尽量保留 256MB
echo "536870912" > memory.max # 最多使用 512MB

3.3 Swap 控制#

# Cgroup v2 的 swap 控制
# memory.swap.max = 最大 swap 使用量
echo "268435456" > memory.swap.max # 最多 256MB swap
# 查看当前 swap 使用
cat memory.swap.current
# 禁用 swap(Docker 默认)
echo "0" > memory.swap.max
# Docker 等价参数
docker run --memory=512m --memory-swap=1g nginx

3.4 OOM 控制与处理#

# OOM 控制组
echo "1" > memory.oom.group # 整个 cgroup 作为 OOM 受害者
# OOM 事件通知(通过 cgroup.events)
cat memory.events
# oom 5 # OOM 事件计数
# oom_kill 3 # OOM kill 计数
# oom_group_kill 2 # 组 OOM kill 计数
# 内存压力统计
cat memory.stat
# anon 134217728 # 匿名页
# file 67108864 # 文件缓存页
# slab 33554432 # Slab 缓存
# pgfault 12345 # 页错误
# pgmajfault 67 # 主要页错误

3.5 内存控制流程#

flowchart TB ALLOC["进程申请内存"] --> CHECK_MAX{"memory.current<br/>&gt; memory.max?"} CHECK_MAX -->|否| ALLOC_OK["分配成功"] CHECK_MAX -->|是| RECLAIM["内核尝试回收内存"] RECLAIM --> RECLAIM_OK{回收成功?} RECLAIM_OK -->|是| ALLOC_OK RECLAIM_OK -->|否| CHECK_SWAP{swap 可用?} CHECK_SWAP -->|是| SWAP_OUT["换出到 swap"] CHECK_SWAP -->|否| OOM["触发 OOM Killer"] OOM --> KILL["杀死 cgroup 内的进程"] style ALLOC_OK fill:#c8e6c9,stroke:#2e7d32 style OOM fill:#ffcdd2,stroke:#c62828 style KILL fill:#ffcdd2,stroke:#c62828

四、IO 控制器#

4.1 io.max:IO 硬限制#

# 限制容器对 /dev/sda 的读写速率
# 格式:major:minor rbps wbps riops wiops
echo "8:0 rbps=104857600 wbps=52428800 riops=1000 wiops=500" > io.max
# 查看当前 IO 限制
cat io.max
# 8:0 rbps=104857600 wbps=52428800 riops=1000 wiops=500
# 查看当前 IO 统计
cat io.stat
# 8:0 rbytes=12345678 wbytes=87654321 rios=1234 wios=567 dbytes=0 dios=0

4.2 io.weight:IO 权重#

# 设置 IO 权重(1-10000,默认 100)
echo "200" > io.weight
# 按设备设置权重
echo "8:0 200" > io.weight

4.3 io.max 与 io.cost:两种限流模型#

Cgroup v2 的 IO 控制器提供两种限流模型,对应不同的使用场景:

模型控制文件限流方式适用场景
io.maxio.max绝对速率上限(BPS/IOPS)严格限制带宽,防止 IO 密集型容器抢占磁盘
io.costio.cost.model + io.cost.qos基于权重的比例分配多容器共享磁盘时的公平调度

io.max 模型是直接限速:设置 rbps=104857600,超过 100MB/s 的读请求就会被延迟,逻辑简单直接。

io.cost 模型更精细。它给每个 IO 操作计算一个”代价”(cost),根据 cgroup 的权重比例分配 IO 预算。代价不是简单的字节数,而是综合考虑了寻道时间、传输时间等磁盘物理特性:

# 启用 io.cost 模型(以 nvme0n1 为例)
echo "8:0 rbps=0 wbps=0 riops=0 wiops=0" > io.max # 先清空 io.max 限制
echo "ctrl=model nvme0n1" > io.cost.model # 指定设备使用 cost 模型
echo "ctrl=qos nvme0n1 rpct=95 wpct=95" > io.cost.qos # 设置 QoS 参数
# 查看代价模型参数
cat io.cost.model
# ctrl=user nvme0n1 model=linear rbps=0 rseqiops=0 rrandiops=0
# wbps=0 wseqiops=0 wrandiops=0
# 查看当前 IO 代价统计
cat io.stat
# 8:0 rbytes=12345678 wbytes=87654321 rios=1234 wios=567
# cost.usage=98765 cost.wait=123456 cost.inflight=7890

cost.usage 是该 cgroup 已消耗的 IO 代价,cost.wait 是因限流而等待的时间(纳秒),cost.inflight 是当前在飞的 IO 代价。

Note

io.max 和 io.cost 不能同时对同一设备生效。io.max 的优先级更高:如果对某设备设置了 io.max,该设备就走 io.max 的直接限速逻辑,io.cost 权重分配不生效。要让 io.cost 生效,需要把 io.max 中对应设备的限制清零。

4.4 io.max 限流:时间片与令牌桶#

io.max 的限流机制基于时间片(time slice)+ 令牌桶,而非简单的”用完就停”。内核实现(blk-throttle)为每个 cgroup 维护一个时间窗口(默认 100ms,即 DFL_THROTL_SLICE = HZ/10),在窗口内跟踪已派发的字节数和 IO 次数。

当一个 IO 请求到来时,限流逻辑分两步判断:

  1. BPS 检查:计算当前时间片内允许的字节数 bytes_allowed = bps_limit * jiffy_elapsed / HZ,如果 bytes_disp + bio_size &gt; bytes_allowed,则计算等待时间
  2. IOPS 检查:计算当前时间片内允许的 IO 数 io_allowed = iops_limit * jiffy_elapsed / HZ,如果 io_disp + 1 &gt; io_allowed,则计算等待时间

等待时间的计算方式是:根据已用配额和限制速率,推算出”还需要等多久才允许下一个 IO”,然后将请求挂入延迟队列,由定时器在到期后重新派发。

sequenceDiagram participant App as 容器进程 participant Throtl as blk-throttle participant Timer as 延迟定时器 participant Disk as 块设备 App->>Throtl: 提交 IO 请求 Throtl->>Throtl: 检查 bytes_disp + bio_size<br/>是否 &gt; bytes_allowed alt 配额充足 Throtl->>Disk: 立即派发请求 Throtl->>Throtl: bytes_disp += bio_size else 配额不足 Throtl->>Throtl: 计算等待时间 jiffy_wait Throtl->>Timer: 注册定时器,jiffy_wait 后唤醒 Timer-->>Throtl: 定时器到期 Throtl->>Disk: 延迟后派发请求 end Note over Throtl: 时间片结束,配额重置<br/>bytes_disp = 0, io_disp = 0

与 CPU 控制器的 CFS 限流对比:CFS 在 quota 用完后直接将进程挂起,等到下一个 period 才恢复,是”硬截断”模式。io.max 则更灵活,它不是等到时间片结束才重置配额,而是根据已用配额动态计算等待时间,让 IO 请求以更均匀的间隔派发,避免突发后长时间静默。

Note

io.max 还支持配额结转(carryover):当配置在限流期间被修改时,内核会计算新旧配置下已等待的配额差值,将多等待的部分结转到新配置中,避免配置变更导致限流行为突变。

4.5 io.cost 限流:虚拟时间与代价模型#

io.cost 模型的核心思想是虚拟时间(vtime)。它不直接限制速率,而是让每个 cgroup 的 IO 消耗在虚拟时间维度上按权重比例推进,从而实现比例分配。

4.5.1 代价计算:IO 不是等价的#

io.cost 首先要解决的问题是:不同类型的 IO 对设备的”代价”不同。一个 4KB 随机读和一个 1MB 顺序写,占用的设备时间天差地别。io.cost 内置了 linear 代价模型,将每个 IO 的代价分为两部分:

  • 基础代价:根据 IO 是顺序还是随机,赋予不同的基础开销(随机 IO 基础代价更高,因为涉及寻道/定位)
  • 大小代价:按 IO 的字节数线性增加
IO_cost = base_cost(seq_or_rand) + size_coeff * io_size

代价的单位是设备时间。如果一个 IO 的代价是 10ms,意味着设备处理这个 IO 大约需要 10ms。这个模型虽然简单,但足以区分随机小 IO 和顺序大 IO 的不同开销。

4.5.2 vtime 分配:权重如何变成 IO 预算#

有了代价度量,下一步是按权重分配 IO 预算。io.cost 引入了**层级权重(hweight)**的概念。考虑下面的 cgroup 层级:

flowchart TB ROOT["root"] --> A["A (weight=100)"] ROOT --> B["B (weight=300)"] A --> A0["A0 (weight=100)"] A --> A1["A1 (weight=100)"]

如果 B 空闲,只有 A0 和 A1 在发 IO,两者权重相等,各得 50%。如果 B 开始发 IO,B 得到 300/(100+300) = 75%,A0 和 A1 各分剩余的 12.5%。这个”展平后的权重比例”就是 hweight,所有活跃 cgroup 的 hweight 之和始终为 1。

关键机制:cgroup 的 vtime 流速与 hweight 成反比。如果 A0 的 hweight 是 12.5%,它的 vtime 流速是设备 vtime 的 1/8(100/12.5)。一个在设备上耗时 10ms 的 IO,在 A0 的 vtime 里算作 80ms。这意味着权重低的 cgroup 消耗 vtime 更快,更快用完预算,从而被限流。

每个 cgroup 跟踪自己已消耗的 vtime。当 cgroup 的 vtime 落后于设备当前 vtime 时,可以继续发 IO;当 cgroup 的 vtime 追上甚至超过设备 vtime 时,IO 被挂起,直到设备 vtime 推进到足够远的位置。

4.5.3 vrate 调整:自适应设备负载#

代价模型不可能完美匹配所有设备的实际性能。设备内部有垃圾回收、IO 混合模式变化等因素,导致实际吞吐量波动。io.cost 通过**vrate(虚拟时间速率)**来动态适配。

vrate 是设备 vtime 相对于挂钟时间的流速。vrate = 100% 时,设备 vtime 与挂钟时间 1<1> 推进,所有 cgroup 加起来可以用满设备带宽。vrate = 75% 时,设备 vtime 只以 75% 的速率推进,所有 cgroup 加起来只能使用 75% 的设备带宽。

vrate 的调整基于两个信号:

  • 请求队列等待(rq wait):当设备饱和时,硬件和软件队列填满,新 IO 必须等待请求槽位。这是最保守的饱和信号,默认启用
  • 完成延迟 QoS:通过 io.cost.qos 配置,当第 N 百分位的 IO 完成延迟超过阈值时,认为设备过载。这比 rq wait 更灵敏,可以更早检测到设备压力
flowchart TB MONITOR["监控设备负载"] --> CHECK_SAT{设备饱和?} CHECK_SAT -->|rq wait 升高<br/>或完成延迟超阈值| LOWER["降低 vrate<br/>所有 cgroup IO 预算收缩"] CHECK_SAT -->|有等待的 cgroup<br/>但设备未饱和| RAISE["提高 vrate<br/>所有 cgroup IO 预算扩张"] CHECK_SAT -->|设备恰好跑满| KEEP["保持 vrate 不变"] LOWER --> ADAPT["cgroup vtime 流速变慢<br/>IO 请求被延迟更久"] RAISE --> ADAPT2["cgroup vtime 流速变快<br/>IO 请求更快获得预算"] style LOWER fill:#ffcdd2,stroke:#c62828 style RAISE fill:#c8e6c9,stroke:#2e7d32 style KEEP fill:#fff9c4,stroke:#f57f17

4.5.4 工作保持:不浪费空闲容量#

比例分配有一个天然问题:如果两个 cgroup 权重各 50%,但 A 只用了 10% 的设备能力,B 本可以用满剩余 90%,但按 50<50> 分配 B 只能拿到 50%,设备利用率只有 60%。这太浪费了。

io.cost 的**工作保持(work conservation)**机制解决了这个问题。它跟踪每个活跃 cgroup 的实际使用量,如果某个 cgroup 的使用量远低于其权重应得的份额,就将其多余权重临时让给其他 cgroup。同时设有”快速回收”机制:如果低使用量的 cgroup 突然需要更多 IO,它的权重会立即恢复,不会被长期惩罚。

4.6 Cgroup v2 IO 控制器 vs v1 blkio#

Cgroup v1 的 IO 控制器叫 blkio,v2 改名为 io。不只是换了个名字,实现机制有本质区别:

维度Cgroup v1 blkioCgroup v2 io
限流机制基于 CFQ 调度器的权重分配io.max 时间片限速 + io.cost vtime 比例分配
硬限制仅支持 throttle(BPS/IOPS),精度低io.max 精确限速,支持配额结转
权重分配依赖 CFQ 调度器,对 NVMe/SSD 无效io.cost 基于代价模型 + vtime,不依赖特定调度器
写回集成不感知页面缓存写回与内核写回(writeback)机制集成
统计精度只统计直接 IO统计包含缓冲写回(buffered writeback)
自适应调节io.cost 通过 vrate 动态适配设备负载

v1 blkio 最根本的问题是依赖 CFQ 调度器。CFQ(Completely Fair Queuing)是机械硬盘时代的调度器,通过旋转和寻道优化来公平分配磁盘时间。CFQ 的权重分配逻辑是:根据 cgroup 的权重比例,轮流将磁盘时间分配给不同 cgroup 的 IO 请求。这对机械硬盘有效,因为寻道是主要开销,合理分配寻道时间就能实现公平。但现代 NVMe/SSD 没有机械寻道,IO 延迟极低且队列深度大,CFQ 的”轮流分配磁盘时间”逻辑失去了物理基础,权重控制在 SSD 上形同虚设。Linux 5.0 之后 CFQ 调度器已被移除,v1 blkio 的权重分配也就无处依附。

v2 的 io.cost 不依赖特定 IO 调度器。它的核心思路是:既然磁盘时间无法直接观测,那就通过代价模型估算每个 IO 的设备时间消耗,再用虚拟时间机制按权重分配。这个思路与 CPU 控制器的 CFS 有异曲同工之处:CFS 用虚拟运行时间(vruntime)按权重分配 CPU 时间,io.cost 用虚拟 IO 时间(vtime)按权重分配 IO 预算。两者都是”用虚拟时间做比例分配”,区别在于 CFS 的 vruntime 可以精确度量(CPU 时间就是挂钟时间),而 io.cost 的 vtime 需要靠代价模型估算,因此额外引入了 vrate 自适应机制来弥补模型误差。

另一个关键改进是写回集成。v1 blkio 只统计进程直接发起的 IO(direct IO),不统计内核的页面缓存写回。一个容器进程写入大量数据后,内核异步将脏页刷回磁盘,这部分 IO 不受 blkio 控制,容器可以绕过限制。v2 的 IO 控制器将写回 IO 归属到产生脏页的 cgroup,写回也受 io.max/io.cost 约束。

Warning

io.cost 模型在 Linux 5.4 引入,5.7 之后趋于稳定。5.4 之前的内核只能用 io.max 做硬限制,无法做基于权重的比例分配。生产环境建议 5.7+。

五、PSI:压力失速信息#

5.1 PSI 原理#

PSI(Pressure Stall Information)严格说是 4.20 引入的独立内核子系统,系统级 /proc/pressure/ 不依赖 cgroup;cgroup v2 在此基础上提供了 per-cgroup 的压力暴露(cpu.pressure/memory.pressure/io.pressure),用来量化单个 cgroup 内资源竞争的严重程度:

# 查看 CPU 压力
cat /sys/fs/cgroup/docker/container1/cpu.pressure
# some avg10=0.00 avg60=0.10 avg300=0.05 total=1234567
# full avg10=0.00 avg60=0.00 avg300=0.00 total=0
# some: 至少一个任务等待资源
# full: 所有任务都在等待资源(更严重)
# avg10/60/300: 最近 10/60/300 秒的百分比

5.2 PSI 在容器中的应用#

# 监控容器的内存压力
watch -n 1 "cat /sys/fs/cgroup/docker/container1/memory.pressure"
# 基于 PSI 触发告警
# 当 avg10 > 50% 时,表示严重内存压力
# 可能需要增加内存限制或优化应用
PSI 指标含义告警阈值
some avg10 > 10%部分任务等待关注
some avg10 > 50%严重竞争告警
full avg10 > 0%所有任务等待严重告警

六、容器运行时与 Cgroup#

6.1 Docker 的 Cgroup 配置#

# Docker 的 Cgroup 参数映射
docker run \
--memory=512m \ # memory.max = 536870912
--memory-reservation=256m \ # memory.low = 268435456
--memory-swap=1g \ # memory.swap.max = 536870912
--cpus=2 \ # cpu.max = 200000 100000
--cpu-shares=2048 \ # cpu.weight = 2048
--cpuset-cpus=0,2 \ # cpuset.cpus = 0,2
--pids-limit=100 \ # pids.max = 100
--device-write-bps /dev/sda:50MB \ # io.max wbps
nginx
# 查看容器的 Cgroup 路径
docker inspect mycontainer --format '{{.CgroupPath}}'

6.2 Kubernetes 的 Cgroup 配置#

# Kubernetes Pod 的资源限制
apiVersion: v1
kind: Pod
spec:
containers:
- name: nginx
resources:
requests:
cpu: "1" # cpu.weight 权重分配
memory: "512Mi" # memory.min 保证
limits:
cpu: "2" # cpu.max 硬限制
memory: "1Gi" # memory.max 硬限制

6.3 containerd 的 Cgroup 管理#

// containerd 的 Cgroup 管理代码(简化)
package cgroups
import (
"fmt"
"os"
"path/filepath"
)
type CgroupConfig struct {
MemoryMax int64 // memory.max
CPUMax string // cpu.max (quota period)
}
func ApplyCgroup(cgroupPath string, config *CgroupConfig) error {
// Cgroup v2 操作的本质:建目录 + 往各控制器文件写值 + 把进程写进 cgroup.procs
path := filepath.Join("/sys/fs/cgroup", cgroupPath)
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
// 所有控制器的写入都是同一个模式:拼路径、写字节、0644
writeControl := func(name, value string) error {
return os.WriteFile(filepath.Join(path, name), []byte(value), 0644)
}
if config.MemoryMax > 0 {
if err := writeControl("memory.max", fmt.Sprintf("%d", config.MemoryMax)); err != nil {
return err
}
}
if config.CPUMax != "" {
if err := writeControl("cpu.max", config.CPUMax); err != nil {
return err
}
}
// 把当前进程加入 cgroup
return writeControl("cgroup.procs", fmt.Sprintf("%d", os.Getpid()))
}

七、Cgroup 在容器运行时中的完整路径#

从 Docker CLI 到内核,Cgroup 配置经过多层转换:

flowchart LR subgraph 用户层["用户层"] CLI["docker run --memory=512m --cpus=2"] end subgraph Docker层["Docker 层"] SPEC["OCI Spec 生成<br/>linux.resources.memory.limit = 536870912<br/>linux.resources.cpu.quota = 200000<br/>linux.resources.cpu.period = 100000"] end subgraph containerd层["containerd 层"] SHIM["shim → runc create<br/>传递 Spec 给 runc init"] end subgraph runc层["runc 层"] CG_MGR["Cgroup Manager<br/>读取 Spec → 写入 cgroup 文件"] end subgraph 内核层["内核层"] FS["cgroup 文件系统<br/>/sys/fs/cgroup/docker/xxx/<br/>memory.max, cpu.max, cgroup.procs"] end CLI --> SPEC --> SHIM --> CG_MGR --> FS style 用户层 fill:#bbdefb,stroke:#1565c0 style Docker层 fill:#c8e6c9,stroke:#2e7d32 style containerd层 fill:#fff3e0,stroke:#e65100 style runc层 fill:#e1bee7,stroke:#6a1b9a style 内核层 fill:#ffcdd2,stroke:#c62828
Warning

Cgroup 的内存限制包含页面缓存(file cache)。当容器读取大量文件时,页面缓存会占用 memory.current,可能触发限流或 OOM。如果应用需要大量文件 IO,考虑适当放宽内存限制或使用 memory.low 设置软保护。

八、eBPF 与 Cgroup#

8.1 Cgroup eBPF 程序#

Cgroup v2 支持附加 eBPF 程序,实现更灵活的控制逻辑:

// eBPF 程序:限制网络连接
// 附加到 cgroup 的 BPF_CGROUP_INET_SOCK_CREATE hook
SEC("cgroup/sock")
int restrict_sockets(struct bpf_sock *ctx) {
// 只允许 TCP 和 UDP
if (ctx->protocol != IPPROTO_TCP &&
ctx->protocol != IPPROTO_UDP) {
return 0; // 拒绝
}
return 1; // 允许
}

8.2 常用的 Cgroup eBPF Hook#

Hook触发时机用途
cgroup/sock创建 socket限制网络协议
cgroup/connect发起连接限制出站连接
cgroup/sendmsg发送消息限制目标地址
cgroup/recvmsg接收消息限制来源地址
cgroup/post_bindbind 之后限制监听端口
cgroup/device设备访问限制设备操作

九、动手实践#

9.1 手动创建 Cgroup 并限制进程#

#!/bin/bash
# 手动创建 Cgroup v2 并限制进程
# 1. 创建 cgroup
CGROUP_PATH="/sys/fs/cgroup/mycontainer"
sudo mkdir -p $CGROUP_PATH
# 2. 启用控制器
echo "+cpu +memory +io +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
# 3. 设置 CPU 限制(1 核)
echo "100000 100000" | sudo tee $CGROUP_PATH/cpu.max
# 4. 设置内存限制(256MB)
echo "268435456" | sudo tee $CGROUP_PATH/memory.max
# 5. 设置 PID 限制
echo "100" | sudo tee $CGROUP_PATH/pids.max
# 6. 启动进程并加入 cgroup
stress-ng --cpu 4 --timeout 60s &
PID=$!
echo $PID | sudo tee $CGROUP_PATH/cgroup.procs
# 7. 观察限流效果
watch -n 1 "cat $CGROUP_PATH/cpu.stat"
# nr_periods 10
# nr_throttled 8 ← 被限流 8 次
# throttled_usec 800000 ← 限流了 800ms
# 8. 清理
sudo rmdir $CGROUP_PATH

9.2 Cgroup 监控脚本#

#!/bin/bash
# 监控容器的 Cgroup 资源使用
CONTAINER=$1
CGROUP=$(docker inspect -f '{{.CgroupPath}}' $CONTAINER 2>/dev/null)
if [ -z "$CGROUP" ]; then
echo "Container not found"
exit 1
fi
CGROUP_FS="/sys/fs/cgroup${CGROUP}"
echo "=== CPU ==="
echo "Limit: $(cat $CGROUP_FS/cpu.max)"
echo "Usage: $(cat $CGROUP_FS/cpu.stat | grep usage_usec)"
echo "Throttled: $(cat $CGROUP_FS/cpu.stat | grep throttled)"
echo ""
echo "=== Memory ==="
echo "Limit: $(cat $CGROUP_FS/memory.max)"
echo "Current: $(cat $CGROUP_FS/memory.current)"
echo "Swap: $(cat $CGROUP_FS/memory.swap.current) / $(cat $CGROUP_FS/memory.swap.max)"
echo "OOM events: $(cat $CGROUP_FS/memory.events | grep oom)"
echo ""
echo "=== IO ==="
echo "Stats: $(cat $CGROUP_FS/io.stat)"
echo "Pressure: $(cat $CGROUP_FS/io.pressure)"
echo ""
echo "=== PSI ==="
echo "CPU: $(cat $CGROUP_FS/cpu.pressure)"
echo "Memory: $(cat $CGROUP_FS/memory.pressure)"

附、实践:用 Cgroup v2 限制进程资源#

Note

本节用 Cgroup v2 的文件系统接口手工限制进程资源,观察限制效果。所有命令需要 root 权限。

附.1 确认 Cgroup v2 挂载#

mount | grep cgroup2
# cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate)
cat /sys/fs/cgroup/cgroup.controllers
# cpuset cpu io memory hugetlb pids rdma

Cgroup v2 挂载在 /sys/fs/cgroup/,所有控制器共享同一棵 cgroup 树。

附.2 创建自定义 cgroup 并启用控制器#

# 创建 cgroup 目录
mkdir -p /sys/fs/cgroup/my-demo
# 启用 CPU 和 memory 控制器(从根 cgroup 委派)
echo "+cpu +memory" > /sys/fs/cgroup/cgroup.subtree_control
# 确认控制器已启用
cat /sys/fs/cgroup/my-demo/cgroup.controllers
# cpuset cpu io memory hugetlb pids rdma

附.3 设置内存限制#

# 设置内存上限为 512MB
echo "536870912" > /sys/fs/cgroup/my-demo/memory.max
# 确认限制生效
cat /sys/fs/cgroup/my-demo/memory.max
# 536870912

附.4 设置 CPU 限制#

# 限制 CPU 使用率为 50%(每 100000 微秒中最多使用 50000 微秒)
echo "50000 100000" > /sys/fs/cgroup/my-demo/cpu.max
# 确认限制生效
cat /sys/fs/cgroup/my-demo/cpu.max
# 50000 100000

cpu.max 的格式是 max quotamax 表示不限,50000 100000 表示每 100ms 周期内最多使用 50ms CPU 时间,即 50% CPU。

附.5 将进程移入 cgroup#

# 启动一个消耗 CPU 的进程
stress --cpu 1 --timeout 30 &
# 将进程 PID 写入 cgroup
echo $! > /sys/fs/cgroup/my-demo/cgroup.procs
# 观察 CPU 使用率被限制在 50%
top -bn1 | grep stress

附.6 观察 PSI 压力指标#

cat /sys/fs/cgroup/my-demo/cpu.pressure
# some avg10=0.00 avg60=0.00 avg300=0.00 total=0
# full avg10=0.00 avg60=0.00 avg300=0.00 total=0
cat /sys/fs/cgroup/my-demo/memory.pressure
# some avg10=0.00 avg60=0.00 avg300=0.00 total=0
# full avg10=0.00 avg60=0.00 avg300=0.00 total=0

PSI(Pressure Stall Information)是 Cgroup v2 的重要特性,some 表示”至少一个进程被延迟”,full 表示”所有进程被延迟”。当 CPU 或内存压力增大时,这些数值会上升,运维可以据此触发自动扩容。

Note

实验结束后清理 cgroup:rmdir /sys/fs/cgroup/my-demo。如果 cgroup 中仍有进程,需要先将它们移回根 cgroup。

十、本章小结#

上一章建立了 Linux Namespace 的原理与实现的认知框架。本章从 Cgroup v1 的多层级问题出发,拆解了 v2 统一层级的设计动机,以及 CPU/内存/IO 三大控制器的限流机制与保护策略。Namespace 回答”进程能看到什么”,Cgroup 回答”进程能用多少”,两者配合才构成完整的容器隔离。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Cgroup v2 深入
https://blog.souloss.cn/posts/container-runtime/cgroup-deep-dive/
作者
Souloss
发布于
2021-09-07
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

相关文章 智能推荐
1
Linux Namespace 深入
容器运行时 Namespace 是容器视图隔离的核心机制。全面剖析 8 种 Linux Namespace 的原理与实现,PID/Mount/Network/User/IPC/UTS/Cgroup/Time,剖析 unshare/clone/setns 三个系统调用的内核行为,理解 Namespace 的继承规则与嵌套关系,为理解 runc 源码和容器安全奠定基础。
2
容器运行时深入系列导读
容器运行时 从 Linux 内核的 Namespace、Cgroup、OverlayFS 出发,深入 OCI 规范、runc 源码、containerd 架构与 shim 机制,再覆盖容器安全、网络、镜像构建三个运行时核心维度,建立从内核机制到工业实现的完整认知链。
3
OverlayFS:容器文件系统
容器运行时 OverlayFS 是容器分层文件系统的核心。全面剖析 OverlayFS 的 lowerdir/upperdir/workdir 三层结构、whiteout 标记文件机制、copy-up 写时复制语义、多层叠加规则,以及容器运行时如何用 OverlayFS 实现镜像层复用和容器可写层,理解「为什么容器启动这么快」和「镜像层是怎么共享的」。
4
runc 源码分析
容器运行时 runc 是 OCI Runtime Spec 的参考实现,也是 Docker/Kubernetes 默认的低层容器运行时。从零讲透 runc 的源码架构,libcontainer 核心库、容器创建/启动的代码路径、Namespace/Cgroup/OverlayFS 的内核交互、安全配置(seccomp/AppArmor/Capabilities),让你从「知道 runc 是什么」到「理解 runc 的每一行关键代码」。
5
containerd 架构
容器运行时 containerd 是工业级容器运行时管理器,是 Docker 和 Kubernetes 的核心依赖。从零讲透 containerd 的架构设计,gRPC API 服务、镜像管理(pull/unpack/mount)、任务管理(create/start/kill)、插件体系、事件系统,以及 containerd 如何在 runc 之上构建完整的容器生命周期管理能力。