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,管理起来简洁很多。

注意:如果你在 Ubuntu 20.04+ 或 CentOS 8+ 上跑 Docker,默认已经是 cgroup v2。但某些老版本的 Docker(≤ 19.03)可能需要手动切换。

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 状态。

避坑经验:我曾经在 cgroup v1 系统上遇到过 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 会尝试隔离该页面。
深入理解:cgroup v2 不再支持 memory.move_charge_at_immigrate(v1 中迁移进程时附带已有内存页的功能),所以如果你的应用场景是「热迁移容器进程」,v2 会让部分存量内存统计丢失。这个我踩过一次,当时迁移后内存统计对不上,排查了很久。

4.6 知识体系一图览

下面这张 SVG 把这章的核心关系串起来了。左边是 v1 vs v2 的对比主线,右边是内存子系统的关键文件与数据结构。个人习惯是,先看 v2 再看 v1,但如果你还在维护老系统,两边都得熟悉。

cgroups 内存控制知识结构 cgroup v1 架构 • 每个子系统独立层级树 • 进程可跨树 • 文件:memory.limit_in_bytes • 文件:memory.memsw.* • 文件:memory.failcnt • 内核版本:4.x 及以下主流 cgroup v2 架构 • 统一层级树(所有子系统) • 进程只属一个 leaf cgroup • 文件:memory.max • 文件:memory.swap.max • 文件:memory.events • 内核版本:5.x+ 默认 内存子系统核心数据结构 • page_counter —— 页级计数与限制 • mem_cgroup —— 每个 cgroup 统计单元 • MEMCG_LOCAL_EVENTS —— 局部事件统计 • memory_failure_handle —— 硬件错误处理 Docker 实践 docker run --memory=800m → cgroup 接口写入

4.7 实际项目中如何验证与调优

我不喜欢光讲理论。说说我在一个宏基因组流程内存优化项目里的做法。

流程跑的是 metaspades,需要大量内存。我先在测试环境里用 --memory=32g 起容器,然后用 cat /sys/fs/cgroup/.../memory.current 实时观察内存消耗曲线。

调优经验:我发现 metaspades 在不同阶段内存需求差异很大(从 4G 到 28G 波动)。如果直接设 --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 文件。这样回看历史趋势时特别清楚。

一个小技巧:在 cgroup v2 下,可以通过 cat memory.events 看 OOM 触发次数。如果这个值大于 0,说明内存不足被杀过进程,赶紧调大限制或优化代码。

嗯,关于 cgroups 的原理和内存控制,就讲到这里。