这段时间在帮人排查一套测试环境,机器一早就卡住,SSH 敲半天都进不去。登上去一看,好几个容器在抢 CPU,内存也快被打穿。想定位到底是哪个容器干的,光靠 namespace 根本看不出来——namespace 只解决“容器能看到什么”,不解决“容器能用多少”。真正管住资源的是 cgroups,也就是 Linux 内核里的控制组机制。这篇继续聊 Linux 内核容器技术,主题就是 cgroups,按系列规划这是第六篇,也是 cgroups 部分的第一篇,先把最核心的模型、接口和实操链路讲透。cgroups 这块内容非常多,我打算分几篇来写。part1 聚焦在:为什么容器离不开它、cgroups v1 的架构模型、怎么手动限制 CPU 和内存、容器运行时怎么对接 cgroups、以及我在实操中踩过的几个坑。最后会花一节预告 v2 的核心变化,给后续内容留个引子。懂 v1 再看 v2,思路会顺很多。1. 为什么说 cgroups 是容器的“粮票”,namespace 只是“眼罩”1.1 一个典型的“吵闹邻居”现场先还原一下我遇到的情况。那台机器上有十几个容器,跑着不同的服务。某个容器里的进程是个纯计算任务,疯狂消耗 CPU,8 个核全被占满。其他容器的接口响应时间从几十毫秒直接飙到好几秒,有的内存也开始 swap,整个机器几乎不可用。这时候你去看进程,能看到一堆进程在跑。但问题来了:这些进程都属于哪个容器?如果你只依赖 namespace,在宿主机上看到的进程列表里,PID 命名空间已经被隔离了。每个容器里的进程号是从 1 开始的,进程视图是独立的。但宿主机上看到的还是真实 PID,你可以用 PID 反查 /proc/PID/cgroup 来确认归属,可这已经是事后定位了。问题的本质是:namespace 管的是“视野”,cgroups 管的是“资源上限”。没有 cgroups 的情况下,任何一个容器里的进程都可以把整台机器的 CPU、内存、磁盘 IO 全部用完。容器所谓的“隔离”,在资源维度上其实是个筛子。1.2 cgroups 回答三个问题:能用多少、用了多少、谁优先cgroups 做的事情,可以简化成三个问题:这个进程组最多能用多少 CPU?这是限额(limit)。这个进程组到目前为止用了多少 CPU、多少内存?这是计量(accounting)。CPU 不够分的时候,谁先谁后?这是优先级(weight)。用食堂打饭来类比。namespace 是给你发了一副眼罩,让你以为自己面前只有自己的餐盘;cgroups 则是饭卡和取餐规则。饭卡上限决定了你今天最多能点多少钱的菜,刷卡记录告诉你已经花了多少,遇到大家都想吃的菜时,食堂按优先级决定谁先拿。容器技术之所以能在一台机器上安全地共存跑业务,靠的不是“大家自觉”,而是内核强制执行的资源配额。Docker 里--memory、--cpus这些参数,翻译成内核语言,都是写进 cgroups 控制文件里的具体数值。这一篇先把 v1 的模型讲清楚。很多老系统、老容器运行时还在用 v1,而且 v1 里的“层级”“子系统”“控制器”这些概念是理解 cgroups 一切机制的地基。2. cgroups v1 的世界观:层级、子系统与控制器2.1 三个绕不开的基本概念cgroups v1 里最关键的是三个概念:子系统(subsystem)、层级(hierarchy)、控制器(controller)。很多人被这几个词绕晕,我换个说法。子系统是内核里的一种资源维度。比如 CPU、内存、磁盘 IO、设备访问、冻结、进程数,这些都是独立的子系统。控制器是子系统的具体实现逻辑,负责处理“限制多少、统计多少、怎么调度”等内核逻辑。在实践里,这两个词经常混着用,因为一个子系统就对应一个控制器实例。层级则是把这些子系统组织起来的树形结构。你可以挂载一棵树,把这棵树绑定给某些子系统,然后在这棵树下面创建目录,每个目录就是一个 cgroup。内核会在每个 cgroup 目录下生成一堆控制文件,读写这些文件就能调整对应子系统的资源限制。常见的子系统列一下,后续实验和容器运行时都会用到:子系统作用v1 关键文件cpu限制 CPU 带宽cpu.cfs_period_us、cpu.cfs_quota_us、cpu.sharescpuacct统计 CPU 使用量cpuacct.usage、cpuacct.statmemory内存限制与统计memory.limit_in_bytes、memory.usage_in_bytes、memory.oom_controlcpuset绑定 CPU 和内存节点cpuset.cpus、cpuset.memsblkio限制块设备 IOblkio.weight、blkio.throttle.*devices设备访问白名单devices.list、devices.allow、devices.denyfreezer冻结进程组freezer.statepids限制进程数pids.maxnet_cls给网络包打标签net_cls.classidnet_prio设置网络接口优先级net_prio.ifpriomap2.2 在 /sys/fs/cgroup 下面到底挂了什么使用 systemd 的主流发行版,会把 cgroups v1 挂载在 /sys/fs/cgroup 下,并且每个子系统一个目录。你用mount | grep cgroup看到的东西大概是这样的:$ mount | grep cgroup cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio) cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct) cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset) cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)注意这里有两个细节。第一,/sys/fs/cgroup 本身是个 tmpfs,而每个子系统目录才是真正的 cgroup 文件系统挂载点。第二,cpu和cpuacct经常合并成一个挂载点,所以路径可能是/sys/fs/cgroup/cpu,cpuacct。不同发行版布局不完全一样,实操时以自己机器上的 mount 输出为准,我下面的命令里会统一用/sys/fs/cgroup/cpu来写,如果你机器上是cpu,cpuacct,照葫芦画瓢替换即可。v1 的一个关键限制是:一个子系统一旦被挂载到某个层级,就不能再挂到另一个层级。比如cpu已经绑定在/sys/fs/cgroup/cpu,cpuacct这棵树上,你就不能再单独创建一个只承载cpu子系统的树。这样设计的初衷是想让系统管理者灵活组合子系统,但实际用起来却带来了很多麻烦,这点后面讲 v2 时会具体说。2.3 tasks、cgroup.procs 与线程组迁移每个 cgroup 目录下都有tasks和cgroup.procs两个文件,这两个文件决定了哪些进程属于这个组。tasks是线程维度的,里面的 ID 是线程 ID。cgroup.procs是进程维度的,里面的 ID 是线程组 ID,也就是主线程的 PID。向tasks里写入一个线程 ID 会把该线程迁移进组,但内核实际上会把这个线程所在的整个进程都迁过去,这个行为非常坑。实践里我基本只操作cgroup.procs,避免因为线程迁移带出奇怪的状态。关于迁移,还有一个要时刻记住的点:把 PID 写进 cgroup.procs 后,这个进程未来 fork 出来的所有子进程都会留在同一个 cgroup 里。也就是说,cgroup 的归属是继承的,子进程一出生就自动落入父进程所在的组。容器的基础就是靠这个特性实现的,容器里的所有进程天然归到同一个 cgroup 下,一起被限制、一起被统计。3. 手动搭一个受控组:CPU 配额与内存上限动手实验3.1 先判断手上是 v1 还是 v2动手之前先确认系统处于哪个模式。v1 的特征是 /sys/fs/cgroup 下有大量子系统目录;v2 的特征是根目录下有cgroup.controllers和cgroup.subtree_control文件,没有一长串子系统挂载点。# v2 典型文件 $ ls /sys/fs/cgroup/ cgroup.controllers cgroup.subtree_control cgroup.procs ...如果你看到的是这种,说明系统默认跑 cgroups v2。但别急,v1 的模型还是要学,因为很多老服务器、老容器运行时、部分内核配置仍在使用 v1。而且理解了 v1 的限制,才能明白 v2 改了什么。下面的实验在 v1 环境上操作更直观,如果你的机器是 v2,可以先跳到第六节看全景,或者临时在内核启动参数里切回 v1 来做练习。3.2 CPU 带宽实验:cfs_period 与 cfs_quotacgroups v1 的 CPU 限制是 CFS 调度器上的带宽控制,核心是cpu.cfs_period_us和cpu.cfs_quota_us两个参数。cfs_period_us是一个周期,单位微秒,默认 100000,也就是 100 毫秒。cfs_quota_us是每个周期内允许进程使用的 CPU 时间,单位也是微秒。如果quota=50000,就意味着每 100 毫秒最多跑 50 毫秒,相当于只能用一个核的 50% 时间,也就是半核 CPU。quota 设为 -1 表示不限制。来做个实验。先建一个控制组:$ sudo mkdir /sys/fs/cgroup/cpu/cpu_demo $ echo 50000 | sudo tee /sys/fs/cgroup/cpu/cpu_demo/cpu.cfs_quota_us然后在这个组里跑 CPU 密集型任务。如果机器上装了stress,直接用它;没有的话可以用dd if=/dev/zero of=/dev/null这种死循环凑数。$ sudo cgexec -g cpu:cpu_demo stress --cpu 1 &等两三秒后看 top,stress 进程的 CPU 使用率会稳定在 50% 左右。如果没装 cgroup-tools,可以用一个子 shell 把自身 PID 写进组里再执行任务:$ sudo sh -c 'echo $$ > /sys/fs/cgroup/cpu/cpu_demo/cgroup.procs && exec stress --cpu 1'注意这里$$在 sh 里是当前 sh 进程的 PID,没问题。如果你在 bash 里写$$,它展开的可能是外层 bash 的 PID,容易把整个 shell 拉进去,后面会踩坑。实验结束清理:$ sudo pkill stress $ sudo rmdir /sys/fs/cgroup/cpu/cpu_demo3.3 内存上限与 OOM 实验内存限制比 CPU 复杂一点,因为内存有 page cache 回收、匿名页、OOM 等机制参与。先建组,设上限:$ sudo mkdir /sys/fs/cgroup/memory/mem_demo $ echo 134217728 | sudo tee /sys/fs/cgroup/memory/mem_demo/memory.limit_in_bytes # 128MB然后在这个组里跑一个吃内存的任务:$ sudo cgexec -g memory:mem_demo stress --vm 1 --vm-bytes 256M进程会很快触发 OOM,因为 256MB 超过了 128MB 限制。查看内核日志:$ dmesg | tail -20你会看到类似 “Out of memory: Killed process” 的记录。这就是 cgroups 内存控制器在工作:超过硬上限且回收不掉时,内核会调用 OOM killer,杀掉组里最“胖”的进程来释放内存。这里有个容易误解的点:memory.limit_in_bytes写进去后,内核并不会立即把已有内存压缩到限额以下。它只是在后续内存分配时按这个上限做检查。如果 cgroup 里已经有 200MB 的 cache,设限 128MB 后,内核会先尝试回收 cache;回收不掉再逐出匿名页;还是不行就 OOM。memory.soft_limit_in_bytes则不同,它是“软上限”。超过软上限不代表立刻被杀,而是系统内存压力大时内核优先回收这个 cgroup 的页。软上限更像优先级提示,不是硬性死亡线。3.4 更省事的两个方案:cgroup-tools 与 systemd-run直接写 sysfs 是底层的做法,真正干活时我更推荐用 cgroup-tools,或者干脆用 systemd-run。cgroup-tools 的优点是命令简洁,语义清晰:$ sudo cgcreate -g cpu,memory:/my_demo $ sudo cgset -r memory.limit_in_bytes=256M /my_demo $ sudo cgset -r cpu.cfs_quota_us=50000 /my_demo $ sudo cgexec -g cpu,memory:/my_demo /path/to/my_programsystemd 系统上还可以直接用 scope 单位:$ sudo systemd-run --scope -p CPUQuota=50% -p MemoryMax=128M stress --vm 1 --vm-bytes 256M这条命令的好处是 systemd 会帮你创建出一个临时 cgroup,并且单位定义里已经帮你把底层文件写好了。--scope表示创建的是一个 scope 单位,适合临时跑前台任务;不加--scope的话会创建一个 service 单位放到后台运行。4. 从 docker run 到 cgroup 目录:一条完整的链路4.1 在容器里看自己住在哪个 cgroup容器内部看/proc/self/cgroup,能看到当前进程属于哪个控制组:$ cat /proc/self/cgroup 0::/docker/abcdef1234567890这是我拿某个容器运行时做的模拟输出,具体路径取决于运行时。在 v1 系统上则是这种多行结构:3:memory:/docker/abcdef1234567890 4:cpu,cpuacct:/docker/abcdef1234567890 5:pids:/docker/abcdef1234567890每一行的格式是:层级 ID、控制器列表、路径。这说明容器运行时把容器进程放进了以容器 ID 命名的 cgroup 目录里,所有资源限制都写在这个目录的控制文件里。这个文件在排查问题时极其有用。宿主机上看到一个可疑进程,先cat /proc/PID/cgroup,就能直接知道它属于哪个容器、哪个 systemd 服务、或者哪个临时 scope。4.2 runc 创建容器时的 cgroup 处理流程现代容器运行时,比如 containerd 和 runc 配合使用时,底层大致是这样的过程:用户执行 docker run,带上--cpus=2、--memory=512m之类的参数。运行时把容器配置转换成 OCI 规格,也就是 config.json。资源限制会放在linux.resources字段下。runc 启动容器前,调用 cgroups 管理器。管理器根据配置创建对应的 cgroup 目录,把cpu.max、memory.max、pids.max这些文件写好。启动容器进程后,把容器进程 PID 写进cgroup.procs。容器进程及其所有后代进程,自动落在这个 cgroup 下。举个例子,docker run --cpus=2在 v2 系统上对应的是cpu.max里写入"200000 100000",意思是每 100 毫秒周期最多用 200 毫秒 CPU,也就是两颗核的完整时间。在 v1 上则对应cpu.cfs_quota_us=200000和cpu.cfs_period_us=100000。4.3 systemd 视角:scope、slice 和 cgroup 的关系systemd 把整个用户态服务管理都建立在了 cgroup 之上。它把每个 unit 映射成一个 cgroup,用 slice 来表示层级目录,用 service 表示后台服务,用 scope 表示临时任务。systemd-run --scope创建的 scope 单位就是一个临时 cgroup,它会被挂在当前 slice 下。systemd 的意义在于:它成为了系统里唯一的 cgroup 管理器,服务启动、停止、重启时,cgroup 目录会被正确创建和清理,不太容易出现孤儿目录。所以容器运行时和 systemd 之间就产生了一个协作问题:cgroup 到底由谁管?这也就是 Kubernetes 圈子里经常争论的 cgroup driver 问题。如果 kubelet 用cgroupfs驱动,它直接写 /sys/fs/cgroup 下的目录;如果 kubelet 用systemd驱动,它让 systemd 帮忙创建 transient unit。两种方式都能跑,但绝对不能混用。混用会导致 systemd 以为某个 cgroup 是自己的,实际上目录被另一个进程先占了;服务重启时可能出现目录删不掉、限制丢失、或者资源统计错乱的情况。5. 实操中踩过的几个 cgroup 坑:排查过程与解法5.1 内存 limit 设置了,但使用量没有立刻降下来有次给一个 Java 服务容器设置内存上限,limit 从 2G 调到 1.5G,结果发现容器内的监控显示内存使用量还是 1.8G,而且过一会儿容器被 OOM 杀掉了。排查过程从/sys/fs/cgroup/memory下的统计文件入手:$ cat memory.usage_in_bytes $ cat memory.stat | head -20memory.stat 里的 cache 和 anonymous 两个值很关键。当时 cache 占了几百 MB,匿名页占了一千多 MB。limit 调低后,内核会先尝试回收 cache,但 Java 进程还在持续分配匿名内存,cache 回收速度跟不上分配速度,于是一部分内存回收还没来得及完成,就直接触发了 OOM。这个坑的教训是:调低内存上限要观察内存回收的滞后性,别指望写一个数字就立刻看到内存占用降下来。更稳的做法是配合memory.high(v2)或者软上限做“软顶”,让进程先被压制,而不是直接进入 OOM 急刹车。5.2 进程已退出,cgroup 目录却 rmdir 失败场景:测试脚本跑完,进程已经退出,想删掉临时 cgroup 目录,结果报错:$ rmdir /sys/fs/cgroup/memory/mem_demo rmdir: failed to remove 'mem_demo': Device or resource busy第一反应是查是不是还有进程在里面:$ cat /sys/fs/cgroup/memory/mem_demo/cgroup.procs $ cat /sys/fs/cgroup/memory/mem_demo/tasks如果没有输出,说明没有进程残留。那就可能是内核里还有引用,常见的是内存页还没完全释放,在 v1 的 memory 子系统里可以用 force_empty 强制回收:$ echo 0 | sudo tee /sys/fs/cgroup/memory/mem_demo/memory.force_empty $ sudo rmdir /sys/fs/cgroup/memory/mem_demo另外还要确认目录里没有子目录。cgroup 目录 rmdir 时要求本身是叶节点,如果里面有子 cgroup,必须先删子目录,再删父目录。遇到这种问题不要急着重启,先按这个顺序排查。5.3 把自己所在的 shell 迁进了受控组这是我最想提醒的一个坑。做实验时很容易为了图省事,执行这样的命令:$ echo $$ > /sys/fs/cgroup/memory/demo/cgroup.procs结果就是这个 shell 自己进了受控组。如果该组内存限制只有几百 MB,而系统内存已经很紧张,shell 想 fork 新命令时分配不了内存,直接进 D 状态,连ps都可能敲不出去。自救办法有两个。一是从另一个终端登录,把 shell PID 迁回原来的 cgroup:$ echo 12345 > /sys/fs/cgroup/cgroup.procs写回根组后,这个 shell 就脱控了。v1 系统则要写回对应的子系统根目录,比如:$ echo 12345 > /sys/fs/cgroup/memory/cgroup.procs $ echo 12345 > /sys/fs/cgroup/cpu/cgroup.procs二是直接 kill 掉那个卡死的 shell。所以我的建议是:任何写 cgroup.procs 的操作,都要想清楚“待会它还能不能出来”。宁可多用一个子 shell 包裹进程,也别把自己搭进去。6. cgroups v2 到底改了什么:part1 先看全景6.1 v1 的三个遗留问题v1 用起来问题不少,最核心的有三点。第一,多层级造成管理混乱。一个进程可以同时属于内存树里的一个 cgroup、CPU 树里的另一个 cgroup、设备树里的第三个 cgroup。你想描述“这个容器整体在哪个组”都费劲,更别说让 systemd、容器运行时、监控系统统一协作。第二,父子边界不清晰。v1 允许一个 cgroup 内部既有自己的进程,又挂载子 cgroup。这就像家里长辈和孩子住同一个房间,算资源时说不清楚哪些资源是长辈的还是孩子的。内核对这种父子混合场景的统计和调度都容易出歧义。第三,控制器之间没有统一的协调机制。不同子系统各自为政,OOM 行为、冻结顺序、迁移路径都不一致。系统管理员需要记住十几套控制文件,出错率极高。6.2 统一层级与 subtree_controlv2 最核心的改动是:所有控制器都挂载到同一个层级上,整个系统只有一棵 cgroup 树,挂在 /sys/fs/cgroup。统一的代价是引入了新的授权机制:一个 cgroup 不能自动拥有所有控制器,必须由它的父亲在cgroup.subtree_control文件中显式声明“允许哪些控制器向下生效”。举例:$ echo '+cpu +memory +pids' > /sys/fs/cgroup/cgroup.subtree_control $ mkdir /sys/fs/cgroup/demo这时 demo 目录下才会出现cpu.max、memory.max、pids.max这些文件。如果你没有在父级开启对应控制器,子 cgroup 里就不会有这些控制文件。这个设计和 v1 的“一挂一大片”完全不同。好处是权限控制的粒度更细了,父 cgroup 可以只把 memory 控制器委托给子 cgroup,CPU 却由父级统一管理。6.3 没有内部进程约束v2 有一个原则叫 “no internal process constraint”,意思是:如果一个 cgroup 需要开启子层级,它自己就不能有内部进程。换句话说,你要在demo下面再创建一个demo/child,并且给 child 开启 CPU 控制器,那 demo 这个 cgroup 本身必须清空,不能有进程直接挂在 demo 下。想挂进程?去 child 里挂。这样整个树的生命周期和资源归属就非常清晰:每个非叶节点只承担“容器”角色,真正跑业务的进程全部在叶节点。这解决了 v1 父子混合导致的资源统计问题。实际使用中,systemd 和容器运行时都遵循这个约束来创建目录结构,所以 v2 下的 cgroup 树看起来干净得多。6.4 文件设置的变化与运行时迁移v2 把很多 v1 的接口改名了,下表是老手迁移时的速查参考:场景v1 文件v2 文件CPU 配额cpu.cfs_quota_us + cpu.cfs_period_uscpu.max(格式quota period)CPU 权重cpu.sharescpu.weight(1-10000,默认 100)内存硬上限memory.limit_in_bytesmemory.max内存软限制memory.soft_limit_in_bytesmemory.low、memory.high进程数限制pids.maxpids.maxOOM 状态memory.oom_controlmemory.events、cgroup.events设备白名单devices 子系统eBPF 钩子或 systemd DeviceAllowv2 的cpu.max写法是一次性给两个值,例如echo '200000 100000' > cpu.max表示最多占 2 核。memory.high则是一个“软节流阀”,超过后内核会尽力回收,但不保证杀死进程,适合做弹性限流。容器运行时也在往 v2 迁移。runc 在新内核上默认使用统一的 cgroup 树,Kubernetes 从某几个版本开始也逐步转向 systemd 驱动的 cgroup 管理。对普通用户来说,v2 带来最直观的感受是:查看进程归属、清理孤儿目录、配置多渠道资源限制都变得简单了。—从 v1 到 v2 的切换不是一朝一夕的事。我在实际维护中,还是偶尔会遇到老系统上的 v1 路径,特别是那些长期不升级的物理机上,容器运行时还在用 cgroupfs 驱动。而这种时候,懂 v1 的架构和文件接口就特别重要,你能快速定位问题,而不是对着网上搜来的 v2 命令发呆。下一篇我打算深入 v2 的子树控制、线程组模式以及内核对 cgroup 的源码级实现,把 controller 的加载、cgroup namespace 和资源控制器之间的协作机制铺开讲。如果你在实操中也踩过 cgroup 相关的坑,欢迎交流,尤其是那些删不掉目录、内存回收不及时的案例,多聊几次才能真正理解 cgroups 的行为边界。
这段时间在帮人排查一套测试环境,机器一早就卡住,SSH 敲半天都进不去。登上去一看,好几个容器在抢 CPU,内存也快被打穿。想定位到底是哪个容器干的,光靠 namespace 根本看不出来——namespace 只解决“容器能看到什么”,不解决“容器能用多少”。真正管住资源的是 cgroups,也就是 Linux 内核里的控制组机制。这篇继续聊 Linux 内核容器技术,主题就是 cgroups,按系列规划这是第六篇,也是 cgroups 部分的第一篇,先把最核心的模型、接口和实操链路讲透。cgroups 这块内容非常多,我打算分几篇来写。part1 聚焦在:为什么容器离不开它、cgroups v1 的架构模型、怎么手动限制 CPU 和内存、容器运行时怎么对接 cgroups、以及我在实操中踩过的几个坑。最后会花一节预告 v2 的核心变化,给后续内容留个引子。懂 v1 再看 v2,思路会顺很多。1. 为什么说 cgroups 是容器的“粮票”,namespace 只是“眼罩”1.1 一个典型的“吵闹邻居”现场先还原一下我遇到的情况。那台机器上有十几个容器,跑着不同的服务。某个容器里的进程是个纯计算任务,疯狂消耗 CPU,8 个核全被占满。其他容器的接口响应时间从几十毫秒直接飙到好几秒,有的内存也开始 swap,整个机器几乎不可用。这时候你去看进程,能看到一堆进程在跑。但问题来了:这些进程都属于哪个容器?如果你只依赖 namespace,在宿主机上看到的进程列表里,PID 命名空间已经被隔离了。每个容器里的进程号是从 1 开始的,进程视图是独立的。但宿主机上看到的还是真实 PID,你可以用 PID 反查 /proc/PID/cgroup 来确认归属,可这已经是事后定位了。问题的本质是:namespace 管的是“视野”,cgroups 管的是“资源上限”。没有 cgroups 的情况下,任何一个容器里的进程都可以把整台机器的 CPU、内存、磁盘 IO 全部用完。容器所谓的“隔离”,在资源维度上其实是个筛子。1.2 cgroups 回答三个问题:能用多少、用了多少、谁优先cgroups 做的事情,可以简化成三个问题:这个进程组最多能用多少 CPU?这是限额(limit)。这个进程组到目前为止用了多少 CPU、多少内存?这是计量(accounting)。CPU 不够分的时候,谁先谁后?这是优先级(weight)。用食堂打饭来类比。namespace 是给你发了一副眼罩,让你以为自己面前只有自己的餐盘;cgroups 则是饭卡和取餐规则。饭卡上限决定了你今天最多能点多少钱的菜,刷卡记录告诉你已经花了多少,遇到大家都想吃的菜时,食堂按优先级决定谁先拿。容器技术之所以能在一台机器上安全地共存跑业务,靠的不是“大家自觉”,而是内核强制执行的资源配额。Docker 里--memory、--cpus这些参数,翻译成内核语言,都是写进 cgroups 控制文件里的具体数值。这一篇先把 v1 的模型讲清楚。很多老系统、老容器运行时还在用 v1,而且 v1 里的“层级”“子系统”“控制器”这些概念是理解 cgroups 一切机制的地基。2. cgroups v1 的世界观:层级、子系统与控制器2.1 三个绕不开的基本概念cgroups v1 里最关键的是三个概念:子系统(subsystem)、层级(hierarchy)、控制器(controller)。很多人被这几个词绕晕,我换个说法。子系统是内核里的一种资源维度。比如 CPU、内存、磁盘 IO、设备访问、冻结、进程数,这些都是独立的子系统。控制器是子系统的具体实现逻辑,负责处理“限制多少、统计多少、怎么调度”等内核逻辑。在实践里,这两个词经常混着用,因为一个子系统就对应一个控制器实例。层级则是把这些子系统组织起来的树形结构。你可以挂载一棵树,把这棵树绑定给某些子系统,然后在这棵树下面创建目录,每个目录就是一个 cgroup。内核会在每个 cgroup 目录下生成一堆控制文件,读写这些文件就能调整对应子系统的资源限制。常见的子系统列一下,后续实验和容器运行时都会用到:子系统作用v1 关键文件cpu限制 CPU 带宽cpu.cfs_period_us、cpu.cfs_quota_us、cpu.sharescpuacct统计 CPU 使用量cpuacct.usage、cpuacct.statmemory内存限制与统计memory.limit_in_bytes、memory.usage_in_bytes、memory.oom_controlcpuset绑定 CPU 和内存节点cpuset.cpus、cpuset.memsblkio限制块设备 IOblkio.weight、blkio.throttle.*devices设备访问白名单devices.list、devices.allow、devices.denyfreezer冻结进程组freezer.statepids限制进程数pids.maxnet_cls给网络包打标签net_cls.classidnet_prio设置网络接口优先级net_prio.ifpriomap2.2 在 /sys/fs/cgroup 下面到底挂了什么使用 systemd 的主流发行版,会把 cgroups v1 挂载在 /sys/fs/cgroup 下,并且每个子系统一个目录。你用mount | grep cgroup看到的东西大概是这样的:$ mount | grep cgroup cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio) cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct) cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset) cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)注意这里有两个细节。第一,/sys/fs/cgroup 本身是个 tmpfs,而每个子系统目录才是真正的 cgroup 文件系统挂载点。第二,cpu和cpuacct经常合并成一个挂载点,所以路径可能是/sys/fs/cgroup/cpu,cpuacct。不同发行版布局不完全一样,实操时以自己机器上的 mount 输出为准,我下面的命令里会统一用/sys/fs/cgroup/cpu来写,如果你机器上是cpu,cpuacct,照葫芦画瓢替换即可。v1 的一个关键限制是:一个子系统一旦被挂载到某个层级,就不能再挂到另一个层级。比如cpu已经绑定在/sys/fs/cgroup/cpu,cpuacct这棵树上,你就不能再单独创建一个只承载cpu子系统的树。这样设计的初衷是想让系统管理者灵活组合子系统,但实际用起来却带来了很多麻烦,这点后面讲 v2 时会具体说。2.3 tasks、cgroup.procs 与线程组迁移每个 cgroup 目录下都有tasks和cgroup.procs两个文件,这两个文件决定了哪些进程属于这个组。tasks是线程维度的,里面的 ID 是线程 ID。cgroup.procs是进程维度的,里面的 ID 是线程组 ID,也就是主线程的 PID。向tasks里写入一个线程 ID 会把该线程迁移进组,但内核实际上会把这个线程所在的整个进程都迁过去,这个行为非常坑。实践里我基本只操作cgroup.procs,避免因为线程迁移带出奇怪的状态。关于迁移,还有一个要时刻记住的点:把 PID 写进 cgroup.procs 后,这个进程未来 fork 出来的所有子进程都会留在同一个 cgroup 里。也就是说,cgroup 的归属是继承的,子进程一出生就自动落入父进程所在的组。容器的基础就是靠这个特性实现的,容器里的所有进程天然归到同一个 cgroup 下,一起被限制、一起被统计。3. 手动搭一个受控组:CPU 配额与内存上限动手实验3.1 先判断手上是 v1 还是 v2动手之前先确认系统处于哪个模式。v1 的特征是 /sys/fs/cgroup 下有大量子系统目录;v2 的特征是根目录下有cgroup.controllers和cgroup.subtree_control文件,没有一长串子系统挂载点。# v2 典型文件 $ ls /sys/fs/cgroup/ cgroup.controllers cgroup.subtree_control cgroup.procs ...如果你看到的是这种,说明系统默认跑 cgroups v2。但别急,v1 的模型还是要学,因为很多老服务器、老容器运行时、部分内核配置仍在使用 v1。而且理解了 v1 的限制,才能明白 v2 改了什么。下面的实验在 v1 环境上操作更直观,如果你的机器是 v2,可以先跳到第六节看全景,或者临时在内核启动参数里切回 v1 来做练习。3.2 CPU 带宽实验:cfs_period 与 cfs_quotacgroups v1 的 CPU 限制是 CFS 调度器上的带宽控制,核心是cpu.cfs_period_us和cpu.cfs_quota_us两个参数。cfs_period_us是一个周期,单位微秒,默认 100000,也就是 100 毫秒。cfs_quota_us是每个周期内允许进程使用的 CPU 时间,单位也是微秒。如果quota=50000,就意味着每 100 毫秒最多跑 50 毫秒,相当于只能用一个核的 50% 时间,也就是半核 CPU。quota 设为 -1 表示不限制。来做个实验。先建一个控制组:$ sudo mkdir /sys/fs/cgroup/cpu/cpu_demo $ echo 50000 | sudo tee /sys/fs/cgroup/cpu/cpu_demo/cpu.cfs_quota_us然后在这个组里跑 CPU 密集型任务。如果机器上装了stress,直接用它;没有的话可以用dd if=/dev/zero of=/dev/null这种死循环凑数。$ sudo cgexec -g cpu:cpu_demo stress --cpu 1 &等两三秒后看 top,stress 进程的 CPU 使用率会稳定在 50% 左右。如果没装 cgroup-tools,可以用一个子 shell 把自身 PID 写进组里再执行任务:$ sudo sh -c 'echo $$ > /sys/fs/cgroup/cpu/cpu_demo/cgroup.procs && exec stress --cpu 1'注意这里$$在 sh 里是当前 sh 进程的 PID,没问题。如果你在 bash 里写$$,它展开的可能是外层 bash 的 PID,容易把整个 shell 拉进去,后面会踩坑。实验结束清理:$ sudo pkill stress $ sudo rmdir /sys/fs/cgroup/cpu/cpu_demo3.3 内存上限与 OOM 实验内存限制比 CPU 复杂一点,因为内存有 page cache 回收、匿名页、OOM 等机制参与。先建组,设上限:$ sudo mkdir /sys/fs/cgroup/memory/mem_demo $ echo 134217728 | sudo tee /sys/fs/cgroup/memory/mem_demo/memory.limit_in_bytes # 128MB然后在这个组里跑一个吃内存的任务:$ sudo cgexec -g memory:mem_demo stress --vm 1 --vm-bytes 256M进程会很快触发 OOM,因为 256MB 超过了 128MB 限制。查看内核日志:$ dmesg | tail -20你会看到类似 “Out of memory: Killed process” 的记录。这就是 cgroups 内存控制器在工作:超过硬上限且回收不掉时,内核会调用 OOM killer,杀掉组里最“胖”的进程来释放内存。这里有个容易误解的点:memory.limit_in_bytes写进去后,内核并不会立即把已有内存压缩到限额以下。它只是在后续内存分配时按这个上限做检查。如果 cgroup 里已经有 200MB 的 cache,设限 128MB 后,内核会先尝试回收 cache;回收不掉再逐出匿名页;还是不行就 OOM。memory.soft_limit_in_bytes则不同,它是“软上限”。超过软上限不代表立刻被杀,而是系统内存压力大时内核优先回收这个 cgroup 的页。软上限更像优先级提示,不是硬性死亡线。3.4 更省事的两个方案:cgroup-tools 与 systemd-run直接写 sysfs 是底层的做法,真正干活时我更推荐用 cgroup-tools,或者干脆用 systemd-run。cgroup-tools 的优点是命令简洁,语义清晰:$ sudo cgcreate -g cpu,memory:/my_demo $ sudo cgset -r memory.limit_in_bytes=256M /my_demo $ sudo cgset -r cpu.cfs_quota_us=50000 /my_demo $ sudo cgexec -g cpu,memory:/my_demo /path/to/my_programsystemd 系统上还可以直接用 scope 单位:$ sudo systemd-run --scope -p CPUQuota=50% -p MemoryMax=128M stress --vm 1 --vm-bytes 256M这条命令的好处是 systemd 会帮你创建出一个临时 cgroup,并且单位定义里已经帮你把底层文件写好了。--scope表示创建的是一个 scope 单位,适合临时跑前台任务;不加--scope的话会创建一个 service 单位放到后台运行。4. 从 docker run 到 cgroup 目录:一条完整的链路4.1 在容器里看自己住在哪个 cgroup容器内部看/proc/self/cgroup,能看到当前进程属于哪个控制组:$ cat /proc/self/cgroup 0::/docker/abcdef1234567890这是我拿某个容器运行时做的模拟输出,具体路径取决于运行时。在 v1 系统上则是这种多行结构:3:memory:/docker/abcdef1234567890 4:cpu,cpuacct:/docker/abcdef1234567890 5:pids:/docker/abcdef1234567890每一行的格式是:层级 ID、控制器列表、路径。这说明容器运行时把容器进程放进了以容器 ID 命名的 cgroup 目录里,所有资源限制都写在这个目录的控制文件里。这个文件在排查问题时极其有用。宿主机上看到一个可疑进程,先cat /proc/PID/cgroup,就能直接知道它属于哪个容器、哪个 systemd 服务、或者哪个临时 scope。4.2 runc 创建容器时的 cgroup 处理流程现代容器运行时,比如 containerd 和 runc 配合使用时,底层大致是这样的过程:用户执行 docker run,带上--cpus=2、--memory=512m之类的参数。运行时把容器配置转换成 OCI 规格,也就是 config.json。资源限制会放在linux.resources字段下。runc 启动容器前,调用 cgroups 管理器。管理器根据配置创建对应的 cgroup 目录,把cpu.max、memory.max、pids.max这些文件写好。启动容器进程后,把容器进程 PID 写进cgroup.procs。容器进程及其所有后代进程,自动落在这个 cgroup 下。举个例子,docker run --cpus=2在 v2 系统上对应的是cpu.max里写入"200000 100000",意思是每 100 毫秒周期最多用 200 毫秒 CPU,也就是两颗核的完整时间。在 v1 上则对应cpu.cfs_quota_us=200000和cpu.cfs_period_us=100000。4.3 systemd 视角:scope、slice 和 cgroup 的关系systemd 把整个用户态服务管理都建立在了 cgroup 之上。它把每个 unit 映射成一个 cgroup,用 slice 来表示层级目录,用 service 表示后台服务,用 scope 表示临时任务。systemd-run --scope创建的 scope 单位就是一个临时 cgroup,它会被挂在当前 slice 下。systemd 的意义在于:它成为了系统里唯一的 cgroup 管理器,服务启动、停止、重启时,cgroup 目录会被正确创建和清理,不太容易出现孤儿目录。所以容器运行时和 systemd 之间就产生了一个协作问题:cgroup 到底由谁管?这也就是 Kubernetes 圈子里经常争论的 cgroup driver 问题。如果 kubelet 用cgroupfs驱动,它直接写 /sys/fs/cgroup 下的目录;如果 kubelet 用systemd驱动,它让 systemd 帮忙创建 transient unit。两种方式都能跑,但绝对不能混用。混用会导致 systemd 以为某个 cgroup 是自己的,实际上目录被另一个进程先占了;服务重启时可能出现目录删不掉、限制丢失、或者资源统计错乱的情况。5. 实操中踩过的几个 cgroup 坑:排查过程与解法5.1 内存 limit 设置了,但使用量没有立刻降下来有次给一个 Java 服务容器设置内存上限,limit 从 2G 调到 1.5G,结果发现容器内的监控显示内存使用量还是 1.8G,而且过一会儿容器被 OOM 杀掉了。排查过程从/sys/fs/cgroup/memory下的统计文件入手:$ cat memory.usage_in_bytes $ cat memory.stat | head -20memory.stat 里的 cache 和 anonymous 两个值很关键。当时 cache 占了几百 MB,匿名页占了一千多 MB。limit 调低后,内核会先尝试回收 cache,但 Java 进程还在持续分配匿名内存,cache 回收速度跟不上分配速度,于是一部分内存回收还没来得及完成,就直接触发了 OOM。这个坑的教训是:调低内存上限要观察内存回收的滞后性,别指望写一个数字就立刻看到内存占用降下来。更稳的做法是配合memory.high(v2)或者软上限做“软顶”,让进程先被压制,而不是直接进入 OOM 急刹车。5.2 进程已退出,cgroup 目录却 rmdir 失败场景:测试脚本跑完,进程已经退出,想删掉临时 cgroup 目录,结果报错:$ rmdir /sys/fs/cgroup/memory/mem_demo rmdir: failed to remove 'mem_demo': Device or resource busy第一反应是查是不是还有进程在里面:$ cat /sys/fs/cgroup/memory/mem_demo/cgroup.procs $ cat /sys/fs/cgroup/memory/mem_demo/tasks如果没有输出,说明没有进程残留。那就可能是内核里还有引用,常见的是内存页还没完全释放,在 v1 的 memory 子系统里可以用 force_empty 强制回收:$ echo 0 | sudo tee /sys/fs/cgroup/memory/mem_demo/memory.force_empty $ sudo rmdir /sys/fs/cgroup/memory/mem_demo另外还要确认目录里没有子目录。cgroup 目录 rmdir 时要求本身是叶节点,如果里面有子 cgroup,必须先删子目录,再删父目录。遇到这种问题不要急着重启,先按这个顺序排查。5.3 把自己所在的 shell 迁进了受控组这是我最想提醒的一个坑。做实验时很容易为了图省事,执行这样的命令:$ echo $$ > /sys/fs/cgroup/memory/demo/cgroup.procs结果就是这个 shell 自己进了受控组。如果该组内存限制只有几百 MB,而系统内存已经很紧张,shell 想 fork 新命令时分配不了内存,直接进 D 状态,连ps都可能敲不出去。自救办法有两个。一是从另一个终端登录,把 shell PID 迁回原来的 cgroup:$ echo 12345 > /sys/fs/cgroup/cgroup.procs写回根组后,这个 shell 就脱控了。v1 系统则要写回对应的子系统根目录,比如:$ echo 12345 > /sys/fs/cgroup/memory/cgroup.procs $ echo 12345 > /sys/fs/cgroup/cpu/cgroup.procs二是直接 kill 掉那个卡死的 shell。所以我的建议是:任何写 cgroup.procs 的操作,都要想清楚“待会它还能不能出来”。宁可多用一个子 shell 包裹进程,也别把自己搭进去。6. cgroups v2 到底改了什么:part1 先看全景6.1 v1 的三个遗留问题v1 用起来问题不少,最核心的有三点。第一,多层级造成管理混乱。一个进程可以同时属于内存树里的一个 cgroup、CPU 树里的另一个 cgroup、设备树里的第三个 cgroup。你想描述“这个容器整体在哪个组”都费劲,更别说让 systemd、容器运行时、监控系统统一协作。第二,父子边界不清晰。v1 允许一个 cgroup 内部既有自己的进程,又挂载子 cgroup。这就像家里长辈和孩子住同一个房间,算资源时说不清楚哪些资源是长辈的还是孩子的。内核对这种父子混合场景的统计和调度都容易出歧义。第三,控制器之间没有统一的协调机制。不同子系统各自为政,OOM 行为、冻结顺序、迁移路径都不一致。系统管理员需要记住十几套控制文件,出错率极高。6.2 统一层级与 subtree_controlv2 最核心的改动是:所有控制器都挂载到同一个层级上,整个系统只有一棵 cgroup 树,挂在 /sys/fs/cgroup。统一的代价是引入了新的授权机制:一个 cgroup 不能自动拥有所有控制器,必须由它的父亲在cgroup.subtree_control文件中显式声明“允许哪些控制器向下生效”。举例:$ echo '+cpu +memory +pids' > /sys/fs/cgroup/cgroup.subtree_control $ mkdir /sys/fs/cgroup/demo这时 demo 目录下才会出现cpu.max、memory.max、pids.max这些文件。如果你没有在父级开启对应控制器,子 cgroup 里就不会有这些控制文件。这个设计和 v1 的“一挂一大片”完全不同。好处是权限控制的粒度更细了,父 cgroup 可以只把 memory 控制器委托给子 cgroup,CPU 却由父级统一管理。6.3 没有内部进程约束v2 有一个原则叫 “no internal process constraint”,意思是:如果一个 cgroup 需要开启子层级,它自己就不能有内部进程。换句话说,你要在demo下面再创建一个demo/child,并且给 child 开启 CPU 控制器,那 demo 这个 cgroup 本身必须清空,不能有进程直接挂在 demo 下。想挂进程?去 child 里挂。这样整个树的生命周期和资源归属就非常清晰:每个非叶节点只承担“容器”角色,真正跑业务的进程全部在叶节点。这解决了 v1 父子混合导致的资源统计问题。实际使用中,systemd 和容器运行时都遵循这个约束来创建目录结构,所以 v2 下的 cgroup 树看起来干净得多。6.4 文件设置的变化与运行时迁移v2 把很多 v1 的接口改名了,下表是老手迁移时的速查参考:场景v1 文件v2 文件CPU 配额cpu.cfs_quota_us + cpu.cfs_period_uscpu.max(格式quota period)CPU 权重cpu.sharescpu.weight(1-10000,默认 100)内存硬上限memory.limit_in_bytesmemory.max内存软限制memory.soft_limit_in_bytesmemory.low、memory.high进程数限制pids.maxpids.maxOOM 状态memory.oom_controlmemory.events、cgroup.events设备白名单devices 子系统eBPF 钩子或 systemd DeviceAllowv2 的cpu.max写法是一次性给两个值,例如echo '200000 100000' > cpu.max表示最多占 2 核。memory.high则是一个“软节流阀”,超过后内核会尽力回收,但不保证杀死进程,适合做弹性限流。容器运行时也在往 v2 迁移。runc 在新内核上默认使用统一的 cgroup 树,Kubernetes 从某几个版本开始也逐步转向 systemd 驱动的 cgroup 管理。对普通用户来说,v2 带来最直观的感受是:查看进程归属、清理孤儿目录、配置多渠道资源限制都变得简单了。—从 v1 到 v2 的切换不是一朝一夕的事。我在实际维护中,还是偶尔会遇到老系统上的 v1 路径,特别是那些长期不升级的物理机上,容器运行时还在用 cgroupfs 驱动。而这种时候,懂 v1 的架构和文件接口就特别重要,你能快速定位问题,而不是对着网上搜来的 v2 命令发呆。下一篇我打算深入 v2 的子树控制、线程组模式以及内核对 cgroup 的源码级实现,把 controller 的加载、cgroup namespace 和资源控制器之间的协作机制铺开讲。如果你在实操中也踩过 cgroup 相关的坑,欢迎交流,尤其是那些删不掉目录、内存回收不及时的案例,多聊几次才能真正理解 cgroups 的行为边界。