Skip to content

OpenClaw 启动 OOM 排查:从怀疑 Node 版本到按容器内存动态设置 V8 堆

记录一次 OpenClaw 网关在容器内启动阶段频繁 OOM 的排查过程:从 Node 22/24 版本差异、固定 max-old-space-size、降低 V8 优化级别等尝试,一直到最终采用提高容器内存上限并在 entrypoint 中按宿主机或 CGroup 限制动态注入 Node.js V8 old space。

问题起点:不是运行一段时间后崩,而是启动阶段直接 OOM

这次问题的起点非常明确:OpenClaw 网关不是运行久了内存泄漏,而是在启动阶段就直接触发 Node.js 堆内存不足。

容器里的现象大概是这样:

text
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

而且这个问题有两个很关键的特征:

  • 崩溃发生在 openclaw gateway run 启动阶段
  • 一旦在更高内存环境里启动完成,后续稳定运行时的资源占用并不算高,大致只有几百 MB

这说明问题不太像“业务运行期内存持续上涨”,而更像是:

  • 启动时需要加载大量模块
  • 初始化路径存在比较高的瞬时峰值
  • V8 在冷启动阶段需要的堆空间,明显高于运行稳定后的 steady-state

这种问题最麻烦的点在于,你看到的是一个最终很轻的进程,但它在启动的那一小段时间里却很重。

第一反应:会不会是切到 Node 22 后,默认堆策略变了

一开始最直观的怀疑对象就是 Node 版本。

基础镜像里原本带的是 Node 24,而我为了镜像一致性,尝试把默认 Node 改成 22。于是最先出现的判断就是:

会不会是 Node 22 和 Node 24 在容器环境里的默认堆策略不一样,导致启动峰值更容易撞到 heap limit?

这个怀疑并不离谱,因为 Node 在容器里的可用内存判断,本来就会受:

  • 宿主机物理内存
  • Docker 内存限制
  • CGroup v1 / v2
  • V8 自己的默认 old space 计算逻辑

多层因素共同影响。

于是排查的第一步,就自然落到了两个方向:

  1. 确认 Node 版本是否真是核心变量
  2. 确认默认堆上限是不是太低

第二步:先做最直接的办法,固定 --max-old-space-size

遇到 Node 启动 OOM,最先想到的办法通常都一样:

bash
NODE_OPTIONS=--max-old-space-size=1536

或者更高一点:

bash
NODE_OPTIONS=--max-old-space-size=1664

这一步的目的很简单:先别急着谈根因,先验证问题是不是“默认堆太小”。

为什么这个办法能缓解,但不适合长期直接写死

固定 max-old-space-size 的问题在于,它很容易把镜像写成一种“只适合某一个部署环境”的状态。

例如:

  • 2G 容器里,1536MB 可能不够
  • 调到 1664MB,又可能在别的更小环境里太激进
  • 某些机器上还有额外 native memory、buffer、系统进程开销,固定值并不稳

也就是说,写死一个数值只能验证问题,不能很好地解决问题。

固定值试出来的结论

这一步非常有价值,因为它至少说明了两件事:

  • 问题确实和 V8 堆上限有关
  • 但问题又不只是“把 heap limit 稍微抬高一点就完事”

实际现象是:

  • 1536MB 会在接近上限时继续 OOM
  • 再往上调,启动期峰值还会继续往上走

换句话说,这不是简单的“少 100MB”问题,而是启动链路本身就存在很高的堆需求。

第三步:继续怀疑 Node 版本本身

在固定堆参数不能彻底解决后,Node 版本问题还是得继续看。

当时的排查逻辑很自然:

  • 如果 Node 24 是基础镜像内置版本,那它更可能是上游默认验证过的路径
  • 如果换成 Node 22 后表现更差,那就要区分:
    • 是 Node 22 本身的问题
    • 还是 OpenClaw 冷启动路径本来就重,只是 Node 22 暴露得更明显

排到后面会发现,Node 版本确实会影响表现,但它不是唯一根因。

