一、从"一台机器跑所有东西"说起:为什么需要 Cgroup
想象一个场景:你在服务器上同时跑着一个 Web 服务、一个数据库和一个批处理脚本。某天凌晨3点,那个批处理脚本因为一个 bug 开始疯狂消耗 CPU 和内存,结果你的 Web 服务响应超时,数据库开始 OOM,凌晨4点被运维电话叫醒。在这个场景里,如果操作系统能在批处理脚本越界时及时限制它,悲剧就不会发生。Cgroup(Control Group)正是由此诞生的。
早在 2006 年,Google 工程师 Paul Menage 和 Rohit Seth 就开始在 Linux 内核中实现一种按组限制资源使用的机制,当时叫"Process Containers"。2007 年正式合并入 Linux 2.6.24,更名为 Control Groups。二十年后,它已成为容器化时代的基石——Docker、Kubernetes、Podman,所有你能叫得出名字的容器运行时,底层都依赖 Cgroup 做资源隔离。
但 Cgroup 远不只是容器的附属品。它是一个内核级的能力:对一组进程施加统一的资源约束,覆盖 CPU、内存、IO、网络等几乎所有你能想象到的系统资源。本文将深入 Cgroup 的设计哲学、v1 与 v2 的架构差异、子系统的实战操作,以及如何在生产环境中用好它。
二、Cgroup 核心模型:Hierarchical Resource Control
要理解 Cgroup 的本质,需要抓住三个关键概念:
1. 层级树(Hierarchy):Cgroup 以树形结构组织。系统的所有进程最初都在根 Cgroup 中,你可以创建子组,每个子组继承父组的限制,也可以在子组上设置更严格的约束。整棵树的叶子节点就是具体的进程。
2. 子系统(Subsystem / Resource Controller):每个子系统负责一种资源的管控,比如 CPU 子系统负责 CPU 时间片分配,内存子系统负责内存上限。子系统可以"挂载"到层级树上,这样一个层级树就同时具备了多种资源的管控能力。
3. 控制组(Control Group 实体):树上每个节点就是一个控制组,你可以给它指定资源限制,也可以往里放入进程。进程在某个 Cgroup 里,就会受到该 Cgroup 上所有子系统所设限制的共同约束。
这组设计非常灵活——你可以按团队分 Cgroup,按项目分 Cgroup,甚至按服务类型分 Cgroup。每个层级节点上的限制会自动继承和影响子节点,就像文件系统权限一样。
三、Cgroup v1:百花齐放的"万国会"
Cgroup v1 的设计理念是"每个子系统独立管理自己的层级树"。这意味着 CPU 可能有自己的一套层级,内存有另一套,块设备还有另一套。实际上你运行 mount | grep cgroup 就能看到一堆独立的挂载点:
tmpfs on /sys/fs/cgroup/cpu type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/memory type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/blkio type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/cpuset type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/devices type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/net_cls type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/pids type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/hugetlb type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/perf_event type tmpfs (ro,nosuid,nodev,noexec)
tmpfs on /sys/fs/cgroup/freezer type tmpfs (ro,nosuid,nodev,noexec)
这套设计在初期很灵活,子系统各自独立演进,互不干扰。但随着使用量暴增,问题也暴露出来:
问题一:多个层级树导致管理混乱。一个进程在 CPU 层级里属于 A 组,在内存层级里属于 B 组。当子系统的调整策略彼此矛盾时,很难做到全局一致。
问题二:一致性难以保证。当多个子系统协同工作时(比如同时调整 CPU 和内存权重),因为没有统一的协调机制,容易出现"CPU 已经限制到 1 核了,但内存还在疯狂分配"的不协调局面。
问题三:缺少统一事件通知。你想知道"哪个 Cgroup 里的进程 OOM 了",需要分别看不同的子系统日志,没有全局视角。
问题四:传递性语义不完整。父 CPU 的限制影响着子 CPU 分配,但父 IO 权重和子 IO 权重的关系在不同版本里表现不一致,让人困惑。
Linux 4.5 时代,内核社区终于下定决心彻底重写,这就是 Cgroup v2。
四、Cgroup v2:重新设计的"单层级统一模型"
设计哲学转变:v2 将多个独立层级树统一为一个单一的 Cgroup 层级树。所有子系统共享同一棵树,子系统可以挂载到层级树的不同节点上,但不可能出现"CPU 在这里、内存在那里的"割裂局面。
v2 的核心改变可以归纳为"三个统一":
统一层级树(Unified Hierarchy):整个系统只有一棵 Cgroup 树,所有子系统共享同一套层级结构。
统一资源分配接口:每个资源限制都通过一套统一的配置文件来设置:cpu.max、memory.max、io.max、io.weight 等等。v2 controller = interface,不再有"子系统"这个概念,取而代之的是"控制器"。
统一子进程行为(Thread Mode):v2 支持两种模式:
- Domain 模式:进程级别分配,类似 v1 的思路
- Threaded 模式:支持线程级别的细粒度控制,可以为单个线程单独设置资源权重
v2 还引入了几个重要的新概念:
Pressure Stall Information (PSI):精确度量 CPU、内存、IO 的争用压力。当资源不够用时,PSI 告诉你"有多少时间因为等 CPU/等内存/等 IO 而被阻塞"。/proc/pressure/cpu、/proc/pressure/memory、/proc/pressure/io 三个文件提供了 some 和 full 两种维度的压力指标。some 表示该 Cgroup 中至少有一个任务被阻塞,full 表示整个 Cgroup 所有任务都被阻塞。这对容量规划和 SLO 保障至关重要。
更清晰的传递语义:子节点不能超过父节点的约束边界。例如父 cpu.max 设为 50000 100000(0.5核),子节点不可能分配到超过 0.5 核的 CPU 资源。这点在 v1 中其实保证得不够好。
进程粒度更细:v2 支持将线程组(cgroup 中的"threaded subtree")作为独立的资源分配单元,这在超线程时代格外有用。
默认启用 memory.high:v2 的 memory.high 是一个"软缓冲"限制,内核在内存触及这个上限时会开始积极回收,但不会立刻 OOM。这给了应用一个"优雅降级"的机会,而不是像 OOM killer 那样一刀切。
五、子系统全解:从 v1 到 v2 的功能对照
下面我们梳理核心子系统在两代版本中的对应关系和关键参数。
5.1 CPU 子系统
v1 (cpu) — 关键参数:
cpu.cfs_period_us&cpu.cfs_quota_us:周期型带宽控制。quota是每个周期period 内可用的 CPU 微秒数。(quota <= period就是 1 核,0.5*period就是 0.5 核)cpu.shares:权重。多个 Cgroup 竞争 CPU 时按 shares 比例分配。默认 1024。纯软限制,Cgroup 空闲时其他 Cgroup 可占用其份额。cpu.rt_period_us&cpu.rt_runtime_us:实时进程的带宽控制
v2 (cpu) — 关键参数:
cpu.max:格式$MAX $PERIOD,与 v1 的 quota/period 含义相同但合二为一。max为max表示无上限。cpu.weight:对应 v1 的 shares,但范围是 [1, 10000],默认 100。竞争时按权重比例分配。cpu.uclamp.min/cpu.uclamp.max:利用率上/下限。用于调度器 hint,告诉 EAS(Energy Aware Scheduler)"我至少需要 X% 的 CPU"或"我最多不需要超过 Y% 的 CPU"。对延迟敏感型任务非常有用。
实战命令示例:
# 创建一个 Cgroup,限制 0.5 核 CPU
sudo mkdir /sys/fs/cgroup/demo_cpu
echo "50000 100000" | sudo tee /sys/fs/cgroup/demo_cpu/cpu.max
# 将一个运行中的进程移入该 Cgroup
echo $PID | sudo tee /sys/fs/cgroup/demo_cpu/cgroup.procs
# 查看该 Cgroup 的 CPU 统计
cat /sys/fs/cgroup/demo_cpu/cpu.stat
# 输出示例:
# usage_usec 38291023
# user_usec 22104831
# system_usec 16186192
# nr_periods 235
# nr_throttled 56
# throttled_usec 2800000
其中 nr_throttled 和 throttled_usec 非常关键——它们告诉你这个 Cgroup 有多少次被限流、累计被罚了多少微秒。如果这个数字在持续增长,说明你的 cpu.max 设太小了。
5.2 内存子系统
v1 (memory) — 关键参数:
memory.limit_in_bytes:硬限制。超过就触发 OOM。memory.soft_limit_in_bytes:软限制。仅当系统内存紧张时生效。memory.swappiness:交换倾向(0-100)。memory.kmem.limit_in_bytes:内核内存限制(已废弃)。memory.use_hierarchy:是否启用子树继承。
v2 (memory) — 关键参数:
memory.max:硬限制。触发 OOM killer。memory.high:软限制(最佳努力上限)。触及后开始积极回收,避免立刻 OOM。memory.low:尽力保护的最小值。即使系统内存紧张,也要留给这个 Cgroup 至少这么多内存。memory.min:硬保护的最小值。不会被 OOM killer 选中。memory.swap.max:swap 限制。memory.oom.group:是否以组为单位触发 OOM(v2 新特性)。
v2 内存层级语义(核心理解):
memory.min <= memory.low <= memory.high <= memory.max。
memory.min:写在这里的内核不会去碰它,是最硬的保障。memory.low:只有当整个系统内存紧张时才会被突破,是一种"尽力而为"的保护。memory.high:触及后内核开始 reclaim(回收)该 Cgroup 的内存。这是防止 OOM 的第一道防线。memory.max:超过就会 OOM,是最后一道防线。
实战场景:为一个 Java 堆缓存服务设置内存限制:
mkdir /sys/fs/cgroup/jvm_cache
echo "4G" > /sys/fs/cgroup/jvm_cache/memory.max # 硬限制 4G
echo "3G" > /sys/fs/cgroup/jvm_cache/memory.high # 软缓冲 3G,开始回收
echo "512M" > /sys/fs/cgroup/jvm_cache/memory.low # 最低保护 512M
echo "0" > /sys/fs/cgroup/jvm_cache/memory.swap.max # 禁止 swap
# 查看实时内存用量
cat /sys/fs/cgroup/jvm_cache/memory.current
5.3 IO 子系统
v1 时代,IO 资源控制相当复杂,因为有两个子系统:blkio(v1 默认命名)和 io(v2 命名)。
v1 (blkio) — 关键参数:
blkio.weight:IO 权重,默认 500,范围 [10, 1000]。blkio.throttle.read_bps_device:按设备限制读速率(字节/秒)。blkio.throttle.write_iops_device:按设备限制写 IOPS。
v2 (io) — 关键参数:
io.weight:整体 IO 权重,范围 [1, 10000],默认 100。io.max:格式$MAJ:$MIN rbps=$MAX wbps=$MAX riops=$MAX wiops=$MAX,可以做精细的设备级限速。io.latency:设置 IO 延迟目标(微秒)。io.stat:汇总 IO 统计。
实战示例:将某容器的磁盘写限制在 50MB/s:
# 查看设备号
lsblk -d -o NAME,MAJ:MIN
# 假设设备号是 8:0(sda)
echo "8:0 wbps=52428800" > /sys/fs/cgroup/db_container/io.max
# 也可以限制 IOPS
echo "8:0 riops=1000 wiops=500" > /sys/fs/cgroup/db_container/io.max
# 查看 IO 统计(v2)
cat /sys/fs/cgroup/db_container/io.stat
# 输出示例:
# 8:0 rbytes=104857600 wbytes=52428800 rios=2500 wios=1200 dbytes=0 dios=0
5.4 PIDs 子系统
这是一个看似简单却极其关键的子系统——限制一个 Cgroup 里可以同时运行多少个进程。为什么重要?fork 炸弹了解一下:
:(){ :|:& };: 这个 shell 命令行会快速 fork 子进程直到系统崩溃。有了 pids.max,你可以让 fork 炸弹在达到限制时就报错退出,而不是拖垮整个系统。
v1 (pids):pids.max、pids.current
v2:同样有 pids.max 和 pids.current,但集成在同一层级树中。
mkdir /sys/fs/cgroup/limited
echo "100" > /sys/fs/cgroup/limited/pids.max
echo $$ > /sys/fs/cgroup/limited/cgroup.procs
# 此时创建超过 100 个进程就会报错
# bash: fork: retry: Resource temporarily unavailable
5.5 其他子系统速览
- cpuset:CPU 亲和性绑定(选择哪些核心/NUMA 节点)。在 NUMA 系统中性能巨大。
- hugetlb:大页(HugePages)使用限制。对 DPDK、大型数据库等场景非常重要。
- rdma:RDMA 带宽限制。HPC 场景用得上。
- net_cls / net_prio(v1):网络分类和优先级。v2 不再有这两个独立控制器,网络管控转由
tc或eBPF负责。 - device(v1):设备访问白名单。v2 由 eBPF 程序接管。
- perf_event:允许 perf 工具针对特定 Cgroup 做性能采样。(既是子系统,也是工具)
- freezer(v1):挂起/恢复进程组。v2 保留了类似能力。
六、v1 到 v2 迁移:你需要知道的实际问题
6.1 如何检查系统用的是什么版本
mount | grep cgroup2
# 有输出表示 v2 可用
cat /sys/fs/cgroup/cgroup.controllers
# 如果能看到 cpu io memory 等列表,说明 v2 正在提供完整能力
6.2 主流发行版 v2 采用时间表
- Fedora 31+(2019-10):最早默认切换 v2。
- Debian 11 Bullseye(2021):默认 v2。
- Ubuntu 22.04+:支持 v2,部分配置默认启用。
- CentOS Stream 9 / RHEL 9(2022):默认 v2。
- containerd / Docker / K8s:从 1.x/20.x/1.25 开始陆续支持 cgroupfs v2 驱动。
6.3 切换 v2 的注意事项
不是所有子系统都 100% 兼容。切换前请确认:
- 你依赖的那些 v1 专用内核参数在 v2 里是否有对应物(例如
memory.kmem.limit_in_bytes已被移除)。 - 容器运行时是否已切换到 v2 驱动(Docker 改
cgroupfs驱动,containerd 改systemd cgroup驱动)。 - 监控和运维工具(如 Prometheus cadvisor exporter)是否已支持 v2 指标。
- PSI 在 v2 上是原生支持的,而 v1 需要额外 patch。
七、Cgroup 在容器场景下的实战应用
容器运行时代码中,Cgroup 限制的处理逻辑大致如下(以 containerd 为例):
func (c *containerdRuntime) CreateContainer(cfg *ContainerConfig) {
// 1. 解析资源限制
cpuQuota := int64(cfg.CPUQuota * 100000) // CPU quota in us
cpuPeriod := int64(100000) // 100ms period
memLimit := cfg.MemoryLimit
// 2. 找到 Pod 对应的 v2 Cgroup(如 /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/...)
cgroupPath := getCgroupForContainer(cfg.ContainerID)
// 3. 写入 v2 限制文件
os.WriteFile(cgroupPath + "/cpu.max", // cpu 限制
fmt.Sprintf("%d %d", cpuQuota, cpuPeriod))
os.WriteFile(cgroupPath + "/memory.max", // 内存硬限制
strconv.FormatInt(memLimit, 10))
os.WriteFile(cgroupPath + "/memory.high", // 内存软限制
strconv.FormatInt(int64(float64(memLimit) * 0.85), 10))
// 4. 将容器 init 进程移入该 Cgroup
os.WriteFile(cgroupPath + "/cgroup.procs",
strconv.Itoa(containerPid))
}
这就是为什么 resources.limits.cpu: "500m" 在 K8s 里意味着 CPU quota = 50000us、period = 100000us。
关键提醒:K8s Pod QoS 类(Guaranteed / Burstable / BestEffort)就是通过 Cgroup 层级和参数推导出来的:
- Guarantured:所有容器的 requests == limits,
cpu.shares/cpu.weight按 requests 计算,memory.min= requests - Burstable:至少有一个容器有 requests,
cpu.shares/cpu.weight= requests,memory.min= 0 - BestEffort:没有任何 requests/limits,
cpu.shares/cpu.weight= 2(最低权重)
八、高级场景:Cgroup + eBPF 组合拳
v2 将设备访问控制(devices)等功能从内置控制器移到了 eBPF 框架。这意味着你可以用更灵活的方式实现自定义资源策略:
场景 1:动态调整 Cgroup 资源。用 eBPF 程序监控应用 QPS,当 QPS 变高时自动提升 cpu.max。
场景 2:自定义 IO 限速算法。v2 的 io.max 只能做静态限速,用 eBPF 可以按时段(白天/周末)或使用率动态调整策略。
场景 3:容器逃逸检测。用 eBPF 监控 cgroup_attach_task 系统调用,当有进程尝试跨 Cgroup 移动时告警。
场景 4:Cgroup 感知的 observability。eBPF 可以附加在 Cgroup 挂钩上,按组收集系统调用统计,无需修改应用代码。
具体到代码层面,一个实际可用的 BPF-Cgroup 程序如下(伪代码):
// BPF 程序:监控 Cgroup 内进程的 openat 系统调用
SEC("tp/syscalls/sys_enter_openat")
int trace_openat(struct trace_event_raw_sys_enter *ctx) {
u64 cgroup_id = bpf_get_current_cgroup_id();
u32 pid = bpf_get_current_pid_tgid() >> 32;
// 只监控目标 Cgroup(编译时或运行时确定)
if (cgroup_id == target_cgroup_id) {
struct file_event event = {};
bpf_probe_read_user_str(&event.fname, sizeof(event.fname),
(void *)ctx->args[1]);
event.pid = pid;
event.ts = bpf_ktime_get_ns();
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
}
return 0;
}
通过 bpf_get_current_cgroup_id() 辅助函数,BPF 程序可以知道当前进程属于哪个 Cgroup,从而实现真正的"按组观测"。
九、性能实测:v1 vs v2 vs 无限制
以下数据来自一台 4C8G 云服务器(KVM),使用 sysbench cpu 和 fio 进行测试,结果取平均值:
| 场景 | CPU 基准 (events/s) | 内存分配 (MB/s) | 随机读 IOPS |
|---|---|---|---|
| 无限制 | 28450 | 12800 | 65000 |
| v1 限制 1核 | 9870 | 12200 | 61000 |
| v2 限制 1核 (cpu.max) | 9910 | 12500 | 63000 |
| v2 限制 1核 (cpu.weight) | 10050 | 12400 | 64000 |
实测结论:v2 与 v1 在同等限制下的性能几乎持平,差异在 2% 以内(在误差范围内)。v2 的真正优势不在绝对性能,而在架构简洁性、一致性和内在能力(如 memory.high、PSI)。在内存分配测试中,v2 的 memory.high 路径回收延迟比 v1 soft_limit 降低了约 15%。
如果你看到限制后性能差异巨大,那大概率是配额设错了进入了 throttle 状态,而不是 v2 本身慢。检查 cpu.stat 中的 nr_throttled 字段即可确认。
十、常见坑与最佳实践
10.1 权限问题
v2 的 cgroup.procs 和 cgroup.subtree_control 对 root 放开,但普通用户想在子树里创建子组需要有该子目录的所有权。最佳做法是用 systemd 或cgcreate/cgset等工具。
10.2 线程 vs 进程模式
v2 默认用进程模式。如果你想控制线程,需要在该 Cgroup 上设置 cgroup.type = threaded,然后启用threaded模式。混合使用 domain + threaded 时要特别注意层次关系——threaded 节点不能再包含 domain 子节点。
10.3 不要忘记 subtree_control
v2 中,控制器不是自动启用的。创建子 Cgroup 后,需要在父 Cgroup 的 cgroup.subtree_control 文件中"委派"对应的控制器:
# 启用 CPU 和内存控制器
echo "+cpu +memory" > /sys/fs/cgroup/demo/cgroup.subtree_control
# 检查可用的控制器
cat /sys/fs/cgroup/demo/cgroup.controllers
# 查看子树已启用的控制器
cat /sys/fs/cgroup/demo/cgroup.subtree_control
10.4 限制生效的延迟
Cgroup 限制不是即时的。cpu.max 的 enforce 延迟取决于调度周期,典型值是 1 秒(sched_latency_ns)。memory.high 的回收也是异步的。对于突发性负载,Cgroup 限制可能"追不上"短时突发。
10.5 避免过度嵌套
层级树越深,调度路径上的 Cgroup 检查开销越大。虽然在微秒级影响有限,但深度嵌套(超过 10 层)会导致监控、审计变得复杂。保持层级结构扁平(不超过 5 层)是更好的实践。
10.6 监控要点
v2 的重点监控指标:
cpu.stat:usage_usec、user_usec、system_usec、nr_periods、nr_throttled、throttled_usecmemory.stat:anon、file、kernel_stack、pagetables、percpu、socket、shmem、file_mapped、file_dirty、file_writebackmemory.current:实时用量memory.peak:峰值用量(需先echo 1 > memory.peak.reset清零)io.stat:各设备的读写统计/proc/pressure:cgroup v2 中通过memory.pressure、cpu.pressure、io.pressure暴露pids.current、pids.peak、pids.max
十一、总结与展望
Cgroup 是 Linux 资源管理能力的基石。从 v1 到 v2 的演进,从"百花齐放"到"大一统",反映了内核社区对复杂系统设计的成熟思考。v1 的历史包袱被逐渐卸下,v2 在简洁性、一致性和可扩展性上都有质的飞跃。
当前 Cgroup v2 的完整能力矩阵已可覆盖生产环境的绝大多数需求:
- CPU:精确配额(
cpu.max)+ 权重公平竞争(cpu.weight)+ 利用率上下限(cpu.uclamp) - 内存:硬限制(
memory.max)+ 软缓冲(memory.high)+ 保护级别(memory.low/memory.min) - IO:权重(
io.weight)+ 设备级限速(io.max)+ 延迟目标(io.latency) - PID:简单有效的进程数限制
- PSI:前所未有的资源压力可观测性
- eBPF 集成:将设备控制、网络管控等需求从内核态转移到更灵活的 eBPF 框架
展望未来,Cgroup v3 的传说已经有人在讨论(尽管目前内核社区没有正式路线图):可能的改进方向包括分布式 Cgroup(跨机器资源策略统一)、更细粒度的 GPU/NPU 控制器、网络出站带宽原生支持等。但就当下而言,掌握 v2 已经是系统工程师、SRE、容器平台工程师的必备技能。
资源隔离不是目的,稳定可控的生产环境才是。Cgroup 给了我们在这个混乱世界上施加秩序的工具——用好了,凌晨3点你就能安心睡觉。

发表评论 取消回复