深入理解cgroups:从CPU内存限制到容器资源治理实战

发布时间:2026/10/18 5:49:35
深入理解cgroups:从CPU内存限制到容器资源治理实战 在做容器相关的工作时我最常被问到的问题不是“镜像怎么构建”而是“进程明明进了容器为什么 CPU 还是被打满”“内存限制设了 2G怎么容器里面只看到 1.5G 就 OOM”。这些问题的答案基本都落在 Linux 内核的 cgroups 机制上。这个系列前面几篇把命名空间、文件系统、网络栈翻了个遍这次终于轮到 cgroups。因为 cgroups 本身内容足够深我打算拆成上下两篇来写这一篇先把整体设计、核心子系统和实验过程讲清楚下一篇再往控制器内部和性能建模方向深入。这篇文章适合谁看适合所有想让容器从“能用”走到“用得稳”的开发者、运维和平台工程师。如果你只是想写 Dockerfile那确实不需要关心 cgroups但如果你想排查容器内存抖动、给离线任务做 CPU 弹性配额、或者在自研运行时里做资源管理那 cgroups 就是绕不开的地基。我会尽量把原理讲明白同时给你可以直接在 Linux 上敲的命令。1. 读懂 cgroups 之前先理清容器资源控制的“设计骨架”1.1 命名空间是“障眼法”cgroups 才是“计量表”很多人一开始接触容器会觉得“容器就是轻量虚拟机”这个类比有道理但也有个致命误导命名空间namespace只能让容器里的进程“看不见”宿主机上的其他东西比如看不到别的进程、看不到宿主机的网络栈、看不到外部挂载点。但“看不见”不等于“管得住”。打个比方酒店给每个房间装了隔断房间里的人看不到隔壁房间在干什么但如果你在房间里半夜开大功率音响整栋楼都会被吵醒。宿主机也一样一个容器里的进程如果疯狂吃 CPU、疯狂写内存其他容器、甚至宿主机本身的进程都会被拖垮。命名空间负责“隔断”cgroups 负责“计量”和“限流”。cgroups 的英文全称是 control groups翻译过来就是“控制组”。它的本质是把一组进程归到一个组里然后针对这个组设置资源上限、做资源统计、控制优先级。你可以把它理解成一个水电表每个容器都接了一根独立的管道管道上装了流量计和阀门cgroups 就是那套阀门拧到哪里、表上读数是多少的实现机制。1.2 两大版本v1 与 v2 的设计哲学差异cgroups 不是一次成型的它经历了两代设计。第一代也就是 cgroups v1是在 Linux 2.6.24 合入的当时的设计思路是“每个子系统一个独立的挂载层级”。所谓子系统subsystem指的是 cpu、memory、pids、blkio、cpuset 这些资源控制器。v1 允许你把 cpu 子系统挂载到一个目录memory 子系统挂载到另一个目录一个进程可以同时存在于多个层级的不同路径之下。这个设计非常灵活但灵活过头了。实际用起来你会遇到一个很尴尬的情况一旦多个子系统各自为政想表达“这组进程同时受到 CPU 和内存双重限制”就很费劲因为你得在多个挂载点下维护同一批进程的路径。如果路径不同步CPU 的配置和内存的配置就难以保证一致性。内核社区花了很大力气去做 v2也就是 cgroup v2它在 4.5 版本开始合并目标很明确把所有控制器统一挂到一棵树下面层级关系全局唯一通过内部进程限制来简化统计模型。现代发行版比如 RHEL 9、Ubuntu 22.04 的内核版本都已经默认支持 v2大部分云厂商的节点镜像也切换到 v2 了。但生产环境里还有大量老节点在跑 v1所以作为从业者两套接口你都得看得懂。后面我会专门用一节来对比它们的行为差异。1.3 在容器体系中cgroups 位于哪一层我们用 Docker 或者 containerd 跑容器的时候表面上看到的是镜像和层但真正落到内核里容器进程被放进两套机制一个是命名空间负责隔离视图另一个就是 cgroups负责限额与统计。你执行docker run -m 512m这类命令时容器运行时最终做的事情就是在 cgroup 文件系统里创建一个目录cgroup 节点往里面写入限制数值然后把进程 PID 写进cgroup.procs让这个进程落到这个组里。理解这个层级关系对排障特别有用。你可以在容器里执行cat /proc/self/cgroup会看到类似这样的输出0::/system.slice/docker-xxxx.scope这个路径就是容器进程当前所属的 cgroup。如果它显示的是 v1 时代的:/docker/xxxx加一堆子系统路径说明节点跑的还是 cgroups v1。这个文件是排障时第一个该看的因为很多资源异常都和你进程实际落在哪个组有关。2. 核心子系统逐一拆解CPU、内存、Pids 与 IO 到底在管什么2.1 CPU 子系统从“按份分”到“按时间片分”CPU 子系统是容器 CPU 限额的底层实现。在 cgroups v1 中它有两类核心控制文件cpu.shares和cpu.cfs_period_us、cpu.cfs_quota_us。这两类文件的语义完全不同一个是相对权重一个是绝对上限。cpu.shares表示这个 cgroup 在 CPU 繁忙时能获得的权重比例。假设有容器 A 的 shares 是 1024容器 B 的 shares 是 512当两个容器同时在跑满 CPU 的任务时A 拿到的 CPU 时间大约是 B 的两倍。但注意如果 CPU 很空闲两个容器的任务都可以用满 CPUshares 不会限制你跑到 200%因为没人跟你抢。所以 shares 解决的是“分蛋糕”问题不是“禁止吃超过多少”的问题。cpu.cfs_period_us和cpu.cfs_quota_us则是真正意义上的“可以吃掉多少 CPU”。它们配合 CFS 调度器的一段时间窗口机制工作。period是窗口周期单位是微秒默认通常是 100000也就是 100 毫秒quota是这个周期内该组进程最多可运行的微秒数。假如你设置echo 100000 /sys/fs/cgroup/cpu/cpu.cfs_period_us echo 150000 /sys/fs/cgroup/cpu/cpu.cfs_quota_us这就代表在每 100 毫秒的窗口里组内所有进程合计最多只能跑 150 毫秒的 CPU 时间等价于最多占用 1.5 个 CPU 核心。为什么虚拟化平台里经常说“限制 1.5 核”原理就在这里不是真的只给 1.5 核而是让调度器保证该组进程平均使用率不超过 1.5。这里有个实际经验quota 不一定要在单个窗口内被用完内核允许某段时间内“突击使用”配额但长时间平均下来会贴近配额。你设了 1.5 核任务在 50 毫秒的脉冲内可以瞬间吃掉 2 核甚至更多但后面会被调度器压制整体长期均值受影响。你要的是“稳定 QoS”还是“允许突发”决定了这两个文件的配置策略。2.2 memory 子系统不是简单记账是回收与 OOM 的博弈memory子系统是生产环境里排查容器内存问题的重点也是最容易踩坑的地方。v1 下主要的文件是memory.limit_in_bytes和memory.usage_in_bytes。v2 下对应变成了memory.max和memory.current名字变了语义大致相当但细节多了不少。当你把memory.limit_in_bytes设置成 2G这个数值表示组内所有进程可以使用的“内核视角内存”上限。注意这个“内核视角内存”包含几部分匿名页进程堆栈、文件页文件读写产生的 page cache、内核对象碎片等。也就是说如果容器里大量读写文件文件缓存也会被记到这个统计里。这会造成一个非常常见的困惑容器里的free明明显示内存很充裕宿主机却因为该 cgroup 超过限制而触发 OOM把进程杀了。内核处理 cgroup 内存超限的流程是这样的当进程尝试分配新内存超过 limit 之后内核先尝试回收这个 cgroup 内的可回收页比如干净的 page cache、可换出的匿名页。如果回收完还是无法满足分配就会走向强制回收或者触发 OOM Killer。cgroups v1 提供了memory.oom_control把值从 0 改成 1 后组内触发 OOM 时内核不会立刻杀进程而是让分配内存的任务进入休眠等待。听起来像是“免死金牌”但注意这会导致进程 hang 住并不是被放行。实际生产中你要是发现容器状态变成不可中断的 D 状态也要检查是不是这个原因。另一个关键点是 OOM 评分。内核在决定杀哪个进程时会结合进程本身的内存占用和它所在 cgroup 的份额做权衡。你可以通过/proc/pid/oom_score看到当前评分数值越大越容易被杀。容器平台在设置memory.limit后内核会动态调整组内进程的 OOM 评分这就是为什么有时候被杀的进程不是内存占用最大的那个而是“在受限 cgroup 里”的进程。2.3 pids、blkio 与 cpuset不常用但关键时刻救命的控制器CPU 和内存是最常被配置的但 pids 和 blkio 也不该忽略。pids 控制器非常好理解pids.max限制该 cgroup 内可以创建的进程/线程总数。默认是 max也就是不限制。但在跑 Java 应用、或者某些中间件 fork 大量线程时这个限制能挡掉一次典型的线程风暴。要注意的是它统计的是“任务数量”也就是每个线程算一个。一个进程开 100 个线程就占 100 个计数。blkio 控制器在 v1 里叫 blkio在 v2 里叫 io。它管的是磁盘读写带宽和 IOPS。v1 提供blkio.weight权重范围 10-1000和blkio.throttle.read_bps_device这类绝对上限。v2 则用io.weight范围 1-10000配合比例调度。真实落地上这类控制器的效果明显弱于 CPU/内存原因是磁盘 IO 经过页缓存和内核异步回刷后“到底算在哪个进程头上”并没有想象中那么精确。比如一个容器写完文件就退出脏页可能由内核的flush线程回刷这时候 IO 会被记账到flush线程所属的 cgroup而不是原来的容器。所以我在生产环境一般不会只靠 blkio 做严格磁盘隔离通常需要配合文件系统层限流或者 Storage QoS 方案。cpuset 控制器是另一个值得专门留意的它负责把容器固定到某几个 CPU 核心上在 NUMA 环境下还能做内存节点亲和性。它的配置不是数值而是 CPU 列表比如echo 0-3 cpuset.cpus把 cgroup 里的进程绑定到 0 到 3 号核心。这对低延迟应用非常重要因为 CPU 亲和性可以减少调度器迁移带来的缓存抖动。3. v2 统一层级这次大修解决了 v1 的哪些真实痛点3.1 v1 的“多个挂载点”为什么让人抓狂我在日常排障中见过太多因为 v1 多挂载点导致的困惑。举例来说一台老节点上你可能会看到/sys/fs/cgroup/cpu/docker/abc /sys/fs/cgroup/memory/docker/abc /sys/fs/cgroup/pids/docker/abc同一个容器在三个不同子系统下都有路径。如果这个容器是被 Docker 管理的路径还能保持一致但如果有些组件直接操作 cgroup或者 systemd 的 slice 体系掺和进来CPU 路径和历史路径就容易对不上。这时候你和同事排查“为什么这个进程的 CPU 限制在内存路径里看不到”会非常痛苦。v1 更大的设计问题是不能有效地处理“层级之间的相互作用”。比如内核想把 CPU 调度、内存回收和 IO 回刷放在一起联动比如当 cgroup 内存紧张开始回收脏页时应该同时降低该 cgroup 的 IO 权重避免大量回刷把磁盘打爆。这种跨控制器协同在 v1 里几乎没法做因为各个控制器分散在不同层级没法快速找到一个完整的组视角。3.2 v2 的核心规则内部进程必须为空cgroups v2 最关键的规则叫 “No Internal Processes”也就是非叶子节点不能直接放进程。一个 cgroup 内部要想“装进程”它必须是叶子节点不能同时拥有子 cgroup 和自己的进程。这条规则一开始看着有点别扭但它其实是在用约束换统计简洁。v1 里一个父 cgroup 可以同时有自己的进程和子 cgroup父层的统计数据到底是只算自己还是算自己加上后代不同控制器答案还不一样非常容易算重或者算漏。v2 强制把进程放到叶子节点后每个非叶子节点的memory.current、cpu.stat天然就是其所有后代的总和。这叫“自上而下的统计一致性”。实际用起来这意味着你新建一个配置树时要注意把进程写到最下层的cgroup.procs而不是中间层。如果你尝试往一个已经有子目录的 cgroup 里写 PID会直接收到EBUSY错误。这类报错在初学 v2 时几乎必然遇到记住“当前目录有子目录就不能直接放入进程”这条规则就行。3.3 容器场景下怎么选版本容器运行时对 cgroups 版本的适配也走过一段路。老版本 Docker 默认跑 v1新版本和 containerd 新版本都优先探测 v2。你在部署新集群时如果内核版本高于 4.15我建议直接开启 v2安装脚本把/sys/fs/cgroup挂载成 cgroup2 类型即可。v2 的接口更统一后续想接 eBPF 做资源观测也更容易。但如果你维护的是老集群还是得老老实实看 v1。两套接口的差异不大主要是文件名的变化我列一个对照表功能cgroups v1 接口cgroups v2 接口内存上限memory.limit_in_bytesmemory.max内存当前用量memory.usage_in_bytesmemory.currentCPU 绝对配额cpu.cfs_period_us / cpu.cfs_quota_uscpu.max格式是 quota periodCPU 相对权重cpu.sharescpu.weightPID 限制pids.maxpids.max开关 OOM Killermemory.oom_controlmemory.oom.group让我解释一下 CPU 上下两个接口的区别v2 的cpu.max把两个值合并成了一个文件比如echo 150000 100000 cpu.max它的意思是 quota150000、period100000与 v1 设置两个文件完全等效。用新接口的好处是你写入和读取都是一整个配置不会出现“只改了 period 忘了 quota”这种半配置状态。4. 动手实操亲手创建一个 cgroup 完成内存与 CPU 限制实验4.1 先确认当前系统的 cgroups 版本理论说再多不如上手敲一遍。我建议你准备一台 Linux 虚拟机内核 5.10 以上。第一步是确认当前挂载的是 v1 还是 v2mount | grep cgroup如果输出里有cgroup2且/sys/fs/cgroup是cgroup2类型那就是 v2如果输出里有cgroup类型的多个挂载点比如cgroup on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)那说明是 v1。用 systemd 的发行版通常会在/sys/fs/cgroup挂一个统一目录下面再按子系统分目录。在 v2 系统上我们可以直接用systemd的接口也可以手工操作。这里我选择手工操作更能看清本质。首先挂载 cgroup2 到临时目录mkdir -p /mnt/cg2 mount -t cgroup2 none /mnt/cg2这样就能得到一个干净的 cgroup2 文件系统。注意生产环境不要手工乱挂 cgroup2因为 systemd 已经占用了/sys/fs/cgroup重复挂载可能干扰节点上的容器运行时。实验请在虚拟机里做。4.2 创建带内存限制的 cgroup并放入一个进程在/mnt/cg2下新建目录目录名就是 cgroup 名mkdir /mnt/cg2/demo此时这个目录里会出现一堆控制器接口文件。我们来设置内存上限为 128Mecho 134217728 /mnt/cg2/demo/memory.max数字 134217728 等于 128 * 1024 * 1024 字节。接下来把当前 shell 放入这个组echo $$ /mnt/cg2/demo/cgroup.procs注意因为/mnt/cg2/demo没有子目录所以它允许直接放入进程。写入后当前 shell 以及后续启动的所有子进程都会受到 128M 内存限制。这一点很关键把 shell 放进去之后你在 shell 里执行的任何程序和脚本都自动属于这个 cgroup。现在用一个工具来制造内存压力。先看系统有没有stress-ng没有就装一下。然后运行stress-ng --vm 1 --vm-bytes 256M --vm-hang 10这个命令会创建 1 个进程尝试分配 256M 内存并挂住。因为我们设了 128M 上限它很快就会触发 cgroup 级别的内存回收并最终触发 OOM。观察dmesg输出你会看到memory: cgroup out of memory Killed process ...这正是 cgroups 第一层保护在起作用。如果你把memory.oom.group设为 1这个 cgroup 内所有进程都会被一起杀掉否则只杀其中得分最高的那个进程。4.3 限制 CPU 实验设置 quota 再观察负载还是基于刚才的democgroup把 CPU 配额设置成 0.5 核echo 50000 100000 /mnt/cg2/demo/cpu.max然后在该 shell 里启动一个计算密集任务stress-ng --cpu 2 --timeout 10stress-ng会尝试把 2 个 CPU 跑满但因为 cgroup 限制整个组最多只能获得 0.5 核的平均 CPU 时间。开另一个终端用top观察你会看到虽然stress-ng启动了 2 个进程两者的 CPU 使用率加起来却始终徘徊在 50% 左右。这个实验结果非常直观地展示了 quota 的作用哪怕进程请求再多的调度愿望内核的 CFS 带宽控制都会按住它。如果你把cpu.max重新改成max 100000限制立刻解除CPU 使用率就能冲上去。注意这里max是特殊值表示不限制。4.4 清理实验环境cgroup 的删除不是简单删目录实验做完要清理。先让进程退出或者手动把所有 PID 从cgroup.procs里移除echo /mnt/cg2/demo/cgroup.procs然后再删除目录。如果目录里没有任何子目录且没有进程引用可以用rmdir删除rmdir /mnt/cg2/demo如果删除失败报Device or resource busy几乎可以确定还有进程停留在这个 cgroup 里。这时候用cat /mnt/cg2/demo/cgroup.procs看看还有谁把它踢到根组去再删。这个习惯在公司里尤其重要手动创建的 cgroup 泄漏会导致旧资源统计一直挂在内存里还会造成宿主机上的资源“去向不明”。5. 生产环境排障与避坑cgroups 最常见的几个坑5.1 内存“超限”但没触发 OOM先看 file cache有个现象我见过很多次容器限制 4G 内存memory.current已经 3.9G但容器内free显示可用内存还有 3 个 G也没 OOM。不用觉得内核算错了真相大概率是 page cache 在作怪容器内读文件时内核把文件内容放进了 page cache这些页虽然可以被回收但在被回收之前会计入当前 cgroup 的memory.current。排查思路是这样的先看memory.stat文件里的anon和file两个值。anon是真正的匿名内存堆、栈、共享内存file是文件内存。如果file占比很高说明内存消耗集中在文件缓存这类内存理论上可以被回收不会马上压垮节点。但要注意如果脏页太多回收时需要先回写磁盘回写期间进程依然会感觉到内存压力吞吐会明显下降。经验之谈不要尝试在生产环境用echo 3 /proc/sys/vm/drop_caches来强行清缓存那会影响宿主机全局。更合理的做法是调整这个 cgroup 内文件读写方式或者通过监控区分anon与file的增长趋势再判断到底是业务内存泄漏还是缓存波动。5.2 cgroup 目录删不掉很可能是僵尸进程或内核引用前面实验里rmdir失败算是温和的生产环境里能看到docker rm半天删不掉容器或者在 cgroup 目录里写进程 PID 时总报No space left on device。这种往往不是磁盘满了而是节点上的 cgroup 层级已经膨胀容器频繁创建删除产生了大量残留 cgroup。有一次我排查一个节点发现systemd-cgtop里有个服务已经停掉但它的 cgroup 目录还在里面挂着几十个已经变成僵尸状态的进程。这些僵尸进程的父进程一直在循环回收失败导致它们始终占着 cgroup 里的名额。我的处理办法是先找到它们的父进程用ps -ef查看 PID把父进程重启后操作系统自动回收了这些僵尸进程cgroup 目录随即可正常清理。这类问题的根子是“谁创建 cgroup 就得谁负责清理”。手工创建的 cgroup 特别容易泄漏所以我建议生产环境凡是需要自定义 cgroup 的功能尽量走 systemd 的systemd-run --scope或者systemd-run --unitxxx --slicexxx让 systemd 来管理生命周期不推荐直接往/sys/fs/cgroup里写文件。systemd 至少会在服务退出时尝试清理能省掉很多手尾。5.3 常见问题速查表症状可能原因排查命令处理建议容器内存显示超限但不 OOM大量 page cache 计入 cgroupcat memory.stat看anon和file确认是缓存还是匿名内存业务侧减少无意义缓存设置 CPU 配额后实际使用仍偏高多容器共享周期突发配额叠加cat cpu.stat看nr_periods和throttled_usec检查同一父级下其他 cgroup 的配额总和无法把 PID 写入 cgroup.procs目录有子目录且是 v2ls查看子目录写到叶子 cgroup或删除子目录容器被杀后目录仍删不掉有进程停留或内核引用cat cgroup.procs清理进程妥善管理 cgroup 生命周期cgroup 统计突然丢失v1 下进程被写入多个层级统计口径不一致cat /proc/self/cgroup考虑迁移到 v2统一层级5.4 几个值得养成的设计习惯在容器平台的后端设计里我建议把这几点当默认约束。第一不要在业务进程里自己挂 cgroup而是由容器运行时或 systemd 统一管理这样生命周期和异常恢复都有依托。第二监控数据一定要区分 cgroups v1 和 v2 的指标来源文件名一样但语义可能有差异比如 v2 的cpu.weight和 v1 的cpu.shares数值范围完全不同误以为是同一种波动范围会导致告警规则失效。第三在设置内存限制时最好给memory.swap.max也留位置如果节点用了 swap你只限制内存不限制 swap高负载时容器会把 swap 吃穿造成更隐蔽的延迟问题。我在实际操练这个系列的过程中最大的体会是cgroups 的接口虽然只是读写文件而已但内核在背后做的事情极其讲究。write()一次文件操作可能触发一次全节点调度器的重新计算也可能触发一次内存回收风暴。它不是普通的“配置文件”而是一套操作系统资源治理的接口。理解了这一点你会对 Linux 的“一切皆文件”哲学多一层敬畏。这一篇先讲到这里。cgroups 的上半场内容集中在“它是什么、为什么这么设计、怎么快速上手”。下一篇我准备继续深入cpuset与 NUMA 亲和、cpu.weight的 CFS 调度语义、io.weight与磁盘回写路径以及如何通过 eBPF 观测 cgroup 内部指标。如果你想在团队里做容器资源治理建议先把这个系列 1-6 篇连起来读至少能把“容器限制”从黑盒变成白盒。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询