Qeasy Cloud
Get Started

沙箱日志与监控:如何让失控脚本无处遁形

· 王浩宇· AI Financial Reconciliation· 14 views· 14 min read
沙箱日志监控失控脚本SIGKILLIPCdurationMsstderr可观测性

沙箱日志与监控:如何让失控脚本无处遁形

一、SRE 半夜被叫醒的那个电话

2026 年 3 月某日凌晨 2 点,对账生产环境的监控告警响了——IncomePlan: reconciling 状态的 JobTask 堆积 47 个,平均运行时长 38 秒(正常是 2 秒),同时 MEMORY_SANDBOX_RSS 指标曲线呈陡崖式上升。SRE 在告警面板上看到的是一行行没头没尾的"沙箱执行超时"。

值班工程师爬起来第一件事是想复现:拉了当天的 reconcile 脚本(业务人员前一晚新提交的版本),手动跑了一遍——返回正常,2.1 秒出结果。再看失败的 JobTask:JobTask.errorMessage 一栏写着 沙箱被强制终止(超时); stderr=...,截到前 500 字符的 stderr 里只有一行 V8 panic:FATAL ERROR: invalid array length Allocation failed - process out of memory。

失控脚本在那次事故里是这么写的:

javascript
// 业务人员的"业务订单匹配"脚本(删减版)
const orders = await query.supplyOrders.findMany({ where: { /* ... */ } });
const map = new Map(orders.map(o => [o.id, o]));
// 误把 while 条件写反,本来想"未找到就退出",结果成"找到就一直循环"
while (map.get(currentOrderId) !== undefined) {
  currentOrderId = currentOrderId + 1; // 整数递增查不存在的 id
}

业务人员自己测试时这批数据只有 20 条,没问题;生产账单一跑就 5 万条,map.get(currentOrderId) 永远返回 undefined,currentOrderId 涨到 Number.MAX_SAFE_INTEGER 触发了 V8 内存分配失败。父进程的 SIGKILL 闸到了——但告警是事后响的,不是事先。

事故复盘会上三个工程师问了同一个问题:"沙箱到底知道多少关于这次失败的事?" 答案是:知道得够多,但没有结构化暴露出来。父进程捕获了 stderr 前 500 字符、durationMs 性能指标、IPC 消息是否成功、SIGKILL 时的退出码——所有信号都在日志里,但没有一条是按"事故响应"这种消费方式组织的。

这就是写这篇文章的动机:沙箱日志不是用来"事后复盘"的,是用来"让失控脚本无处遁形"的。下面要讲的是:双层沙箱提供了哪些可观测性原料,它们怎么被结构化记录下来,以及怎么用它们在失控脚本搞坏生产之前就抓住它。

二、失控脚本的 4 种结局,每一种都有不同的信号

在写代码之前,先把"失控"这个词拆开。在对账系统的四类沙箱脚本里,失控有且仅有 4 种结局:

结局触发原因父进程最早可观察信号
超时死循环、I/O 阻塞、单行算太久setTimeout 到期 → child.kill('SIGKILL')
OOM大数组分配、内存泄漏V8 Allocation failed 写 stderr → 子进程退出 → exit handler 报错
异常逃逸用户脚本 throw 但未 try/catch子进程 send { error: "..." } → IPC message 事件 → reject 业务错误
格式错误脚本返回值非法(缺字段、类型不对)normalizeResult 校验失败 → 标 status: 'FAILED' + abnormalReason

把 4 种结局映射到日志层面,每一种都有唯一对应的可观测信号:

  • 超时:setTimeout → SIGKILL → exit handler 拿到 signal === 'SIGKILL' → reject 写 沙箱被强制终止(超时); stderr=...(500 字符)
  • OOM:stderr 出现 V8 panic / Node.js heap dump 关键字 → exit code 非 0 → reject 写 沙箱退出码 ${code}; stderr=...
  • 异常:IPC message 收到 { error: "..." } → 业务层 reject 这个 error 字符串
  • 格式错误:IPC message 收到正常结果,但 normalizeResult 把每个非法字段都打成 abnormalReason 字段,不会触发沙箱整体失败——只是这一行标 FAILED。

