引言:为什么 Cgroup v2 是容器基础设施的分水岭

2019 年 Linux 4.5 内核中 Cgroup v2 正式被标记为稳定接口,2021 年 Ubuntu 22.04 与 RHEL 9 将其设为默认选项,Docker 20.10 与 containerd 1.6 全面切换 — 这不是版本迭代,而是 Linux 资源管理范式的根本转变。在 Kubernetes 集群中,Pod 的 CPU 限流(throttling)、内存回收(reclaim)、IO 优先级等核心调度行为,最终都通过 Cgroup v2 的系统调用落地。理解它的内部机制,意味着你能从内核层面解释"为什么我的容器被 OOM Kill"以及"为什么 CPU limits 导致性能抖动"。

本文将系统性地拆解 Cgroup v2 的架构设计、三大核心控制器的工作原理、递归统计模型、压力信息传播机制,以及在 Kubernetes 和容器运行时中的生产级调优策略。

一、从 v1 到 v2:架构设计的范式转变

1.1 Cgroup v1 的结构性困境

Cgroup v1 最被诟病的是多层级(Multi-Hierarchy) 设计 — 每个控制器(hierarchy)可以独立挂载到不同的目录树。比如 cpu 控制器在 /sys/fs/cgroup/cpu,memory 控制器在 /sys/fs/cgroup/memory,blkio 控制器在 /sys/fs/cgroup/blkio。问题在于:同一个进程在不同层级中的分组可能不一致 — 进程 A 在 cpu 层级属于 groupA,在 memory 层级却属于 groupB。这种"语义割裂"让资源约束策略无法收敛,Kubernetes 早期不得不在 RunC 层做大量一致性校验。

另一个问题是层级爆炸:当需要同时约束 CPU 和内存时,v1 要求这两个控制器必须挂在同一个层级,但 cpu 和 memory 不能同时挂载(非-threaded模式下)。于是催生了 cpu,cpuacct 这样的联合挂载,进一步加剧了管理的复杂度。

1.2 v2 的统一层级(Unified Hierarchy)模型

Cgroup v2 最核心的设计原则是 "一个进程属于且仅属于一个 cgroup"。整个系统中只有一棵统一的 cgroup 树,所有控制器遵守相同的分组规则。

# 查看当前 cgroup 版本
$ mount | grep cgroup
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)

$ cat /proc/self/cgroup
0::/user.slice/user-1000.slice/session-3.scope

# 对比 v1 的多行输出 (每行一个层级)
# 12:devices:/user.slice
# 8:cpu,cpuacct:/user.slice
# 7:memory:/user.slice

在 v1 中一个进程会同时出现在多行,而在 v2 中只有一个统一的 cgroup 路径。这就从根本上解决了"语义割裂"的问题。

1.2 v2 控制器接口矩阵

控制器控制对象核心参数适用场景
cpuCPU 时间分配cpu.weight / cpu.max / cpu.uclampCPU 限流、份额分配
memory物理内存与交换空间memory.max / memory.high / memory.min内存限制、OOM 防护
io块设备 IO 带宽io.max / io.weight / io.latency磁盘 IO 隔离与限流
pids进程/线程数量pids.max防止 fork 炸弹
rdmaRDMA 资源配额rdma.max高性能网络隔离
misc稀有硬件资源misc.maxGPU/DPU 配额
perf_event性能事件采样(只读事件暴露)容器级性能监控
hugetlbHugePages 数量hugetlb.2MB.max / hugetlb.2MB.current数据库大页分配

二、CPU 控制器:从 Completely Fair Scheduler 到带宽控制

2.1 cpu.weight:按比例分配的艺术

Cgroup v2 的 cpu.weight 替代了 v1 的 cpu.shares,取值范围 1-10000(默认 100),不是绝对值而是相对权重。当 CPU 资源发生争抢时,各 cgroup 获得的 CPU 时间按照权重比例分配。例如:

/sys/fs/cgroup/
├── system.slice/            # weight=100 (默认)
├── user.slice/              # weight=100 (默认)
└── workload/
    ├── critical.service     # weight=800  → 占 800/(800+200)=80%
    └── best-effort.service  # weight=200  → 占 200/(800+200)=20%

但要注意:weight只在CPU 饱和时才起作用。如果 critical.service 只用了 30% CPU,最佳 effort 可以享用剩余的 70%;只有两者都争抢 100% CPU 时,才按权重比例分配。这与 K8s 中 requests.cpu 的语义一致。

