线上一个 Next.js SSR 服务部署在 8GB 的 K8s Pod 里,隔三差五被 OOM Kill。运维把 --max-old-space-size 从 4GB 调到 6GB 想「给它更多内存」,结果 OOM Kill 反而更频繁了。
这个反直觉的现象背后,是一个被很多 Node 开发者误解的事实:--max-old-space-size 控制的不是进程能用多少内存,而只是 V8 堆里 old space 这一个区的上限。调大它,某些情况下恰恰是在把进程推向 K8s 的 SIGKILL。
这篇文章把一套在 Node 22 + SSR + K8s 生产环境踩出来的内存治理经验做一次通用化梳理,并把涉及的概念------V8 堆结构、进程 RSS 组成、Node OOM 与 cgroup OOM Kill 的区别、Node 22 的 lazy decommit------逐个讲清楚。
--max-old-space-size 管的只是 old space
要理解为什么调大它可能有害,先得知道 V8 的内存是分区管理的。一个 Node 进程的 V8 堆大致分成几块:
| 区域 | 存什么 | --max-old-space-size 是否控制 |
|---|---|---|
| new space | 新分配的短命对象,Scavenge 算法频繁回收 | 否(由 --max-semi-space-size 控制) |
| old space | 从 new space 晋升的长命对象,主堆 | 是 |
| code space | JIT 编译后的机器码 | 否 |
| map space | 对象的隐藏类 hidden class | 否 |
| large object space | 超过单页大小的大对象 | 否 |
也就是说,--max-old-space-size 只给 old space 一个区设上限。而进程实际占用的物理内存远不止 old space。
进程 RSS 才是 K8s 真正计较的东西
K8s 的 memory limit 限制的是 cgroup 看到的物理内存,对应到 Node 进程就是 RSS(Resident Set Size,常驻内存集)。RSS 的组成大致是:
text
RSS = V8 堆(new + old + code + map + large object)
+ V8 堆外的 C++ 对象(Buffer / TypedArray 的 backing store)
+ 原生模块的内存(sharp / node-canvas / grpc 等 C++ 扩展)
+ libuv 线程池、网络与文件缓冲
+ Node 与 V8 运行时本身
对一个 SSR 应用,堆外部分尤其可观:
- SSR 渲染:每个请求把 React 组件树渲染成 HTML 字符串、序列化 RSC 载荷,峰值期产生大量临时 Buffer。
- 响应压缩:gzip / brotli 中间件对响应体的压缩缓冲。
- 代理与连接池:BFF 或反向代理转发请求时的连接与缓冲。
- 原生模块:图像处理、PDF、加密等 C++ 扩展,内存完全在 V8 堆外。
--max-old-space-size设的是 RSS 一个组成部分的上限,而不是 RSS 本身。这是后面所有结论的基础。
为什么调大 heap 反而更容易被杀
这里要区分两种「内存炸了」,它们的表现和处理完全不同:
| Node 自身 OOM | K8s OOM Kill | |
|---|---|---|
| 触发者 | V8(old space 触顶) | 内核 cgroup(RSS 触顶 limit) |
| 信号 | 抛 JavaScript heap out of memory |
SIGKILL(9) |
| 诊断信息 | 有 JS 错误栈,能定位 | 无任何栈,进程瞬间消失 |
| 可观测性 | 进程退出前可被监控捕获 | 不可捕获,只剩内核日志一行 |
当你把 --max-old-space-size 调大,old space 被允许涨到更高,留给堆外内存的空间就更少。一旦堆外(压缩缓冲、连接池、原生模块)叠加上来,RSS 会先于 old space 触顶 → 撞上 cgroup limit → 内核直接 SIGKILL。
这就是开头那个反直觉现象的根因:6GB old space 再叠加 1~2.7GB 堆外,在 8GB Pod 里轻松越过 cgroup 上限,于是从「偶尔 Node OOM(有栈可查)」恶化成「频繁 K8s SIGKILL(无栈难查)」。
治理目标不是「给最大内存」,而是:让 Node 自身 OOM 先于 K8s SIGKILL 发生。把 old space 上限压到给堆外留足余量的水平------宁可 Node 报一个有栈的 OOM,也不要内核甩一个没栈的 SIGKILL。
RSS 不随 GC 回落,别误判为内存泄漏
排查内存时还有一个常见的认知坑:GC 明明回收了对象,RSS 却不下降,于是被误判成内存泄漏。
这不是泄漏,而是 V8 堆管理叠加操作系统页回收策略的正常现象,和具体 Node / V8 版本无关:
- V8 回收对象后,空出的页通常先进入空闲列表留作复用,不会立刻还给 OS;
- 即便 V8 用
madvise通知 OS 这些页可回收,在 Linux 的MADV_FREE语义下,物理页也要等系统出现内存压力时才真正回收------在此之前 RSS 看起来就一直停在高位。
所以判断泄漏要看 heapUsed 的长期趋势,不是 RSS:heapUsed 在多次 GC 后仍只涨不跌,才是泄漏信号;RSS 高位不回落、而 heapUsed 平稳,是正常的「水位线」,不必惊慌。这一点和 K8s 的关系是:cgroup 计量的恰是这个不回落的 RSS,所以运行时给堆外留的余量必须按 RSS 峰值算,而不是按 heapUsed 算。
配置原则:build 和 runtime 要分两档
很多人给构建和运行时用同一个 --max-old-space-size,这是不对的,因为两个阶段的内存竞争格局完全不同。
构建阶段 :编译器(Next / webpack / Turbopack / tsc)是内存的绝对主力,几乎没有堆外大户和它抢。可以把 old space 设到可用物理内存的 75-80%,让编译器尽情用。
生产运行时 :old space 要和前面列的一堆堆外消费者共享 Pod 内存。经验起点是设到 Pod 内存的 约 50%,给堆外留出另一半。
bash
# 构建:物理内存 75-80%(假设构建机 16GB)
NODE_OPTIONS="--max-old-space-size=12288" next build
# 运行时:Pod 内存约 50%(假设 Pod 8GB)
NODE_OPTIONS="--max-old-space-size=4096" node server.js
一个常见反模式:build 用 8192 跑得好好的,就把 runtime 也设成 8192,结果 runtime 频繁 OOM Kill。build 能用满是因为没人和它抢,runtime 不能。
怎么测出属于你自己的数字
上面的 75% 与 50% 是起点,不是终点。堆外组成由你的部署架构决定,差异可以很大,必须按自己实测来定。先看「怎么跑这个 Next 应用」------把 Next 几种部署形态按对运行时内存的影响排一下:
| 部署形态 | 怎么启动 | 需完整 node_modules | 运行时堆外大户 | heap 配置取向 |
|---|---|---|---|---|
默认(next start) |
next build 后 next start |
需要 | 仅 Next SSR 运行时 | 按 Pod 内存折算 |
standalone (output: 'standalone') |
Next 产出最小 server.js,node server.js 启动 |
不需要 (@vercel/nft 追踪最小依赖子集) |
仅 Next SSR 运行时(与默认相近) | 按 Pod 内存折算 |
| 自定义服务器 | 自己用 Express / Koa 包 next() 托管 |
需要 | Next + Express + 你加的中间件(compression / 反向代理...) |
更保守,给堆外多留 |
静态导出(output: 'export') |
纯静态文件,传 CDN / 静态托管 | 不需要(无运行时) | 无 Node 运行时进程 | 不涉及 |
一个容易踩的误解:standalone 省的是产物体积和冷启动,不是运行时内存 ------它的运行时堆外组成和默认
next start基本一致(都只有 Next 自己的 server)。真正抬高堆外的是自定义服务器叠加的中间件。指望「切 standalone 解决 OOM」是找错方向。
除部署形态外还有一个正交维度:
- 重原生模块的应用 :图像 / PDF / 加密等 C++ 扩展(如
sharp、node-canvas)内存完全在 V8 堆外,无论哪种部署形态都得给 old space 多留余量。
测量靠 process.memoryUsage(),关注这几个字段:
ts
const m = process.memoryUsage();
// m.rss 进程总物理内存 ------ 对标 cgroup limit 的就是它
// m.heapTotal V8 已申请的堆大小
// m.heapUsed V8 实际使用的堆
// m.external 绑定到 V8 的 C++ 对象(含 Buffer 的一部分)
// m.arrayBuffers ArrayBuffer / TypedArray 占用
做法:压测打到预期峰值 QPS,周期性采样 rss 与 heapUsed,观察峰值 RSS 与 old space 上限的关系,反推 runtime 设多少能让 RSS 稳定在 limit 以下。rss 与 heapUsed 的差值,就是你这个应用堆外内存的真实体量。
OOM 发生时的排查决策
当服务真的内存出问题,先分清是哪一种 OOM,再对症下药:
核心纪律:看到 Node OOM 先怀疑泄漏、不要无脑调大配额 ------配额是治标,泄漏不修永远会再炸。看到 K8s SIGKILL 先查堆外,因为这往往说明 RSS 超了、但 old space 可能还没满。
小结
把这套经验压成几条可以带走的结论:
--max-old-space-size只管 V8 old space,不等于进程能用的内存;K8s 计较的是 RSS。- SSR 应用堆外内存占比高,old space 设太高会挤压堆外、招致 K8s SIGKILL------比 Node OOM 更难排查。
- 目标是让 Node OOM 先于 SIGKILL:构建阶段 old space 可设物理内存 75-80%,运行时设 Pod 的约 50%。
- Node 22 的 lazy decommit 让 RSS 有水位线效应,进一步要求运行时留足堆外余量。
- 比例是起点,务必用
process.memoryUsage()压测实测自己的数字,不要照搬任何人的配置。
参考与数据源
- Node.js 官方文档:CLI options(
--max-old-space-size)、process.memoryUsage() - V8 官方博客:Trash talk: the Orinoco garbage collector
- Kubernetes 文档:Assign Memory Resources to Containers
- Next.js 文档:output: standalone