Skip to content

快照故障排查

构建 V8 启动快照 会加载整个模块图并序列化堆。硬性约束是 写入堆的所有内容都必须是可序列化的纯 JavaScript。只要有一个依赖在模块求值阶段 打开了 socket、启动了 timer,或初始化了原生绑定,就可能导致整个 blob 无法构建—— 或者构建成功,但在恢复时崩溃。

本页说明如何定位罪魁祸首模块,以及如何修复。

唯一的规则:堆里不能有「活的」资源

快照在构建期冻结堆、在恢复期解冻。有三类值无法通过这个往返过程:

  • 原生(C++ 支撑的)绑定——llhttp HTTPParsernghttp2、TLS SecureContext、DNS ChannelWrap、原生 addon 等。
  • libuv 句柄——打开的 socket、监听中的 server、timer、文件句柄、watcher、 以及 IPC channel。
  • Node 的惰性 web 全局 getter——fetchHeadersRequestResponseFormDataWebSocketEventSourceMessageEventCloseEvent,以及 Blob/File(还有 node:buffer 自身的 File/Blob getter)都是访问器属性, 首次触碰时会初始化 Node 内建 undici(→ 原生 http/http2)。访问器本身不可序列化。

当一个包在模块求值阶段、或在 configWillLoad(构建停止点)之前创建了上述 任意一种资源,它就是快照不安全的。同一个包如果把这些工作推迟到只在请求期运行的 函数里,通常就没问题。

Egg 已经替你处理了什么

bundler 默认把 Node 网络栈保持为 external 且惰性加载(httphttpshttp2tlsdnsinspector,以及它们的 node: 形式),并把 undici 支撑的 web 全局 对象替换为构建期桩。只有当某个第三方依赖不在该列表上的 builtin触发了约束时, 你才需要本页。机制细节见 工作原理

第 1 步——确定失败发生面

快照可能在两个不同的点失败,错误特征会告诉你是哪一个。

构建期失败

egg-bin snapshot build 先打包应用,然后包裹执行 node --snapshot-blob <blob> --build-snapshot worker.js。当 V8 序列化器遇到不可 序列化的值时,会以原生方式中止进程——没有可捕获的 JS 错误。你会看到下列之一:

text
# 子进程在序列化某个原生绑定时被杀
Error: <path>/node --snapshot-blob … --build-snapshot worker.js was killed by signal SIGSEGV

# 或非零退出
Error: <path>/node … exited with code 1

# 并且因为没有产出 blob:
snapshot build finished but no blob was written at <output>/snapshot.blob

如果是应用在加载元数据时抛出了普通错误(错误的配置、缺失的文件),worker 会在退出前 打印出来:

text
[egg-bundler] failed to build snapshot: <message>

第一类意味着某个模块捕获了不可序列化的东西;第二类是普通的启动错误——按修复普通 启动失败的方式处理即可。

还有第三类、更少见的错误来自 bundler 自身,在 node 运行之前就发生:

text
snapshot prelude: an externalRequire helper was emitted but the lazy hook
could not be injected (its signature did not match). …

这意味着 @utoo/pack 为 external require 生成的代码形态变了(通常是版本升级),导致 惰性 external 派发无法注入。bundler 故意 fail closed,而不是产出一个会在构建期加载 网络栈的 blob——这不是应用 bug。请对齐 @eggjs/egg-bundler / @utoo/pack 的版本, 或提个 issue。

恢复期失败

egg-scripts start --snapshot-blob <blob>(或 node --snapshot-blob)会恢复堆。 常见特征:

