Linux CGroup 提权解析:从 V1 泥潭到 V2 的架构演进
抛开枯燥的内核术语,从一线排障的视角,系统梳理 CGroup 的核心细节、控制器的底层逻辑,以及生产环境中的避坑指南。
CGroup(Control Group)是 Linux 资源治理体系的绝对核心。本质上,容器技术(Docker、Kubernetes)能够实现资源隔离和配额,底层依赖的都是 CGroup。
如果你在平时运维或者排障时,遇到过以下现象,那么大概率绕不开它:
- 节点内存明明没满,某个容器却突然被内核 OOM Kill 掉;
- 给 Pod 设了
cpu: 1,平时监控负载很低,但 P99 延迟却经常周期性飙高; - 机器升级了内核或发行版,原本运行良好的运维脚本全线报错报错。
市面上有很多介绍 CGroup 的文章,但大多停留在“怎么敲命令”的表面。这篇文章,我们将从工程落地的视角,带你保留足够的底层技术细节,还原从 CGroup V1 到 V2 的演进脉络。
致命三板斧:别猜了,先搞清环境
排障第一定律:先看环境。很多时候你照着网上的教程敲不通,是因为环境压根不是你想的那样。
第一步:确认机器跑的是 cgroup v1 还是 v2
stat -fc %T /sys/fs/cgroup
# cgroup2fs = v2
# tmpfs = v1(通常你会看到里面还单独挂载了一堆控制器子目录)
第二步:精准定位进程所属的 cgroup 想知道某个发狂的进程受谁管辖?拿着它的 PID 直接看:
cat /proc/<pid>/cgroup
第三步:对症下药找核心状态文件
- 查 OOM 和内存压力:看
memory.current、memory.max以及memory.events。 - 查 CPU 节流和卡顿:盯紧
cpu.stat,特别是nr_throttled和throttled_usec。 - 查进程数拉满:找
pids.current和pids.max。
为什么内核需要 CGroup?
在没有 CGroup 之前,我们管资源只能靠进程优先级(nice)和调度策略。但这无法实现“硬隔离”。比如,一个死循环会把 CPU 挤占到别人无法响应;一个打满日志的进程会让整块磁盘 IO 抖动到无法自理。
CGroup 允许你把一组独立的进程框起来,当成一个资源控制实体。你可以明确限制:这个框里所有的进程加起来,只能用 1 核 CPU、512M 内存,每秒最多写 10MB 磁盘,且总进程数不能超过 200。
CGroup V1 时代的术语与架构
要理解这套系统,我们得先看 V1 时代定下来的基础架构。虽然 V1 的设计饱受诟病,但它是理解一切的起点。
- Task(任务):在 CGroup 的语境里,最小单元其实是线程(LWP,轻量级进程),而不是传统意义上的进程。
- CGroup(控制组):把一群 Task 圈在一起,统一套用一套资源限制。
- Subsystem(子系统/控制器):专门干活的模块,比如管 CPU 的叫 cpu 控制器,管内存的叫 memory。
- Hierarchy(层级树):CGroup 以树状目录组织,子目录继承父目录的属性。
V1 最独特(也最乱)的地方在于:允许多棵层级树并存。你可以将 CPU 控制器挂载在 A 树,将 Memory 挂载在 B 树。这虽然极其灵活,但也让系统状态如同乱麻。
用内置工具可以快速确认 V1 的挂载关系:
lssubsys -m
# 或者直接用 mount | grep cgroup
进程与线程的语义之坑(tasks vs cgroup.procs)
如果你在 V1 里建了一个目录,比如 /sys/fs/cgroup/cpu/demo,你会发现里面包含 tasks 和 cgroup.procs 两个列表文件。
这是很多人的第一道坎:
- 将线程 ID写进
tasks,内核只移动这个线程。 - 将进程 PID写进
cgroup.procs,内核会把该进程下的所有线程打包移动过去。
如果你写偏了(比如把多线程进程的 PID 或者主 TID 写进了 tasks),那么实际上只有主线程受到了 CPU 限制,其他工作线程继续裸奔狂跑,最终导致“配额不生效”的幻觉。
可以通过下面这个小实验观察差异:
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
static void *spin(void *arg) {
long tid = syscall(SYS_gettid);
printf("thread tid=%ld\n", tid);
while (1) {}
return NULL;
}
int main() {
printf("main pid=%d\n", getpid());
pthread_t t1, t2;
pthread_create(&t1, NULL, spin, NULL);
pthread_create(&t2, NULL, spin, NULL);
while (1) {}
return 0;
}
跑起来后 gcc -O2 -pthread t.c -o t && ./t,分别用 ps -T -p <pid> 看线程。如果你将 PID 写入 cgroup.procs,整个进程会被控制;如果误将 tid 写入 tasks,你会发现部分线程已经脱缰。
V1 控制器硬核实战
我们来看看几个绝对核心的控制器是怎么干活的。
CPU 节流玄学(CFS Quota)
很多人困惑:为何 CPU 限制没顶满,应用却卡得要命?因为 CPU 限制(CFS Quota)的底层不是限流,而是时间片切割。
核心参数就俩:
cpu.cfs_period_us:时间窗口大小(默认 100ms 或 100000us)cpu.cfs_quota_us:在这 100ms 里允许你跑多久的 CPU。
如果设为 20% CPU(quota=20ms, period=100ms):
echo 100000 > /sys/fs/cgroup/cpu/demo/cpu.cfs_period_us
echo 20000 > /sys/fs/cgroup/cpu/demo/cpu.cfs_quota_us
echo $$ > /sys/fs/cgroup/cpu/demo/cgroup.procs
如果你的进程在前 10ms 里开了好几个线程并发,把 20ms 配额挥霍一空,接下来 90ms 内,内核将直接把你冻结(Throttle)。这时候去查 cpu.stat,你会看到 nr_throttled 这个数字在不断上涨,应用在微观层面经历了数百次的“昏迷”。这往往被忽视,确是导致 P99 抖动的真凶。
除了限制上限,cpuset 还能限制进程在特定的核上跑,常用来给数据库独占核心:
echo 0 > /sys/fs/cgroup/cpuset/demo/cpuset.mems
echo 2-3 > /sys/fs/cgroup/cpuset/demo/cpuset.cpus
内存刺客与 OOM
V1 的 memory 控制器很粗暴:
memory.limit_in_bytes:硬碰硬的红线。一旦越线,内核立刻祭出 OOM Killer 杀掉最胖的进程。
写个不断 realloc 的 C 脚本,然后将其加入 cgroup 限制中,你会切身感受到 OOM 的冷酷:
echo 200M > /sys/fs/cgroup/memory/demo/memory.limit_in_bytes
echo $$ > /sys/fs/cgroup/memory/demo/cgroup.procs
./memhog 500 # 让它申请 500M 内存
不出意外进程直接暴毙。通过 memory.failcnt 或检查 dmesg | tail 能看到系统级的 OOM Kill 击杀记录。
磁盘 I/O 阀门
日志收集器和备份脚本经常占用大量 I/O 带宽。用 blkio 可以强行卡死某块盘的读写速率。(通过块设备主次号,如 sda 对应的 8:0 等)。
# 将 8:0 设备写速度限制在 10MB/s (10485760 bytes)
echo "8:0 10485760" > /sys/fs/cgroup/blkio/demo/blkio.throttle.write_bps_device
这对隔离噪音邻居来说极其好用。如果还要限制并发进程拉起速率,可以用 pids.max,一旦超过设定额度,直接返回经典报错:fork: Resource temporarily unavailable。
走向 V2:统一层级的架构救赎
V1 的灵活性最终成了系统维护的灾难:复杂的挂载、交错的控制器树,使得管理容器时的边界极其模糊。
CGroup V2 做了一个历史性的决定:统一层级(Unified Hierarchy)。
- 全局只有一棵 cgroup 树,所有的 io、memory、cpu 层级一并收束在一起。
- 强制要求只有叶子节点(没有子目录的节点)才能挂载进程并生效限制。中间节点只允许用于**向层级下方授权(Delegation)**控制器的管理权。
在 V2 下手动配置限制是这样的:
# V2 的全量挂载
mount -t cgroup2 none /sys/fs/cgroup
# 为当前节点打开 CPU 和内存控制器向下授权
echo "+cpu +memory" > /sys/fs/cgroup/cgroup.subtree_control
# 建立真正的叶子节点
mkdir /sys/fs/cgroup/demo
echo "100000 100000" > /sys/fs/cgroup/demo/cpu.max # 限制 1 核
echo "300M" > /sys/fs/cgroup/demo/memory.max # 限制硬内存
echo $$ > /sys/fs/cgroup/demo/cgroup.procs # 迁入进程
如果你尝试在带有运行中进程的父节点上再开启控制器,内核会强硬拒绝,报错 “non-empty cgroup” 规则。这终结了长期以来的权限越界痛点,也是现代 Rootless 容器能够顺畅运行的关键基底工程支持。
V2 参数进化与护城河机制
迁移到 V2 不仅仅是改了路径,参数和控制逻辑也发生了翻天覆地的转变。
内存软限制的威力:memory.high
V1 碰到 limit 就触发 OOM 杀人,V2 为了避免一踩线就炸的问题,引入了两道防线:
memory.high:高压力水位线。触碰这条线绝对不会触发 OOM 击杀进程。然而内核会向这组进程施加极其严酷的回压(Throttling 拖慢内存分配节奏,大量触发 swap 与强行脏页回收),进而逼迫进程释放资源。memory.max:最终硬红线。越过依然直接执行 OOM Kill。
在工程实践中,这个设计具有巨大价值。将 memory.high 兜在 memory.max 以下几十兆的空间。当应用发生内存泄漏或突发高并发抢占时,memory.high 被触碰,业务吞吐骤降并伴随巨大延迟。你的端侧或外部监控系统会比 OOM 报警更早地侦测到 P99 数据告警,给你留出长达数分钟的关键时间用来导出内存、保存现场,乃至执行快速切流转移,防止生产级节点爆炸崩溃引发连锁反应。
PSI:压力停顿信息
V2 中原生新增了 PSI(Pressure Stall Information) 系统追踪指标。这直接解决了“机器卡顿但各个指标看着正常”的历史遗留疑难杂症。无论系统是在争抢 CPU 时间、等待 IO 缓冲还是挤压内存,PSI 能让你直接在控制树下清晰看到整体的受迫停顿时长。
与现代云生态协同联动
如今,我们极少手动去敲 cgroup 的设定。
Systemd:
你的 Linux 只要用了 Systemd,所有进程就已经生在 cgroup 系统里了。Systemd 横向切出了 system.slice (系统默认)、user.slice (用户进程) 等分组。
它提供了开箱即用的指令管控:
systemd-run --scope -p MemoryMax=200M -p CPUQuota=50% bash
Docker 与 Kubernetes:
K8s 里面的 Limits 几乎是原封不动地翻译成了底层的控制指令交给所在的运行环境。但在由于系统内 Namespace 的视图隔离,当你在容器里去读自己的 /proc/1/cgroup 时,看到的路径是相对被剥开处理的。
必须到宿主机侧基于真实 PID /proc/<PID>/cgroup 才能顺藤摸瓜摸到最终绑定着的实际叶子节点控制树。
生产资源治理避坑心法
1. 限制留出余地 (Headroom)
对于 CPU 来说,Kubernetes requests 与 limits 如果卡得太近或定得太紧,哪怕微小的高频抖动也会马上引发系统物理限流进而影响时延。把 Request 根据常态算牢,但保留一定的 Limits 空间。
2. 别把治标当治本 CGroup 是用来保证机器资源兜底稳定性的隔离墙,并不是来帮你掩盖代码内存泄漏和糟糕调度算法的手术刀。
3. 排错有章法
下次碰到 CPU 不高却卡死,内存富足却遭抹杀的悬疑案,严禁靠推测找 Bug。
照章办事:先定 PID 再摸层级 -> 判断 V1 还是 V2 环境 -> 直奔目标节点的 cpu.stat 以及 memory.events 或 dmesg。CGroup 永远不会说谎,你承受的所有内核级控制毒打,它全都完整写入了那些不起眼的文本日志里面。