Linux 内核 cgroup v2 深度实战:容器资源隔离与生产级调优

在云原生时代,容器技术的基石是 Linux 内核的 cgroup(Control Groups)机制。从 2006 年 Google 工程师提出 Process Containers,到如今 cgroup v2 成为 Kubernetes 1.28+ 的默认资源管理器,这场演进的背后是对"公平调度"与"强隔离"这一永恒命题的不懈追求。本文将从内核源码级别完整剖析 cgroup v2 的架构设计、核心子系统实现、容器运行时集成方案,以及生产环境中资源争抢、OOM 排查、PSI 压力诊断的实战方法论。

一、cgroup v1 的困境与 v2 的架构革命

1.1 v1 的历史包袱

cgroup v1 于 2013 年随 Linux 3.8 进入主线,其"按子系统独立挂载"的设计在当时灵活,但在容器化场景中暴露出严重问题:

子系统分裂:CPU、memory、blkio、net_cls 等子系统各自维护独立的层级树,导致一个容器在不同 hierarchy 中的位置不一致,控制器之间的协同几乎不可能。例如 memory 限制已经触发 OOM,但 CPU 子系统对这个容器的负载一无所知。

线程组语义模糊:v1 的 CPU 子系统对线程组(thread group)的资源记账存在限制,无法精确追踪一个进程组的总体 CPU 消耗。

层级管理复杂:每个子系统单独挂载,默认挂载点多达 12+ 个,运维人员需要理解每个挂载点的含义才能正确配置容器。

1.2 v2 的 Unified Hierarchy

cgroup v2(由 Facebook 的 Tejun Heo 主导,Linux 4.5 引入,4.15 生产可用)的核心设计哲学是"一个控制器,一棵树":

# v1: 每个子系统独立挂载
/sys/fs/cgroup/cpu/docker/abc123/
/sys/fs/cgroup/memory/docker/abc123/
/sys/fs/cgroup/blkio/docker/abc123/

# v2: 统一挂载点
/sys/fs/cgroup/docker/abc123/    # 单一视图,统一控制

v2 的三大核心改进:

统一层级(Unified Hierarchy):所有控制器在同一棵树下操作,资源限制决策点从分散的子系统收敛为单一逻辑点,消除了 v1 中"CPU 子系统不知道 memory 子系统在做什么"的根本缺陷。

基于权重的资源分配:v2 的 CPU 子系统抛弃了 v1 的 cpu.shares,引入 cpu.weight(1-10000),使用改进的对数算法计算权重比例,避免了整数溢出的同时提供更平滑的分配曲线。

递归资源统计(Recursive Accounting):v2 的资源使用统计天然包含所有后代 cgroup,不需要像 v1 那样为每个子树单独遍历,极大提升了监控效率。

二、cgroup v2 核心子系统深度剖析

2.1 CPU 子系统:从 CFS 带宽控制到 UCLAMP

v2 的 CPU 资源控制围绕三个核心接口:cpu.weight、cpu.max、cpu.uclamp_min/max。

cpu.weight(权重分配)

对应内核中的 CFS(Completely Fair Scheduler)带宽分配。权重不是绝对值,而是比例关系:

/sys/fs/cgroup/A/cpu.weight = 5000
/sys/fs/cgroup/B/cpu.weight = 1000
/sys/fs/cgroup/C/cpu.weight = 4000
# A:B:C 获得 CPU 时间比例 = 50% : 10% : 40%

权重算法的实现位于 kernel/sched/fair.c 的 calc_delta_fair() 函数中。当 vruntime 相等的两个进程竞争 CPU 时,权重高的进程获得更长的实际运行时间片:

// kernel/sched/fair.c
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
    if (unlikely(se->load.weight != NICE_0_LOAD))
        delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
    return delta;
}

cpu.max(硬带宽上限)

格式为 $MAX $PERIOD,表示每个 PERIOD 微秒内最多使用 $MAX 微秒 CPU 时间:

# 限制容器最多使用 2 个 CPU 核心 (200000us/100000us)
echo "200000 100000" > /sys/fs/cgroup/container_cpu/cpu.max

# 限制为 0.5 核 (50000us/100000us)
echo "50000 100000" > /sys/fs/cgroup/container_cpu/cpu.max