现象含义
反序列化中途原生 fatal Check failed: current == end_slot_indexNode.js < 24 上恢复。请始终在 Node.js >= 24 上恢复。egg-scripts 在能判定目标 < 24 时会拒绝启动;若自定义 --node 的版本无法读取,则会落到下面的快照内守卫。
[egg-bundler] V8 snapshot restore requires Node.js >= 24, but this process is vX快照自带的守卫触发了(你绕过了 egg-scripts,或它的版本探测 fail-open 了)。
Error: Cannot find module '<pkg>'某个 external 依赖缺失。external 在恢复时会被 require() 真实加载,且解析根锚定在 worker.js 所在目录——请让 worker.js 与包含这些依赖的 node_modules 放在一起。
Aop Advice(X) not found in loadUnits某个 tegg 装饰类在 bundle 里保留了错误的源码路径(见 tegg 装饰器)。
某个「打包前还好好的」库内部深处抛 TypeError该库错误地处理了构建期的成员代理桩(见 惰性 external 边界情况)。
globalThis.fetch(...) 静默无反应恢复后 web 全局对象仍是空操作桩(见 已知限制)。
[egg-bundler] failed to restore snapshot: <err>在完成延后生命周期(snapshotDidDeserializedidReadylisten)时抛出的任何其他错误。

第 2 步——定位罪魁祸首模块(构建失败)

先检查构建环境

egg-bin snapshot build 在 spawn node --build-snapshot 前会剥离它自己注入的 TypeScript loader,但会继承你 shell 环境的其余部分。如果 NODE_OPTIONS 装入了自定义 loader 或 hook(--require ts-node/register--loader …--import …),它会随快照 构建子进程一起进入,并可能把不可序列化状态拉进堆——此时构建会以原生方式中止, 且没有任何应用层面的原因。请先在干净环境里构建:

bash
$ unset NODE_OPTIONS   # 去掉任何继承来的 --require / --loader / --import
$ egg-bin snapshot build

打开调试日志

bundler 和启动器的每个阶段都通过 util.debuglog 打日志。用 NODE_DEBUG 打开相关 命名空间:

bash
# bundler 流水线(manifest → entry → pack → prelude)
$ NODE_DEBUG='egg/bundler/*,egg/bin/commands/snapshot' egg-bin snapshot build

# 启动器(恢复守卫、spawn)
$ NODE_DEBUG='egg/scripts/commands/start' egg-scripts start --snapshot-blob ./dist-bundle/snapshot.blob

常用命名空间:

命名空间追踪内容
egg/bundler/bundler打包开始、externals 解析结果、prelude/惰性 hook 注入计数
egg/bundler/entry-generator收集到的 bundle 入口、生成的 worker 入口路径
egg/bundler/manifest-loader发现并 externalize 了哪些内容
egg/bundler/snapshot-prelude哪些 external 的导出名无法读取
egg/bin/commands/snapshot实际 spawn 的 node --build-snapshot … 命令

直接读原生中止信息

构建会以继承 stdio 的方式 spawn 子进程,因此 V8 序列化器的中止信息已经打印到你的 终端。为了更快迭代,可以从输出目录手动执行被包裹的命令——这正是 egg-bin 所运行的:

bash
$ cd ./dist-bundle
$ EGG_BUNDLE_SNAPSHOT=build \
    node --snapshot-blob ./snapshot.blob --build-snapshot ./worker.js

序列化器中止时通常会指出它无法编码的对象类型(例如某个原生句柄),其周边栈会指向 创建该对象的模块。那就是你的首要嫌疑。

如果只想打印这条命令(含全局 exec 参数)而不运行它,用 egg-bin snapshot build --dry-run——它会先打包,然后把精确的 node --snapshot-blob … --build-snapshot worker.js 命令打印出来,而不 spawn。

--skip-bundle 二分

--skip-bundle 只对已有的 worker.js 重跑快照步骤,跳过(缓慢的)打包。由于 bundle 是单一自包含文件,你可以在 worker.js 里注释掉某个 import/require, 几秒钟内重跑快照步骤:

bash
# 1. 打包一次
$ egg-bin snapshot build --output ./dist-bundle
# 2. 编辑 ./dist-bundle/worker.js——注释掉某个嫌疑模块的求值
# 3. 只重跑快照构建
$ egg-bin snapshot build --output ./dist-bundle --skip-bundle

如果去掉某个模块的求值后 blob 能构建成功,那这个模块就是元凶。

--force-external 确认

