Page Cache 与 Swap:容器内存使用量为什么总在临界点?

📅 2026/7/24 16:36:15
Page Cache 与 Swap:容器内存使用量为什么总在临界点?
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 接口rootecs-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/bigfilebs1Mcount40021|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各涨到 ~400MBmemory.current从 1MB 飙到 413MB——但容器没 OOM甚至没有任何告警。这 400MB 不是被吃掉的内存而是内核用空闲内存做的文件读缓存。它随时可以被回收。三、内存压力下Page Cache 会自动让位如果 page cache 真能被回收那就该在内存紧张时看到它缩水。我们来验证在同一个容器里先写 400MB 文件再在后台申请 300MB匿名内存不可回收观察 file 字段会不会被自动回收。rootecs-a8bb-0002:~# docker run -m 512m --name pc-test -v /root/memhog:/memhog alpine sh -c ddif/dev/zeroof/tmp/bigfilebs1Mcount4002/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看数字会小得多rootecs-a8bb-0002:~# docker run -d -m 512m --name pc-stat -v /root/memhog:/memhog alpine sh -c \dd if/dev/zero of/tmp/bigfile bs1M count400 2/dev/null; sleep 3600rootecs-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/379MB1rootecs-a8bb-0002:~# # 同一时刻的 cgroup 真实数据rootecs-a8bb-0002:~# grep -E ^(anon|file|inactive_file) /sys/fs/cgroup/.../memory.statanon45056file419434496inactive_file419434496rootecs-a8bb-0002:~# cat /sys/fs/cgroup/.../memory.current432222208# ≈ 412MB含缓存对比非常刺眼指标数值含义memory.current原始432222208 ≈412MB含 page cache 的全部记账docker statsMEM USAGE12.2MiBmemory.current − inactive_file≈ 412 − 400docker stats的内存使用其实是working set工作集≈memory.current − inactive_file。Dockercadvisor认为不活跃的页缓存随时能回收不该算作已用。所以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_reclaimabledentry/inode └─ 实在不够 → 匿名页换出到 swap见下文 │ ▼ 仍超限 → OOM Killer5.4 正确评估真实水位的口径你想知道的该看的指标危险信号究竟有多少内存不能回收memory.stat的anonanon 接近memory.max工作集含热缓存anon active_file接近memory.max是否真被 OOM 杀过memory.events的oom_killoom_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 并拉开全局swappinessrootecs-a8bb-0002:~# free -h | grep SwapSwap: 0B 0B 0B# 原本没开rootecs-a8bb-0002:~# ls /sys/fs/cgroup/memory.swappinessls: cannot access/sys/fs/cgroup/memory.swappiness:No suchfileor directory# v2 无 per-cgroup swappinessrootecs-a8bb-0002:~# sysctl vm.swappinessvm.swappiness0# 默认 0不会主动 swaprootecs-a8bb-0002:~# fallocate -l 2G /swapfile chmod 600 /swapfile \mkswap/swapfileswapon/swapfile rootecs-a8bb-0002:~# sysctl vm.swappiness60rootecs-a8bb-0002:~# free -h | grep SwapSwap:2.0Gi 0B2.0Gi# 已就绪6.2 禁用 Swap同样 800M直接 OOM-m 512m --memory-swap 512m等价于memory.swap.max 0不允许任何 swaprootecs-a8bb-0002:~# docker run -m 512m --memory-swap 512m --name sw-no -v /root/memhog:/memhog alpine /memhog 800allocated16MB... allocated496MB# 被 OOM Killer 杀死rootecs-a8bb-0002:~# docker inspect sw-no --format OOMKilled{{.State.OOMKilled}} ExitCode{{.State.ExitCode}}OOMKilledtrueExitCode137cgroup 里memory.swap.max 0匿名内存无处可去 → OOM。6.3 允许 Swap800M 也能活-m 512m --memory-swap 1g让memory.swap.max 512M内存 swap 共 1GBrootecs-a8bb-0002:~# docker run -d -m 512m --memory-swap 1g --name sw-yes -v /root/memhog:/memhog alpine /memhog 800rootecs-a8bb-0002:~# sleep 6rootecs-a8bb-0002:~# CID$(docker inspect sw-yes --format {{.Id}})rootecs-a8bb-0002:~# CG/sys/fs/cgroup/system.slice/docker-${CID}.scoperootecs-a8bb-0002:~# echo memory.max $(cat $CG/memory.max)memory.max536870912# 512MBRAM 上限rootecs-a8bb-0002:~# echo memory.current $(cat $CG/memory.current)memory.current536784896# ≈ 512MBRAM 部分已顶满rootecs-a8bb-0002:~# echo memory.swap.max $(cat $CG/memory.swap.max)memory.swap.max536870912# 512MB可换出到 swap 的上限rootecs-a8bb-0002:~# echo memory.swap.current $(cat $CG/memory.swap.current)memory.swap.current307576832# ≈ 293MB已换到 swaprootecs-a8bb-0002:~# docker inspect sw-yes --format OOMKilled{{.State.OOMKilled}}OOMKilledfalse# 没被杀活下来了rootecs-a8bb-0002:~# grep -E ^(anon|file) $CG/memory.statanon534650880# ≈ 510MB 在 RAM划重点800MB 的匿名内存 510MB 留在 RAMmemory.current顶到 512MB 293MB 进 swapmemory.swap.current。因为 swap 吸收了溢出部分容器没有被 OOM。代价是那 293MB 的访问会变慢磁盘 IO。6.4 v1/v2 的 swappiness 差异实测验证Cgroup v1每个 cgroup 有独立的memory.swappiness0~100可单独控制该 cgroup 多愿意 swap。Cgroup v2本内核 6.8.0-106没有 per-cgroup 的memory.swappiness文件已用ls验证不存在。是否 swap 完全由全局/proc/sys/vm/swappiness控制。这意味着同一台机器上的所有容器共享同一套 swap 倾向你没法让数据库容器别 swap、批处理容器随意 swap。另外注意本实验必须sysctl vm.swappiness60才能让 swap 真正发生若全局swappiness0即使memory.swap.max设得再大内核也几乎不换出。6.5 一个隐蔽的坑docker 默认的 swap 翻倍本文所有-m 512m实验包括上一篇 OOM如果不显式指定--memory-swapDocker 在 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默认允许容器总共用到 1GB512M RAM 512M swap只要宿主机有 swap。这在内存用满才开始慢的场景下极容易掩盖真实内存压力——你的容器其实已经把 512M 全用了只是偷偷换到了磁盘。生产上若要严格限制务必显式--memory-swap512m关掉 swap或设置一个明确的总量。七、排查思路怎样正确评估容器真实水位别只看百分比。先cat容器 cgroup 的memory.stat看anon占比。危险信号排序memory.events的oom_kill 0→ 已经 OOM 过最紧急anon持续逼近memory.max→ 马上会 OOMfile/inactive_file很大 → 多半正常是缓存利用得好。swap 是延迟而非解决memory.swap.current持续增长说明匿名内存已超过 RAM性能已在下滑。workingset 口径真实工作集 ≈anon active_filedocker stats的 MEM USAGE 已减掉 inactive_file可作为快览但精细排障看memory.stat。八、解决方案与最佳实践监控anon而非memory.currentPrometheus cadvisor 取container_memory_working_set_bytes会比container_memory_usage_bytes更接近危险线再补一条container_memory_swap监控。明确 swap 策略内存敏感型服务数据库建议--memory-swap512m关 swap避免抖动批处理/可重试任务可开 swap 当安全网。别让默认翻倍坑你生产镜像/编排显式写死--memory-swap。page cache 不是敌人高file缓存说明 I/O 命中率高不要因为memory.current高就去优化掉缓存。调大memory.high做软限流v2 的memory.high超过会被节流主动回收限流比memory.max直接杀更平滑。九、小结与思考题小结memory.current高 ≠ 内存紧张——其中大部分可能是可随时回收的 Page Cache。判断容器是否危险核心是看anon是否逼近memory.maxdocker stats已减去 inactive_file可作快览但不能替代memory.stat。Swap 能延缓匿名内存超限导致的 OOM但带来 IO 抖动且 cgroup v2 本内核不支持 per-cgroup swappiness只能靠全局vm.swappiness控制。Docker 默认会悄悄给 swap 翻倍额度生产务必显式声明。思考题一个容器只做读大文件后立刻丢弃的工作memory.current会一直涨吗为什么提示inactive_file 回收如果vm.swappiness0但memory.swap.max512M容器匿名内存超限会被 OOM 还是换出为什么docker stats显示 5%但容器随后被 OOM——可能吗什么场景下会这样为什么 cgroup v2 取消了 per-cgroup 的memory.swappiness这给多租户调度带来了什么限制本篇与上一篇《OOM Killer》共同构成容器内存模块。下一系列《内核调试工具》将从 perf / ftrace / eBPF 三件套教你看穿上述现象背后的内核执行路径。