2、理解 Docker 内存管理原理

聊 Docker 内存优化,得先搞明白底层怎么管内存的。说白了,Docker 本身不干这事儿,它靠的是 Linux 内核的 cgroups 机制。我刚开始接触那会儿也迷糊,以为容器里 free -m 看到的就是全部。后来踩了坑才明白——没那么简单。

2.1 内存隔离的基石:cgroups

cgroups(Control Groups)是 Linux 内核提供的一个功能。它能把进程分组,然后对每组做资源限制、优先级控制、审计等等。Docker 就用它来给每个容器圈一块“内存地盘”。

每个容器启动时,Docker 都会在 /sys/fs/cgroup/memory/docker/<容器ID> 下创建一组文件。这里面有几个关键参数,你直接看就清楚了:

/sys/fs/cgroup/memory/docker/<容器ID>/
├── memory.limit_in_bytes           # 容器最大可用内存(硬限制)
├── memory.soft_limit_in_bytes      # 容器软限制,超了不一定立即死
├── memory.memsw.limit_in_bytes     # 内存+swap总上限
├── memory.usage_in_bytes           # 当前内存使用量
├── memory.max_usage_in_bytes       # 历史最高使用量
├── memory.failcnt                  # 请求内存被拒绝的次数
├── memory.oom_control              # OOM Killer 开关
└── memory.stat                     # 详细内存统计数据

我个人习惯先看 memory.failcnt,如果这个值不为 0,说明容器已经发生过内存争抢。你想想看,生产环境里一个容器频繁触发 OOM,那对业务的影响是灾难性的。

2.2 控制组怎么“控制”的?

当一个进程在容器里申请内存时,内核会检查它所属的 cgroup 是否还能分配。内核维护了一棵“资源树”,每个节点都有自己的限制值。

这里有个重要的点:cgroups 的内存限制是实时生效的,不需要重启。你可以动态调整一个运行中的容器:

# 把容器的内存限制调整为 512MB
echo "536870912" > /sys/fs/cgroup/memory/docker/<容器ID>/memory.limit_in_bytes

# 或者通过 Docker API 热更新
docker update --memory 512m <容器名>

嗯,这里要注意:如果你把限制调得太低,比如从 2G 降到 256M,而容器当前已经用了 512M,那内核会立即触发 OOM Killer 去杀掉进程。我在项目里有一次线上扩容操作失误,就因为调小限制导致服务挂了,后来我写了严格的变更脚本才解决这个问题。

2.3 swap 与内存的“爱恨纠葛”

默认情况下,Docker 会允许容器使用 swap。swap 启用时,内存限制其实是 memory.limit_in_bytes + memory.memsw.limit_in_bytes。也就是说,你给容器设了 1G 内存 + 1G swap,实际可用的“假内存”是 2G。

这会导致一个很坑的情况:你觉得容器只用了 800M,但其实它已经把 swap 写满了。一旦 swap 用尽,系统开始真正 OOM,性能会骤降。

配置方式 实际效果 适用场景
--memory=1g 限制内存 1G,swap 默认与内存相同 开发环境,临时测试
--memory=1g --memory-swap=2g 内存 1G + swap 1G,总计 2G 需要临时内存爆发,但能接受性能损失
--memory=1g --memory-swap=-1 swap 无限,内存限制无效 基本不要用,极其危险
--memory=1g --memory-swap=1g swap 完全禁用,只有物理内存 生产环境推荐

我建议,生产环境一定要禁用 swap,或者严格限制 swap 上限。很多性能抖动的问题,最后查出来都是 swap 在作祟。

2.4 OOM Killer 与优先级

当系统内存不足时,内核会启动 OOM Killer,根据 oom_score 选择杀掉哪个进程。这个分数怎么算的?简单来说:占用内存越大、存活时间越短、属于容器的进程,分数越高。

Docker 提供了一个 --oom-kill-disable 选项,可以禁止内核杀掉这个容器里的进程。但不要轻易用——一旦内存溢出,系统可能会崩溃。

# 启动容器时,如果内存超了,宁可挂掉也不要被内核误杀
docker run --memory=512m --oom-kill-disable --name app1 myapp