把嫌疑包推 bundle 既是诊断手段也是修复手段。external 永远不会在构建期被求值 ——它在恢复时被真实 require()——所以如果 --force-external <pkg> 让构建成功了, 说明该包在 import 时捕获了不可序列化状态:

bash
$ egg-bin snapshot build --force-external some-native-client

第 3 步——修复

按下面的大致顺序,选用最轻量的可行修复。

1. 让包保持 external

最适合在 import 时打开连接、启动 timer 或加载原生 addon 的第三方包。它会留在快照 之外,并在恢复时被真实 require:

bash
$ egg-bin snapshot build \
    --force-external undici \
    --force-external some-native-driver

该包(及其自身依赖)必须安装在部署目标上,因为它是在运行期加载的,并没有被烤进 blob。反向标志 --inline-external <pkg> 会把解析器自动 externalize 的包强制塞回 bundle。

2. 把 builtin 加入 egg.snapshot.lazyModules

对于在 import 时初始化原生状态、但不在默认惰性列表里的 builtin(或类 builtin 的 id),在 package.json 里添加。它会被合并到默认值之上,在构建期被打桩,在恢复时被 真实加载:

json
{
  "egg": {
    "snapshot": {
      "lazyModules": ["node:zlib", "node:perf_hooks"]
    }
  }
}

内建默认值已经覆盖了 httphttpshttp2tlsdnsinspector(含它们的 node: 形式)——这些无需自行列出。

3. 实现快照生命周期钩子

你自己的 Boot 代码持有不可序列化的资源(timer、socket、logger 流、连接池), 在序列化前释放、在恢复后重建:

js
class AppBootHook {
  constructor(app) {
    this.app = app;
  }

  async snapshotWillSerialize() {
    // 在写入 blob 前关闭/解绑不可序列化资源
    clearInterval(this.timer);
    this.timer = null;
  }

  async snapshotDidDeserialize() {
    // 在恢复后的真实进程里重建
    this.timer = setInterval(() => this.app.doWork(), 1000);
  }
}

module.exports = AppBootHook;

完整约定见 快照生命周期钩子。在单进程快照模式下, agent.js Boot 类的钩子也会运行——agent 的 snapshotWillSerialize/ snapshotDidDeserialize 在 app 的之前触发——因此 agent.js 持有的资源需要同样的 处理,且 failed to restore snapshot 错误也可能来自 agent 钩子。

4. 把工作移出模块作用域

最干净的修复往往就在你自己的代码里:把资源创建从顶层模块体移到一个在请求期运行的 函数中(或放进 didReady/snapshotDidDeserialize)。一个在求值期只定义类和函数的 模块永远是快照安全的;而一个在求值期就建立连接启动 timer 的模块则不是。

js
// ✗ 在模块求值期运行 → 被写入快照
const client = new SomeClient({ keepAlive: true });

// ✓ 首次使用时、在真实进程里创建
let client;
function getClient() {
  return (client ??= new SomeClient({ keepAlive: true }));
}

5. 避开 web 全局对象

globalThis.fetch 和其他 undici 支撑的全局对象在恢复后仍是空操作桩(Node 的 惰性 getter 无法被重新装回恢复后的堆)。请改用按需懒加载的 HTTP 客户端——把 urllibundici 保持 external,在恢复时真实 require:

js
// ✗ 恢复后空操作
await fetch(url);

// ✓ 真实客户端,恢复时真实加载
const { request } = require('urllib');
await request(url);

失败模式详解

tegg 装饰器:「Aop Advice not found」

tegg 装饰器(@SingletonProto@HTTPController@Advice 等)会在模块求值时 从调用栈用硬编码的栈深度捕获类的源码路径。在 bundle 里所有用户栈帧都坍缩到 worker.js 上,因此一个读取比常规更深栈帧的装饰器(典型是 @Advice)会捕获到 worker.js 而非自己的文件,于是 tegg 在恢复时无法把 proto 匹配到对应的 load unit。

