
1. 从一次线上事故说起为什么每个后端人都该吃透 Cgroup去年冬天的一个凌晨我被电话叫醒。一台跑了十几个微服务的宿主机内存监控曲线像坐过山车一样冲顶然后 OOM Killer 开始无差别扫射把最核心的订单服务给干掉了。事后复盘发现问题根源不是代码有内存泄漏而是某个批处理任务所在的容器根本没有设置内存上限它把整台机器的内存吃光后内核只能挑看起来最占内存的进程下手。这个坑让我彻底意识到Cgroup 不是运维的专属知识而是每个部署服务的人都必须掌握的基本功。Cgroup全称 Control Groups是 Linux 内核提供的一套机制用来对进程组进行资源限制、优先级控制、统计和隔离。你可以把它理解成给进程划宿舍每个宿舍住几个人、能用多少电、能占多大地方都由宿主机统一分配。容器技术Docker、containerd、Kubernetes底层做资源控制靠的就是 Cgroup。没有它容器就只是看起来隔离实际上一个容器能把整台机器拖垮。这篇文章适合三类人看一是刚接触容器、搞不清--memory和--memory-swap区别的开发者二是被 OOM 和 CPU 抢占折磨过的后端工程师三是想从原理层面理解 Kubernetes 资源模型requests/limits的架构同学。我会从 Cgroup 的整体设计讲起把 CPU 配额、内存限制、IO 控制这些核心机制拆开揉碎配上可以直接复现的命令和参数计算过程最后聊聊实际踩过的坑。读完你应该能做到给一个服务算出合理的资源配额看懂/sys/fs/cgroup里的每一个数字并且在出问题时知道去哪儿查。2. Cgroup 整体设计与版本演进v1 和 v2 到底选哪个2.1 Cgroup 的核心抽象层级、子系统与控制组要理解 Cgroup先记住三个概念子系统subsystem也叫 controller、层级hierarchy、控制组cgroup。子系统就是具体管某类资源的模块比如cpu管子系统的 CPU 调度memory管内存blkio管块设备 IOpids管进程数量。层级是一棵由控制组构成的树每个层级可以挂载一个或多个子系统。控制组就是树上的节点进程挂在节点上就继承了该节点及其祖先节点的资源约束。这里有个关键点很多人搞混一个进程可以同时属于多个层级的不同控制组。比如它可以在 CPU 层级里属于 A 组在内存层级里属于 B 组。这种设计让资源控制非常灵活但也带来了 v1 的复杂性问题——每个子系统一棵独立的树管理起来像一团乱麻。用生活类比v1 就像你家里有电表、水表、燃气表三块表各自独立抄表、独立缴费互不干涉v2 则是把三块表合并成一块智能总表统一管理、统一视图。这就是为什么 v2 被设计出来的核心原因。2.2 v1 的碎片化问题与 v2 的统一树设计Cgroup v1 最大的毛病是一个子系统一棵树。假设你有 100 个容器每个容器都要限制 CPU 和内存那么在 v1 下你要在 cpu 层级建 100 个目录在 memory 层级再建 100 个目录两边还得保证命名一致、挂载点对应。更麻烦的是不同子系统对同一个进程组的理解可能不一致导致资源统计和限制出现偏差。Cgroup v2 做了彻底重构所有子系统挂载在同一棵树上通过cgroup.controllers和cgroup.subtree_control两个文件来动态启用/禁用子系统。进程在树上的位置唯一确定资源视图统一。v2 还引入了无内部进程规则no internal process rule如果一个控制组要往下分配资源它自己就不能直接挂进程必须把进程放到叶子节点。这条规则保证了资源分配的层级清晰避免了 v1 里父组和子组同时跑进程导致统计混乱的问题。那实际该选哪个我的建议很直接新系统一律上 v2。主流发行版Ubuntu 22.04、Debian 11、CentOS Stream 9、Fedora 31默认已经启用 v2。Kubernetes 从 1.25 开始也正式支持 v2。只有当你依赖某些还没适配 v2 的老旧工具比如某些老版本的监控 agent时才需要退回 v1 或者用混合模式。检查当前系统用的是哪个版本一条命令就够stat -fc %T /sys/fs/cgroup/ # 输出 cgroup2fs 表示 v2输出 tmpfs 表示 v12.3 混合模式下的取舍与迁移注意事项现实中大量生产环境还处在混合模式systemd 把 v1 的部分子系统和 v2 混着用。这种状态下最容易出问题的是内存和 CPU 的统计口径不一致。比如你在 v1 的 memory 层级设了限制但 systemd 又在 v2 树上管着同一批进程两边打架最后谁生效取决于挂载顺序。迁移到纯 v2 时我踩过两个坑。第一个是内核启动参数需要在 GRUB 里加systemd.unified_cgroup_hierarchy1同时去掉systemd.unified_cgroup_hierarchy0之类的旧参数改完必须update-grub并重启。第二个是工具兼容性迁移前先用systemd-cgls和systemd-cgtop看看现有层级结构确认没有关键服务依赖 v1 的特定路径。迁移后重点验证容器的内存限制是否真的生效——我见过迁移后--memory参数被静默忽略的情况原因就是容器运行时还在按 v1 路径写配置。提示迁移前务必在测试环境完整跑一遍重点观察 OOM 行为和 CPU 限流是否正常。生产环境建议保留回滚方案GRUB 参数改回去重启即可。3. CPU 资源控制配额、权重与实时调度的实操细节3.1 cpu.cfs_quota_us 与 cpu.cfs_period_us 的配合逻辑CPU 限制是 Cgroup 里最常被误解的部分。核心就两个参数cpu.cfs_period_us调度周期默认 100000 微秒即 100ms和cpu.cfs_quota_us一个周期内允许使用的 CPU 时间单位微秒。配额除以周期就是你能用的 CPU 核数。比如 quota 设为 50000period 保持 100000那就是 0.5 个核quota 设为 200000就是 2 个核。这里有个反直觉的点quota 可以超过 period。设成 400000 就是 4 个核内核完全支持。很多人以为 quota 最大就是 period这是错的。计算逻辑很简单可用核数 cpu.cfs_quota_us / cpu.cfs_period_us举个例子你要给一个服务分配 1.5 个核可以设 period100000、quota150000。也可以设 period50000、quota75000效果一样但周期越短限流的粒度越细突发流量下的响应更平滑代价是调度开销略增。我一般保持默认 100ms除非有明确的低延迟需求。手动操作一遍感受最直观# 创建一个控制组 mkdir /sys/fs/cgroup/cpu/myapp # 限制为 1.5 核 echo 100000 /sys/fs/cgroup/cpu/myapp/cpu.cfs_period_us echo 150000 /sys/fs/cgroup/cpu/myapp/cpu.cfs_quota_us # 把当前 shell 放进去 echo $$ /sys/fs/cgroup/cpu/myapp/tasks # 跑个死循环压测另开终端看 cpu.stat cat /sys/fs/cgroup/cpu/myapp/cpu.statcpu.stat里的nr_throttled和throttled_time是关键指标。前者是被限流的次数后者是累计被限流的时间纳秒。如果这两个值持续增长说明你的服务在饿肚子要么调大 quota要么优化代码。3.2 cpu.shares 权重机制不是硬限制是相对分配cpu.sharesv2 里叫cpu.weight是另一套逻辑它不限制上限只决定竞争时的分配比例。默认值 1024。假设两个组 A 和 Bshares 分别是 1024 和 2048那么在 CPU 满载时B 能拿到 A 的两倍 CPU 时间。但如果 A 闲着B 可以独占整个 CPU。这个特性特别适合重要但不常跑的服务。比如你的核心 API 平时负载低但一旦有请求就必须优先响应那就给它高 shares批处理任务给低 shares反正它不急。注意 shares 只在资源竞争时起作用机器空闲时设多少都一样。v1 和 v2 的换算关系大致是v2 的cpu.weight范围是 1-10000默认 100和 v1 的 shares 不是简单线性对应但语义一致。Kubernetes 里的requests在 CPU 上映射的就是 shares/weight而limits映射的是 quota。这就是为什么 requests 可以超卖因为只是权重而 limits 不能因为是硬上限。3.3 用 stress-ng 验证 CPU 限流是否真的生效光看配置不够得实测。我习惯用stress-ng做验证# 安装 apt install stress-ng -y # 在限制为 1 核的 cgroup 里跑 4 个 CPU 压测进程 stress-ng --cpu 4 --timeout 30s跑的同时用top观察你会看到这 4 个进程加起来只占约 100% 的 CPU即 1 个核而不是 400%。如果看到接近 400%说明限制没生效八成是进程没被正确放进 cgroup或者系统处于混合模式导致配置写错了树。注意tasks文件只影响当前已存在的线程新 fork 的线程默认继承父线程的 cgroup。但如果你用cgexec或容器运行时它们会处理好继承关系。手动测试时务必确认压测进程确实在目标组里用cat /proc/pid/cgroup核对。4. 内存限制从 memory.limit_in_bytes 到 OOM 的完整链路4.1 硬限制、软限制与 swap 的三方博弈内存控制是 Cgroup 里最容易出人命的模块。v1 的核心文件是memory.limit_in_bytes硬限制和memory.soft_limit_in_bytes软限制。硬限制是红线超过就触发回收回收不动就 OOM。软限制是建议值只在系统内存紧张时才起作用平时可以超。v2 里对应的是memory.max和memory.high。memory.high是节流阈值超过后内核会强制回收并让进程变慢但不会杀进程memory.max才是真正的 OOM 红线。这个设计比 v1 更细腻你可以用 high 做软刹车给应用一个自我调整的缓冲避免直接被杀。swap 的处理要特别小心。v1 有memory.memsw.limit_in_bytes内存swap 总量v2 有memory.swap.max。关键规则memsw 限制必须大于等于 memory 限制否则写入会报错。很多人设了 memory512M、memsw256M结果配置直接失败。正确做法是先设 memory再设 memsw且 memsw ≥ memory。4.2 memory.stat 里每个字段的含义与排查价值memory.stat是排查内存问题的体检报告字段很多但真正要盯的就几个字段含义排查价值anon匿名页堆、栈应用真实内存占用涨得快说明有泄漏file文件页缓存可回收不算真正的压力rss常驻内存anon 部分 file最直观的占用指标cache页缓存总量包含 file 和 swap cachepgmajfault主缺页次数频繁增长说明在疯狂换入换出oom_killOOM 击杀次数大于 0 就要立刻查原因我排查内存问题时第一步永远是cat memory.stat看anon和rss第二步看memory.failcnt达到限制的次数。如果 failcnt 在涨但没 OOM说明内核在努力回收如果 oom_kill 大于 0那就是真的撑不住了。4.3 一次真实的 OOM 排查从 failcnt 到定位元凶回到开头那次事故。我当时的排查路径是这样的# 1. 确认哪个 cgroup 发生了 OOM dmesg | grep -i oom | tail -20 # 2. 看该组的统计 cat /sys/fs/cgroup/memory/group/memory.stat | grep -E anon|rss|oom cat /sys/fs/cgroup/memory/group/memory.failcnt # 3. 看限制值 cat /sys/fs/cgroup/memory/group/memory.limit_in_bytes结果发现那个批处理组的memory.limit_in_bytes是9223372036854771712——这是无限制的默认值。也就是说容器运行时根本没给它设限制。进一步查容器配置发现启动脚本里--memory参数被一个环境变量覆盖成了空值。修复很简单在启动脚本里加校验--memory为空时直接报错退出而不是静默用默认值。这个教训值钱的地方在于默认无限制是最危险的配置。任何跑在共享宿主机上的服务都必须显式设置内存上限。我现在的习惯是所有容器启动模板里--memory和--memory-swap都是必填项CI 阶段就做校验。提示memory.limit_in_bytes显示一个巨大的数字接近 2^63就代表无限制。写配置时千万别依赖默认值显式设置才是正道。5. 容器资源控制实战Docker 与 Kubernetes 的映射关系5.1 Docker 的 --memory、--cpus 参数背后做了什么Docker 的资源参数最终都会翻译成 Cgroup 文件写入。理解这层映射你就能预判参数的实际效果Docker 参数Cgroup v1 文件Cgroup v2 文件--memorymemory.limit_in_bytesmemory.max--memory-swapmemory.memsw.limit_in_bytesmemory.swap.max--cpus1.5cpu.cfs_quota_us150000cpu.max150000 100000--cpu-shares512cpu.sharescpu.weight--pids-limit100pids.maxpids.max注意--memory-swap的语义它设的是内存swap的总量。如果--memory512m --memory-swap1g那容器最多用 512M 内存加 512M swap。如果--memory-swap不设默认等于--memory的两倍旧版本行为新版本默认等于--memory即禁用 swap。这个默认值变过所以永远显式设置别猜。--cpus1.5会被翻译成 quota150000、period100000。如果你用--cpu-period和--cpu-quota手动设效果一样但--cpus更直观。我一般用--cpus只有在需要精细控制周期时才用底层参数。5.2 Kubernetes requests/limits 与 Cgroup 的对应Kubernetes 的资源模型建立在 Cgroup 之上但加了一层抽象CPU requests→cpu.sharesv1或cpu.weightv2决定调度时的权重CPU limits→cpu.cfs_quota_us硬上限Memory requests→ 主要用于调度决策不直接写 CgroupMemory limits→memory.limit_in_bytes硬上限这里有个经典误区Memory requests 不写 Cgroup。它只告诉调度器这个 Pod 至少要多少内存才放得下实际的内存约束全靠 limits。所以如果你只设 requests 不设 limitsPod 理论上可以吃光节点内存。生产环境我强烈建议 memory requests 和 limits 设成相等避免超卖导致的不确定性。CPU 则相反requests 和 limits 可以不同。requests 保证基本权重limits 防止突发占用过多。但要注意如果 limits 设得太低CPU 限流会导致延迟飙升。我见过一个 API 服务 limits 设 500m结果高峰期大量请求被 throttleP99 延迟从 50ms 涨到 2s。后来调到 2000m 才恢复正常。5.3 用 cAdvisor 和 metrics 验证配额是否落地配置写完怎么确认真的生效两个手段。第一进容器看 Cgroup 文件docker exec -it container cat /sys/fs/cgroup/memory.max docker exec -it container cat /sys/fs/cgroup/cpu.max第二用 cAdvisor 或 Prometheus 采集container_cpu_cfs_throttled_seconds_total和container_memory_working_set_bytes指标。前者持续增长说明 CPU 被限流后者接近 limit 说明内存吃紧。我习惯在 Grafana 上做一个面板把 throttled 比例和内存使用率放一起看一眼就能判断配额是否合理。注意容器内看到的 Cgroup 路径取决于运行时和挂载方式。如果容器内看不到/sys/fs/cgroup可能是运行时没挂载或者用了 cgroup namespace。这种情况下从宿主机查/proc/pid/cgroup更可靠。6. 常见问题与排查技巧实录6.1 配置不生效的五大原因速查表现象可能原因排查方法内存限制无效进程不在目标 cgroupcat /proc/pid/cgroupCPU 限制无效混合模式写错树stat -fc %T /sys/fs/cgroup/写入报错memsw memory先设 memory 再设 memsw容器启动失败配额小于运行时需求看dmesg和容器日志限制时有时无子 cgroup 覆盖了父配置systemd-cgls看层级这张表是我这几年排查问题的经验浓缩。最坑的是最后一条Cgroup 的配置是层级继承的子组可以设得比父组更严但不能更松。如果你在父组设了 1G子组想设 2G实际生效的是 1G。很多人不知道这点改了半天子组配置没反应其实是父组卡着。6.2 那些年我踩过的坑从参数误设到监控盲区第一个坑--memory-swap设成 -1。文档说 -1 表示不限制 swap听起来很美好实际上等于让容器可以无限换出性能直接崩盘。我现在的原则是生产环境禁用 swap--memory-swap等于--memory。第二个坑CPU quota 设成 0。0 不是不限制而是完全禁止使用 CPU容器会直接卡死。想不限制就别写这个参数或者设成 -1。第三个坑监控只看平均值。内存和 CPU 的瞬时峰值才是 OOM 和限流的元凶。平均值看着很健康峰值早就爆了。一定要看 P99 和 max。第四个坑忽略 pids 限制。有个服务疯狂 fork 子进程把宿主机的进程号耗尽导致整台机器无法创建新进程。加上--pids-limit后问题解决。这个参数平时不起眼出事就是大事。6.3 给服务算配额的实用方法论最后分享一套我常用的配额计算方法。CPU 方面先压测出单实例的 P99 CPU 使用量然后 limits 设为峰值的 1.5 倍requests 设为平均值的 1.2 倍。内存方面用memory.stat里的anon峰值加 30% 缓冲作为 limitsrequests 等于 limits。这套方法不完美但比拍脑袋强得多。具体操作用stress-ng或真实流量压测同时采集container_memory_working_set_bytes和container_cpu_usage_seconds_total取 P99 值。然后按上面的系数算。上线后持续观察 throttled 比例如果超过 5% 就调大 CPU limits如果内存使用率长期低于 50%可以适当下调。这套流程我在多个项目里跑过最大的价值不是算出精确数字而是建立了一个可迭代的反馈闭环。配额不是一次设死的而是随着业务变化不断调整的。有了监控数据调整就有依据不用靠猜。提示新服务上线前两周把内存 limits 设得宽松一点比如峰值的 2 倍观察真实使用曲线后再收紧。宁可浪费一点资源也别让 OOM 在半夜叫醒你。