内核实现位于 kernel/sched/core.c 的 cfs_bandwidth 机制。当 cgroup 在一个周期内耗尽 CPU 配额后,其所有进程会被"节流"(throttled),直到下一个周期开始。此时进程状态显示为R(运行中)但其实不会获得 CPU 时间片。

cpu.uclamp_min / cpu.uclamp_max(利用率夹具)

Linux 5.3+ 引入的 UCLAMP(Utilization Clamping)机制,允许用户空间设置一个 cgroup 的 CPU 利用率期望范围。内核的 EAS(Energy Aware Scheduler)和Freq governor 会根据这个 hint 调整 CPU 频率和任务放置:

# 要求 systemd 关键服务至少获得 80% 的 CPU 利用率映射
echo "80" > /sys/fs/cgroup/system.slice/cpu.uclamp_min

# 限制批处理任务最多使用 30% 的 CPU 频率提升空间
echo "30" > /sys/fs/cgroup/user.slice/batch.slice/cpu.uclamp_max

2.2 内存子系统:三层水位线与 OOM 防护

v2 的内存控制比 v1 精细得多,提供 memory.min、memory.low、memory.high、memory.max 四个水位线:

memory.min  (硬保护) ─┐
                      ├── 三层防御体系
memory.low (尽力保护) ─┤
                      │
memory.high(软限制)  ─┤
                      │
memory.max (硬上限)  ─┘

内存压力传导方向:max → high → low → min

memory.min(不可回收内存)

设置此值后,内核保证该 cgroup 至少有这么多内存不会被回收(除非系统处于极端内存不足)。交换也不会发生在此处。用于保护数据库缓存等关键工作集:

# 保证 MySQL 容器至少有 2GB 工作集不被回收
echo "2147483648" > /sys/fs/cgroup/system.slice/mysql.service/memory.min

memory.high(节流触发点)

当 cgroup 内存使用量超过此值,内核的 reclaim 机制会同步地回收该 cgroup(以及其后代)的内存,导致进程短暂阻塞。这不是 OOM,而是一种"温和的压力反馈":

# 软限制容器内存为 4GB
echo "4294967296" > /sys/fs/cgroup/docker/container_abc/memory.high

与 memory.max 的关键差异:high 不触发 OOM killer,只是节流;max 是直接触发 OOM 的最后防线。实践中通常设置 high = max 的 90%,给应用一个"减速带"而非"撞墙"。

memory.max(强行限制)

Kubernetes 中 resources.limits.memory 被直接映射为 memory.max。当 cgroup 内存使用量触及此值且无法回收时,cgroup OOM killer 介入:

# K8s: resources.limits.memory = 2Gi
echo "2147483648" > /sys/fs/cgroup/kubepods.slice/.../memory.max

PSI(Pressure Stall Information)

v2 时代伴随的内核特性(Linux 4.20+),通过 memory.pressure、cpu.pressure、io.pressure 提供资源压力的量化指标:

some avg10=12.34 avg60=08.50 avg300=03.21 total=987654321

# some = 至少有一个任务被阻塞的平均概率
# avg10/avg60/avg300 = 最近 10s/60s/300s 的移动平均
# total = 自 cgroup 创建以来的累计阻塞时间(微秒)

相比传统监控(看 Free 内存、CPU idle),PSI 直接告诉你"有多少任务在等待资源",是容器化场景下最精准的内存压力指标。

2.3 IO 子系统:IO cgroup v2 与 BFQ

v2 统一了 IO 控制,核心接口为 io.weight 和 io.max:

io.weight(权重比例)

类似 cpu.weight,控制 IO 带宽的比例分配:

/sys/fs/cgroup/db/io.weight = 800    # 默认 100
/sys/fs/cgroup/log/io.weight = 50
# DB 日志获得 800:50 = 94% : 6% 的 IO 时间

io.max(设备级别限速)

按照 "$MAJ:$MIN rbps= wbps= riops= wiops=" 格式,对指定设备做绝对值限制:

# 对 /dev/sda (8:0) 限速:读 100MB/s,写 50MB/s,读 2000 IOPS,写 1000 IOPS
echo "8:0 rbios=104857600 wbios=52428800 riops=2000 wiops=1000" \
    > /sys/fs/cgroup/docker/container_abc/io.max