bundler 会自动纠正这一点:它在序列化前根据 manifest 里 tegg 的 decoratedFiles 为每个装饰导出重新打上 filePath。如果你写了自定义装饰器、在非常规深度捕获栈帧 并撞上这个错误,请确认该装饰文件属于某个 tegg module(这样它才会出现在 decoratedFiles 里),或带上装饰器的栈深度提个 issue。

惰性 external 边界情况

external 模块在构建期由一个成员代理表示:一个会记录对它执行的属性/调用/构造路径的 桩(这样 class X extends pkg.Base {}DataTypes.INTEGER(11).UNSIGNED 仍能工作), 并在恢复时把该路径重放到真实模块上。

代理对常见模式是忠实的,但如果某个库对它在构建期拿到的值做了不寻常的处理——例如在错误 模板里把它强转成字符串,或基于某种奇特的 typeof 分支——就可能错误处理这个桩并抛出 令人困惑的 TypeError。如果你看到类似 String.prototype.toString requires that 'this' be a String 的错误、源头在某个依赖内部且发生在恢复期,说明该依赖在期望具体值的地方 收到了成员代理。把该依赖保持 --force-external(这样它永远不会被代理)是可靠的修复。

请求期文件缺失(运行期资源)

一个能干净恢复的快照,仍可能在某个处理器读取文件时 ENOENT。只有 app/ 下的 非源码文件(加上强制拷贝目录 app/publicapp/assetsapp/static)会被拷贝到 worker.js 旁边。源码扩展名文件(.ts/.js/.json/…)、app/ 之外的资源、以及 符号链接资源都不会被拷贝。而且由于 bundle 把 __dirnameimport.meta.url 重写成了输出目录,一个执行 fs.readFileSync(path.join(__dirname, 'tpl.html'))new URL('./x', import.meta.url) 的模块会相对 bundle 输出目录解析——如果该文件从未 被拷贝过去,就会在请求期(而非构建或恢复期)失败。

module.yml 里声明这些额外资源,它们就会被拷贝进 bundle:

yaml
bundle:
  runtimeAssets:
    roots: ['app', 'resources']
    forceCopyDirs: ['app/public', 'resources/templates']

构建成功,但 blob 缺失

node --build-snapshot 可能在没写出 blob 的情况下以 0 退出——例如入口在 snapshotWillSerialize 钩子之后、setDeserializeMainFunction 之前抛了错。 egg-bin snapshot build 会检查 blob 是否存在,并以 snapshot build finished but no blob was written at <path> 显式失败。请带上 NODE_DEBUG 重跑,并检查 worker 自己的输出找到底层错误。

配置参考

机制位置用途
--force-external <pkg>egg-bin snapshot build 标志(可重复)把包留在 bundle 外,恢复时真实加载。
--inline-external <pkg>egg-bin snapshot build 标志(可重复)把被自动 externalize 的包强制塞回 bundle。
egg.snapshot.lazyModules应用 package.json向惰性 external 集合添加 builtin/类 builtin 的 id(合并到默认值之上)。
snapshotWillSerialize() / snapshotDidDeserialize()app.js / agent.js Boot 类释放并重建你自己代码持有的资源。
--pack-alias <spec>=<target>egg-bin snapshot build 标志(可重复)打包期重定向某个模块说明符。
--skip-bundleegg-bin snapshot build 标志只对已有 worker.js 重跑快照步骤。
--dry-runegg-bin snapshot build 标志打印 node --build-snapshot 命令但不 spawn。
bundle.runtimeAssets.roots / forceCopyDirs应用 module.yml把额外的非源码文件拷进 bundle,使其在请求期存在。
--no-sourcemapegg-scripts start 标志当自动注入的 --import source-map-support/register 干扰恢复启动时(TypeScript 应用)将其去掉。
NODE_OPTIONS环境变量构建前必须不含自定义 --loader/--require/--import(它们会随快照子进程一起进入)。
NODE_DEBUG=egg/bundler/*环境变量追踪 bundler/启动器流水线。

构建/恢复流程与支持范围见主页 V8 启动快照

Born to build better enterprise frameworks and apps