4. cgroups 原理与内存控制:cgroup v1 vs v2
说实话,刚开始接触容器内存限制时,我也一头雾水。
明明 Docker 跑起来了,docker stats 看到的数字也对,但一进容器看 /proc/meminfo,还是物理内存总量。后来才搞清楚,Docker 的限制靠的是 cgroups,而不是修改容器内的内核参数。这一章,我就带你把 cgroups 的内存限制机制吃透。
4.1 cgroups 是什么?为什么需要它?
cgroups(Control Groups)是 Linux 内核提供的一种机制,用来限制、记录、隔离进程组的资源使用(CPU、内存、磁盘 I/O 等)。你可以把它理解为一个「资源配额管理系统」。
没有 cgroups 的时候,一个容器里的进程可以吃光宿主机的所有内存。有了它,你就能精确控制每个容器最多用多少内存。
4.2 cgroup v1 vs v2:两代架构的差异
我刚开始折腾 Docker 的时候,用的是 cgroup v1。后来升级到新内核,发现有些系统默认切成了 v2。两者差别不小,我踩过坑,所以重点给你讲讲。
| 对比维度 | cgroup v1 | cgroup v2 |
|---|---|---|
| 层级管理 | 每个子系统独立挂载一棵树(如内存、CPU 各自一树) | 所有子系统共用一棵统一层级树 |
| 进程管理 | 一个进程可以属于不同子树的不同 cgroup | 一个进程只能属于一个 cgroup(leaf cgroup) |
| 接口文件 | 分散在多个目录(如 /sys/fs/cgroup/memory/) |
统一在 /sys/fs/cgroup/ 下,文件命名更规范 |
| 内存控制器 | memory.memsw.limit_in_bytes 等 | memory.max、memory.current |
| Swap 管理 | 需要单独配置 memsw 文件 | 通过 memory.swap.max 统一管理 |
| 内核版本 | 主流 ≤ 4.x,部分 5.x 仍支持 | 从 Linux 4.5 开始,5.x + 成为默认 |
为什么会这样?v1 设计太灵活,导致配置复杂,而且不同子系统之间的协调容易出问题。v2 统一了层级,每个进程只有一个 cgroup,管理起来简洁很多。
4.3 内存子系统关键文件解析
无论 v1 还是 v2,核心都是几个接口文件。我在项目中排查内存泄漏时,最常看的就是它们。
4.3.1 cgroup v1 内存控制接口
挂载点通常在 /sys/fs/cgroup/memory/。假设你的 Docker 容器 ID 为 abc123,那么对应的 cgroup 目录大概是:
/sys/fs/cgroup/memory/docker/abc123/
几个关键文件:
| 文件 | 含义 | 示例值 |
|---|---|---|
memory.limit_in_bytes |
内存上限(字节) | 838860800(800MB) |
memory.usage_in_bytes |
当前已用内存 | 419430400(400MB) |
memory.max_usage_in_bytes |
历史峰值 | 629145600(600MB) |
memory.soft_limit_in_bytes |
软限制(可超限) | 536870912(512MB) |
memory.memsw.limit_in_bytes |
内存 + Swap 上限 | 1073741824(1GB) |
memory.failcnt |
达到内存限制的次数 | 3 |
查看一个进程当前所属的 v1 cgroup:
cat /proc/12345/cgroup | grep memory
# 输出: 5:memory:/docker/abc123
4.3.2 cgroup v2 内存控制接口
v2 的接口更直观,所有文件都在 /sys/fs/cgroup/ 下。Docker 容器对应的路径类似:
/sys/fs/cgroup/system.slice/docker-abc123.scope/
常用文件:
| 文件 | 含义 | 示例值 |
|---|---|---|
memory.max |
内存上限(字节) | 838860800 |
memory.current |
当前内存使用 | 419430400 |
memory.swap.max |
Swap 上限 | 209715200 |
memory.peak |
内存使用历史峰值 | 629145600 |
memory.events |
OOM 触发次数等事件 | oom 1 oom_kill 1 |
同样,查看当前进程属于哪个 cgroup:
cat /proc/12345/cgroup
# 输出: 0::/system.slice/docker-abc123.scope
# v2 只有一行,因为没有独立子系统树
4.4 Docker 如何利用 cgroups 实现内存限制?
Docker 在启动容器时,会根据你传入的 --memory 参数,自动写入对应的 cgroup 文件。
举个例子,运行:
docker run --memory=800m --memory-swap=1g myimage
Docker 会做以下操作(以 cgroup v2 为例):
echo 838860800 > /sys/fs/cgroup/system.slice/docker-xxx.scope/memory.max
echo 1073741824 > /sys/fs/cgroup/system.slice/docker-xxx.scope/memory.swap.max
# 注意:memory-swap 包含 memory 本身,所以 swap 实际可用 = 1g - 800m = 200m
当容器进程尝试分配超过 memory.max 的内存时,内核会触发 OOM Killer,把进程杀掉。这也就是你经常看到的 Killed 或 OOMKilled 状态。
memory.soft_limit_in_bytes 与 memory.limit_in_bytes 配置不一致,导致容器内存飙升到软限制后开始申请 Swap,但 Swap 上限又没配,结果宿主机直接 OOM 了。所以我的习惯是:要么全部用 v2,要么在 v1 下统一配置 memsw 限制。
4.5 内存数据结构的核心概念
cgroup 内存子系统内部,用几个核心数据结构来管理统计和限制。这里不是让你去改内核,而是要理解它们的存在,才能看懂监控数据。
- page_counter:用于计数和限制页级内存使用。每分配一个页面就增加计数,达到上限后返回失败或触发 OOM。
- mem_cgroup:每个 cgroup 对应一个 mem_cgroup 结构体,包含 hierarchy(层级)、usage(使用量)、soft_limit(软限制)、oom_control(OOM 控制)等字段。
- MEMCG_LOCAL_EVENTS:记录局部事件统计,比如 page fault、swap in/out 次数。
- memory_failure_handle:当内存页出现硬件错误(如 ECC 错误)时,cgroup 会尝试隔离该页面。
memory.move_charge_at_immigrate(v1 中迁移进程时附带已有内存页的功能),所以如果你的应用场景是「热迁移容器进程」,v2 会让部分存量内存统计丢失。这个我踩过一次,当时迁移后内存统计对不上,排查了很久。4.6 知识体系一图览
下面这张 SVG 把这章的核心关系串起来了。左边是 v1 vs v2 的对比主线,右边是内存子系统的关键文件与数据结构。个人习惯是,先看 v2 再看 v1,但如果你还在维护老系统,两边都得熟悉。
4.7 实际项目中如何验证与调优
我不喜欢光讲理论。说说我在一个宏基因组流程内存优化项目里的做法。
流程跑的是 metaspades,需要大量内存。我先在测试环境里用 --memory=32g 起容器,然后用 cat /sys/fs/cgroup/.../memory.current 实时观察内存消耗曲线。
--memory=32g,容器大部分时间浪费了闲置内存。后来我用了 cgroup v2 的 memory.reclaim 接口做主动回收,在低峰期把内存还给宿主机。
验证内存限制是否生效的最直接方法:
# 查看容器 cgroup 路径
docker inspect <container_id> | grep cgroup
# 进入路径,读取文件
cat /sys/fs/cgroup/<path>/memory.current
# 对比 docker stats
docker stats <container_id> --no-stream
我习惯把这两个值做一个脚本,每 5 秒采样一次,写入 CSV 文件。这样回看历史趋势时特别清楚。
cat memory.events 看 OOM 触发次数。如果这个值大于 0,说明内存不足被杀过进程,赶紧调大限制或优化代码。
嗯,关于 cgroups 的原理和内存控制,就讲到这里。