v2 默认使用 BFQ(Budget Fair Queueing)作为权重调度器,相比 v1 的 CFQ,BFQ 在混合读写工作负载下提供更公平的延迟保证,特别适合容器存储后端。

三、内核实现:cgroup v2 的工作机制

3.1 cgroup 的挂载与生命周期

cgroup v2 的挂载只有一次:

mount -t cgroup2 none /sys/fs/cgroup

挂载后整个文件系统由内核动态生成,每个子目录对应一个 cgroup。当你 mkdir /sys/fs/cgroup/test 时,内核内部调用 cgroup_mkdir(),初始化该 cgroup 在所有控制器上的 cgroup_subsys_state(css)以及资源记账结构。

关键生命周期回调链:

cgroup_mkdir()
  └── cgroup_apply_control_enable()
        └── 遍历每个 subsys->css_alloc() 分配 css
        └── subsys->css_online() 上线控制器
  └── proc_cgroup_show() 更新 proc 视图

3.2 资源记账的底层机制

CPU 记账的时间精度达到纳秒级。每个 task_struct 中的 sched_entity 维护 exec_start(开始执行时刻)和 sum_exec_runtime(累计执行时间),在上下文切换时通过 account_cgroup_runtime() 更新。

v2 通过 css_rstat_flush()(per-CPU 统计合并)实现高效的全局统计读取。更新路径如下:

schedule() → context_switch()
  └── finish_task_switch()
        └── __balance_callbacks()
              └── cgroup_dec_tasks()
                    └── 更新 percpu 计数器

# 统计读取路径(proc 或 cgroup 文件读取)
cgroup_flush_stats()
  └── css_rstat_flush()
        └── 合并所有 CPU 上的 percpu 计数器

v1 每次读 cpuacct.usage 需要遍历整个 cgroup 树;v2 通过 percpu 计数器让读取操作变为 O(CPU_COUNT) 而不再是 O(tasks),在大规模部署下性能提升十分显著。

3.3 内存限制的实现路径

当容器进程分配内存时,__alloc_pages() 触发 charge(计费)链:

__alloc_pages()
  └── get_page_from_freelist()
        └── memalloc_noreclaim_clear()
  → page_counter_try_charge()
        └── page_counter_uncharge(success: continue)
        └── 触发 memory.high 回收:
            try_charge() returns false → charge 失败
                → cgroup_direct_reclaim() 同步回收
                → 回收失败 → cgroup_oom()
        └── 触发 memory.max 失败:
            → out_of_memory()/cgroup_oom()

四、容器运行时的 cgroup v2 集成

4.1 containerd 的配置

containerd 从 1.4 开始支持 cgroup v2,通过 SystemdCgroup = true 标识启用 systemd 作为 cgroup 驱动:

# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".containerd]
  SystemdCgroup = true

[plugins."io.containerd.grpc.v1.cri"]
  SystemdCgroup = true

containerd 创建容器时按以下路径创建 cgroup:

/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/  ← Pod QoS
  └── kubepods-burstable-podUUID.slice/                  ← 按 Pod 组织
        └── cri-containerd-CONTAINERID.scope/             ← 按容器隔离

4.2 Kubernetes 的 cgroup 映射

K8s 1.28+ 默认使用 cgroup v2,Pod 的 QoS 级别直接映射到不同的 cgroup 目录:

# Guaranteed (所有容器的 requests == limits)
/sys/fs/cgroup/kubepods.slice/kubepods-guaranteed.slice/

# Burstable (至少有一个容器 requests < limits)
/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/

# BestEffort (没有任何资源请求)
/sys/fs/cgroup/kubepods.slice/kubepods-besteffort.slice/

Pod 的 resources.requests.cpu 映射为 cpu.weight:

// 简化后的权重计算逻辑
weight = 1 + ((request_millicores - 2) * 9998 / (1000000 - 2))
// 即 2m → weight=1, 1000m (1核) → weight=10

resources.limits.cpu 映射为 cpu.max:

cpu.max = "${limit * 1000} 100000"
# 例:limit=2核 → "200000 100000"