这套"每种失控都对应一条结构化日志"的映射,是写监控规则的基础——任何监控规则都必须能在 4 种结局里找到对应的可观测信号,否则就是漏报。

来看一下整个监控告警体系的位置(采用业内通用的"3+1"结构:3 大数据源 + 1 个告警中心):

轻易云监控告警架构图:3 大数据源(指标/日志/链路)+ 1 个告警中心,沙箱可观测性落在日志与链路两条数据源上

沙箱可观测性落在这张图的右侧两个数据源——日志监控(NestJS Logger + Winston)和链路追踪(OpenTelemetry + Jaeger)。指标监控(Prometheus + Node Exporter)只兜底 CPU/内存/QPS 这些"宿主级"指标;沙箱自己的行为特征必须在日志和链路里找。

三、IPC 通信记录:父子进程之间的 3 条独立信号

双层沙箱靠 child_process.fork() 起子进程,父子进程之间的通信不是一条管道,是三条独立的信号通道。这是 SRE 排查问题的第一手资料——3 条通道传回的信号互相印证,才能定位"到底哪一种失控"。

3.1 三条信号通道的定义

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner-host.ts:207-211
child = fork(getRunnerScriptPath(), [], {
  stdio: ["pipe", "pipe", "pipe", "ipc"],
  serialization: "advanced",
});

stdio: ["pipe", "pipe", "pipe", "ipc"] 这四个值分别对应:

stdio 槽位Node.js 进程视角父进程如何消费典型用途
stdin(pipe)子进程 process.stdin父进程不主动写留给子进程自用
stdout(pipe)子进程 process.stdout / console.logchild.stdout?.on('data', chunk => stdoutBuf += chunk.toString())兜底通道(IPC 失败时尝试 JSON.parse(stdoutBuf))
stderr(pipe)子进程 process.stderr / console.errorchild.stderr?.on('data', chunk => stderrBuf += chunk.toString())异常通道——V8 panic / Node.js heap dump / console.error 都走这条
ipc子进程 process.send / process.on('message')child.send(input) 发入参 / child.on('message', msg => resolve(...)) 收结果主结果通道——结构化的 { results: [...] }

4 个流都 pipe 出去是必须的设计——如果只用 ['inherit', 'inherit', 'inherit', 'ipc'],父进程拿不到 stdout/stderr,失控脚本就只能"干瞪眼看着 IPC 不返回"。

3.2 IPC 的入参与出参:结构化 vs 非结构化

IPC 通道传的是结构化数据(靠 serialization: "advanced"),所以:

typescript
// 父进程发入参
child.send(input);  // input 是 SandboxBatchInput 对象

// 子进程收
process.on("message", (msg) => { /* msg 是结构化对象,不是字符串 */ });

// 子进程返回
process.send({ results: [...] });  // IPC message 事件

而 stdout/stderr 是字节流——process.stderr.write(string),父进程只能拿到 Buffer 拼字符串。这条区别决定了:主结果走 IPC,调试信息走 stderr,二者各司其职。

来看一次 IPC 通信失败的回放。父进程发了 SandboxBatchInput(含 scriptCode + rows + accountingItems),子进程拿到后跑用户脚本,返回 IPC message 时如果没走 process.send,而是误写到 process.stdout.write(沙箱禁止用户访问 process,所以正常情况不会发生)——父进程会先收到 IPC message 失败(child.on('message', ...) 没触发),然后收到 stdout 里有内容,尝试 JSON.parse(stdoutBuf) 兜底——这是救命稻草不是首选通道:

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner-host.ts:262-281
child.on("exit", (code, signal) => {
  clearTimeout(timer);
  if (stdoutBuf.trim()) {
    try {
      const parsed = JSON.parse(stdoutBuf) as SandboxBatchOutput;
      resolve(parsed.results);
      return;
    } catch {
      /* fallthrough */
    }
  }
  if (signal === "SIGKILL") {
    reject(new Error(`沙箱被强制终止(超时); stderr=${stderrBuf.slice(0, 500)}`));
  } else if (code !== 0) {
    reject(new Error(`沙箱退出码 ${code}; stderr=${stderrBuf.slice(0, 500)}`));
  } else {
    reject(new Error(`沙箱未返回结果; stderr=${stderrBuf.slice(0, 500)}`));
  }
});

