进程只占 10 MB,容器却因为内存被杀
容器的内存上限是 300 MB,里面只有一个进程,常驻内存 10 MB,结果容器退出码 137、
OOMKilled=true。另一个容器里 JVM 设了-Xms1g,启动后看常驻内存只有 150 MB。还有一个服务,监控上的内存用量长期贴着上限,进程的 RSS 加起来却小得多。
这三件事的原因是同一个:容器的内存用量是内核按 cgroup 记的账,它和「进程申请了多少」「进程的 RSS 是多少」都不是一回事。账上有进程看不见的项目,也有申请了却没有入账的部分;不同的项目在内存紧张时的待遇也不同,有的可以直接回收,有的只能靠杀进程腾出来。
本文在带内存上限的一次性容器里(cgroup v2,内核 7.0)逐步操作,每一步之后读取进程和 cgroup 的计数,把这本账的每一项对上。实验见文末。
一、申请不等于占用
向内核申请内存(mmap、malloc 大块内存、JVM 预留堆)得到的只是一段虚拟地址。物理内存要等到第一次写入某一页时才分配,这次分配由一次缺页异常触发。
实验里映射 256 MB 匿名内存,然后逐页读、逐页写:
| 操作 | 虚拟内存 VmSize | 常驻内存 VmRSS | 缺页次数 | cgroup 匿名页 |
|---|---|---|---|---|
| 映射 256 MB | +256 MB | 不变 | 0 | 不变 |
| 每页读 1 字节 | 不变 | 不变 | 65536 | 不变 |
| 每页写 1 字节 | 不变 | +256 MB | 65536 | +256 MB |
| 再写一遍 | 不变 | 不变 | 0 | 不变 |
256 MB 是 65536 个 4 KB 的页。只读没写过的匿名页,内核把它们都指向同一个全零的页,不分配内存;写的时候才真正分配。写过之后页已经在内存里,再访问不产生缺页。
打开透明大页后逐页写同样的 256 MB,缺页只有 128 次:内核一次分配 2 MB。所以缺页次数不能直接换算成内存量。
-Xms 也是申请。 在 2 GB 上限的容器里:
| JVM 参数 | Runtime.totalMemory() | 常驻内存 |
|---|---|---|
-Xms1g -Xmx1g | 1024 MB | 150 MB |
-Xms1g -Xmx1g -XX:+AlwaysPreTouch | 1024 MB | 1170 MB |
JVM 认为自己有 1 GB 的堆,操作系统只给了它实际碰过的那部分。堆随着对象分配逐步被碰到,常驻内存在运行中慢慢涨到接近 -Xmx 加上堆外的部分。两个后果:
- 启动后观察到的内存用量不是稳态。按它来定容器的内存上限,运行一段时间后会被杀。
- 节点或容器的内存其实不够时,不是启动失败,而是运行到某一刻才 OOM。
-XX:+AlwaysPreTouch让 JVM 在启动时把整个堆写一遍,代价是启动变慢,好处是不够就在启动时暴露,并且之后的分配不再夹带缺页的开销。
二、账上不只有进程的内存
memory.current)不等于容器里各进程的常驻内存之和。进程读写文件产生的页缓存、写进 tmpfs 的文件、内核为它分配的内存都算在容器头上,却不出现在任何进程的 RSS 里。用量碰到上限时,内核先回收文件页;匿名页和 tmpfs 在没有交换空间时无处可去,回收不出足够的内存就触发 OOM。文件页。 进程写了一个 200 MB 的文件:进程的常驻内存没有变化,容器的 file 增加了 200 MB。这些是页缓存,写入的数据先放在内存里,再由内核写回磁盘;之后读这个文件也从这里取。页缓存记在第一个使用它的 cgroup 头上。
用 read 读文件,数据从页缓存拷到进程自己的缓冲区,页缓存不算进程的 RSS。用 mmap 把文件映射进地址空间再访问,这些页同时计入进程的 RSS(RssFile 一项,实验里 +200 MB),但在 cgroup 的账上仍然是同一份文件页,不会算两次。
tmpfs。 向 /dev/shm 写 200 MB:file 增加 200 MB,其中 shmem 增加 200 MB。tmpfs 上的文件没有磁盘可以写回,内存就是它唯一的存放位置。对它调用丢弃缓存(posix_fadvise(DONTNEED))没有任何变化,只有删除文件才释放。
容器里的 tmpfs 有三个常见来源:/dev/shm、Docker 的 --tmpfs 挂载、Kubernetes 里 medium: Memory 的 emptyDir 卷。往这些地方写的日志、临时文件、解压出来的数据,全部计入容器的内存用量。
内核内存。 页表、目录项与 inode 缓存、套接字缓冲区等内核为这个容器分配的内存也在账上,本文的实验里它只有几 MB,但连接数很多或文件数很多的服务可以到上百 MB。
查看这本账,读容器里的 /sys/fs/cgroup/memory.stat:anon、file、shmem、kernel 几行加起来大致等于 memory.current。
三、碰到上限时,谁能让路
容器的用量碰到上限(memory.max)时,内核先尝试在这个容器内部回收内存,回收不出来才触发 OOM,杀掉容器里的进程。能不能回收,看是哪一类内存。
/dev/shm 写 400 MB 文件,容器都被杀死,后一种情况下进程自己的常驻内存只有 10 MB。文件页可以回收。 300 MB 上限的容器读一个 600 MB 的文件:页缓存涨到 286 MB 停住,内核一边读入新的页一边丢掉旧的,memory.events 里 max 计数增加了 151 次(碰到上限的次数),oom_kill 是 0。随后申请并写入 200 MB 匿名内存也成功了:页缓存从 286 MB 降到 92 MB,给匿名页让出了空间。
所以「内存用量贴着上限」本身不说明问题。只要进程读写文件,页缓存就会把可用的额度占满,这是内核在充分利用内存。
匿名页不能回收。 没有交换空间时(Kubernetes 的常规配置),匿名页无处可去。逐页写 400 MB 匿名内存,写过 200 MB 之后容器被杀,退出码 137。
tmpfs 也不能回收。 向 /dev/shm 写 400 MB 文件,写过 200 MB 之后容器被杀。被杀之前进程自己的常驻内存只有 10 MB。从进程的角度看它几乎没用内存;从 cgroup 的角度看,这 300 MB 和匿名页一样收不回来。
文件页的「可以回收」还有一个前提:页是干净的,即内容已经写回磁盘。刚写入、还没写回的脏页要等回写完成才能回收。写入速度持续超过磁盘回写速度时,容器里可回收的内存会比 file 一项显示的少。
四、次缺页与主缺页
缺页分两种,区别在于要不要等磁盘:
- 次缺页(minor fault):数据已经在内存里(页缓存里有,或者只需要一个清零的新页),内核只需要建立映射。微秒级。
- 主缺页(major fault):数据不在内存里,要从磁盘读进来。毫秒级,取决于磁盘。
用 mmap 访问那个 200 MB 的文件:
| 场景 | 次缺页 | 主缺页 |
|---|---|---|
| 页缓存已丢弃,顺序访问每一页 | 503 | 1 |
| 页缓存已在内存,再访问一遍 | 504 | 0 |
| 页缓存已丢弃,关闭预读,访问其中 800 页 | 0 | 800 |
第一行说明主缺页的次数会严重低估磁盘读:内核发现是顺序访问,提前把后面的数据读进来(预读),后续的访问到达时数据已经在内存里,只算次缺页。51200 页的数据从磁盘读入,主缺页只记了 1 次。关闭预读之后,每访问一页就是一次主缺页。
主缺页对线上服务的意义在于它是内存不足的早期信号。容器的额度被匿名页占得越多,留给页缓存的越少,刚被回收的文件页很快又要用,就得重新从磁盘读。服务不会被杀,但延迟上升、磁盘读增加。程序自己的代码段也是文件页,极端情况下连执行代码都在反复缺页。memory.stat 里的 pgmajfault 和 workingset_refault_file 持续增长,说明这个容器在反复读回刚被回收的页。
五、kubectl top 看到的是哪一个
kubectl top 和 kubelet 判断节点内存压力时用的是「工作集」:
工作集 = memory.current - inactive_file内核把文件页分成 inactive_file(最近只用过一次,回收时最先收走)和 active_file(被反复访问)。工作集把前者扣掉,认为它们随时可以回收;后者仍然算在内。
同一个 200 MB 的文件,两种访问方式之后:
| 访问方式 | active_file | inactive_file | 工作集 |
|---|---|---|---|
mmap 后访问两遍 | 200 MB | 0 | 205 MB |
read 读两遍 | 0 | 200 MB | 6 MB |
进程自己的匿名内存都只有 4 MB。哪些页算活跃由内核的回收算法决定,随内核版本变化,这里的数字只对应实验所用的内核;可以确定的是,工作集里可能包含相当数量的、其实可以回收的文件页。
于是有三个不同的数:
- 进程 RSS:匿名页加上映射进来的文件页。不含
read/write产生的页缓存、不含 tmpfs 文件、不含内核内存。 - 工作集:
kubectl top显示的值,kubelet 用它判断节点是否有内存压力、决定驱逐哪些 Pod。可能比 RSS 大得多。 memory.current:与上限比较的值。碰到上限并且回收不出来时,内核杀进程。
监控里「内存用量」的曲线,先确认它画的是哪一个。
六、排查与配置
容器因为内存被杀,或者内存用量看起来不对时,按这个顺序看:
- 读
memory.stat,把memory.current拆开:anon、file、shmem、kernel各占多少。 anon大:是进程自己的内存。JVM 看堆、元空间、线程栈、直接内存(见 JVM 调优与线上排查);其他语言看堆分析工具。shmem大:找 tmpfs 上的文件,包括已经删除但仍被进程打开的(见 日志删掉了,磁盘空间没有回来)。给内存型的emptyDir设置sizeLimit,不要把日志和临时文件写到/dev/shm。file大而anon、shmem不大:通常是正常的页缓存。看memory.events的oom_kill是否为 0、pgmajfault是否持续增长,来判断额度是否真的不够。memory.events里max的计数很高但没有 OOM:容器经常碰到上限,靠回收文件页维持。依赖页缓存的服务(消息队列、搜索、嵌入式存储引擎)这时已经在损失性能。
配置上的几条:
- 内存上限要覆盖匿名内存的稳态峰值,再给页缓存留出余量。Java 服务按
-Xmx加堆外内存来算,不按启动后观察到的用量;可以用-XX:+AlwaysPreTouch让堆的占用从启动起就是实数。 - 容器里的工具看到的不一定是容器的账:
free、/proc/meminfo显示的是整台机器(见 容器不是轻量虚拟机)。 - 请求值(request)决定调度,上限(limit)决定什么时候被杀,两者的关系见 requests 与 limits 各管什么。
配套实验
- codesphere-labs/kubernetes/container-memory-accounting:cgroup v2 容器里匿名内存的申请与触碰、透明大页、文件页与 tmpfs 的记账、300 MB 上限下的回收与 OOM、两种缺页、工作集的计算,以及
-Xms与AlwaysPreTouch的常驻内存(验证记录)
参考资料
- Linux 内核文档:Control Group v2(Memory 一节)(
memory.current、memory.max、memory.stat、memory.events各项的含义) - Linux 手册页:proc_pid_status(5)(
VmRSS、RssAnon、RssFile、RssShmem) - Linux 内核文档:Transparent Hugepage Support
- Kubernetes 文档:节点压力驱逐(工作集的计算方式与
inactive_file) - Kubernetes 文档:emptyDir 卷(
medium: Memory的文件计入容器内存) - Kubernetes 文档:为容器和 Pod 分配内存资源
- JDK 25 文档:java 命令(
-XX:+AlwaysPreTouch)