resources.requests.memory 和 resources.limits.memory 分别映射为 memory.min 和 memory.max。中间用 memory.high 作为软限制:K8s 将 memory.high 设置为 limits.memory 的 90%。

4.3 systemd 与 cgroup v2 的协同

Linux 发行版中 systemd 是 cgroup 树的"根管理者"。通过 systemctl set-property 可以直接给 systemd service 设置 cgroup 参数:

# 限制 nginx service 最多使用 4 核 CPU + 4GB 内存
systemctl set-property nginx.service \
    CPUQuota=400% \
    MemoryMax=4G \
    MemoryHigh=3.6G \
    IOWeight=500

对应到 cgroup 文件:

/sys/fs/cgroup/system.slice/nginx.service/cpu.max = "400000 100000"
/sys/fs/cgroup/system.slice/nginx.service/memory.max = "4294967296"
/sys/fs/cgroup/system.slice/nginx.service/memory.high = "3865470566"
/sys/fs/cgroup/system.slice/nginx.service/io.weight = "500"

五、生产环境实战:资源争抢与故障排查

5.1 CPU Throttling 诊断

容器 CPU 被节流时应用表现为"CPU 使用率不高但请求延迟飙升"。排查路径:

# 1. 查看 cgroup 的 CPU 节流次数和累计时间
cat /sys/fs/cgroup/.../cpu.stat

# 典型输出:
nr_periods 12345                    # 累计周期数
nr_throttled 567                    # 被节流的次数
throttled_time 8912345678           # 累计节流时间(纳秒)= 8.9秒

# 计算节流率 = nr_throttled / nr_periods
# > 10% 通常意味着需要调整 CPU limits 或优化应用

v2 相比 v1 的改进:cpu.stat 是唯一文件,不像 v1 分散在 cpu.stat、cpuacct.usage_percpu、cpuacct.usage 等多个文件中。

5.2 OOM 事件定位

cgroup OOM 比系统级 OOM 更为隐蔽,因为 OOM 仅在当前 cgroup 内触发:

# 监控 cgroup OOM 事件
tail -f /sys/fs/cgroup/.../memory.events

# 输出:
oom 2                   # 累计 OOM kill 次数
oom_kill 1              # 最近一次 kill 的进程数
low 0                   # 触碰 memory.low 的次数
high 45                 # 触碰 memory.high 被节流的次数
max 2                   # 触碰 memory.max 的次数

查看 OOM 时的内存组成:

cat /sys/fs/cgroup/.../memory.stat

# 关键字段:
anon 2147483648         # 匿名映射(堆内存),OOM 主要来源
file 536870912          # 页缓存(文件缓存),通常可回收
kernel_stack 16777264   # 内核栈占用
pagetables 4194304      # 页表占用(进程多时可观)
percpu 0                # percpu 内存
sock 8388608            # socket 缓冲区

5.3 PSI 驱动的自适应调度

使用 PSI 实现弹性伸缩的优先级控制:

# 监控脚本示例(bash)
while true; do
    PSI=$(cat /sys/fs/cgroup/docker/db/memory.pressure | grep "^some" | awk '{print $2}' | cut -d= -f2)
    # 如果最近 10s 内存压力超过 20%,暂停非关键任务
    if (( $(echo "$PSI > 20" | bc -l) )); then
        echo "High memory pressure: $PSI%. Pausing batch jobs."
        systemctl stop batch-processor
    fi
    sleep 5
done

5.4 IO 延迟异常排查

容器 IO 延迟飙升但磁盘利用率不足,可能原因:

blkio cgroup 权重不公平:日志容器的 IO 权重与数据库相同,大量写入抢占 IO 带宽。

# 示例:将数据库容器的 IO 权重从默认 100 提升到 800
echo "800" > /sys/fs/cgroup/kubepods/.../db/io.weight
# 日志容器设为 50
echo "50" > /sys/fs/cgroup/kubepods/.../log/io.weight

块设备队列拥塞:检查 io.stat:

cat /sys/fs/cgroup/.../io.stat
# 输出:8:0 rbytes=1234567 wbytes=987654 rios=1000 wios=500 dbytes=0 dios=0
# 其中 8:0 是 /dev/sda 的设备号