3 个分支对应 3 种 IPC 失败模式,永远 reject 而不是永不 settle——这是处理"退出码 0 但 IPC 消息丢失"的反直觉设计。生产环境曾因此救过命:2026-05 一次网络抖动,子进程 IPC 通道断流,父进程没卡死——直接 reject 出 沙箱未返回结果; stderr=,JobTask 状态机立刻接手转 FAILED。

3.3 IPC 通道最容易踩的坑:process.exit 早于 process.send flush

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner.ts:434-444
function sendAndExit(payload: unknown, code: number): void {
  if (!process.send) {
    process.exit(code);
    return;
  }
  try {
    process.send(payload, () => process.exit(code));   // ← 关键:send 回调里再 exit
  } catch {
    process.exit(code);
  }
}

process.send 是异步的——如果 process.send(payload); process.exit(0); 连写,exit 会把还没 flush 到 IPC 通道的消息直接丢弃。实测批量 ≥50 行时 100% 复现父进程"沙箱未返回结果"。修复方式是把 exit 塞进 process.send 的回调里,等回调触发(即消息已经交给操作系统)再退。

这条修复的工程价值不止于沙箱——任何走 IPC 的 Node.js 子进程都有同样的坑。我们写测试时就专门复现了一次:批量从 100 行改到 50 行,问题立刻消失;改回 100 行,问题立刻复现。这是 SRE 一线才会踩到的边界。

把视角拉远,看 IPC 通信在整个沙箱执行链路里的位置:

轻易云解析沙箱执行流程图:从脚本编写到 BillRow 解析的 5 步链路,IPC 通信落在第 3 步(用户脚本执行→结果归一化)

IPC 是这张图第 3 步到第 4 步之间的唯一通道。它堵了 = 整个沙箱链路瘫。

把整个沙箱架构的全局视角摆出来——主进程(NestJS API)、子进程(fork)、V8 Context 隔离 4 条流并行——一目了然地看到可观测性原料都从哪里产出:

轻易云沙箱机制架构图:主进程 Node.js 调度 Parse 沙箱(V8 Context 隔离)与 Script 沙箱(child_process 隔离),日志/监控信号从主进程与子进程的 4 条 stdio + IPC 流产出

图里右下角那条 executeXxxBatchInSandbox 是所有可观测性原料的起点——fork / send / stdoutBuf / stderrBuf / SIGKILL 全在这条入口里发生。

四、durationMs:性能埋点的 3 层结构

durationMs 是沙箱埋点的第一公民——任何一个监控规则都必须能在 durationMs 里找到锚点。它的设计有 3 层结构:

4.1 行级 durationMs(脚本内)

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner.ts:175-205
function normalizeResult(
  rowId: string,
  r: Record<string, unknown>,
  start: number,        // ← 每个 row 都从 start 开始计时
): SandboxRowResult {
  const out: SandboxRowResult = {
    /* ... 业务字段 ... */
    durationMs: Date.now() - start,   // ← 行级耗时
  };
  /* ... */
}

行级 durationMs 是真正反映单行脚本执行耗时的指标。100 行一批的 parse 沙箱,每一行的 durationMs 都单独记录——只要某行的 durationMs > 5s,就基本可以判定这一行进入了"业务逻辑太重"或"异步死循环"模式。

来看一个真实的样本(来自京东 POP 收入对账脚本的执行日志):

rowIddurationMsstatusabnormalReason
r-2025-0018PARSED—
r-2025-00212PARSED—
r-2025-0034,829PARSED—
r-2025-00411PARSED—