# 或者调整 OOM 优先级(数值越小,越不容易被 kill)
docker run --memory=512m --oom-score-adj=-500 --name app2 myapp
避坑指南
我曾经在生产环境对一个关键数据库容器设了 --oom-kill-disable。结果某次流量突增,容器内存暴涨,系统开始频繁 swap,最后整个宿主机被拖死。所有容器一起挂了。后来我再也不敢给独占内存的容器设这个参数,而是用 --oom-score-adj 降低它的被杀优先级,同时保留内核的兜底机制。

2.5 内核中的 memory reclaim 机制

当容器快达到内存上限时,内核会触发 memory reclaim(内存回收)。它优先回收 page cache(文件缓存),然后是匿名页(进程堆栈数据)。如果还不满足,就进入 direct reclaim——线程直接参与回收,会导致明显的性能下降。

我怎么观察这个?看 memory.stat 中的三个关键指标:

# 从宿主机的 cgroup 文件中读
cat /sys/fs/cgroup/memory/docker/<容器ID>/memory.stat

# 重点关注下面几项:
total_cache           # page cache 大小
total_rss             # 物理内存占用(不含 swap)
total_pgfault         # 缺页中断次数
total_pgmajfault      # 主缺页(需要磁盘 I/O)
total_swap            # swap 使用量

如果 total_pgmajfault 持续上升,说明内存不够用,频繁换页了。这时候你加内存比优化代码更管用。

2.6 容器 vs 宿主机的内存视图差异

你可能会奇怪:为什么容器里执行 free -h 看到的内存和宿主机不一样?

原因很简单:容器看到的 /proc/meminfo 是被 namespace 隔离过的。内核给每个容器虚拟了一份,只显示它自己 cgroup 允许的“假内存”。实际上,宿主机看到的是全部物理内存。

举个例子:我的宿主机有 64G 内存,跑了一个限了 1G 的容器。在容器里 free -h 显示总内存 1G,已用 300M,可用 700M。这是假的——实际宿主机已经用了 50G,只是内核不想让容器知道。

所以排查内存问题时,千万别只进容器看。要从宿主机角度查 docker stats 或 cat /sys/fs/cgroup/memory/docker/容器ID/memory.stat 才真实。

2.7 一图看懂 Docker 内存管理架构

下面这张图是我自己画的,从容器到内核再到物理资源,把各个层级串起来。你仔细看一遍,后面讲优化参数时思路会更清晰。

Docker 容器内存管理整体架构 容器 1 (限 512M) 进程 P1 — 进程 P2 — 进程 P3 cgroups memory 子系統 limit_in_bytes | soft_limit | oom_control | memsw_limit Linux 内核内存管理 page cache · 匿名页 · 回收机制 (kswapd / direct reclaim) OOM Killer · swap 物理硬件资源 物理内存 (48GB) · swap 磁盘 · 多核 CPU · 磁盘 I/O

从上到下,每一层都在做资源仲裁。容器申请内存 → cgroup 检查限额 → 内核分配物理页或触发回收 → 最终落到硬件。当内核发现超额时,要么触发 OOM Killer 杀掉进程,要么触发 swap 换入换出。

2.8 日常查阅内存状态的小技巧

最后分享几个我日常排查用的命令,不复杂,但特别管用:

# 1. 快速查看所有容器实时内存
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}"

# 2. 查看某个容器的详细 cgroup 统计
docker exec <容器名> cat /sys/fs/cgroup/memory/memory.stat

# 3. 如果容器内没有 cgroup 访问权限,从宿主机查
cat /sys/fs/cgroup/memory/docker/$(docker inspect --format '{{.Id}}' <容器名>)/memory.stat

# 4. 看历史峰值内存(这个最有用)
cat /sys/fs/cgroup/memory/docker/$(docker inspect --format '{{.Id}}' <容器名>)/memory.max_usage_in_bytes
个人经验:每次发布新版本前,我都会在预发环境跑一遍 memory.max_usage_in_bytes 对比,如果新版本比旧版本峰值高了 20% 以上,我就要找研发聊一聊了。

理解了这些原理,你就能明白后面章节为什么要那样设参数、为什么要做那些限制。说白了,所有的优化都是为了让内核在压力下做出更符合你预期的事情——而不是让它自己去猜。