2.2 cpu.max:硬性与软性限流的本质区别

cpu.max 是真正的硬性限流,内置 CFS 带宽控制(CFS Bandwidth Control,即 CFS-BWC)。格式为 $MAX $PERIOD,单位为微秒:

# 限制最多使用 2 个 CPU 核的算力
echo "200000 100000" > /sys/fs/cgroup/workload/cpu.max

# 限制最多使用 0.5 核 (50ms/100ms)
echo "50000 100000" > /sys/fs/cgroup/workload/cpu.max

# 在每个 100ms 周期内,该 cgroup 最多运行 50ms

当 cgroup 的累计运行时间超过 quota 时,它将在当前 PERIOD 剩余时间内被强制挂起(throttled)。内核会在 cpu.stat 中记录 throttled_time、throttled_count 等指标:

$ cat /sys/fs/cgroup/workload/cpu.stat
nr_periods 1523        # 经历的总周期数
nr_throttled 847       # 被限流的周期数
throttled_usec 42350000 # 累计限流时间 (42.35秒)
usage_usec 12345678    # 累计运行时间

这里有个关键细节:v2 中 cpu.max 同时作用于所有 CPU 核。如果 container 申请了 2 核,它可以在 2 个核上各跑满 50ms,也可以在一个核上跑满 100ms,只要总时间不超过 200ms。这种"总时间池"的设计比 v1 灵活很多。

2.3 cpu.uclamp:延迟敏感型任务的新武器

Cgroup v2.1 引入了 Utilization Clamping 机制,通过 cpu.uclamp.min 和 cpu.uclamp.max 对 cgroup 的 CPU 利用率进行上下界限制,取值范围 0.00-1.00 (即 0%-100%)。

# 保证关键服务至少获得 50% CPU (即使系统空闲也不会被降到更低)
echo "50000 100000" > /sys/fs/cgroup/rt-service/cpu.uclamp.min

# 限制批处理任务最多使用 30% CPU (防止在低优先级时段干扰其他任务)
echo "30000 100000" > /sys/fs/cgroup/batch/cpu.uclamp.max

uclamp 影响的是调度器的 PEVT(Per-Entity Load Tracking) 计算。当设置了 uclamp.min 后,即使任务实际只用了 10% CPU,调度器也会按 50% 计算,从而保障其在频率调优(CPUFreq)时获得更高主频。这对终端 UI 和实时音视频业务至关重要。

三、内存控制器:多层防护与回收艺术的平衡

3.1 memory.high vs memory.max:平滑限流与硬性 OOM

Cgroup v2 内存控制器最精妙的设计在于分层限制策略:

  • memory.max:硬性限制。cgroup 的总内存(RSS + 缓存)超过此值时直接触发 OOM Killer。这等同于 K8s 的 limits.memory。
  • memory.high:软性目标(soft limit)。cgroup 超过此值时不会立即被 Kill,而是触发内存回收流程(reclaim):内核会优先回收该 cgroup 的 page cache 和 slab 缓存,让进程进入直接回收(direct reclaim)状态,产生 I/O 阻塞来自然限流。建议设置为 memory.max 的 90% 左右。
  • memory.low:保护阈值。该值以下的内存不会被全局回收影响,用于保障 critical 服务的热数据。
  • memory.min:绝对保护。内核永远不会回收该值以下的内存,即使系统内存已耗尽。
# Kubernetes Pod 对应 cgroup 树的典型配置
/sys/fs/cgroup/kubepods.slice/
├── kubepods-burstable.slice/          # Burstable QoS
│   └── pod-xxx.slice/
│       ├── memory.max = 2147483648    # 2GB上限
│       ├── memory.high = 1879048192   # 1.8GB触发回收
│       └── memory.low = 1073741824    # 1GB保护
├── kubepods-besteffort.slice/         # BestEffort QoS
│   └── pod-yyy.slice/
│       ├── memory.max = 1073741824    # 1GB试探性保护
│       └── memory.high = 966367641
└── kubepods-guaranteed.slice/         # Guaranteed QoS
    └── pod-zzz.slice/
        └── memory.max = 2147483648    # requests=limits,无高低围栏

3.2 内存回收的三次机会