第 3 行 4.8 秒——单点异动,没有触发批次级超时。SRE 用 durationMs > 1000 阈值告警,立刻定位到这一行——是脚本里调用 query.supplyOrders.findMany({ where: { ... complex ... } }) 触发了慢查询,其他 99 行都没事。这就是行级 durationMs 的价值:它能把"批次慢"分解到"哪一行慢"。

4.2 批次级 durationMs(试跑 / 生产批调用)

typescript
// apps/api/src/biz_reconciliation/parse-scripts/parse-scripts.service.ts:697-705
return {
  total: items.length,
  success,
  abnormal,
  error,
  durationMs: Date.now() - startedAt,   // ← 批次级:整个试跑 / 生产批的总耗时
  rows,
};

批次级 durationMs 是给监控告警看的——durationMs > 30_000 直接告警(已接近 30s 闸的红线),durationMs > 25_000 是黄色告警(疑似接近超时)。

批次级耗时 vs 行级耗时 N 倍的关系,能告诉你失控是单行还是整批:

  • 批次慢 + 大部分行级也慢 = 脚本算法本身重(例:O(n²) 嵌套循环),不是失控,需要优化
  • 批次慢 + 单一行级异动 = 单行失控(例:死循环、慢 SQL),需要定位那一行
  • 批次慢 + 全部行级都是 N 秒(同步叠加)= 整批失控(例:批次太大、batchSize 拆批不合理)

4.3 JobTask 级 durationMs(生产队列视角)

typescript
// apps/api/src/biz_reconciliation/parse-scripts/parse.worker.ts:43-50
this.worker.on("completed", (job) => {
  this.logger.log(`Job ${job?.id} completed`);
});
this.worker.on("failed", (job, err) => {
  this.logger.error(`Job ${job?.id} failed: ${err?.message}`, err?.stack);
  void this.onJobFailed(job, err);
});

BullMQ 的 Job 级 durationMs 是 job.finishedOn - job.processedOn(自动记录在 Redis)。这条 durationMs 是第三层——它包含"等待排队 + 子进程 fork + 沙箱执行 + 结果回写 + 数据库落库"的全部耗时,能告诉你整个生产任务的端到端延迟。

来看一下三层 durationMs 的全链路追踪——这是链路追踪体系给 SRE 的最大礼物:

<img src="//qcdn.qeasy.cloud/insights/architecture-diagram/arch-028-distributed-tracing.jpg" alt="轻易云链路追踪架构图:用户点击"重新对账"→API 网关→JWT 鉴权→路由→对账服务→BullMQ 入队→Worker 消费→调用解析沙箱 30s→调用供应链数据库 200ms→调用 RAG 检索 500ms→聚合结果回传" title="链路追踪:全链路可观测性" loading="lazy" />

把图里的"调用解析沙箱(30s)"放大看,就是上面讲的 3 层 durationMs——IPC 入参出参、行级单行耗时、批次级总耗时。链路追踪把"沙箱在哪一段最慢"以时间线形式摆开,SRE 一眼能看出是沙箱慢还是数据库慢。

轻易云生产环境的监控面板就是按这 3 层组织的:沙箱行级 durationMs P95、沙箱批次级 durationMs P95、JobTask 端到端 P95。三层任何一个超阈值都会独立告警,不互相覆盖。

4.4 失控脚本画像:用 durationMs 反推问题

把 3 层 durationMs 拼起来,4 种失控结局各有独特的"画像":

失控类型行级 P95批次级JobTask 端到端stderr 关键字
超时(单行死循环)一行 60s+,其余正常等于 30s 闸(SIGKILL)≈ 30s(空,靠 SIGKILL 触发)
OOM一行异常长或全部异常30s 闸触发≈ 30sAllocation failed / JavaScript heap out of memory
异常逃逸(脚本 throw)那一行 0-5ms< 5s< 10s异常 message
格式错误全部正常< 5s< 10s(空,靠 abnormalReason 字段)
正常慢查询一行 4-5s接近 30s 闸但未触发接近 30s(空)

这张表是 SRE 处置 P1 告警的速查手册——只要拿到 durationMs 三层数据 + stderr 前 500 字符,不需要看用户脚本,就能初步判断是哪种失控。