更准确地说:

  • Node 22 和 Node 24 在容器内的堆行为不完全一样
  • openclaw gateway run 的启动链路本身就很重
  • 单靠“切回某个 Node 版本”并不能彻底规避这个问题

这一步给出的结论不是“Node 22 一定不行”,而是:

不能把全部问题都归因到 Node 版本上,否则排查会走偏。

第四步:开始怀疑不是版本,而是启动路径本身

继续往下看,就会发现这个问题越来越像“启动冷路径过重”,而不是“Node 版本单点问题”。

因为它符合这种典型特征:

  • 启动时 OOM
  • 启动后内存并不高
  • 提高堆上限会把失败点往后推,但不会真正让启动路径变轻

这个时候,排查重点就从“哪个版本更省”转成了:

启动阶段到底在做什么?有没有办法让这条路径更轻?

第五步:尝试从 Node 参数层面继续压峰值

既然固定 max-old-space-size 不够,那自然还会继续尝试别的 V8 参数。

思路主要有两类:

1. 继续调内存相关参数

比如:

  • 更高的 --max-old-space-size
  • 不同的预留空间
  • 更保守的容器内存与堆比例

这类尝试的共同问题是:它们本质上还是“给更多堆”,不是“让启动更轻”。

2. 降低优化级别,试图压低启动期峰值

后面还试过从 V8 优化器入手。

思路是这样的:

  • 冷启动时,JIT / tier-up / 编译优化本身也会吃掉不少资源
  • 如果把优化级别压低,理论上能降低启动峰值

这类方案的代表就是类似:

  • 降低优化层级
  • 关闭部分编译器能力
  • 更保守地运行 V8

这些参数为什么最后没有成为最终方案

因为它们虽然在某些实验里确实能把启动期 OOM 往后推,甚至避免早期直接崩掉,但副作用也很明显:

  • 冷启动变慢
  • readiness 起来得更晚
  • 后续运行性能也可能受到影响

这里有一个很现实的取舍:

如果你的问题是“偶发启动期峰值太高”,那么把整个 Node/V8 运行模式长期压低,并不是一个特别好的默认方案。

它更像是应急实验手段,而不是稳定的镜像默认行为。

为什么最终没有把“降低优化级别”设成默认

原因很直接:

  • 它改变的是 Node 的运行方式
  • 而不是 OpenClaw 启动链路本身

这意味着你是在用整体性能换启动容错。

如果只是临时验证,可以接受;但作为长期默认值,会带来两个问题:

  1. 后续性能边界更难预估
  2. 问题本身并没有被真正“修正”,只是被绕开

所以在这一步之后,思路就逐渐收敛到了更务实的路线:

不再试图用更激进的 Node/V8 行为去“硬压”启动峰值,而是正视它确实需要更多启动期内存。

排查到这里,真正的问题已经清楚了

到这个阶段,问题已经可以重新定义:

  • 这不是单纯的 Node 22 / Node 24 切换问题
  • 也不是简单的固定 15361664 就能解决的问题
  • 而是 OpenClaw 在容器内启动时,存在一个明显高于 steady-state 的冷启动峰值

于是最终的方向就很自然了:

  1. 容器内存上限不要再卡得过低
  2. Node old space 不再写死,而是根据可用内存动态计算

这两个动作放在一起,才构成了真正可落地的解决方案。

最终方案:提高容器内存上限 + 按可用内存动态注入 --max-old-space-size

最终采用的方案不是某一个单点参数,而是两个动作组合起来:

方案一:提高容器内存上限

这一步本质上是在承认一个事实:

OpenClaw 的启动峰值,就是高于一个过于保守的小容器限制。

如果硬把容器卡在一个非常紧的上限里,就会让 Node 在冷启动阶段不断撞墙。

所以最终没有再坚持“无论如何都必须在极低上限内冷启动”,而是把容器可用内存上限调高到足以容纳启动峰值的范围。

这一步的好处是直接的:

  • 冷启动有足够的缓冲区
  • 避免启动过程被频繁 GC 和 heap limit 反复打断
  • 不必把 V8 调成过于保守的非常规运行模式

