Page Cache 与 Swap:容器内存使用量为什么总在临界点?
实验环境:Ubuntu 24.04 / 内核 6.8.0-106-generic / Cgroup v2 / 华为云 FlexusX 8C16G
本文所有命令输出均来自真实实验机,可直接复现。
一、引子:那个"虚高"的内存水位
上一篇文章我们复现了容器 OOM。但更多时候,你遇到的不是 OOM,而是困惑:
“我的容器
docker stats显示内存 80% 了,会不会马上 OOM?”
“为什么memory.current一直贴着上限,但容器稳如老狗,从来不被杀?”
“我明明只写了个日志文件,容器内存怎么涨了 400M?”
如果你只看memory.current或docker stats的"内存使用率"来判断容器是否危险,你会长期误报。背后的两个"隐形推手"是Page Cache(页缓存)和Swap。本文用真实数据拆解:为什么容器内存"看着满"却没事,以及怎样才算"真的快 OOM 了"。
二、问题复现:写个文件,内存就涨了 400M
我在宿主机编译了一个静态memhog(每 1MB 真实写满一页),配合dd做文件 I/O。进一个-m 512m的容器观察 cgroup 接口:
root@ecs-a8bb-0002:~# docker run -m 512m --name pc-test -v /root/memhog:/memhog alpine sh -c 'cat/sys/fs/cgroup/memory.currentddif=/dev/zeroof=/tmp/bigfilebs=1Mcount=4002>&1|tail-1grep-E"^(file|anon|inactive_file|active_file|file_mapped) "/sys/fs/cgroup/memory.statcat/sys/fs/cgroup/memory.current '实测输出(已精简行号):
[1] 初始 memory.current: 1056768 # ≈ 1MB,空容器 [2] dd 写 400M 文件到 /tmp/bigfile 419430400 bytes (400.0MB) copied, 2.09s, 190.9MB/s [3] 写后 memory.stat(文件相关字段): anon 122880 file 419434496 # ≈ 400MB!全是文件页缓存 file_mapped 0 inactive_file 419434496 active_file 0 [4] 写后 memory.current: 433070080 # ≈ 413MB发生了什么:dd往/tmp/bigfile写了 400MB,内核把这些数据放进Page Cache(页缓存),于是file与inactive_file各涨到 ~400MB,memory.current从 1MB 飙到 413MB——但容器没 OOM,甚至没有任何告警。
这 400MB 不是"被吃掉的内存",而是内核用空闲内存做的文件读缓存。它随时可以被回收。
三、内存压力下,Page Cache 会自动让位
如果 page cache 真能被回收,那就该在内存紧张时看到它"缩水"。我们来验证:在同一个容器里,先写 400MB 文件,再在后台申请 300MB匿名内存(不可回收),观察 file 字段会不会被自动回收。
root@ecs-a8bb-0002:~# docker run -m 512m --name pc-test -v /root/memhog:/memhog alpine sh -c 'ddif=/dev/zeroof=/tmp/bigfilebs=1Mcount=4002>/dev/null /memhog300&# 后台申请 300MB 匿名内存sleep6grep-E"^(file|anon|inactive_file|active_file) "/sys/fs/cgroup/memory.statcat/sys/fs/cgroup/memory.current '输出:
[6] 压力后 memory.stat: anon 315977728 # ≈ 301MB(新申请的匿名内存) file 212602880 # ≈ 203MB(被回收了约 197MB!) inactive_file 212598784 active_file 4096 [7] 压力后 memory.current: 536829952 # ≈ 512MB,正好顶到上限关键结论:
- 写文件后
file ≈ 400MB;申请 300MB 匿名内存后,file自动降到 ~203MB,腾出的空间让给了匿名内存。 memory.current从 413MB 升到 ~512MB(上限),全程没有 OOM。- 这证明:Page Cache 是可回收内存,内核在分配匿名页失败时,会先把不活跃的 file 页换出去,而不是杀进程。
四、docker stats 的口径:它其实"减过"缓存
前面我们看到memory.current是 413MB,但如果你用docker stats看,数字会小得多:
root@ecs-a8bb-0002:~# docker run -d -m 512m --name pc-stat -v /root/memhog:/memhog alpine sh -c \"dd if=/dev/zero of=/tmp/bigfile bs=1M count=400 2>/dev/null; sleep 3600"root@ecs-a8bb-0002:~# docker stats --no-stream pc-statCONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS 57c341b6e2ec pc-stat0.00%12.2MiB / 512MiB2.38% 736B/126B 0B/379MB1root@ecs-a8bb-0002:~# # 同一时刻的 cgroup 真实数据:root@ecs-a8bb-0002:~# grep -E "^(anon|file|inactive_file) " /sys/fs/cgroup/.../memory.statanon45056file419434496inactive_file419434496root@ecs-a8bb-0002:~# cat /sys/fs/cgroup/.../memory.current432222208# ≈ 412MB(含缓存)对比非常刺眼:
| 指标 | 数值 | 含义 |
|---|---|---|
memory.current(原始) | 432222208 ≈412MB | 含 page cache 的全部记账 |
docker statsMEM USAGE | 12.2MiB | memory.current − inactive_file(≈ 412 − 400) |
docker stats的"内存使用"其实是working set(工作集)≈memory.current − inactive_file。Docker(cadvisor)认为"不活跃的页缓存随时能回收,不该算作已用"。所以docker stats比memory.current更接近真实压力,但也因此低估了 page cache 占用的物理页——它只减了inactive_file,没减active_file和slab_reclaimable。
五、内核原理深挖
5.1 什么是 Page Cache
当进程读/写文件,内核不会每次都直接怼磁盘,而是把文件内容缓存在内存的页缓存里。好处:重复读命中缓存,速度提升几个数量级。代价:它占用了"看起来像已用"的内存。在 cgroup v2 里,这些页计入memory.stat的file字段,并细分为:
inactive_file:长时间未被访问、最容易被回收的缓存。active_file:近期被访问、仍在"热"列表的缓存(回收优先级低于 inactive)。file_mapped:被 mmap 映射到进程地址空间的缓存(回收时需先解映射,成本更高)。
5.2 cgroup v2 memory.stat 字段解读(实测常用)
anon 进程堆/栈/匿名mmap,不可回收,"真·占用" file 所有文件页缓存(= active_file + inactive_file + 其他) inactive_file 冷缓存,OOM 时首选回收对象 active_file 热缓存,回收前先降级为 inactive file_mapped 被 mmap 的缓存 slab_reclaimable 内核 slab 中可回收部分(dentry/inode 缓存等)v1/v2 差异:v1 的
memory.stat字段命名不同(如total_cache、total_rss、total_mapped_file),且 v1 还有memory.stat中的total_inactive_file等带total_前缀;v2 去掉了total_前缀,并把cache改名为file、rss改名为anon。另外 v2 新增了workingset相关统计(workingset_refault_anon/file等),用于衡量"回收后再次发生缺页"的压力信号。
5.3 回收机制:LRU 双链表
内核用active/inactive两条 LRU 链表管理页。新缓存进inactive,被访问则升级到active;内存紧张时,先扫描inactive_file回收。这就是为什么上面实验里inactive_file从 400MB 被砍到 203MB,而匿名内存不受影响。
ASCII 示意:
内存紧张,分配匿名页失败 │ ▼ shrink_node() 回收路径 │ ├─ 先回收 inactive_file(冷文件缓存) ← dd 产生的 400MB 在这里被砍 ├─ 再回收 active_file(热文件缓存,先降级) ├─ 再回收 slab_reclaimable(dentry/inode) └─ 实在不够 → 匿名页换出到 swap(见下文) │ ▼ 仍超限 → OOM Killer5.4 正确评估"真实水位"的口径
| 你想知道的 | 该看的指标 | 危险信号 |
|---|---|---|
| 究竟有多少内存不能回收 | memory.stat的anon | anon 接近memory.max |
| 工作集(含热缓存) | anon + active_file | 接近memory.max |
| 是否真被 OOM 杀过 | memory.events的oom_kill | oom_kill > 0 |
| 缓存占比(通常无害) | file / memory.current | 占比高反而说明"内存利用率高",不是危险 |
结论:判断容器是否危险,核心看anon是否逼近memory.max,而不是memory.current或docker stats的百分比。
六、Swap 实验:内存超限时的"缓兵之计"
Page Cache 能回收,但匿名内存(堆)不可回收。当匿名内存超过memory.max,要么 OOM,要么——换到 Swap。
6.1 宿主机先开 Swap
实验机默认无 swap,且 cgroup v2 在本内核没有 per-cgroup 的memory.swappiness(后面详解)。先建 swapfile 并拉开全局swappiness:
root@ecs-a8bb-0002:~# free -h | grep SwapSwap: 0B 0B 0B# 原本没开root@ecs-a8bb-0002:~# ls /sys/fs/cgroup/memory.swappinessls: cannot access'/sys/fs/cgroup/memory.swappiness':No suchfileor directory# v2 无 per-cgroup swappinessroot@ecs-a8bb-0002:~# sysctl vm.swappinessvm.swappiness=0# 默认 0,不会主动 swaproot@ecs-a8bb-0002:~# fallocate -l 2G /swapfile && chmod 600 /swapfile \&&mkswap/swapfile&&swapon/swapfile root@ecs-a8bb-0002:~# sysctl vm.swappiness=60root@ecs-a8bb-0002:~# free -h | grep SwapSwap:2.0Gi 0B2.0Gi# 已就绪6.2 禁用 Swap:同样 800M,直接 OOM
-m 512m --memory-swap 512m等价于memory.swap.max = 0(不允许任何 swap):
root@ecs-a8bb-0002:~# docker run -m 512m --memory-swap 512m --name sw-no -v /root/memhog:/memhog alpine /memhog 800allocated16MB... allocated496MB# 被 OOM Killer 杀死root@ecs-a8bb-0002:~# docker inspect sw-no --format "OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}"OOMKilled=trueExitCode=137cgroup 里memory.swap.max = 0,匿名内存无处可去 → OOM。
6.3 允许 Swap:800M 也能活
-m 512m --memory-swap 1g让memory.swap.max = 512M(内存 + swap 共 1GB):
root@ecs-a8bb-0002:~# docker run -d -m 512m --memory-swap 1g --name sw-yes -v /root/memhog:/memhog alpine /memhog 800root@ecs-a8bb-0002:~# sleep 6root@ecs-a8bb-0002:~# CID=$(docker inspect sw-yes --format {{.Id}})root@ecs-a8bb-0002:~# CG=/sys/fs/cgroup/system.slice/docker-${CID}.scoperoot@ecs-a8bb-0002:~# echo "memory.max = $(cat $CG/memory.max)"memory.max=536870912# 512MB(RAM 上限)root@ecs-a8bb-0002:~# echo "memory.current = $(cat $CG/memory.current)"memory.current=536784896# ≈ 512MB(RAM 部分已顶满)root@ecs-a8bb-0002:~# echo "memory.swap.max = $(cat $CG/memory.swap.max)"memory.swap.max=536870912# 512MB(可换出到 swap 的上限)root@ecs-a8bb-0002:~# echo "memory.swap.current = $(cat $CG/memory.swap.current)"memory.swap.current=307576832# ≈ 293MB(已换到 swap!)root@ecs-a8bb-0002:~# docker inspect sw-yes --format "OOMKilled={{.State.OOMKilled}}"OOMKilled=false# 没被杀,活下来了root@ecs-a8bb-0002:~# grep -E "^(anon|file) " $CG/memory.statanon534650880# ≈ 510MB 在 RAM划重点:800MB 的匿名内存 = 510MB 留在 RAM(memory.current顶到 512MB) +293MB 进 swap(memory.swap.current)。因为 swap 吸收了溢出部分,容器没有被 OOM。代价是那 293MB 的访问会变慢(磁盘 IO)。
6.4 v1/v2 的 swappiness 差异(实测验证)
- Cgroup v1:每个 cgroup 有独立的
memory.swappiness(0~100),可单独控制"该 cgroup 多愿意 swap"。 - Cgroup v2(本内核 6.8.0-106):没有 per-cgroup 的
memory.swappiness文件(已用ls验证不存在)。是否 swap 完全由全局/proc/sys/vm/swappiness控制。这意味着同一台机器上的所有容器共享同一套 swap 倾向,你没法让"数据库容器别 swap、批处理容器随意 swap"。 - 另外注意:本实验必须
sysctl vm.swappiness=60才能让 swap 真正发生;若全局swappiness=0,即使memory.swap.max设得再大,内核也几乎不换出。
6.5 一个隐蔽的坑:docker 默认的 swap 翻倍
本文所有-m 512m实验(包括上一篇 OOM),如果不显式指定--memory-swap,Docker 在 v2 下会默认把memory.swap.max设为等于memory.max。上一篇 oom-test 的内核日志里就有这行佐证:
memory: usage 524288kB, limit 524288kB, failcnt 49 swap: usage 0kB, limit 524288kB, failcnt 0 # swap 上限默认 = 512MB!也就是说:docker run -m 512m默认允许容器总共用到 1GB(512M RAM + 512M swap),只要宿主机有 swap。这在"内存用满才开始慢"的场景下极容易掩盖真实内存压力——你的容器其实已经把 512M 全用了,只是偷偷换到了磁盘。生产上若要严格限制,务必显式--memory-swap=512m(关掉 swap)或设置一个明确的总量。
七、排查思路:怎样正确评估容器真实水位
- 别只看百分比。先
cat容器 cgroup 的memory.stat,看anon占比。 - 危险信号排序:
memory.events的oom_kill > 0→ 已经 OOM 过,最紧急;anon持续逼近memory.max→ 马上会 OOM;file/inactive_file很大 → 多半正常,是缓存利用得好。
- swap 是延迟而非解决:
memory.swap.current持续增长,说明匿名内存已超过 RAM,性能已在下滑。 - workingset 口径:真实工作集 ≈
anon + active_file;docker stats的 MEM USAGE 已减掉 inactive_file,可作为快览,但精细排障看memory.stat。
八、解决方案与最佳实践
- 监控
anon而非memory.current:Prometheus + cadvisor 取container_memory_working_set_bytes会比container_memory_usage_bytes更接近危险线;再补一条container_memory_swap监控。 - 明确 swap 策略:内存敏感型服务(数据库)建议
--memory-swap=512m关 swap,避免抖动;批处理/可重试任务可开 swap 当安全网。 - 别让默认翻倍坑你:生产镜像/编排显式写死
--memory-swap。 - page cache 不是敌人:高
file缓存说明 I/O 命中率高,不要因为memory.current高就去"优化"掉缓存。 - 调大
memory.high做软限流:v2 的memory.high超过会被节流(主动回收+限流),比memory.max直接杀更平滑。
九、小结与思考题
小结:memory.current高 ≠ 内存紧张——其中大部分可能是可随时回收的 Page Cache。判断容器是否危险,核心是看anon是否逼近memory.max;docker stats已减去 inactive_file,可作快览但不能替代memory.stat。Swap 能延缓匿名内存超限导致的 OOM,但带来 IO 抖动,且 cgroup v2 本内核不支持 per-cgroup swappiness,只能靠全局vm.swappiness控制。Docker 默认会悄悄给 swap 翻倍额度,生产务必显式声明。
思考题:
- 一个容器只做"读大文件后立刻丢弃"的工作,
memory.current会一直涨吗?为什么?(提示:inactive_file 回收) - 如果
vm.swappiness=0但memory.swap.max=512M,容器匿名内存超限会被 OOM 还是换出?为什么? docker stats显示 5%,但容器随后被 OOM——可能吗?什么场景下会这样?- 为什么 cgroup v2 取消了 per-cgroup 的
memory.swappiness?这给多租户调度带来了什么限制?
本篇与上一篇《OOM Killer》共同构成"容器内存"模块。下一系列《内核调试工具》将从 perf / ftrace / eBPF 三件套,教你看穿上述现象背后的内核执行路径。