你有没有遇到过这种怪事?
压测跑得好好的,容器突然被 OOM Kill 了。你赶紧进容器敲了个free -h,一看内存快吃满了,心想“资源不够,加!” 加完内存,跑一会儿又被杀了。
坑爹的是,你明明在 K8s 里给容器设了内存上限 2Gi,但free显示的却是宿主机的 64Gi 内存?而且 used 那列的数字远超 2Gi?
读完后你能搞懂:
- 为什么容器里的
free看到的是“假”内存 - 页缓存(Page Cache)怎么把你给坑了
- 内核回收页缓存的时间差为啥能杀人
- 3 条命令精准看 cgroup 真实内存占用
一、先捅破这层窗户纸:free这命令在容器里就是“骗子”
我亲自踩过这个坑。那会儿刚上容器化,业务总在凌晨 OOM,监控显示容器内存 1.8Gi(上限 2Gi),但进容器一查free,used 高达 50Gi(宿主机的)。
关键点:free读的是/proc/meminfo,而/proc/meminfo从来都是节点的全局信息。不管你在不在容器里,它都返回整台物理机/虚拟机的内存数据。
# 进容器执行 $ free -h total used free shared buff/cache available Mem: 62Gi 28Gi 12Gi 1.2Gi 21Gi 31Gi62Gi total?我容器明明只配了 2Gi 上限。这就离谱。
正确姿势:想看 cgroup 限制的真实内存,用cat /sys/fs/cgroup/memory/memory.limit_in_bytes(cgroup v1)或cat /sys/fs/cgroup/memory.max(cgroup v2)。
# cgroup v2(常见于新系统) $ cat /sys/fs/cgroup/memory.max 2147483648 # 2Gi,这才是真的上限二、页缓存那点破事:它算使用量,但能回收
另一个常见误会:free里的used包含了页缓存。页缓存是啥?就是文件读写时内存里留的副本,目的是加速 I/O。
比如你容器里拷贝个大文件:
$ cp /data/bigfile /dev/null执行完你再free,发现 used 蹭蹭涨。但这不是你程序泄漏内存,是系统拿空闲内存做了缓存。
内核的承诺:当程序真需要内存时,页缓存可以被回收。所以在 cgroup 统计里,页缓存默认算作“可回收”的内存。
但问题来了——回收需要时间。
我遇到过线上事故:一个 Java 应用突然申请 500Mi 内存,而此时容器空闲内存只剩 200Mi,剩下的 300Mi 全是页缓存。内核开始回收页缓存,但 IO 压力大,回收速度没跟上,两三秒后 OOM Killer 直接就把 Java 进程给砍了。
你猜怎么着?过了半分钟,页缓存回收完了,内存又富裕了。进程白死了。
三、亲手复现:用一段代码把容器搞 OOM
来,跟着我做。这是个最小复现案例。
前置条件:Docker 已装,最好有个测试环境(别在生产搞)。
1. 起一个带内存限制的容器
$ docker run -it --rm --memory=512m --memory-swap=512m alpine sh设置了 512Mi 硬上限。
2. 看 cgroup 真实上限
# 容器内执行 $ cat /sys/fs/cgroup/memory/memory.limit_in_bytes 536870912 # 512Mi3. 模拟页缓存填满
# 生成一个 400Mi 的文件 $ dd if=/dev/zero of=/tmp/bigfile bs=1M count=400 $ cat /tmp/bigfile > /dev/null & # 后台读,刷页缓存4. 看内存占用(两种视角的差异)
# 假视角 - free $ free -m total used free shared buff/cache available Mem: 62785 28000 30000 200 4785 32000 # 这 total 明显是宿主机的 # 真视角 - cgroup 统计 $ cat /sys/fs/cgroup/memory/memory.stat | grep -E "^(cache|rss|total_rss)" cache 420000000 # 页缓存约 400Mi rss 51200000 # 常驻内存 50Mi真正的内存压力要看rss + cache是否接近 limit。这里总占用约 450Mi,离 512Mi 还差一点。
5. 手动触发 OOM
# 再申请 100Mi 匿名内存(用 stress 或自己写个 malloc) $ dd if=/dev/zero of=/dev/shm/oom bs=1M count=100如果在 cgroup 限制边缘,内核会先回收页缓存。回收不过来就杀进程。你可以dmesg看内核日志:
$ dmesg | tail -20 [12345.678] Memory cgroup out of memory: Kill process 1234 (dd) score 1000 or sacrifice child四、怎么安全地看容器内存?我推荐这 3 条命令
命令1(最稳):cat /sys/fs/cgroup/memory/memory.stat+ 自己算
$ cat /sys/fs/cgroup/memory/memory.stat | awk '{if($1=="rss") rss=$2; if($1=="cache") cache=$2} END {print "真实使用量(MiB): "(rss+cache)/1024/1024}'命令2(K8s 用户):kubectl top pod或kubectl top pod --containers
$ kubectl top pod my-app NAME CPU(cores) MEMORY(bytes) my-app 150m 438Mi # 这个已经是扣除可回收页缓存的数值kubectl top拿的是 cgroup 的usage_in_bytes减去total_inactive_file(不活跃页缓存),比较贴近真实压力。
命令3(docker 用户):docker stats --no-stream
$ docker stats --no-stream CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O abc123 app 2.3% 438MiB / 512MiB 85.5% ...它和kubectl top逻辑类似,已经帮你减掉可回收的 page cache。
我个人不推荐用free做任何容器内存监控,这命令在容器场景基本等于自欺欺人。
五、常见坑:明明内存没超,容器还是挂了
场景:你设了 limit 2Gi,监控显示 used 1.8Gi,但容器 OOM 了。
大概率原因:页缓存 + rss 超过 limit,但监控工具只看了 rss。
我见过挺多团队用ps的 RSS 累加或者top看 RES 列,这些都不包含页缓存。而你容器里跑了个日志采集,或者某个模块写临时文件,页缓存悄悄涨到了 500Mi,加上 1.8Gi 的 RSS 已经 2.3Gi 了,内核当然要杀人。
解决办法:监控系统要拉 cgroup 的total_rss + total_cache,或者直接用container_memory_working_set_bytes指标(Prometheus node_exporter 或 cAdvisor 都有)。
六、最后说几个实用建议(都是血泪教训)
- 告警阈值别设 95%——页缓存回收需要时间,留 20% buffer 吧,设到 80% 就该扩容了。
- 应用启动时预热会导致页缓存暴涨——比如 Java 类加载、Python 读一堆依赖文件。建议启动时预留额外内存,或者等稳定了再看监控。
/proc/meminfo在容器不可信——任何时候都别用。用 cgroup 接口。- OOM 后留现场:
dmesg看内核日志,docker inspect看退出码(137 就是 OOM kill)。
(顺便提一嘴,cgroup v2 的文件路径和名字变了,memory.limit_in_bytes改成memory.max,memory.stat结构也略有调整。别在旧脚本上硬套。)
你遇到过因为看错free导致半夜起来加机器的经历吗?评论区聊聊。