容器技术常被一句话概括为「隔离」,但隔离其实是两件事:namespace 让进程看不见别人的世界,cgroup 决定进程能用多少资源。第 8 篇讲过 namespace 与 overlayfs 的视图隔离,本篇补上另一半——资源限制与计量的真正执行者 cgroup。读懂它,容器的 OOM、CPU 延迟毛刺与 Kubernetes 的 request/limit 之谜才有答案。下文行为性结论均以内核官方文档为准,截至 2026-10-29。
cgroup 解决什么问题
一台宿主机上跑几十个服务,CPU、内存、磁盘 IO 与进程号全是共享的。没有约束时有三类典型事故:
- 资源争抢:某个服务悄悄吃满 CPU 或带宽,同机的其他服务跟着变慢,而系统整体看还有大量余量,问题难以定位。
- noisy neighbor(吵闹的邻居):本质不是资源不够,而是缺少机制把「谁可以用多少」讲清楚、管起来,邻居超用无人能拦。
- OOM 不可控:内核的全局 OOM killer 要等机器内存耗尽才出手,选谁、何时选都不受应用控制,一个失控进程能把整台机器拖下水。
第 8 篇说过,namespace 只隔离视图:PID 空间、挂载点、网络栈各看各的,底层物理资源仍然共用。把「能看见」和「能用多少」分开,是理解容器的关键——限制与计量全部由 cgroup 完成。它把进程组织成组,按组挂资源策略;对使用者暴露为一个目录加一组接口文件:写文件即设策略,读文件即看计量。Docker、Kubernetes、systemd 都只是这些文件的编写者。
从 v1 到 v2:一次迟到的重构
cgroup 起于 2006 年 Google 工程师 Paul Menage 与 Rohit Seth 等人的「process containers」项目,2007 年底为避开与容器术语的混淆更名为 control groups,随内核 2.6.24 于 2008 年 1 月进入主线。v1 允许每个控制器挂一棵独立层级树,进程可同时属于多棵树。维护者 Tejun Heo 后来承认 v1「设计跟着实现走」:各控制器的参数传播、语义一致性各行其是,多树归属让「这个进程到底归谁管」都难以回答,LWN 当年的评价是「内核开发者又爱又恨的功能之一」。
v2 由 Tejun Heo 主导重构,2016 年 3 月随内核 4.5 合入,核心变化有三:
- 单一层级树:所有控制器挂同一棵树,进程只属于一个 cgroup,/sys/fs/cgroup 只有一套目录,语义解释权唯一。
- 无内部进程约束:非根 cgroup 要给子组分配领域资源,自己就不能留进程——进程只待在叶子,根除外。这直接排除了「父组进程与子组进程互抢资源、各控制器解释不一」的混乱;例外是 threaded 子树,允许一个进程的线程分属不同组。
- 明确的委托机制:把子树连同 cgroup.procs、cgroup.threads 与 cgroup.subtree_control 的写权限授予非特权用户,或用 nsdelegate 把 cgroup namespace 当作委托边界。被委托者可在子树内继续分组、分配收到的资源,但写不了父级的控制器接口,也逃不出父级限额——这是无根容器安全的基石。
现状(截至 2026-10-29):Fedora 31 于 2019 年 10 月 29 日成为首个默认启用 v2 的主流发行版,此后 Debian 11、Ubuntu 21.10、RHEL 9、Arch(2021 年 4 月起)、Container-Optimized OS(M97 起)陆续跟进;Kubernetes 自 1.25 起 v2 支持 GA,且自 1.35 起弃用 cgroup v1、默认不再在 v1 节点启动 kubelet。新集群已是 v2 的天下。
| 维度 | cgroup v1 | cgroup v2 |
|---|---|---|
| 层级模型 | 每控制器一棵树,进程可属多树 | 单一层级,进程只属一组 |
| 内部进程 | 允许,语义不明 | 禁止(threaded 子树例外) |
| 接口文件 | 各树分散,命名与语义不一 | 统一目录,接口收敛 |
| 委托 | 权限模型粗糙,边界模糊 | subtree_control 与 nsdelegate,边界清晰 |
| 现状 | Kubernetes 1.35 起弃用 | 主流发行版与新集群默认 |
核心控制器拆解
cpu:限额与权重
cpu.max 是两个值「配额 周期」,默认 max 100000——周期 100 毫秒、不限配额;写 200000 100000 即「每 100 毫秒最多消耗 200 毫秒 CPU 时间」,等价于限 2 核。超配额的线程会在周期内被搁置到下一周期,这就是节流。cpu.weight(1~10000,默认 100)是相对权重,只在发生争抢时起作用,取代 v1 的 cpu.shares。cpu.stat 里的 usage_usec、nr_periods、nr_throttled 与 throttled_usec 是判断节流最直接的证据。要点:max 定上限,weight 定争抢时的份额;只设 limit 不设 request 的负载,落到 cgroup 层面就是「有上限、无保障」。
memory:回压与硬限
memory.high 与 memory.max 的分工是本控制器的精髓:
- memory.high 是节流线:超过后进程被施加高强度回收与分配节流,永不触发 OOM killer。它是「优雅降级」的把手,让应用在触顶前先变慢,给监控告警和扩容留出时间。
- memory.max 是硬限:用量逼近时先做直接回收,实在收不回去,就在该 cgroup 内发起 OOM。
- memory.events 记录历史:high 与 max 是两类压力事件的计数,oom 与 oom_kill 是内核出手记录,oom_group_kill 记整组击杀次数。线上排查先看它,比只看当前水位有用得多。
- memory.oom.group(内核 4.19 起)置 1 后组内进程要么全活要么全杀,适合「半死不如重开」的服务;oom_score_adj 为 -1000 的进程永不入选。
- 另有 memory.min 与 memory.low 两级保护水位:前者声明这些内存不许回收,后者是尽力保留。
io 与 pids
io.max 按设备设 rbps、wbps、riops、wiops 四类上限,由 blk-throttle 执行;io.weight 在成本模型或 BFQ 调度器下分相对权重;io.latency 换一种思路——给组定目标延迟,达不到就压制同层其他组。pids.max 限制组内任务总数,超限的 fork 与 clone 直接返回 EAGAIN,是防 fork 炸弹的最后一道闸,也管得住「线程池建到一半悄悄失败」这类怪病。
| 控制器 | 关键接口 | 超限行为 | 典型用途 |
|---|---|---|---|
| cpu | cpu.max/cpu.weight/cpu.stat | 周期内搁置线程(节流) | 限额、保份额、查毛刺 |
| memory | memory.high/memory.max/memory.events | high 回收节流;max 触发组内 OOM | 防失控、优雅降级 |
| io | io.max/io.weight/io.latency | 限速或按延迟目标压制 | 隔离存储带宽 |
| pids | pids.max/pids.events | fork 返回 EAGAIN | 防 fork 炸弹 |
Kubernetes 里的 cgroup
kubelet 把每个 Pod 挂进 kubepods 组,再按 QoS 分子组;容器的 request 与 limit 被翻译成上述接口文件——limit 变成 cpu.max 与 memory.max 这样的硬限,request 则影响调度份额与保护水位。Pod 的 QoS 分三级:Guaranteed 要求所有容器的 CPU 与内存 request 等于 limit;CPU 与内存什么都不设的是 BestEffort;其余为 Burstable。
节点级 OOM 的选择逻辑由 oom_score_adj 驱动:每个进程可带 -1000~1000 的调整值,越高越先被杀,-1000 永不选中。kubelet 按 QoS 赋值——Guaranteed 容器 -997,BestEffort 1000,Burstable 按公式「1000 − 1000 × 容器内存 request ÷ 节点内存」计算、下限 3(若算出 1000 则取 999),kubelet 与 kube-proxy 自身拿 -999。这些常量与公式出自 kubelet 源码 policy.go。于是节点内存告急时先死 BestEffort 与超用内存的 Burstable,Guaranteed 几乎免疫;而容器撞上自己的 memory.max 时,OOM 发生在该容器 cgroup 内部,与其他容器无关——这就是「容器 OOMKilled、节点明明很空闲」的由来。
flowchart TD
A["容器进程申请内存"] --> B{"超过 memory.high?"}
B -- 否 --> C["正常放行"]
B -- 是 --> D["强制回收与分配节流"]
D --> E{"用量回落到 high 以下?"}
E -- 是 --> C
E -- 否 --> F{"触及 memory.max 且回收无效?"}
F -- 否 --> D
F -- 是 --> G["在该 cgroup 内发起 OOM"]
G --> H{"memory.oom.group 置 1?"}
H -- 是 --> I["整组进程一起被杀"]
H -- 否 --> J["按 oom_score 选单个牺牲者"]
J --> K["oom_score_adj 越高越先中选"]
Kubernetes 还在把更多语义下沉到 v2 接口。1.37 起进入 Beta 并默认启用特性门的 Memory QoS,可把 memory.high 设为「request + memoryThrottlingFactor ×(limit − request)」,并用 memory.min/memory.low 落实请求级保护;特性门虽默认开启,缺省却不设值,需要显式配置才生效。另一桩长期纠缠是 systemd:它视自己为唯一的 cgroup 写入者,kubelet 又要建自己的层级,官方要求 kubelet 与容器运行时统一使用 systemd driver——历史上混用 cgroupfs 与 systemd 两套驱动,曾让大量集群踩坑,症状是 kubelet 反复拉起 Pod 甚至起不来。
实战观测与调优
第一步确认版本:stat -fc %T /sys/fs/cgroup,输出 cgroup2fs 即 v2,tmpfs 是 v1。第二步先看事件计数再看构成:memory.events 告诉你「发生过什么」,memory.stat 告诉你「内存由什么构成」——重点看 anon(匿名页,应用真实占用)、file(页缓存,可回收)、sock、pgmajfault 与 workingset_refault(页缓存被挤出去又被迫读回,是内存压力伤到性能的直接信号);cpu.stat 的 nr_throttled 与 throttled_usec 则是诊断延迟毛刺的关键。
常见误配案例:
| 案例 | 现象 | 根因与处置 |
|---|---|---|
| CPU limit 过紧 | 平均 CPU 利用率不高,p99 延迟周期性毛刺 | limit 1 核即每 100 毫秒周期 100 毫秒配额,内核按 5 毫秒切片发放,多线程应用可能整段冻结到下一周期;放宽 limit,或对延迟敏感服务去掉 limit 只留 request |
| memory.high 贴着工作集 | 应用持续变慢,CPU 空转在回收上 | 高线设太低触发持续直接回收;内核 5.9 之前还有活锁前科,按 Kubernetes 官方建议升级内核并留出余量 |
| 语言运行时误读 v2 | JVM 或 Node 堆按错误上限初始化,动辄 OOM | OpenJDK 需 8u372/11.0.16/15 以上,Node.js 需 20.3.0 以上,才能可靠识别 v2 限制 |
| cgroup driver 不一致 | kubelet 反复重启,Pod 起不来 | kubelet 与容器运行时必须同为 systemd driver |
| pids.max 过小 | 线程池创建失败,报 EAGAIN 类错误 | 按应用线程模型上调 pids.max |
CPU limit 的争论值得单独说,这是 cgroup 领域最著名的争议:配额按周期与切片发放,即使整机空闲,应用也可能在周期末被整段冻结。Kubernetes 仓库的 issue 67577 与 51135 沉淀多年;Indeed 工程团队 2019 年的复盘显示,内核 4.18 一个时钟偏差修复反而让节流更易触发,88 核机器上每周期可能闲置约 0.87 核配额,彻底修复落在 5.4 及之后版本并向旧版回移;2020 年 Hacker News 上「去掉 CPU limit 让服务变快」的讨论也曾广泛流传。观点与取舍:社区相当一部分实践(含多家头部公司的公开分享)对延迟敏感服务去掉 CPU limit、只保留 request,用资源超卖换尾延迟;要不要跟进,取决于你的服务对毛刺与对超卖哪个更敏感,没有标准答案。
常见误区
- 「容器 OOM 一定是节点内存不够」——多数情况是容器自己撞了 memory.max,OOM 发生在它自己的 cgroup 里,节点此时可能很空闲。
- 「设了 limit 就稳了」——limit 只管上限,不管争抢份额;CPU limit 还会主动引入节流毛刺。
- 「memory.high 超了会被杀」——不会,只有回收与节流;杀人是 memory.max 的事。
- 「v1 与 v2 接口通用」——路径与语义都变了,v1 的 cpu.shares 到 v2 变成 cpu.weight,取值范围也从 2~262144 收敛为 1~10000。
- 「cgroup 是 Docker 的功能」——它是内核机制,比 Docker 早五年进入主线;Docker、Kubernetes、systemd 都只是写这些文件的用户。
参考资料
- 内核文档 Control Group v2:https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html —— 所有接口文件语义与行为性结论的权威出处。
- Kubernetes 文档 Pod Quality of Service Classes:https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/ —— QoS 判定标准、逐出顺序与 Memory QoS 的官方说明。
- Kubernetes 文档 About cgroup v2:https://kubernetes.io/docs/concepts/architecture/cgroups/ —— 发行版采纳清单、版本要求与 Java/Node 迁移注意事项。
- Indeed Engineering:Unthrottled: How a Valid Fix Becomes a Regression:https://engineering.indeedblog.com/blog/2019/12/cpu-throttling-regression-fix/ —— CPU limit 节流毛刺的内核级深度复盘。
- LWN:Understanding the new control groups API:https://lwn.net/Articles/679786/ —— v2 统一层级设计的来龙去脉。
- kubelet 源码 pkg/kubelet/qos/policy.go:https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/qos/policy.go —— oom_score_adj 赋值常量与公式的最终出处。
- Wikipedia:Cgroups:https://en.wikipedia.org/wiki/Cgroups —— 起源、更名与两代版本的时间线。
本文为技术科普,具体行为以所用内核版本的实测与内核文档为准。
读者留言
COMMENTS 暂无还没有留言,来说第一句?