1.9G 内存的服务器上,nuxt build 在第 36 秒被杀

线上事故记录

一台 2 核 1.9G 的火山服务器,前端和后端都打算在上面构建。跑到大约三十六秒,构建进程消失,日志最后几行:

<--- Last few GCs --->
[1:0x...]  34812 ms: Mark-Compact 1997.2 -> 1995.1 MB
<--- JS stacktrace --->
FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
 1: 0xabcdef v8::base::...

退出码 134。128 + 6,也就是 SIGABRT。

这个退出码是第一个关键信息,我一开始的反应完全错了:以为是 Nuxt 构建报错,翻了半天的构建输出找"错误"两个字,其实上面那几行才是全部真相。后来我给自己定了条规矩——构建挂了先看退出码,134 是 V8 自己 abort,137 才是被内核 SIGKILL。

第一步是确认是谁下的手。

dmesg -T | tail -30 | grep -i "killed process"

没有输出。说明内核没参与,是 V8 撞了自己的堆上限。Node 在 1.9G 的机器上默认给 V8 的堆上限不到 2G,而 nuxt build 的峰值要更多,于是先撞软限制。

抬上限:

ARG NODE_OPTIONS=--max-old-space-size=3072
ENV NODE_OPTIONS=$NODE_OPTIONS

写成 ARG 而不是硬编码,是为了将来 CI 构建时能覆盖,也让这个值在 Dockerfile 里可见、可 review。

再跑,这次不是三十六秒崩,而是越跑越慢,最后整个 SSH 会话都卡住,容器起不来。dmesg 这次有内容了:

Out of memory: Killed process 2143 (node) total-vm:3219456 kB, anon-rss:1802112 kB

内核杀的。原因很清楚:上限抬到 3072,但物理内存只有 1.9G,也没有 swap,进程一路申请到真的没得给,OOM killer 挑了最大的那个下手。

所以 swap 必须一起加:

fallocate -l 2G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

第二篇 2G 是后来补的(一共 4G),因为第一次跑通之后我又撞了一次。fstab 那行不能漏,漏了重启就没 swap,表现是"上次能构建这次不行"。

vm.swappiness 默认 10,意思是内核很不愿意用 swap,宁可回收页缓存、宁可杀进程。构建期间我临时调到 60:

sysctl vm.swappiness=60      # 构建完调回 10

调完的结果:构建能跑完了,耗时从预期的四十秒变成三分多钟。这个交换我认了,因为失败的代价是重跑,重跑也是几分钟,而且失败还不出产物。

三个数字要记下来,将来换机器直接照着判断:

  • 1792 不够,还在三十六秒那个位置崩;
  • 3072 能过,靠 4G swap 兜住;
  • 峰值 anon-rss 大约 1.8G,也就是说这台机器光是构建就要把内存+swap 用到接近极限,同时跑着 MySQL 的话不能让它俩撞在一起。

最后我做的其实是对的决定:把构建挪到 CI,服务器只 pull 镜像。这条链路通了之后,swap 和堆上限这些都不再是发布流程的一部分——但我没把 swap 删掉,因为运行时的 Node 进程也需要一点缓冲空间。

有一件事我到现在也没解决,写在这里免得别人踩:Dockerfile 里如果用了带 $ 锚点的 sed 去改文件,而构建上下文是从 Windows 打包上传的(CRLF 行尾),那条 sed 会静默不匹配,不报错也不改。我为这个白跑了两轮十几分钟的构建。判断方法是构建完 docker run --rm 镜像 cat 那个文件,看改动在不在。

相关阅读