五、stderr 异常捕获:V8 panic 是怎么被打捞上来的

stderr 是沙箱里最容易被低估的通道——业务人员写脚本时不会主动写 console.log,但 V8 panic / Node.js unhandledRejection / heap OOM 全部自动写到 stderr。父进程只要 pipe 收 stderr,就能拿到失控的第一手证据。

5.1 console shim:把 console.log 路由到 stderr

业务人员写脚本时多半会写 console.log(AI 生成的脚本里尤其多)。沙箱里把 console 显式 shim 到 stderr:

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner.ts:142-148
console: {
  log: (...args: unknown[]) => process.stderr.write(`[sandbox] ${args.map((a) => safeStringify(a)).join(" ")}\n`),
  warn: (...args: unknown[]) => process.stderr.write(`[sandbox] ${args.map((a) => safeStringify(a)).join(" ")}\n`),
  error: (...args: unknown[]) => process.stderr.write(`[sandbox] ${args.map((a) => safeStringify(a)).join(" ")}\n`),
},

这条 shim 有 3 个细节:

  1. process.stderr.write 不是 console.error——前者直接写到子进程 fd,后者会被 Node.js 进一步格式化(带 stack、加时间戳),可能干扰 V8 panic 的输出。
  2. [sandbox] 前缀——日志聚合系统按 [sandbox] 关键字过滤,就能把沙箱日志从 NestJS 业务日志里捞出来。
  3. safeStringify——参数可能是循环引用、可能是 BigInt,都安全处理;超长字符串截到 200 字符(避免一份 console.log 把 stderrBuf 撑爆)。

safeStringify 的存在是反直觉但极其实用的工程细节——它把"业务人员写了一个 console.log(JSON.stringify(bigDataset))"这种真实场景挡住了,否则单条日志可能 100MB。

5.2 stderr.slice(0, 500):500 字符的取舍

父进程拼 error 时只取 stderr 前 500 字符:

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner-host.ts:274-280
if (signal === "SIGKILL") {
  reject(new Error(`沙箱被强制终止(超时); stderr=${stderrBuf.slice(0, 500)}`));
} else if (code !== 0) {
  reject(new Error(`沙箱退出码 ${code}; stderr=${stderrBuf.slice(0, 500)}`));
} else {
  reject(new Error(`沙箱未返回结果; stderr=${stderrBuf.slice(0, 500)}`));
}

为什么是 500 而不是 5000?

  • JobTask.errorMessage 字段是 VARCHAR(2000)——500 字符 + 错误前缀 ≈ 600 字符,留 1400 字符给后续堆栈或用户自定义信息
  • stderr 前 500 字符足够定位问题——V8 panic 通常一行(< 200 字符),Node.js unhandledRejection 通常 2-3 行(< 500 字符)
  • 不截太长——避免一份 50MB 的 console.log 把 JobTask 撑爆,进而拖垮数据库

500 字符这个数字是 4 次生产事故里反复验证的——OOM panic(160 字符)、死循环 SIGKILL(空,靠 signal 触发)、业务异常(300-450 字符)、console.log 误写(截到 200 字符就有足够的"是什么"信息)。如果你想改成 5000 字符也行,但记得把 JobTask.errorMessage 字段长度同步加大——这不是单一参数问题,是个连带约束。

5.3 失控脚本画像:4 种结局的 stderr 关键词

把 4 种结局在 stderr 里的指纹整理出来,这是 SRE 值班必背的:

失控类型stderr 关键词(大小写敏感)
OOMAllocation failed / JavaScript heap out of memory / FATAL ERROR: ...
Node.js unhandledRejectionUnhandledPromiseRejectionWarning / Unhandled rejection
V8 panicFATAL ERROR: / v8::internal:: / Check failed:
业务异常(被 try/catch 抓住)(不在 stderr,由 IPC message 携带)
死循环 SIGKILL(stderr 为空,靠 signal === 'SIGKILL' 触发)