方案二:不要写死 old space,而是动态计算

只提高容器内存还不够。

因为如果镜像里的 NODE_OPTIONS 继续写死,比如永远固定:

bash
--max-old-space-size=1536

那么它在不同环境里的适配能力依然很差。

因此最终在 entrypoint.sh 中做的事情是:

  • 先检测当前 NODE_OPTIONS 里是否已经带了 --max-old-space-size
  • 如果用户已经显式设置,就不覆盖
  • 如果用户通过环境变量手动指定,也优先使用手动值
  • 否则自动读取:
    • 宿主机总内存
    • CGroup 内存限制
  • 取有效可用内存,再计算一个合理的 old space 大小

这样做的本质是:

把“拍脑袋写死一个数”改成“按实际部署环境动态收敛”。

动态计算逻辑为什么比固定值更稳

最终提交里的思路大致可以概括成下面几步:

  1. 从系统内存和 CGroup 限制里取一个“实际可用上限”
  2. 根据这个上限计算合适的 old space
  3. 留出系统与 native memory 的预留空间
  4. 只在用户没有显式指定时自动注入

这个思路解决了固定值最大的几个问题:

  • 不同机器/不同容器限制下,参数不再完全失真
  • 小内存环境下不会盲目给太大的 old space
  • 大内存环境下也不会被过小的固定值卡死
  • 用户仍然保留手动覆盖能力

这就比“写死 1536”或者“写死 1664”稳很多。

最终落地代码:看最新提交就够了

这次最终确认采用的实现,已经直接落在 openclaw 仓库最新提交里。

最新提交是:

text
6d1770f

提交主题是:

text
在 entrypoint.sh 中新增基于系统内存或 CGroup 限制自动计算 Node.js V8 旧空间大小的逻辑

核心动作包括:

  • 检测 NODE_OPTIONS 是否已经显式包含 --max-old-space-size
  • 读取宿主机内存
  • 读取 CGroup 限制
  • 计算有效总内存
  • 动态计算 old space
  • 自动注入 NODE_OPTIONS

如果想直接看最终实现,可以查看该仓库的最新提交记录:

  • 仓库:mora-na/openclaw
  • 提交:6d1770f2027575cfcb0024543289525dac521bfe

这次排查里最大的经验

这次问题看起来像是一个简单的“Node OOM”,但真正排下来,经验点其实很典型。

1. 启动 OOM 和运行期 OOM,是两类完全不同的问题

如果一个进程启动后只占几百 MB,但启动时需要远高于 steady-state 的峰值,那就不能用“运行期内存监控”去反推启动策略。

2. 固定 max-old-space-size 更适合验证,不适合作为长期默认

它能帮助你快速确认问题是不是 heap limit,但不适合直接写死在镜像里当最终方案。

3. Node 版本很重要,但不能把所有问题都归因到版本

版本会影响表现,但如果启动链路本身就重,换版本通常只能改变失败方式,不会神奇地让问题消失。

4. 降低优化级别是实验手段,不一定是好默认

它可能压低峰值,但代价通常是更慢的启动和更难预测的后续性能。

5. 真正稳定的方案,往往是“资源边界 + 参数自适应”

最后真正有效的,不是一个神奇参数,而是:

  • 承认启动峰值需要空间
  • 给容器足够的上限
  • 让 Node 堆参数按环境动态收敛

收尾

如果把这次排查过程压缩成一句话,那就是:

一开始以为是 Node 22 替换 Node 24 带来的问题,后来发现核心矛盾其实是 OpenClaw 冷启动阶段的内存峰值;最终没有继续押注某个固定版本或某个写死参数,而是采用“提高容器内存上限 + 在 entrypoint 中根据宿主机/CGroup 限制动态设置 Node old space”的方案来解决。

这类问题最怕一上来就把方案写死。

这次比较幸运的是,虽然前面试了不少路,但最后收敛出来的方案足够简单,也足够工程化:资源边界给够,参数自动适配。