cgroup memory:内存从全局资源变成局部预算
上一篇讲了 SLUB 如何在页之上管理小对象的分配。到此为止,所有讨论都假设一个全局的内存资源池——进程共享同一组物理页,回收和 OOM 是全局决策。容器化环境打破了这个假设:一个容器不应该消耗完整机器的内存,它的 OOM 不应该波及其他容器。
核心问题可以压成一句话:
memcg 把全局 VM 策略投影到层级资源边界里,使回收、统计和 OOM 都带上 cgroup 语义。
问题从哪里来
传统 Linux 内存管理是全局视角:所有进程共享物理内存,kswapd 按全局水位线回收,OOM killer 从全局选 victim。这在单租户系统上没问题,但在多租户场景(容器、虚拟化、共享主机)上不够:
第一,隔离性缺失。一个行为异常的容器可以消耗所有可用内存,触发全局 OOM,导致无关容器的进程被杀。
第二,资源可预测性缺失。一个容器无法知道自己"还能用多少内存"——这取决于其他容器当前的使用情况。
第三,统计粒度缺失。管理员无法回答"这个服务用了多少内存"——/proc/meminfo 只有全局数据。
cgroup memory controller(memcg)解决这些问题:为每个 cgroup 建立独立的内存预算、独立的使用统计、独立的回收和 OOM 边界。
cgroup v2 memory controller 的接口
cgroup v2 的内存控制器通过文件系统接口暴露配置和统计。以一个 cgroup 路径 /sys/fs/cgroup/my-service/ 为例:
| 文件 | 读/写 | 含义 |
|---|---|---|
memory.current |
R | 当前使用的内存(字节) |
memory.max |
RW | 硬上限,超过触发 OOM |
memory.high |
RW | 软上限,超过触发节流和加强回收 |
memory.low |
RW | 最佳努力保护,低于此值不轻易回收 |
memory.min |
RW | 硬保护,低于此值绝不回收 |
memory.events |
R | 事件计数器(low、high、max、oom、oom_kill) |
memory.stat |
R | 详细分类统计 |
memory.pressure |
R | PSI 内存压力指标 |
这些接口构成一个分层的内存管理模型:
1 | |
memory.high 是推荐的主要控制手段——它不触发 OOM,而是通过节流进程的内存分配速度来施压,给回收机制留出时间。memory.max 是最后防线。
内存记账:什么被计入
memcg 追踪以下内存:
匿名页:brk、mmap(MAP_ANONYMOUS) 分配的内存。首次写入(page fault)时计入分配者所在的 cgroup。
page cache:文件读写产生的缓存页。首次进入 page cache 时计入触发 I/O 的 cgroup。
内核内存:slab 对象(dentry、inode 等)、页表、网络 buffer。通过 memory.stat 中的 kernel 和 slab 字段可见。
共享内存和 tmpfs:shmem 页面计入最先触发分配的 cgroup。
关键规则:内存跟随 cgroup,不跟随进程。如果进程 A 在 cgroup-X 中分配了内存,然后被迁移到 cgroup-Y,该内存仍然计入 cgroup-X。只有新分配的内存才计入新 cgroup。
1 | |
memcg 回收:局部压力,局部响应
没有 memcg 时,回收是全局的:kswapd 按 node 水位线扫描所有页面。有 memcg 后,回收增加了一个维度:当某个 cgroup 接近 memory.high 或 memory.max 时,回收只针对该 cgroup 拥有的页面。
1 | |
每个 memcg 维护自己的 lruvec——独立的活跃/不活跃链表。这意味着一个 cgroup 的回收不需要扫描其他 cgroup 的页面,也不会影响其他 cgroup 的热页面。
memory.low 和 memory.min 在全局回收时生效:当 kswapd 进行全局扫描时,受 memory.low 保护的 cgroup 的页面被跳过(除非保护被过量承诺);受 memory.min 保护的页面在任何情况下都不被回收。
memcg OOM 与全局 OOM
当一个 cgroup 达到 memory.max 且回收无法释放足够内存时,memcg OOM 触发。与全局 OOM 的关键区别:
| 维度 | memcg OOM | 全局 OOM |
|---|---|---|
| 触发条件 | cgroup 达到 memory.max | 系统全局内存耗尽 |
| victim 范围 | 只杀该 cgroup 内的进程 | 全局选择 |
| 外部影响 | 不影响其他 cgroup | 任何进程都可能被杀 |
memory.events |
oom、oom_kill 计数增加 | 不体现在 cgroup events |
memory.oom.group 控制 OOM 行为:设为 1 时,OOM 杀死该 cgroup 内的所有进程(而不是只选一个 victim)。这对需要原子性终止的工作负载(如一组协作进程)更合适。
这就是为什么容器被 OOM kill 时宿主可能还有大量空闲内存——问题不在全局内存不足,而是该容器的 memory.max 限制被触及。
模式提炼:全局资源变成层级预算
1 | |
| 维度 | 全局视角 | memcg 视角 |
|---|---|---|
| 可用内存 | MemAvailable | memory.max - memory.current |
| 回收触发 | node watermark | cgroup 接近 high/max |
| 回收范围 | 全 node LRU | per-memcg lruvec |
| OOM 范围 | 全局 oom_score | cgroup 内部 |
| 统计粒度 | /proc/meminfo | memory.stat per cgroup |
这种"全局池拆分为局部预算"的模式在分布式系统中非常普遍:数据库连接池的 per-tenant 限制、网络 QoS 的 per-flow 带宽预算、云平台的 per-account quota。核心思路是:总资源有限时,通过预算划分实现隔离和公平性。
一个最小实验:在 cgroup 中观察内存限制
1 | |
编译与运行:
1 | |
如果不方便操作系统 cgroup,用 systemd-run 更简洁:
1 | |
完成实验后清理:
1 | |
读取实验结果
在 64MB 限制的 cgroup 中运行时预期看到:
memory.current 递增:每轮分配 16MB 并 touch 后,memory.current 增加约 16MB。前 3-4 轮(共 48-64MB)应该成功。
接近 memory.max 时的行为:第 4 轮可能开始变慢(内核在尝试回收 cgroup 内的页面)。如果没有可回收页面(纯匿名页,无 swap),第 4 或第 5 轮的 mmap + touch 将触发 memcg OOM。
memory.events 变化:
high:如果设置了memory.high且被触及,此计数器增加max:接近memory.max时增加oom:OOM 被触发时增加oom_kill:进程实际被杀时增加
关键观察:
- 程序被 OOM kill 时,
dmesg或journalctl -k会显示类似Memory cgroup out of memory: Killed process <pid>的消息。这与全局 OOM 的Out of memory: Killed process不同——前者明确指出是 cgroup 限制。 - 宿主的
/proc/meminfo中MemAvailable可能仍然很大——cgroup OOM 是局部事件。 - 如果系统有 swap 且 cgroup 没有禁用 swap(
memory.swap.max),匿名页会先被 swap out 而不是直接 OOM。
模式提炼:限制不等于预留,保护不等于保证
1 | |
memory.max 不预留物理内存——它只在使用量到达时执行限制。所有 cgroup 的 memory.max 之和可以超过物理内存(超量承诺)。类似地,memory.min 的总和超过物理内存时,保护承诺无法全部兑现。这和航空公司超售机票是同一个模型:统计上不会所有人同时出现,但极端情况下需要有降级机制。
源码锚点
| 入口 | 读它的目的 |
|---|---|
mm/memcontrol.c |
memcg 核心:charge/uncharge、reclaim、OOM 触发 |
mm/vmscan.c |
shrink_lruvec():per-memcg 回收路径 |
mm/memcontrol.c |
mem_cgroup_oom():memcg OOM 判定和 kill |
kernel/cgroup/cgroup.c |
cgroup v2 文件系统接口 |
include/linux/memcontrol.h |
struct mem_cgroup 定义 |
读 mem_cgroup_charge() 时可以带着一个问题:当一个 page fault 要把新页面 charge 到 memcg 但该 memcg 已达 memory.max 时,代码如何决定"先尝试回收"还是"直接 OOM"?
研究生迁移表
| Linux 概念 | 一般系统设计 |
|---|---|
| memcg memory.max | per-tenant 资源配额 |
| memcg memory.high | 软限流(throttling、backpressure) |
| memcg memory.low/min | 最低保障(reserved capacity) |
| per-memcg LRU | per-tenant 缓存淘汰策略 |
| memcg OOM | 租户级别的熔断(circuit breaker) |
| memory.events | per-tenant SLI 指标 |
| cgroup hierarchy | 组织层级(org → team → service) |
| memory charge/uncharge | 资源使用计量(metering) |
资源隔离的核心架构模式:全局池 + 层级预算 + 局部执行。Kubernetes 的 resource limits/requests 直接映射到 memcg 的 max/min。数据库的 per-query memory limit、网络的 per-flow rate limit、云平台的 per-account compute quota 都是同一个模式的实例。
常见误解
第一个误解是认为容器看到的 free 命令输出反映的是容器的可用内存。free 读取的是 /proc/meminfo——这是全局信息。容器内要看自己的可用内存,应该读 memory.current 和 memory.max 的差值(或者使用 cgroup-aware 的工具)。
第二个误解是认为 memory.high 和 memory.max 只是"软限制"和"硬限制"的区别。memory.high 的机制是节流(throttle)——进程的内存分配被阻塞,直到回收释放出空间。这可能导致进程变慢但不会被杀。memory.max 才触发 OOM kill。两者的故障模式完全不同:前者是性能降级,后者是进程终止。
第三个误解是认为迁移进程到另一个 cgroup 会"移动"它的内存使用。不会。已分配的页面仍然计入原 cgroup。只有进程后续新分配的内存才计入新 cgroup。要真正移动内存使用,需要进程释放旧内存并重新分配。
第四个误解是认为 memory.min 保证进程能分配到那么多内存。memory.min 只保护已有的内存不被回收——它不保证分配一定成功。如果系统真的没有空闲物理页(所有内存都被 memory.min 保护),新分配仍然会失败。
第五个误解是把 cgroup v1 的 memory.limit_in_bytes 等同于 v2 的 memory.max。虽然功能类似,但 v2 的内存控制器在层级语义、统计精度和回收策略上有本质改进。v2 的 memory.high 在 v1 中没有直接对应物。生产系统应优先使用 cgroup v2。
练习
第一,用 systemd-run --user --scope -p MemoryMax=32M bash 创建一个 32MB 限制的 shell。在其中运行 cat /sys/fs/cgroup/$(cat /proc/self/cgroup | grep ^0:: | cut -d: -f3)/memory.current 观察基础内存使用。然后运行 head -c 50M /dev/urandom > /dev/null 看 page cache 是否被计入和回收。
第二,设置一个 cgroup 的 memory.high=32M 和 memory.max=64M,运行一个持续分配内存的程序。观察:程序在超过 32M 后是否变慢(被节流)?在什么时候被 OOM kill?memory.events 中 high 和 oom 的计数如何变化?
第三,查看一个运行中的 Docker 容器的内存统计:cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.stat。对比 anon、file、slab 的比例。对于一个数据库容器,哪个类别占比最大?为什么?
第四,阅读 mm/memcontrol.c 中 try_charge_memcg() 的实现。追踪从"charge 请求"到"触发回收"到"回收失败后 OOM"的完整路径。标记每个决策点的条件。
第五,在一台运行多个容器的机器上,设置 memory.low 保护一个关键服务容器。然后用另一个容器制造内存压力。观察被保护容器的 memory.events.low 是否增加——如果增加,说明保护被部分突破(全局压力过大)。
系列导航
- 上一篇:SLUB:小对象为什么不直接按页分配
- 本文:cgroup memory:内存从全局资源变成局部预算
- 下一篇:OOM Killer:内核什么时候决定杀进程