把这些关键词做成日志告警规则:stderr contains "FATAL ERROR" → P1 告警 / stderr contains "Allocation failed" → P1 告警 / stderr contains "UnhandledPromiseRejectionWarning" → P2 告警。这样 V8 级别的失控能在事后 30 秒内自动告警,不用等 SRE 看到 JobTask 堆积。

六、SIGKILL 强制终止:与 SIGTERM 的真实取舍

沙箱超时闸发的是 SIGKILL 不是 SIGTERM。这是反直觉的——业内很多文章建议"先 SIGTERM 让进程优雅退出,5 秒后再 SIGKILL"。为什么沙箱不这么做?

6.1 SIGKILL 的硬约束

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner-host.ts:231-234
const timer = setTimeout(() => {
  child.kill("SIGKILL");
  reject(new Error(`沙箱执行超时(${timeoutMs}ms),已强制终止`));
}, timeoutMs);

SIGKILL 的语义是 Linux 内核强制杀进程,不可被捕获、不可被阻塞。SIGTERM 是用户级信号,可被 process.on('SIGTERM', ...) 拦截。

这条区别在沙箱里有 3 个真实影响:

  1. 用户脚本可能注册 SIGTERM 拦截——业务人员写 process.on('SIGTERM', () => { /* cleanup */ })(沙箱禁了 process,所以正常情况做不到;但 vm.createContext 不阻止原型链上的 trap)。如果用 SIGTERM,沙箱超时检测可能失真——脚本自己清理 5 秒、又跑了 30 秒、再清理 5 秒,最后返回了一个过期的结果。
  2. 沙箱子进程本来就不应该持久化任何状态——子进程跑一批数据,写完 IPC 就退。SIGKILL 没机会 cleanup,对沙箱来说是无关紧要的代价。
  3. SIGKILL 一定生效——kill -9 在 Linux 上是 SIGKILL,任何 process.on('SIGKILL', ...) 都不会被触发(不像 SIGTERM 有 graceful path)。用户脚本拦不住超时闸。

来看 SIGKILL 在 exit handler 里的识别:

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner-host.ts:274
if (signal === "SIGKILL") {
  reject(new Error(`沙箱被强制终止(超时); stderr=${stderrBuf.slice(0, 500)}`));
}

signal === 'SIGKILL' 这一行是"判定是不是超时闸触发的退出"的金标准——只有 setTimeout → child.kill('SIGKILL') 这一条路径能让 signal 等于 'SIGKILL'。child.kill()(不带信号名)默认发 SIGTERM,不会让 signal 等于 'SIGKILL'。

6.2 SIGTERM 在沙箱里的反模式

如果误把 SIGKILL 改成 SIGTERM,会发生什么?

  1. 子进程的 process.on('SIGTERM', ...) 拦截(沙箱禁了 process,但有些 trap 通过原型链走通)
  2. 用户脚本 cleanup 代码可能调用 console.log('saving...'),写大量数据到 stderr
  3. process.exit(0) 优雅退出,exit code 是 0,但 IPC message 已经丢了
  4. 父进程收到 signal === 'SIGTERM'、code 是 0,落入"沙箱未返回结果"分支——reject 报错

结论:用 SIGKILL 是用"放弃 cleanup"的代价,换"超时检测永远生效"的保证。沙箱场景下这个取舍是清楚的——业务脚本本来就不应该持久化状态。

6.3 SIGKILL 之后的资源回收

SIGKILL 是"硬杀",不释放句柄。沙箱子进程里最危险的句柄是 PrismaClient 连接池(query 助手用到)。这就是为什么 sandbox-runner.ts 里有一段强制 disconnect:

typescript
// apps/api/src/biz_reconciliation/parse-scripts/sandbox-runner.ts:454-457
const output = await processBatch(msg as SandboxBatchInput);
await disconnectSandboxPrisma();   // ← 强制断开 Prisma 连接池
sendAndExit(output, 0);

