这个系列写到第六篇了。前几篇我们把命名空间拆开揉碎讲了一遍,搞清楚了容器里的进程是如何看待文件和网络的,也就是解决了“看得见什么”的问题。但有一个问题一直没正面展开:容器凭什么只能用它该用的CPU和内存?如果某个容器里跑了个内存泄漏的进程,凭什么隔壁容器还能稳住不崩?这就是cgroups——control groups——真正干的事。本篇作为cgroups的第一部分,先带你把它的骨架、版本差异、核心文件系统和最基础的资源限额实操过一遍。
这篇内容适合谁看?如果你是刚接触底层技术的开发者,这篇能帮你把“容器有资源限制”这句口号落到内核接口的实际操作上;如果你已经在生产环境碰过容器OOM、CPU争抢这类问题,这篇能帮你建立问题排查的内核视角。我会尽量把每一步命令、每一个字段都解释到“为什么这么设计”,而不是直接丢给你一句“照抄就完事”。
1. cgroups到底解决了什么问题
1.1 namespace管“看得见”,cgroups管“用多少”
容器技术老是喜欢把两个概念绑在一起说,但实际上它们解决的是截然不同的两件事。namespace是让容器里的进程只能看到自己的那套视图:自己的PID表、自己的网络栈、自己的挂载点,它解决的是“可见性”问题。cgroups则完全不管你看得见什么,它只管你在物理机层面到底消耗了多少资源,以及允不允许你消耗这么多,解决的是“可配额”问题。
一个很典型的场景就能看出两者差异:两个容器共享同一个物理内核,namespace把它们的文件系统隔离得清清楚楚,但如果不做任何cgroups限制,某个容器里的进程一旦进入死循环,能把整个宿主机的CPU全部吃干,另一个容器里的服务哪怕只是做一个普通请求都会慢如蜗牛。命名空间帮不了你这个忙,因为它压根不追踪CPU时间片;要拦住这个失控的进程,必须靠cgroups在调度器层面、内存分配层面进行限额和记账。理解了这个区别,后面看容器运行时各种参数就不会再迷惑了——它同时在做两件事,一件是namespace隔离,一件是cgroups限额。
1.2 cgroups的四个核心能力
把cgroups拆开看,内核提供了一整套能力,不只是“限制”这么简单:
- 资源限制:设定CPU时间片上限、内存使用上限、块设备IO带宽上限、网络带宽上限等。这是容器技术最依赖的能力,也是生产环境发生OOM时最先想去查的地方。
- 优先级控制:可以给不同的控制组配置不同的CPU份额权重和IO优先级。比如基础监控组件可以占比低一些,核心业务进程可以占比高一些,大家挤在一起时不至于平均主义。
- 资源审计与统计:内核会持续记录每个控制组的真实资源消耗情况。运维同学最常用的
memory.current、cpu.stat就来自这里,这比在容器内自己估算要准确得多。 - 进程挂起与恢复:cgroups的freezer控制器可以像按暂停键一样冻结一组进程。我常用它做实验环境的临时停机,或者对一个失控进程组先冻结快照,再进去排查,比直接kill温柔很多。
这四个能力里,最容易被低估的是“资源统计”。很多人以为限制好配额就完事了,其实统计能力才是排查问题的起点:内存泄漏是从哪个容器的memory.current开始异常上涨的?CPU限流是从哪个时间窗口的cpu.stat里看到throttled次数增多的?没有这套统计,你连问题在哪个容器里都定位不到。
1.3 一个生活化的理解方式
如果让我给刚接触这块的朋友打个比方,我会说namespace相当于给每个合租室友发了一间独立卧室,你看不到别人房间里的陈设,也碰不到别人的东西;cgroups则是给整间房定下来的公共水电规则:每月电费额度多少、热水用多少分钟、公共区域Wi-Fi带宽怎么分配。卧室负责隐私,水电表负责限流,两者各司其职,缺一不可。这个类比虽然粗糙,但方向是对的:namespace是“空间隔离”,cgroups是“资源配置”。
2. 先搞清楚版本:v1与v2的设计差异
2.1 早期v1架构:每个子系统管理一棵树
cgroups从内核早期版本开始就一直存在,但真正进入生产环境广泛使用的是v1设计。v1的核心模型是:每个资源子系统(subsystem)各自维护一棵独立的层级树。
什么叫子系统?简单说就是负责某一类资源管理的模块,最常用的有:
cpu:CPU时间片调度与限额memory:内存使用统计与上限控制blkio:块设备IO带宽限制devices:控制组内进程对设备文件的访问权限freezer:冻结与恢复进程组
在v1里,cpu是一棵树,memory是另一棵树,你可以在/sys/fs/cgroup/cpu下建控制组,同时在/sys/fs/cgroup/memory下建同名控制组,它们互相独立、各管各的。一个进程可以同时存在于多棵树中,在CPU树里属于某个组,在内存树里又属于另一个组。
这种设计的好处是灵活,想单独管CPU就只管CPU那棵树。但坏处也很明显:多棵树之间的关系非常复杂。假设你在CPU树里创建了一个父子嵌套结构,在内存树里又创建了另一套嵌套结构,阅读和运维成本迅速上升。更麻烦的是线程粒度问题:同属一个进程的不同线程,在v1下有可能被分配到不同的tasks列表,这对希望整组进程保持一致资源契约的场景很不友好。内核社区在维护v1的过程中逐渐意识到,这套模型扩展性太差,于是才有了v2。
2.2 v2的关键变化:一棵统一的树
cgroups v2从设计上直接推翻了“多树并存”的模型,改为所有控制器统一挂到一棵层级树上。你不用再分别去找cpu和memory目录,整个系统就一个/sys/fs/cgroup挂载点,目录结构就是层级树本身,你创建的每一个子目录都是一个控制组,所有你想启用的控制器都在同一个组里协同工作。
v2还引入了一个非常关键的动态开关机制。每个cgroup目录下都有两个重要文件:
cgroup.controllers:列出当前控制组可用的控制器列表cgroup.subtree_control:列出当前控制组已经启用并下发给子组的控制器列表
你需要在父组里向cgroup.subtree_control写入+cpu +memory,子组里才能看到cpu和memory控制的对应文件。如果不写,子组里即使有目录,也不会出现cpu.max、memory.max这些资源接口文件。这个设计很巧妙,它让控制器的启停不再是系统级的事,而是每个子树范围内可以动态调整的事。
v2另一个让人印象深刻的约束是“内部分配限制”,英文就叫no internal process constraint。简单理解就是:在v2的层级结构里,非根控制组不能同时既拥有自己的进程,又继续持有子控制组。进程只能放在树的叶子节点。这个规则让整个树结构变成了一条清晰的单向分配链:每个节点要么负责分配资源给子节点,要么负责承载进程本身,职责不会混淆。
2.3 现在系统里到底跑的是哪套
截至我写这篇内容的时间点,主流发行版的系统初始化基本都是cgroups v2了。你可以用一个简单命令确认自己的宿主机处于哪个版本:
stat -fc %T /sys/fs/cgroup如果输出是cgroup2fs,说明当前跑的是v2;如果输出是tmpfs,大概率是v1或者v1 v2混合挂载的旧系统。
需要注意:v1和v2不是互斥的,早期内核支持将部分控制器挂载到v1树、另外一些控制器挂载到v2树。不过日常使用中我强烈建议统一走v2,不只因为它是内核社区的主推方向,更因为容器运行时和systemd这些核心组件对v2的支持已经非常成熟,你把精力集中在v2的文件结构上,几乎所有现代工具的配置难点都能顺带理解。
3. 实操第一回合:先把cgroup v2的“性格”摸清楚
3.1 这不是普通目录,是内核接口
很多第一次看/sys/fs/cgroup的人容易犯一个错误:把它当成一个普通的sysfs目录去翻。实际上,这个路径挂载的是cgroup2fs文件系统,你在里面看到的不是普通文件,而是内核状态在你面前的实时投影。
先看最基础的内容,直接列出根目录:
ls -la /sys/fs/cgroup/你会看到一堆形如cgroup.前缀的文件,以及一些系统服务自动创建的子目录。重点先关注这三个文件:
cat /sys/fs/cgroup/cgroup.controllers cat /sys/fs/cgroup/cgroup.subtree_control cat /sys/fs/cgroup/cgroup.procscgroup.controllers显示当前根控制组可用的全部控制器。cgroup.subtree_control显示根控制组已经开放给了哪些子控制组。如果+cpu +memory已经出现在里面,说明系统已经把CPU和内存两个控制器下放给子树了;如果只有+memory,那你的子组里就不会出现cpu.max文件。
cgroup.procs很有意思,它列出了根控制组里的进程PID列表。所有没有被归入任何子控制组的进程,默认都属于根控制组。你可以把它理解为系统默认的“收纳筐”,所有孤儿资源都算在根组头上。
3.2 亲手创建第一个控制组
说一万句理论,不如动手建一个组。下面这套操作我建议你在一台测试机器上跑一遍,整个过程大概二十秒,但它能帮你建立对v2文件系统的肌肉记忆。
第一步,创建子控制组目录。目录可以随意命名,这里用一个模拟项目名demo:
sudo mkdir /sys/fs/cgroup/demo创建完之后,进入目录看看多了什么文件:
ls -la /sys/fs/cgroup/demo/你会看到里面自动继承了一批cgroup.开头的文件,以及当前已经被根组下放的控制器文件。注意,如果根组的cgroup.subtree_control里没有加入cpu或memory,你会发现这个目录里也找不到对应的cpu.max或memory.max文件。
第二步,试着在demo目录下再建一个子组,并观察它对资源的继承方式:
sudo mkdir /sys/fs/cgroup/demo/child cat /sys/fs/cgroup/demo/child/cgroup.controllers cat /sys/fs/cgroup/cgroup.procs你会看到demo/child刚创建时cgroup.controllers是空的,因为控制器要层层下放。如果你想让child也能管CPU和内存,需要先在demo目录下打开对应的控制器开关:
sudo sh -c 'echo "+cpu +memory" > /sys/fs/cgroup/demo/cgroup.subtree_control' cat /sys/fs/cgroup/demo/child/cgroup.controllers这个“层层开启”的机制一开始确实容易忘记,我在这上面踩过不止一次坑——创建了子组、写好了配置脚本,结果一执行就报“找不到文件”,最后发现是父组根本没下放控制器。
第三步,把进程放进去。最简单的验证方式就是启动一个永不退出的睡眠进程:
sudo sh -c 'echo $$ > /sys/fs/cgroup/demo/cgroup.procs'注意,这里用了$$,也就是当前shell的PID。执行完之后你可以在demo/cgroup.procs里看到自己的shell进程号,甚至你的shell所在的整个会话都因为子进程继承关系而受到了该控制组的影响。这一点非常值得记住:进程放入控制组之后,它fork出来的所有子进程依然留在同一个控制组里,不会自动跳出。
3.3 清理控制组时的正确姿势
用完控制组之后,你得知道自己下一步该做什么,否则会留下一些“进程已经跑了但保底状态很诡异”的隐患。
清理的步骤是:先让组内进程迁移回父组,再删除目录。直接把进程从demo/cgroup.procs迁回根组:
sudo sh -c 'echo $$ > /sys/fs/cgroup/cgroup.procs'或者如果进程已经退出,其实demo/cgroup.procs会变成空文件,你就能直接卸载目录:
sudo rmdir /sys/fs/cgroup/demo/child sudo rmdir /sys/fs/cgroup/demo如果你不迁移进程就直接rmdir,一般会得到一个Device or resource busy的报错。这个报错理论上会吓到不少人,实际上它只是在告诉你:这个目录下面还绑定着活进程,内核不会允许你删掉一个正在承载进程的控制组。记住这个排查思路,它在生产环境里经常出现。
4. 实操第二回合:CPU与内存限额,亲手压一压
4.1 CPU时间片的分配逻辑
v2里控制CPU上限的关键文件是cpu.max,它的格式非常简单:
$MAX $PERIOD含义是:在一个调度周期$PERIOD内,该控制组内所有进程最多能消耗$MAX微秒的CPU时间。默认的PERIOD通常是100000微秒,也就是100毫秒。如果MAX写成max,表示不对CPU做上限限制。
举个真实例子,假设你只想给一个容器分配半个CPU核心:
sudo sh -c 'echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max' cat /sys/fs/cgroup/demo/cpu.max这行配置的意思是:每100毫秒周期内,demo组最多用50毫秒CPU时间,换算下来就是0.5个核的占用上限。这个配额粒度很细,从几毫秒到几十毫秒都能指定,不像某些资源管理工具那样只能按整数核分配。
如果要把组里的CPU上限彻底放开,就写:
sudo sh -c 'echo "max 100000" > /sys/fs/cgroup/demo/cpu.max'动手验证一下。先放一个不断消耗CPU的进程到demo组里,我通常用一个简单的循环脚本:
sudo sh -c 'while :; do :; done & echo $! > /sys/fs/cgroup/demo/cgroup.procs'这个脚本会产生一个无限空循环,疯狂占用单核CPU。如果你开了top或者htop,能看到这个进程的CPU占用率在100%左右,而整个demo组被限制在50%的配额内,系统会通过调度器强制限流。如果这时候读CPU统计文件:
cat /sys/fs/cgroup/demo/cpu.stat你会看到nr_throttled(被限流的次数)和throttled_usec(被限流的总微秒数)在持续增加。这就是内核把“配额”变成“实际调度行为”的直接证据。
注意:
cpu.max限制的是组内所有进程加起来的累计CPU时间,不是一个进程单独的量。组里有四个进程,它们共享这50毫秒配额,谁先抢到谁先跑,但总量不会突破上限。
4.2 内存配额:限制的是上限,不是预留
v2里内存控制核心文件是memory.max、memory.current、memory.events。memory.max写入的是该控制组内所有进程允许使用的物理内存上限,单位是字节。memory.current是当前实时使用量。memory.events记录内存达到上限时触发的max事件次数。
配置一个具体限额,比如限制demo组最多只能用64MB内存:
sudo sh -c 'echo 67108864 > /sys/fs/cgroup/demo/memory.max' cat /sys/fs/cgroup/demo/memory.max这里有个很容易误解的地方:memory.max限制的是“物理内存”的使用总量,但它的计算口径包括了文件缓存。也就是说,如果组内进程读了一堆文件,这些页面缓存在内核里也算在该组的memory.current里。所以当你看到memory.current快接近memory.max时,不要急着判断是进程真的在泄漏,先排查一下是不是文件缓存占了大头。
验证内存限制最直接的方式是写一个小程序,或者用一些现成的内存压力工具。这里我借用Python来演示:
sudo sh -c 'python3 -c " import time buf = [] while True: buf.append(bytearray(1024 * 1024)) time.sleep(0.1) " & echo $! > /sys/fs/cgroup/demo/cgroup.procs'这段代码会持续申请1MB大小的字节数组,直到demo组触顶。运行一段时间后,再读memory.current和memory.events:
cat /sys/fs/cgroup/demo/memory.current cat /sys/fs/cgroup/demo/memory.events不出意外的话,memory.current会停在接近64MB的位置,memory.events里会出现max次数增长。组内某个进程大概率会被内核的OOM killer杀掉,对应的oom_kill计数也会加一。
这段操作强烈建议在测试机做,不要在生产环境随便拿内存控制器试刀。我当年第一次试的时候直接把测试机的数据库容器给杀掉了,教训就是:内存限制触发时内核是直接动杀手锏的,不是跟你商量着来的。
4.3 性能影响不是零,要知道这事有代价
很多人觉得cgroups既然是内核原生提供的,那性能开销一定为零。这话只对了一半。cgroups的统计和限制本身确实非常轻量,它是在CPU调度路径和内存管理路径上顺带做了一些记账,正常负载下可以忽略不计。但一旦你启用非常细粒度的IO限速或者频繁读写memory.events这类文件,内核中的锁竞争就会显现出来。
我个人的经验是,内存控制器对读写性能的影响比CPU控制器更敏感,尤其是在大量线程同时对不同控制组分配内存的高并发场景。如果你在做性能基准测试,建议先测一组开cgroups与不开cgroups的对比数据,再用这个数据去评估线上服务是否需要精细化调优。
5. 容器运行时怎么用cgroups的
5.1 Docker、runc与cgroups的配合关系
如果你用过主流容器运行时,那你其实早就在跟cgroups打交道了,只是隔了一层封装。以最典型的方式举例:你执行一条资源限制命令,比如:
docker run --cpus=0.5 --memory=64m --name demo busybox sleep 300这条命令的底层逻辑会通过容器运行时把参数翻译成cgroups文件写入。容器运行时会在/sys/fs/cgroup下创建一个类似.../docker/<容器ID>的子控制组目录,然后把--cpus=0.5翻译成cpu.max里写入50000 100000,把--memory=64m翻译成memory.max里写入67108864。这个映射关系极其直接,你甚至可以在/sys/fs/cgroup下面找到正在运行的每个容器对应的目录,直接去查看它的memory.current或cpu.stat。
这也解释了一个现象:为什么在容器内执行top看CPU使用率和在宿主机看到的不完全一样?因为容器内看到的只是它自己的namespace视角和普通进程统计,而宿主机上按cgroup维度统计的是这个组所有进程的累计资源消耗。两者口径不同,不要混为一谈。
5.2 systemd也是cgroups的重要玩家
不只是容器运行时在用cgroups,systemd作为系统初始化进程早已把cgroups深度集成到了服务管理里。你在/sys/fs/cgroup下看到的很多子目录,例如system.slice,就是systemd为系统服务创建的控制组单元。每一个systemd服务默认就落在它自己的控制组里,这样systemctl status才能查到服务的资源占用情况,systemd-cgtop才能按服务维度展示CPU和内存使用量。
如果你是自己在写服务脚本,并且想用systemd的方式来管理cgroups,可以直接用systemd-run创建临时scope:
sudo systemd-run --scope -p MemoryMax=64M -p CPUQuota=50% ./some_long_running_binary这条命令会在systemd的管理下新建一个临时的scope单元,并应用对应的资源限制,效果等同于手动创建cgroup再写文件。这个方法的好处是,systemd会替你维护好控制组的生命周期,不用你手动去创建和清理目录。
不过要注意一个常见问题:systemd和手动创建的cgroup目录之间可能产生管理冲突。如果你在systemd已经管理的目录下擅自改动资源文件,systemd下次重载时可能会覆盖你的配置。实际环境中我的建议是:能用systemd的统一入口就用systemd,实在需要精细化手动操作,就单独建一个systemd没在管的子树区域。
5.3 容器内存统计里那些容易读错的字段
在cgroups v2的控制组目录里有一组专门记录内存事件的字段,它们对排查容器OOM非常有帮助,但也最容易读错:
memory.current:当前物理内存使用量,含文件缓存。memory.max:允许的最大物理内存使用量,写入max表示不限制。memory.peak:历史峰值内存用量,这个字段是我排查内存泄漏时必看的,它能告诉你“曾经涨到过多高”。memory.events:包含high、max、oom_kill等计数。high是触发警告阈值,max是触顶事件,oom_kill是内核真正杀进程的次数。
有一次我在线上看到一个容器频繁重启,dmesg里没有出现OOM日志,还是通过读容器的memory.events发现oom_kill计数不断增长才定位到问题。这类跨层信息在容器外往往容易被忽略,大家在定位资源问题的时候,建议直接去对应控制组目录里把这些事件文件从头到尾看一遍。
6. 常见问题与排查技巧实录
6.1 踩过的坑,逐个拆给你看
先整理一个我实际踩过、也在社区里经常见到的坑点速查表:
| 症状 | 常见原因 | 排查方向 |
|---|---|---|
写cpu.max时报“No such file or directory” | 父组没有下放cpu控制器给子组 | 检查父目录的cgroup.subtree_control,先执行echo "+cpu" |
rmdir控制组报“Device or resource busy” | 组内仍有活着的进程 | 先迁移进程回父组,再删除目录 |
| 容器内内存限制触发了,但进程没被杀 | 读的是memory.peak不是memory.current,或者OOM killer选了别的进程 | 结合dmesg查完整日志 |
把PID写入tasks不再生效 | v2没有tasks文件,只有cgroup.procs | v2里所有线程统一通过cgroup.procs管理 |
| 限制没生效,CPU照常跑满 | cgroup.subtree_control中cpu未打开 | 查看该目录的cgroup.controllers是否包含cpu |
其中第二个坑我印象最深。当时我在清理一个测试容器的控制组,组内还有一个sleep进程留在里面,直接rmdir一直报busy,我还以为文件系统坏掉了。后来才明白,cgroups不是普通的空目录,只要有进程绑定在组里,它就不允许被删除。这个“目录还有活人”的语义,之后帮我避开了很多线上误操作。
6.2 排查时快速定位问题的方法
排查cgroups相关的问题,我一般遵循下面这个顺序:
先确认内核版本与挂载模式。用stat -fc %T /sys/fs/cgroup看是cgroup2fs还是tmpfs。紧接着再看根目录的cgroup.subtree_control,确认资源控制器到底下放没有。很多“配了没效果”的问题在这一步就能看到答案。
再顺着进程找控制组。一个进程属于哪个控制组,可以读它的/proc/PID/cgroup文件:
cat /proc/PID/cgroup输出类似于:
0::/system.slice/demo.service这个路径直接告诉你进程落在了哪棵树的哪个节点上。沿着这个节点路径去/sys/fs/cgroup对应目录,看看该目录下的限额文件写没写对。
然后看统计文件,而不是只看限额文件。cpu.stat的nr_throttled、memory.current、memory.events才是实际运行后的效果反馈。如果cpu.max配置了但nr_throttled一直不涨、CPU却依然吃满,说明配置根本没作用到目标进程组上,或者进程在另一个组。
最后记得看系统日志。OOM事件在dmesg里通常有非常明确的记录,它会告诉你哪个cgroup内哪个进程被选为牺牲品。把memory.events里的oom_kill计数跟dmesg对照着看,基本可以还原整个事件的来龙去脉。
6.3 系统负载高峰期的排查顺序
线上遇到宿主机突然hang住或者load飙高的时候,cgroups能帮你把定位范围快速收敛。我的建议是先看systemd-cgtop,它按控制组维度展示资源占用,比直接top看单个进程容易发现问题。假如某个容器组的CPU占用异常,就进对应控制组目录看cpu.stat的限流计数是否持续飙升,再进容器内看具体进程。
如果内存告急,先在宿主机上看根控制组的memory.current,确认整体水位。再用memory.peak对比各容器,找出谁急速拉升。随后结合dmesg看内核有没有OOM动作。这套组合拳能让你在几分钟内把问题从“整机异常”缩小到“某个控制组内的某个进程”,省掉大量盲目翻日志的时间。
写在最后
我始终觉得,cgroups和namespace放在一起记才是最省力的:namespace决定了进程的“视野边界”,cgroups决定了进程的“资源边界”。理解了这两个词,再看容器、再看systemd的服务管理,你会觉得内核的这些设计其实是一套非常有逻辑的语言,而不是一堆零散文件。
这一篇侧重cgroups的整体架构和v2核心文件操作,算是一个地基。后面的系列文章我会接着把CPU调度器怎么和cgroups配合、内存回收路径如何与cgroups联动、以及IO控制器和嵌套层级这些更深入的话题展开。如果你是用容器跑业务的人,建议你上手多敲几遍文中的命令,尤其是自己创建一个控制组、限制CPU和内存、再亲手验证一遍,那种“内核真的听我话”的直观感受,比看十篇理论都管用。
最后再分享一个小技巧:无论任何时候,修改cgroups配置前可以先看一眼cgroup.procs里的现有进程,动作之前弄清楚把谁放进去、把谁移出来,能避免九成以上的误操作。祝操作顺利,少踩坑。