六、高级场景:cpuset 与 NUMA 优化

cgroup v2 的 cpuset 控制器在生产环境中对高性能应用至关重要:

# MySQL 独占 NUMA node 0 的 CPU 16-31,禁止使用 node 1
echo "16-31" > /sys/fs/cgroup/mysql/cpuset.cpus
echo "0" > /sys/fs/cgroup/mysql/cpuset.mems

# Redis 绑定到 NUMA node 1 的 CPU 0-7
echo "0-7" > /sys/fs/cgroup/redis/cpuset.cpus
echo "1" > /sys/fs/cgroup/redis/cpuset.mems

NUMA 跨节点访问延迟约为本地节点的 1.5-2 倍。通过 cpuset 强绑定,允许应用的内存分配发生在本地 NUMA 节点,减少跨节点内存访问。Kubernetes 中对应 TopologyManager 的 single-numa-node 策略。

v2 还引入 cpuset.cpus.exclusive 和 cpuset.cpus.partition,支持将 CPU 核心"分区"(partition)为独占模式,完全消除邻居干扰:

# 将 CPU 8-15 设置为独占分区
echo "8-15" > /sys/fs/cgroup/critical/cpuset.cpus
echo "root" > /sys/fs/cgroup/critical/cpuset.cpus.partition

七、cgroup v2 的局限与未来展望

7.1 当前不足

网络控制缺失:v2 至今没有内置的网络带宽控制器(net_cls/net_prio 被剥离),容器网络限速仍依赖 tc (traffic control) 或 eBPF。Canonical 和 Red Hat 正在推动 net 标准化。

RDMA 控制不完善:rdma 控制器在 v2 中仅支持限额报告不支持动态调整,对 HPC 和 AI 训练场景不友好。

PID 限制器:pids 控制器只支持硬上限(pids.max),没有类似 weight 的软限制方案。

7.2 未来方向:cgroup 与 eBPF 融合

Linux 社区正在探索将 eBPF 程序附加到 cgroup hook 点,实现完全自定义的资源控制器:

// 概念性代码:cgroup/BPF 资源控制器
SEC("cgroup/dev")
int bpf_cgroup_dev_access(struct bpf_cgroup_dev_ctx *ctx)
{
    struct cgroup_ctx *cg = bpf_get_ctx();
    
    // 用 BPF map 自定义 IO 调度策略
    u32 *limit = bpf_map_lookup_elem(&io_limit_map, &cg->id);
    if (limit && ctx->access > *limit)
        return 0; // 拒绝访问
    
    return 1; // 允许访问
}

这意味着未来内核可能不内置特定资源控制器,而是通过 eBPF 让系统管理员实现自己的资源调度策略——这是 cgroup v3 或 eBPF-cgroup 融合的方向。

另一个值得关注的趋势是 cgroup-aware OOM killer 的改进,当前 v2 的 OOM 对进程的评估仅基于内存消耗量,未来的设计将加入"进程重要性评分"(基于 oom_score_adj 和 BPF 自定义函数)来决定 kill 策略,让 OOM 更智能。

八、总结

cgroup v2 不是 v1 的简单升级,而是 Linux 容器资源管理哲学的一次重构。从 Unified Hierarchy 的架构设计,到权重分配算法的精进,再到 PSI 压力指标和 UCLAMP 的引入,v2 的每一个特性都指向同一个目标:让操作系统更精确、更公平地为容器工作负载分配资源。

对于生产环境的关键建议:

监控优先于限制:先用 PSI 和 memory.events 观测真实压力,再决定 cpu.max/memory.high 的阈值。盲目设限导致不必要节流比不设限更糟糕。

分层防御:memory.min 保护核心缓存 + memory.high 软节流 + memory.max 兜底,三层配合比单一 memory.limit_in_bytes 更平滑。

权重优于硬限:在节点利用率 70% 以下的工作中,cpu.weight 比 cpu.max 更能充分利用空闲 CPU,同时避免突发负载下的过度节流。

NUMA 意识不可少:多核服务器上 cpuset + NUMA 绑定的性能收益往往远超应用层优化。

cgroup v2 是现代 Linux 基础设施的基石组件,理解其内部机制,是每个云原生工程师的核心能力。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部