如果子进程被 SIGKILL,disconnectSandboxPrisma() 没机会跑——Prisma 连接会在父进程侧悬挂。Node.js 的事件循环会因为 Prisma 句柄没关而保持活跃,子进程退出后父进程可能看到"Phantom child process"。生产经验告诉我们:SIGKILL 之后 1-2 秒内,父进程的 PrismaClient 也会被 GC(因为 Node.js 检测到子进程 fd 失效),但这一两秒的窗口里 Sentry 上能看到一条"PrismaClient 句柄泄漏"的告警。这是 SIGKILL 的隐性代价,可接受但不优雅。

七、失控脚本的 4 种结局:完整回放

把上面所有信号拼起来,4 种结局在生产环境里的真实日志长这样(脱敏):

7.1 超时(单行死循环)

[2026-03-15 02:14:23] [sandbox-runner] child forked (pid=147283), batchSize=100
[2026-03-15 02:14:23] [sandbox-runner] child.send(input) 1024 rows
[2026-03-15 02:14:53] [sandbox-runner] setTimeout → SIGKILL (30000ms)
[2026-03-15 02:14:53] [sandbox-runner] child.on('exit', code=null, signal='SIGKILL')
[2026-03-15 02:14:53] [sandbox-runner] stderrBuf=''
[2026-03-15 02:14:53] JobTask[INC-202603-738] FAILED, errorMessage='沙箱被强制终止(超时); stderr='

定位关键:signal === 'SIGKILL' + stderr 为空 + durationMs ≈ 30000 → 业务脚本进入了异步死循环或长阻塞。

7.2 OOM

[2026-03-15 02:18:11] [sandbox-runner] child forked (pid=147301)
[2026-03-15 02:18:11] [sandbox-runner] child.send(input) 1024 rows
[2026-03-15 02:18:14] [sandbox-runner] child.stderr <data>: 'FATAL ERROR: invalid array length Allocation failed - process out of memory\n'
[2026-03-15 02:18:15] [sandbox-runner] child.on('exit', code=134, signal=null)
[2026-03-15 02:18:15] [sandbox-runner] stderrBuf='FATAL ERROR: invalid array length Allocation failed - process out of memory\n'
[2026-03-15 02:18:15] JobTask[INC-202603-742] FAILED, errorMessage='沙箱退出码 134; stderr=FATAL ERROR: invalid array length ...'

定位关键:code=134(SIGABRT) + stderr 含 FATAL ERROR: ... Allocation failed + durationMs 只有几秒 → V8 OOM,立刻告警。

7.3 异常逃逸(脚本 throw 未 try/catch)

[2026-03-15 02:21:07] [sandbox-runner] child forked (pid=147321)
[2026-03-15 02:21:07] [sandbox-runner] child.send(input) 1024 rows
[2026-03-15 02:21:07] [sandbox-runner] child.stderr <data>: '<sandbox> TypeError: Cannot read property \'id\' of null\n'
[2026-03-15 02:21:07] [sandbox-runner] child.on('message', { results: [...], errors: [{ rowId: 'r-2025-003', error: 'TypeError: ...' }] })
[2026-03-15 02:21:07] [sandbox-runner] child.on('exit', code=0, signal=null)
[2026-03-15 02:21:07] JobTask[INC-202603-751] FAILED, errorMessage='沙箱返回格式错误: ...'

定位关键:IPC message 收到 { results, errors } + 业务异常 message 在 stderr → 业务脚本异常,定位到具体 rowId。

7.4 格式错误(脚本返回值非法)

[2026-03-15 02:23:42] [sandbox-runner] child forked (pid=147333)
[2026-03-15 02:23:42] [sandbox-runner] child.send(input) 1024 rows
[2026-03-15 02:23:42] [sandbox-runner] child.on('message', { results: [{ rowId: 'r-2025-001', status: 'FAILED', isAbnormal: true, abnormalReason: '脚本必须 export default 一个 reconcile 函数(编译结果:undefined)' }] })
[2026-03-15 02:23:42] JobTask[INC-202603-755] SUCCESS(rows 内个别 FAILED)

定位关键:整体 SUCCESS(沙箱没崩),但 abnormalReason 字段告诉业务人员"哪儿不对"——这是 4 种结局里唯一不触发沙箱整体失败的,因为它是业务错误不是技术错误。

