Node.js SSR 内存治理:为什么 --max-old-space-size 不等于进程内存

线上一个 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 看起来就一直停在高位。
graph LR A[请求峰值] --> B[old space 涨到高位] B --> C[GC 回收对象] C --> D[heapUsed 下降] D --> E[但 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 buildnext start 需要 仅 Next SSR 运行时 按 Pod 内存折算
standaloneoutput: 'standalone' Next 产出最小 server.jsnode 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++ 扩展(如 sharpnode-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,周期性采样 rssheapUsed,观察峰值 RSS 与 old space 上限的关系,反推 runtime 设多少能让 RSS 稳定在 limit 以下。rssheapUsed 的差值,就是你这个应用堆外内存的真实体量。

OOM 发生时的排查决策

当服务真的内存出问题,先分清是哪一种 OOM,再对症下药:

graph TD A[服务内存异常] --> B{日志里有<br/>JavaScript heap out of memory} B -->|有| C[Node 自身 OOM] B -->|没有 进程被 SIGKILL| D[K8s OOM Kill] C --> E[先查是否内存泄漏<br/>采样 heapUsed 是否只涨不跌] E --> F[确认无泄漏后<br/>再评估调高 old space] D --> G[查堆外消费<br/>压缩缓冲 连接池积压 原生模块] G --> H[降 old space 给堆外让空间<br/>或扩 Pod limit]

核心纪律:看到 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() 压测实测自己的数字,不要照搬任何人的配置。

参考与数据源

相关推荐
开发者联盟league1 小时前
使用k8s安装Jenkins
容器·kubernetes·jenkins
成为你的宁宁2 小时前
【基于 Prometheus Operator 实现 K8s 环境下 Redis Cluster 集群监控部署】
redis·kubernetes·prometheus
是一个Bug3 小时前
Docker 与 Kubernetes:从“集装箱”到“远洋舰队”
docker·容器·kubernetes
java_cj3 小时前
阅读 k8s 源码的准备工作
云原生·容器·kubernetes
fred_kang3 小时前
Claude Code 在 Windows 切换 Node.js 版本后命令失效的排查与解决
node.js
开发者联盟league3 小时前
使用Jenkins整合Sonarqube/Gitlab/Harbor/Kubernetes实现CICD
kubernetes·gitlab·jenkins
xiaofeichaichai12 小时前
Webpack
前端·webpack·node.js
Python私教15 小时前
把开源 Agent 打包成"解压双击即用"的 Windows 便携包:一条命令的完整实现
node.js
鹤落晴春17 小时前
【K8s】Pod调度、configMaps
云原生·容器·kubernetes