资讯动态

cgroups 核心模型与实操:从 v1 到 v2 的资源限制机制详解

发布时间:2026/10/21 23:37:23 来源:尧图企业网站定制
这段时间在帮人排查一套测试环境机器一早就卡住SSH 敲半天都进不去。登上去一看好几个容器在抢 CPU内存也快被打穿。想定位到底是哪个容器干的光靠 namespace 根本看不出来——namespace 只解决“容器能看到什么”不解决“容器能用多少”。真正管住资源的是 cgroups也就是 Linux 内核里的控制组机制。这篇继续聊 Linux 内核容器技术主题就是 cgroups按系列规划这是第六篇也是 cgroups 部分的第一篇先把最核心的模型、接口和实操链路讲透。cgroups 这块内容非常多我打算分几篇来写。part1 聚焦在为什么容器离不开它、cgroups v1 的架构模型、怎么手动限制 CPU 和内存、容器运行时怎么对接 cgroups、以及我在实操中踩过的几个坑。最后会花一节预告 v2 的核心变化给后续内容留个引子。懂 v1 再看 v2思路会顺很多。1. 为什么说 cgroups 是容器的“粮票”namespace 只是“眼罩”1.1 一个典型的“吵闹邻居”现场先还原一下我遇到的情况。那台机器上有十几个容器跑着不同的服务。某个容器里的进程是个纯计算任务疯狂消耗 CPU8 个核全被占满。其他容器的接口响应时间从几十毫秒直接飙到好几秒有的内存也开始 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 时间单位也是微秒。如果quota50000就意味着每 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 等两三秒后看 topstress 进程的 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_bytes256M /my_demo $ sudo cgset -r cpu.cfs_quota_us50000 /my_demo $ sudo cgexec -g cpu,memory:/my_demo /path/to/my_programsystemd 系统上还可以直接用 scope 单位$ sudo systemd-run --scope -p CPUQuota50% -p MemoryMax128M 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带上--cpus2、--memory512m之类的参数。运行时把容器配置转换成 OCI 规格也就是 config.json。资源限制会放在linux.resources字段下。runc 启动容器前调用 cgroups 管理器。管理器根据配置创建对应的 cgroup 目录把cpu.max、memory.max、pids.max这些文件写好。启动容器进程后把容器进程 PID 写进cgroup.procs。容器进程及其所有后代进程自动落在这个 cgroup 下。举个例子docker run --cpus2在 v2 系统上对应的是cpu.max里写入200000 100000意思是每 100 毫秒周期最多用 200 毫秒 CPU也就是两颗核的完整时间。在 v1 上则对应cpu.cfs_quota_us200000和cpu.cfs_period_us100000。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.highv2或者软上限做“软顶”让进程先被压制而不是直接进入 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 控制器委托给子 cgroupCPU 却由父级统一管理。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 periodCPU 权重cpu.sharescpu.weight1-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 的行为边界。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