当 cgroup 内存逼近 memory.high 时,内核通过三步策略尝试控制使用量:

  1. Background Reclaim:内核 kswapd 进程在后台扫描该 cgroup 的 LRU 链表,优先回收 inactive_cache(冷页面);
  2. Direct Reclaim:申请新内存时进程同步参与回收,触发 read I/O 把脏页写回磁盘,进程被阻塞;
  3. Memory Pressure Stall:如果回收后仍然超过 memory.max,触发 cgroup 内部的 OOM 选择最差进程杀死。

观察 memory.stat 可以精确量化回收效果:

$ cat pod-zzz/memory.stat
anon 1048576000              # 进程匿名内存(RSS) ~1GB
file 536870912               # Page Cache 512MB
slab 52428800                # Slab 50MB
pgactivate 234567             # 热页面激活计数
pgdeactivate 123456           # 冷页面回收计数
pgrefill 98765                # 冷页重新激活 (说明回收策略过激进)
workingset_refuse 4567        # 工作集被拒绝计数
oom_kill 0                    # OOM 杀死进程数

pgrefill 和 workingset_refuse 是两个关键告警信号:如果 reclaim 导致大量冷页被重新访问(io load 激增),说明 memory.high 设得太低,正在过度回收活跃工作集。

3.3 memory.swap.max:交换空间的精细控制

Cgroup v2 支持对交换空间的独立限制,格式为 max 或 $MAX 字节数:

# 禁止该 cgroup 交换 (K8s Guaranteed 典型配置)
echo "0" > /sys/fs/cgroup/pod-zzz/memory.swap.max

# 允许交换不超过物理限制的 50%
echo "1073741824" > /sys/fs/cgroup/pod-yyy/memory.swap.max

# memory.current 现在包含 swap 使用量
$ cat /sys/fs/cgroup/pod-yyy/memory.swap.current
536870912

这对 OLTP 数据库有重要意义:禁止 OS 把 InnoDB Buffer Pool 的页面交换出去,同时允许日志归档等低优先级线程适度换出。

四、IO 控制器:从 CFQ 到 Weight+BPS+IOPS 的三维控制

4.1 io.weight:设备级权重分配

与 CPU cgroup 类似,IO weight 也是相对值(1-10000,默认 100)。v2 大幅简化了 IO 控制,全球唯一 IO 控制器取代了 v1 的 blkio(权重) 和 blkio.throttle(限流)两个独立控制器。

# 查看磁盘设备号 (主设备号:次设备号)
$ lsblk -d -o NAME,MAJ:MIN
sda   8:0
nvme0n1   259:0

# NVMe SSD, critical DB weight=1000
echo "259:0 weight 1000" > /sys/fs/cgroup/db-group/io.weight

# SATA HDD, backup weight=50
echo "8:0 weight 50" > /sys/fs/cgroup/backup-group/io.weight

权重分发使用时间片轮转与 v1 的 CFQ 大体一致。但有细微差别:v2 的 io.weight 基于异步 IO 统计,而 v1 仅统计同步 IO,这意味着使用 io_uring 的业务会获得更公平的权重分配。

4.2 io.max:rbps/wbps/riops/wiops 四维硬性限流

# 限制 NVMe 设备读带宽 1GB/s, 写带宽 500MB/s, IOPS 各 10万
echo "259:0 rbps=1073741824 wbps=536870912 riops=100000 wiops=100000" \
  > /sys/fs/cgroup/pod-zzz/io.max

# 同时限制 SATA 设备总带宽 100MB/s
echo "8:16 rbps=104857600 wbps=104857600" > /sys/fs/cgroup/pod-yyy/io.max

要支持 io.max,块设备层必须开启 BFQ(Budget Fair Queueing) 或 Kyber 调度器。推荐在 NVMe 设备上使用 none(多队列直通) + MQ-Deadline,或在 HDD 上使用 BFQ:

$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline kyber bfq

# 选择 BFQ (某些设备需要 reboot 生效)
echo bfq > /sys/block/sda/queue/scheduler

4.3 io.latency:延迟 SLO 的保护队列

这是 v2 相比 v1 最具杀伤力的特性:基于目标延迟(target latency)的自适应 IO 限流,格式为 $MAJ:$MIN $TARGET_US:

# 保证 NVMe 设备上该 cgroup 的读延迟不超过 500μs
echo "259:0 500" > /sys/fs/cgroup/db-critical/io.latency