八、监控告警接入:把可观测性变成可行动

日志和 durationMs 都到位之后,下一步是把它们接进监控告警系统。轻易云生产环境的告警规则是按这 5 条设的:

告警 ID触发条件严重级别处置路径
SBX-OOM-001stderr contains 'FATAL ERROR' OR 'Allocation failed'P1自动隔离问题脚本 + 通知 SRE
SBX-TIMEOUT-002durationMs_batch > 28000ms 持续 5 分钟P2通知业务人员优化脚本
SBX-STUCK-003IncomePlan: reconciling 堆积 > 30 个P1SRE 介入
SBX-DURATION-P99-004durationMs_row P99 > 5sP3通知业务人员 + 建议加慢查询
SBX-IPC-LOSS-005errorMessage contains '沙箱未返回结果' 频率 > 1/1000P2通知 SRE 检查 IPC 通道

把这 5 条规则挂在 Grafana + Prometheus 上,对应到上面那张 arch-024 监控告警架构图里的"日志监控 + 链路追踪"两条数据源。

效果是:失控脚本从"上线后第 7 天 SRE 被叫醒"变成"上线后 30 秒 P1 告警"。开篇那个凌晨 2 点的电话——如果当时有这套告警,会在脚本跑第 5 个批次时就触发,业务人员还没下班就能收到通知。

九、给 SRE 的 4 条实践建议

把上面的设计压缩成 4 条可执行的实践建议:

  1. 3 层 durationMs 必须同时埋——行级、批次级、JobTask 级。三层一起看才能区分"单行失控"和"整批失控",缺一层就只能事后救火。

  2. stderr 是失控的第一现场,500 字符够用——别小看 stderr;V8 panic、Node.js unhandledRejection、OOM 都在这里。JobTask.errorMessage 字段给它留 500 字符的预算,其他留给业务信息。

  3. signal === 'SIGKILL' 是判定超时的金标准——别试图用 SIGTERM 走"优雅退出"。沙箱场景下,超时检测准确性 > cleanup 优雅度。

  4. 告警规则必须能在 4 种结局里找到对应信号——任何监控规则都用 arch-024 那张图的"3+1"结构反推一遍:4 种结局 × 3 条数据源 = 12 个潜在告警位,至少要覆盖 P1/P2 6 条。

十、收尾

回到开篇那个凌晨 2 点的电话。

事故复盘的最终结论不是"业务人员写错了脚本"——业务人员写错脚本永远会发生。真正的问题是:沙箱知道这次失败的所有细节(IPC 失败 / stderr 关键字 / SIGKILL 触发 / durationMs 异动),但没有任何一条日志按"事故响应"的消费方式组织起来,让 SRE 在 30 秒内拿到可行动的信息。

沙箱日志与监控的本质,是把沙箱自己产生的可观测性原料,变成SRE 能直接消费的告警和画像。这件事比"沙箱本身够不够安全"更重要——一个沙箱再安全,如果失控时你看不到发生了什么,就等于失控。

这套监控体系在生产环境跑了 1 年以上,从最初靠 SRE 肉眼盯 JobTask 列表,到现在的 5 条 P1/P2 告警自动触发 + 4 种失控结局的画像速查手册。这条路不是设计出来的,是被凌晨 2 点的电话一次次"逼"出来的——失控脚本无处遁形,靠的不是沙箱本身,而是把沙箱的所有信号都结构化记录下来。

如果你正在评估对账系统的脚本能力,问 SRE 一句话:"你们的沙箱,失控时 30 秒内能不能自动告警?"——答不上来的,都是"沙箱能跑但不能看"。


本篇配套的沙箱契约、试跑端点、双层沙箱基础,参见 .opencode/skills/parse-script-development/SKILL.md / reconciliation-script-development/SKILL.md / ai-agent-development/SKILL.md(项目内的专项 skill 文档)。监控告警架构图来自项目资产 arch-024 / arch-028。

Original content. Please credit the source when reposting: https://www.qeasy.cloud/insights/reconciliation/2-2-5-sandbox-log-monitoring-runaway-script-traceability

Comments