Skip to content

进程只占 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 MB65536+256 MB
再写一遍不变不变0不变

256 MB 是 65536 个 4 KB 的页。只读没写过的匿名页,内核把它们都指向同一个全零的页,不分配内存;写的时候才真正分配。写过之后页已经在内存里,再访问不产生缺页。

打开透明大页后逐页写同样的 256 MB,缺页只有 128 次:内核一次分配 2 MB。所以缺页次数不能直接换算成内存量。

-Xms 也是申请。 在 2 GB 上限的容器里:

JVM 参数Runtime.totalMemory()常驻内存
-Xms1g -Xmx1g1024 MB150 MB
-Xms1g -Xmx1g -XX:+AlwaysPreTouch1024 MB1170 MB

JVM 认为自己有 1 GB 的堆,操作系统只给了它实际碰过的那部分。堆随着对象分配逐步被碰到,常驻内存在运行中慢慢涨到接近 -Xmx 加上堆外的部分。两个后果:

  • 启动后观察到的内存用量不是稳态。按它来定容器的内存上限,运行一段时间后会被杀。
  • 节点或容器的内存其实不够时,不是启动失败,而是运行到某一刻才 OOM。-XX:+AlwaysPreTouch 让 JVM 在启动时把整个堆写一遍,代价是启动变慢,好处是不够就在启动时暴露,并且之后的分配不再夹带缺页的开销。

二、账上不只有进程的内存 ​

memory.current:容器的内存用量匿名页 anon堆、栈、malloc文件页 inactive_file最近只用过一次文件页 active_file被反复访问tmpfs(shmem)/dev/shm、内存卷内核内存页表、slab 等不可回收(无交换时)可回收,最先被收走可回收,排在后面不可回收部分可回收进程 RSS = 匿名页 + 映射进地址空间的那部分文件页;read/write 产生的页缓存、tmpfs 文件、内核内存都不在内工作集 = memory.current - inactive_filekubectl top 显示的和 kubelet 判断节点内存压力时用的都是它;容器是否被 OOM 杀死看的是 memory.current 与上限申请(mmap、malloc、-Xms)只增加虚拟地址空间,不计入以上任何一项;第一次写入某一页时才分配物理内存并记账第一次访问触发缺页:内存里已有数据(或只需清零)是次缺页,需要从磁盘读是主缺页上图各段的宽度只是示意,不代表比例
图 1 · 容器的内存用量(cgroup v2 的 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,杀掉容器里的进程。能不能回收,看是哪一类内存。

上限 300 MB读 600 MB 文件文件页 286 MB正常再写 200 MB 匿名内存匿名页 204 MB文件页 92正常写 400 MB 匿名内存匿名页:写过 200 MB 之后触顶退出码 137向 /dev/shm 写 400 MBtmpfs:写过 200 MB 之后触顶退出码 137页缓存占满上限是正常状态,不是泄漏;匿名页与 tmpfs 逼近上限才是危险信号Kubernetes 里 medium: Memory 的 emptyDir 就是 tmpfs,写进去的文件计入容器的内存用量
图 2 · 实测(上限 300 MB,不允许换出):读一个 600 MB 的文件,页缓存涨到 286 MB 后停住,旧的页被回收,没有 OOM;这时再申请 200 MB 匿名内存也能成功,页缓存让出了位置。直接写 400 MB 匿名内存,或者向 /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 的文件:

场景次缺页主缺页
页缓存已丢弃,顺序访问每一页5031
页缓存已在内存,再访问一遍5040
页缓存已丢弃,关闭预读,访问其中 800 页0800

第一行说明主缺页的次数会严重低估磁盘读:内核发现是顺序访问,提前把后面的数据读进来(预读),后续的访问到达时数据已经在内存里,只算次缺页。51200 页的数据从磁盘读入,主缺页只记了 1 次。关闭预读之后,每访问一页就是一次主缺页。

主缺页对线上服务的意义在于它是内存不足的早期信号。容器的额度被匿名页占得越多,留给页缓存的越少,刚被回收的文件页很快又要用,就得重新从磁盘读。服务不会被杀,但延迟上升、磁盘读增加。程序自己的代码段也是文件页,极端情况下连执行代码都在反复缺页。memory.stat 里的 pgmajfault 和 workingset_refault_file 持续增长,说明这个容器在反复读回刚被回收的页。

五、kubectl top 看到的是哪一个 ​

kubectl top 和 kubelet 判断节点内存压力时用的是「工作集」:

text
工作集 = memory.current - inactive_file

内核把文件页分成 inactive_file(最近只用过一次,回收时最先收走)和 active_file(被反复访问)。工作集把前者扣掉,认为它们随时可以回收;后者仍然算在内。

同一个 200 MB 的文件,两种访问方式之后:

访问方式active_fileinactive_file工作集
mmap 后访问两遍200 MB0205 MB
read 读两遍0200 MB6 MB

进程自己的匿名内存都只有 4 MB。哪些页算活跃由内核的回收算法决定,随内核版本变化,这里的数字只对应实验所用的内核;可以确定的是,工作集里可能包含相当数量的、其实可以回收的文件页。

于是有三个不同的数:

  • 进程 RSS:匿名页加上映射进来的文件页。不含 read/write 产生的页缓存、不含 tmpfs 文件、不含内核内存。
  • 工作集:kubectl top 显示的值,kubelet 用它判断节点是否有内存压力、决定驱逐哪些 Pod。可能比 RSS 大得多。
  • memory.current:与上限比较的值。碰到上限并且回收不出来时,内核杀进程。

监控里「内存用量」的曲线,先确认它画的是哪一个。

六、排查与配置 ​

容器因为内存被杀,或者内存用量看起来不对时,按这个顺序看:

  1. 读 memory.stat,把 memory.current 拆开:anon、file、shmem、kernel 各占多少。
  2. anon 大:是进程自己的内存。JVM 看堆、元空间、线程栈、直接内存(见 JVM 调优与线上排查);其他语言看堆分析工具。
  3. shmem 大:找 tmpfs 上的文件,包括已经删除但仍被进程打开的(见 日志删掉了,磁盘空间没有回来)。给内存型的 emptyDir 设置 sizeLimit,不要把日志和临时文件写到 /dev/shm。
  4. file 大而 anon、shmem 不大:通常是正常的页缓存。看 memory.events 的 oom_kill 是否为 0、pgmajfault 是否持续增长,来判断额度是否真的不够。
  5. memory.events 里 max 的计数很高但没有 OOM:容器经常碰到上限,靠回收文件页维持。依赖页缓存的服务(消息队列、搜索、嵌入式存储引擎)这时已经在损失性能。

配置上的几条:

  • 内存上限要覆盖匿名内存的稳态峰值,再给页缓存留出余量。Java 服务按 -Xmx 加堆外内存来算,不按启动后观察到的用量;可以用 -XX:+AlwaysPreTouch 让堆的占用从启动起就是实数。
  • 容器里的工具看到的不一定是容器的账:free、/proc/meminfo 显示的是整台机器(见 容器不是轻量虚拟机)。
  • 请求值(request)决定调度,上限(limit)决定什么时候被杀,两者的关系见 requests 与 limits 各管什么。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权