# 如果邻居 cgroup 的 IO 冲击导致延迟超过阈值
# 内核将强制 throttler 临停高负载 cgroup,直到延迟恢复

核心机制是反馈控制回路:每个请求完成后内核测量实际延迟,与 target 比较后按比例抑制超发者的 R/W 速率。这种设计天然适配 NVMe 多队列深度(low-latency)场景,而 v1 的 per-request 统计开销太大无法落地。

五、压力阻塞信息(PSI):资源竞争的终极度量

5.1 PSI 量化资源压力

Cgroup v2 引入了 Pressure Stall Information (PSI),这是衡量资源压力最精确的metrics:

$ cat /sys/fs/cgroup/workload/cpu.pressure
some avg10=23.45 avg60=18.72 avg300=12.56 total=984765213

$ cat /sys/fs/cgroup/workload/memory.pressure
some avg10=45.67 avg60=32.12 avg300=28.93 total=2345678901
full avg10=12.34 avg60= 8.56 avg300= 6.78 total= 987654321

$ cat /sys/fs/cgroup/workload/io.pressure
some avg10=67.89 avg60=54.32 avg300=41.23 total=3456789012
full avg10=34.56 avg60=21.09 avg300=15.67 total=1234567890

关键指标解读:

  • some:该 cgroup 中至少有一个任务因资源不足被阻塞的时间占比。some 表示部分资源被占用。
  • full:该 cgroup 中所有非空闲任务同时被阻塞的时间占比。full 表示资源已完全用尽。
  • avg10 / avg60 / avg300:过去 10s / 60s / 300s 的指数加权移动平均。
  • total:累计阻塞时间(微秒),用于绘图和异常检测。

5.2 PSI 在 K8s 中的生产级应用

Kubernetes 1.27+ 的 KubeletInformer 引入了 memory.pressure 事件,可直接用于 KEDA 的弹性伸缩:

# 当 PSI full avg10 > 80% 持续 30s 触发扩容
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: api-gateway-scaler
spec:
  scaleTargetRef:
    name: api-gateway
  triggers:
  - type: metrics-api
    metadata:
      targetValue: "80"
      url: "http://prometheus:9090/api/v1/query"
      valueLocation: "data.result.0.value.1"
      query: |
        cgroup_pressure_full_avg10{controller="memory",
          cgroup_name="kubepods-burstable.slice/api-gateway-xyz"}

PSI 还能用于 eBPF 驱动的负载均衡:在内核态根据当前 cgroup 的压力值实时迁移调度实体(sched_entity),避免在用户态做决策带来的延迟。

六、递归统计与生产级监控体系

6.1 cgroup.stat 与 nr_descendants

$ cat /sys/fs/cgroup/kubepods.slice/cgroup.stat
nr_descendants 47           # 后代 cgroup 总数
nr_dying_descendants 2       # 正在销毁的 cgroup 数

$ cat /sys/fs/cgroup/kubepods.slice/cgroup.events
populated 1                  # 1=至少有一个进程, 0=空
frozen 0                     # cgroup.freeze 冻结状态

6.2 使用 eBPF 实现容器级资源审计

// BPF 程序追踪 cgroup 级别内存分配
SEC("memcg_cache_alloc")
int trace_memcg_alloc(struct pt_regs *ctx) {
    u64 cgroup_id = bpf_get_current_cgroup_id();
    struct cgroup_stat *stat = bpf_map_lookup_elem(&cgroup_stats, &cgroup_id);
    stat->alloc_count++;
    stat->alloc_bytes += (u64)PT_REGS_PARM3(ctx); // size
    return 0;
}

结合 BCC 工具箱的 cgtop,可生产级实时监控:

$ cgtop -c memory -n 5
CGROUP                                  RSS        CACHE      SWAP       IO/sec
kubepods-burstable/pod-api             1.2GB      320MB      0          4500
kubepods-burstable/pod-gateway         890MB      180MB      12MB       1200
kubepods-guaranteed/pod-db             2.0GB      0          0          28000
kubepods-besteffort/pod-agent          120MB      40MB       0          80

七、K8s QoS 与 Cgroup v2 的映射关系

Kubernetes 定义了三类 QoS,与 Cgroup v2 参数的精确映射如下:

QoS 级别K8s 条件cpu.weightmemory.highmemory.swap.max
Guaranteedrequests=limits(所有资源)weight=100*(millicores/1000)不设置0 (禁用)
Burstablerequests<limits 或只设 limitsweight=100*(requests/1000)limits的90%limits-requests
BestEffort未设任何 requests/limitsweight=100 (最低优先级)节点可用内存的90%允许全部

以 250m CPU / 256Mi memory requests, 500m CPU / 512Mi limits 为例,对应的 cgroup 参数为:

# cpu.weight = 100 * 250 / 1000 = 25 (但最低为 1)
echo "25" > cpu.weight

# memory.high ≈ 512Mi * 0.9 = 460Mi  
echo "482344960" > memory.high    # 460 * 1024 * 1024

# memory.max = 512Mi (硬限)
echo "536870912" > memory.max

# memory.swap.max = 512Mi - 256Mi = 256Mi
echo "268435456" > memory.swap.max

八、生产故障案例与排错工具箱

8.1 Node 抖动真相:CPU throttle 的隐蔽影响

某金融客户反馈其 K8s 服务 P99 延迟周期性毛刺。最终定位到原因:Java 应用配置的 CPU limits=2,但没有设 requests。由于节点上突发的高优先级 daemon (如监控 agent)饱和了 CPU 2s 窗口,导致 Java 进程在 100ms 周期内的 40ms 被强制限流。Throttled 导致 STW(Stop-The-World) GC 暂停无法在对应时间窗内完成,GC 完成后又被 throttler 挂起,形成正反翻的延迟波峰。

排查命令:

# 排查 throttle
$ cat /sys/fs/cgroup/kubepods-burstable.slice/*/cpu.stat | grep throttled

# 排查 PSI
$ cat /sys/fs/cgroup/kubepods.slice/memory.pressure

# 排查节点 overcommit
$ kubectl describe node $NODE | grep -A 5 "Allocated resources"

修复方案:将 requests 从 0 调整为 1500m,limits 保持 2000m (Burstable),让权重(weight)从 100 提升到 1500,获得公平的 CPU 份额。

8.2 内存回收风暴:memory.high 设得过低

某电商推荐系统使用 Memcached 旁路缓存时因 memory.high 设得过低(实际容量的 70%),导致 page cache 被 kswapd 频繁回收,而业务又在拼命填充页面。这造成了"reclaim thrashing":内核在 5ms 内回收的页面在下一个 5ms 内又被分配,导致 iowait 飙升、吞吐量下降 60%。

关键指标告警:

$ cat memory.stat | grep -E "pgrefill|workingset_refuse|slab_unreclaimable"
pgrefill 8765432          # 过高 → 正在过度回收活跃页面
workingset_refuse 34567   # 过高 → 工作集检测异常
slab_unreclaimable 234567890  # 过高 → dentry/cache 泄漏风险

修复方案:将 memory.high 提升到实际容量的 93%,并通过 memory.min 保障 Memcached 工作集的最小保护值。

九、总结与最佳实践检查清单

Cgroup v2 不是 v1 的简单升级,它用统一层级+分层限制+压力暴露的三位一体设计解决了容器资源隔离的工程化难题:

CPU 调优清单:

  • 优先使用 weight 做弹性分配,仅在明确的 SLA 场景下用 max 强限
  • 延迟敏感型服务配置 uclamp.min,避开 CPUFreq 调频延迟
  • 长期监控 cpu.stat.throttled_count,发现隐性限流

内存调优清单:

  • 必须配置 memory.high,当 max 的 85%-95%,避免 OOM 的"硬着陆"
  • Guaranteed 服务设置 memory.min=max,在全局节点压力时获得保护
  • 结合 io.latency 保护 DB 的 IO 带宽,避免磁盘 IO 等待加剧内存回收

IO 调优清单:

  • NVMe 设备使用 io.latency 替代 io.max 做延迟 SLO 控制
  • HDD 设备的混合读写场景开 BFQ + io.weight 分配时间片
  • 用 io.stat 监控 ionice 旁路 IO(log 落盘等),防止"隐形邻居"

K8s 生态与 Cgroup v2 的深度融合,正在让"声明式资源管理"从 API 层贯通到内核层 — 你写的 requests/limits 不再只是调度提示,而是一条精确的硬件运行时间片合同。理解它,是每一位容器平台工程师从"配置工"走向"系统工程师"的关键一步。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部