
想搞清楚 Sandcastle 这个项目得先把脑子里那个“沙箱”的概念拆开。Matt Pocock 做的这个“沙箱编排框架”重点从来不是“如何造一个隔离环境”而是“如何同时调度和管理一批隔离环境”。我花了一个周末把它的设计思路过了一遍又照着这个思路用 TypeScript 重写了一个最小版本跑通了并发执行、超时回收、队列调度。这篇文章就把整个拆解过程、选型逻辑和踩坑记录完整写出来给你一份可以直接参考的沙箱编排落地思路。Sandcastle 适合谁看三类人最需要一是做低代码平台、插件系统、在线代码执行器的前端工程师二是要在 Node 后端跑不可信用户脚本的开发者三是想理解 iframe、Worker、vm 这些隔离手段到底该怎么选、怎么组合的人。它解决的核心问题也很具体当你需要跑的不是一段代码而是几十上百段互相隔离、随时可能崩溃或死循环的代码时靠手写 iframe 或者 new Function 已经完全管不过来了编排层就是为此存在的。1. 先搞清楚Sandcastle 到底编排的是什么1.1 沙箱拆的是信任边界先说一个基本判断沙箱不是用来“防自己人”的是用来跑“你不信任”的代码。现代应用里有大量场景需要加载第三方或用户写的代码典型的有这么几类低代码平台的用户自定义脚本比如在表单提交后执行一段 JS 做数据转换在线编辑器里的“运行”按钮用户在网页上敲一段 JavaScript你要在浏览器里安全执行插件系统让第三方开发者给应用写扩展逻辑但不能让他们直接访问主应用的全局状态多租户规则引擎每个租户上传一条审核规则按规则过滤数据。这些代码一旦直接跑在宿主页面或者宿主进程里后果是很直接的读走 cookie、动 localStorage、改全局对象、无限循环把主线程卡死、甚至调用 Node 的 fs 模块把磁盘文件删了。所以必须把这部分代码扔进一个“隔离的盒子”里让它只能通过你提供的接口和外界通信不能伸手够到宿主环境。这个“盒子”就是沙箱。但很多项目只考虑了“怎么造一个盒子”没有考虑“盒子多了怎么管”。Sandcastle 这类沙箱编排框架补的正是第二块。1.2 从单沙箱到多沙箱编排我见过不少项目是这样起步的先写一个execute(code)函数内部new Function(code)一跑了之或者动态创建一个 iframe 塞进去。单看的话跑两三个脚本完全没问题。可一旦业务起来问题就全来了用户同时提交了 50 个脚本每个都要隔离开你打算创建 50 个 iframe还是排队某个脚本死循环了你怎么把它挑出来杀掉而不影响其他脚本脚本 A 占用了 1GB 内存脚本 B 还在正常跑你怎么限制 A沙箱执行到尾要返回结果结果太大把主线程消息通道堵了怎么办这些问题已经不是“隔离”层面的问题而是“编排”层面的问题。隔离好比给每个陌生访客安排一个单独的房间编排则是一栋楼的物业管理系统谁来、住哪间、住多久、水电怎么算、出事怎么清退。Sandcastle 的核心思路就是把第二层做成通用框架你只需要告诉它“我要执行什么代码、用什么隔离策略、最多跑多久”剩下创建沙箱、排队、调度、强制回收这些脏活它来管。1.3 Sandcastle 的定位和适用场景顺着这个思路看Sandcastle 的定位就非常清楚了它是一个轻量级的沙箱编排层不绑定某一种具体的隔离方式。你在浏览器里跑它可以基于 iframe你在 Node 里跑它可以基于 worker_threads 或子进程你想做快速实验它也可以把 Node 内置的 vm 模块接进来。上层暴露给你的是一组统一 APIregisterTask、submit、onResult、cancel之类。适合用 Sandcastle 的场景有几个共同点第一代码量不固定可能很多也可能很少第二代码来源不可控可能来自用户、第三方或者租户第三执行结果是异步的需要统一回收第四你需要控制整体资源不能让某一个任务占满所有能力。反过来如果你的场景只有一段固定脚本而且你完全信任它那不需要沙箱也不需要考虑编排直接跑就行。沙箱编排是有成本的这个成本值得付的前提是“代码不可信”和“规模上来了”两个条件同时成立。2. 隔离机制怎么选iframe、Worker 还是 vm2.1 三种主流隔离层的对比Sandcastle 之所以不绑定单一隔离手段是因为不同运行环境下的最优隔离方案完全不同。先说主流选项我直接给你一张对比表隔离方式运行环境隔离强度通信方式主要缺点最适用场景iframe浏览器较强隔离 DOM 和 JS 全局postMessage创建开销大父页面和 iframe 共用渲染进程同步死循环仍可能阻塞 UI需要跑带 DOM 操作的不可信脚本Web Worker浏览器/Node(worker_threads)较强独立线程无 DOMpostMessage结果需结构化克隆无法操作 DOMCPU 密集型任务Node vmNode较弱官方明确说不适合不可信代码同步/异步调用不是安全边界可逃逸只做内部工具、可信代码的快速执行子进程Node最强操作系统级隔离IPC/stdio开销最大启动慢强安全要求、需要限制 CPU/内存这张表里最容易被误用的是 Node vm。Node 官方文档写得很直白vm 模块不是安全机制尤其不要用它跑不可信代码。因为 vm 创建的 context 只是在 JavaScript 层面隔离了全局对象但逃逸手段不少比如原型链污染、constructor回溯等。所以它只适合用来跑你自己写的、或者经过严格规约的可信代码。iframe 是浏览器里最常见的沙箱方案因为有sandbox属性可以控制脚本权限。但它有一个很多人忽略的缺陷iframe 里的 JavaScript 和父页面跑在同一条主线程上。如果 iframe 里跑while(true){}整个页面照样卡死。所以如果你要执行的是“可能写死循环”的第三方代码光用 iframe 是不够的必须配合 Worker 或者外部进程只把最终结果通过消息传回来。Web Worker 的好处是真正独立线程主线程不会被同步死循环卡住坏处是没有 DOM很多脚本跑不了而且消息传值走结构化克隆函数、DOM 节点、Symbol 都不能传。2.2 Sandcastle 的 adapter 设计与默认策略理解了三种隔离方式的优劣你就明白为什么 Sandcastle 的核心抽象是 adapter。它不会替你决定“必须用 iframe”或“必须用 Worker”而是给你定义一套统一的隔离层接口你按环境注入对应的 adapter 实现。我当时自己复刻的时候接口是这么定义的interface SandboxAdapter { // 在隔离环境里执行一段代码拿到结果或抛出错误 execute(code: string, options: { timeout: number; context?: Recordstring, unknown; }): Promiseunknown; // 主动销毁沙箱释放资源 terminate(): Promisevoid; }这个接口非常薄但足够覆盖大多数场景。浏览器里你可以写一个 iframeAdapterNode 里可以写 workerAdapter 或 childProcessAdapter跑内部可信脚本可以写 vmAdapter。上层编排逻辑完全不知道底层到底用的哪种隔离方式它只依赖execute和terminate两个能力。选 adapter 的核心逻辑其实就一句话看你拿这段代码要干什么。要操作 DOM只能选 iframe而且要接受它可能阻塞 UI 的风险尽量短任务要跑纯计算或者执行不可信代码优先 Worker在后端强安全隔离场景直接上子进程加资源限制别心疼那点启动开销。2.3 通信层编排器与沙箱之间的 RPCadapter 只是打通了“能跑”真正让沙箱和编排器协作的是通信层。如果你实现过 iframe 沙箱一定见过这种代码父页面postMessage发任务iframe 监听消息执行完再postMessage回传结果。这是最朴素的 RPC但直接在生产环境用会出很多问题消息没有唯一 ID回传结果时不知道对应哪个请求没有超时请求发出去了沙箱一直不回应Promise 永远 pending错误对象无法直接序列化父页面拿到的只是“Error”字符串真实堆栈丢了沙箱里主动上报的事件和请求响应混在一起难解析。Sandcastle 的通信层解决这些问题的方式我拆解下来就是一个带 ID 的消息信封interface SandboxMessage { id: string; // 请求唯一 ID用来匹配响应 type: request | response | event; channel: string; // 业务通道名比如 task:execute payload: unknown; // 实际数据 error?: { name: string; message: string; stack?: string; }; }每次发起请求前生成一个id发到沙箱沙箱执行完回传同样id的 response编排器再根据id找到对应 Promise 去 resolve 或 reject。超时用setTimeout实现到时间就把对应 Promise reject并调用 adapter 的terminate把卡死的沙箱强杀。这里有个细节值得反复强调错误对象不能直接用postMessage传。浏览器结构化克隆无法完整保留 Error 的属性和原型链你收到的基本只剩一个 message。正确做法是在沙箱内部先把错误转成普通对象function serializeError(err: unknown) { if (err instanceof Error) { return { name: err.name, message: err.message, stack: err.stack }; } return { name: UnknownError, message: String(err), stack: undefined }; }我在第一个版本里没做这步结果所有错误到主线程都变成{}排查了整整一下午。这个坑你遇到一次就会长记性。3. 编排层才是重头戏调度、限流与生命周期3.1 任务队列与并发控制adapter 解决“怎么跑”编排层解决“什么时候跑、能不能跑、跑多少个”。沙箱不是免费的每个 iframe 占内存每个 Worker 占线程每个子进程占系统资源。如果用户一口气提交 200 个任务你直接创建 200 个沙箱机器可能瞬间被打爆。所以编排层第一件事就是任务队列。最简单的实现就是一个数组 一个并发计数器class TaskScheduler { private queue: Array() Promiseunknown []; private running 0; constructor(private maxConcurrency: number) {} submit(task: () Promiseunknown): Promiseunknown { return new Promise((resolve, reject) { this.queue.push(() task().then(resolve, reject)); this.pump(); }); } private pump() { if (this.running this.maxConcurrency) return; const next this.queue.shift(); if (!next) return; this.running; next().finally(() { this.running--; this.pump(); }); } }这个队列的语义是超过maxConcurrency的任务先排队前一个任务结束从队尾取下一个。不少框架在此基础上还会加优先级我是建议你一开始就把“优先级”考虑进去否则后面业务要加“VIP 任务先跑”会很痛苦。优先级的实现不复杂队列改成按(priority, enqueueTime)排序的小顶堆即可。并发数怎么定不是拍脑袋我用的经验公式是如果主要跑 Worker 或子进程maxConcurrency取min(CPU 核心数, 4)因为单机并行超过核心数后收益骤减如果主要跑 iframemaxConcurrency要按内存估可用内存 * 0.5 / 平均每个 iframe 内存占用拿不准就压测从 2 开始逐步往上调直到成功率开始下降或 CPU 打满。队列本身也要设上限。任务生产者速度远大于消费速度时无界队列会吃掉所有内存。我当时给队列加了一个maxQueueSize满了直接抛QueueFullError这比默默排队更符合预期让调用方知道当前系统已经过载。3.2 资源限制执行时长、内存与 CPU 时间队列只管并发管不了单个沙箱“能干多野”。沙箱的资源限制要分四个维度看执行时长、内存、CPU 占用、输出数据大小。执行时长最直观每个任务必须带timeout。但超时设多少是个学问。设太短正常任务被误杀设太长资源被垃圾任务占领。我一般这么算先收集同类任务的历史执行耗时取 P95再乘 1.5 到 2 的系数加 100ms 固定缓冲。比如历史 P95 是 300ms那 timeout 就设300 * 1.5 100 550ms。如果是首次运行没有历史数据保守设 5 秒后续根据统计逐渐收紧。内存限制最麻烦。iframe 和 Worker 都没有直接的内存上限 API。Web Worker 你只能通过间接手段控制比如限制消息体大小、限制任务返回的数据量Node 的子进程可以设置resourceLimits这是比较靠谱的方案import { Worker } from node:worker_threads; const worker new Worker(./sandbox-worker.js, { resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16, maxMemory: 128 } });如果你跑的是不可信脚本我建议优先用带resourceLimits的 worker_threads或者直接上容器级别的隔离。真正要限制到精度浏览器端是做不到的只能接受一个事实前端沙箱适合限“行为”和“耗时”限“内存”要靠后端。CPU 占用也要防。虽然 Worker 里的死循环不会卡住主线程但会一直占满一个 CPU 核心。这种情况只能用超时器 worker.terminate()强杀。iframe 里的死循环更危险因为它和主线程同一条线程所以如果脚本可能写死循环一定要把执行放到 Worker 里并且设好超时后 terminate。输出数据大小是我见过最容易被忽略的。一个沙箱任务可能返回一个几十 MB 的字符串直接postMessage传回来不仅慢还可能把通信通道堵住。我在 adapter 层加了结果大小校验1MB 的结果直接拒绝并报错需要大结果让调用方主动走文件或 ID 引用方式获取而不是一股脑塞进消息里。3.3 沙箱实例的生命周期管理沙箱不能每次用完就丢掉也不能无限创建不回收。生命周期管理直接决定内存涨不涨。我把沙箱实例状态拆成四个idle、running、recycling、dead。idle表示沙箱已创建但空闲可以接受新任务running表示正在执行recycling表示当前任务结束、沙箱正在做清理重置dead表示已被 terminate等待垃圾回收。为什么要引入idle状态因为创建 iframe 和 Worker 开销不小。一个 Worker 从创建到可以接收消息可能几十毫秒频繁创建销毁会很亏。所以 Sandcastle 这类框架通常会维护一个“暖池”也就是预先创建几个空闲沙箱任务来了直接复用避免冷启动。但复用又带来一个麻烦沙箱里的全局状态会残留。上一次用户脚本往沙箱里塞了个window._cache xxx下一次任务进来这个残留还在可能导致结果污染。所以我做的是两个策略结合短任务复用沙箱长任务或执行敏感代码时按任务新建沙箱、用完销毁。复用之前调用reset()把全局对象里被业务塞进去的属性清掉。热更新也是生命周期里容易出问题的一环。你可能不希望因为一段脚本代码升级把所有沙箱都重启那会造成正在执行的任务被中断。我的做法是“滚动更新”标记旧版本沙箱为不再接收新任务等池子里已有任务结束后再销毁同时创建新版本沙箱去接新任务。这样既保持连续可用又在两三个周期内完成全部替换。dead状态的清理也要做到位。terminate 之后要移除消息监听器、取消定时器、把引用置空不然浏览器或 Node 的内存里会留着沙箱的痕迹。这个看似简单但漏一次多跑几百个任务内存就上来了。4. 动手复刻一个最小 Sandcastle4.1 最小架构与目录结构纸上谈兵没用我直接给你看我复刻的最小版本。这个版本不需要完整功能但核心编排骨架和 adapter 抽象都在你拿过去可以替换成自己的业务逻辑。sandcastle-min/ ├── src/ │ ├── orchestrator.ts // 编排器队列、并发、超时 │ ├── adapter.ts // 沙箱适配器接口 │ ├── adapters/ │ │ ├── workerAdapter.ts // 基于 Web Worker 的浏览器适配器 │ │ └── vmAdapter.ts // 基于 Node vm 的实验适配器 │ └── messages.ts // RPC 消息信封定义与序列化工具 └── examples/ └── basic.ts // 最小可运行示例这个目录跟 Sandcastle 原版的思想是一致的编排逻辑放上层隔离细节放 adapter通信协议抽成独立模块。你后面想换成 iframe只需要新增一个iframeAdapter.ts编排器一行不改。4.2 核心代码任务注册与适配器接口编排器对外只暴露三个核心方法submit提交任务、cancel取消任务、onEvent监听沙箱主动上抛的事件。任务不是裸代码字符串而是一个带元信息的结构interface SandboxTask { id: string; code: string; timeout: number; priority?: number; context?: Recordstring, unknown; }submit内部做的事情是生成任务 ID不信任外部传入的 ID、包装 Promise、入队、触发调度。注意任务 ID 必须由编排器自己生成因为调用方可能传重复 ID到时取消和结果匹配会乱。编排器和 adapter 之间的协作走execute(code, options)接口。这个接口接受超时时间和上下文数据返回 Promise。adapter 内部自己决定“如何超时”“如何 terminate”编排层完全不感知。4.3 队列调度与并发执行调度器我上面的示例代码已经给出来了这里补上超时和取消的逻辑。submit返回的是一个 Promise同时这个 Promise 必须能被外部取消submit(task: SandboxTask): PromiseSandboxResult { let cancelFn: () void () {}; const resultPromise new PromiseSandboxResult((resolve, reject) { const wrapped async () { const controller new AbortController(); cancelFn () { controller.abort(); reject(new Error(Task cancelled)); }; const timer setTimeout(() controller.abort(), task.timeout); try { const data await this.runner.execute(task.code, { timeout: task.timeout, context: task.context, signal: controller.signal }); clearTimeout(timer); resolve({ taskId: task.id, ok: true as const, data }); } catch (err) { clearTimeout(timer); const e normalizeError(err); reject({ taskId: task.id, ok: false as const, error: e }); } }; this.scheduler.submit(wrapped); }); return resultPromise; }这里的关键点是AbortController一定要在任务真正开始执行前就创建并且取消时要同步把 Promise reject不能让外部拿着一个永远不结束的任务。超时和取消共用一个AbortController省去两套逻辑。4.4 worker adapter 实现与实测浏览器端最省心的 adapter 是 Worker 加 Blob URL。我用这个跑通了完整的编排流程代码不长export class WorkerAdapter implements SandboxAdapter { execute(code: string, options: { timeout: number }) { return new Promise((resolve, reject) { const blob new Blob( [ self.onmessage async (e) { try { const fn new Function(use strict; e.data.code); const result await fn(); self.postMessage({ ok: true, result }); } catch (err) { self.postMessage({ ok: false, error: serializeError(err) }); } }; ], { type: application/javascript } ); const worker new Worker(URL.createObjectURL(blob)); const timer setTimeout(() { worker.terminate(); reject(new Error(Worker timeout)); }, options.timeout); worker.onmessage (e) { clearTimeout(timer); worker.terminate(); e.data.ok ? resolve(e.data.result) : reject(new Error(e.data.error.message)); }; worker.onerror (err) { clearTimeout(timer); worker.terminate(); reject(new Error(err.message)); }; worker.postMessage({ code }); }); } terminate() {} }我实测了一个场景50 个任务每个任务内部做一个耗时约 50ms 的计算。单线程串行执行大约需要 2.5 秒用maxConcurrency 4的编排调度后总耗时降到 700ms 左右接近理论极限50ms * ceil(50 / 4) 750ms但任务间有创建 Worker 的开销所以略慢一点。这个实验最有价值的地方是告诉你并发数不是越大越好。我有一次把maxConcurrency调到 8在一台四核机器上总耗时反而比 4 还要慢因为线程切换和主线程通信成本上来了。所以并发参数强烈建议先压测再上线不要盲目堆。5. 常见问题与排查技巧实录5.1 iframe 沙箱白屏或脚本不执行最常见的 iframe 沙箱问题是iframe 加载出来一片白脚本没有执行。原因通常是sandbox属性没给够权限。sandbox默认会关闭很多能力包括脚本执行如果你写了iframe sandbox但没加allow-scripts那里面所有 JavaScript 都不会跑。排查看两个地方iframe 元素的 sandbox 属性值另外就是在控制台 Console 里有没有 CSP 拦截报错。CSP 也会拦 iframe。如果页面自己的frame-src或child-src策略没放行当前 iframe 的地址iframe 会被浏览器直接拦掉。用srcdoc方式创建的话还得注意部分环境下srcdoc和 CSP 的配合问题。我的排查习惯是先打开控制台看有没有报错再临时移除 CSP 做一步对比实验确定是不是策略导致的。5.2 vm 上下文“没有 window/document”很多人用 Node vm 跑脚本发现 context 里既没有window也没有document然后以为是自己没传。实际原因是vm.createContext()创建的是一个全新的 V8 context是另一个不完整的全局环境它没有浏览器宿主对象。这是特性不是 bug。你想要在 vm 里访问自定义 API正确做法是显式注入const ctx vm.createContext({ fetch: mySafeFetch, console: restrictedConsole, setTimeout: wrapSetTimeout });但要注意你注入进去的对象如果本身就带着危险能力等于把后门送进了沙箱。比如把真实的fs模块传进去那沙箱隔离还有什么意义。我见过不止一个项目在 vm 里注入了require结果一条require(child_process)就逃逸了。5.3 死循环把主线程拖死如果你在 iframe 里跑第三方代码遇到同步死循环整个页面会卡死这是因为 iframe 脚本和父页面共享渲染进程的主线程。timeout在 iframe 场景不太救得回来因为同步死循环会阻塞定时器回调。解法是把执行移到 Worker。Worker 中的死循环只占用 Worker 自己的线程主线程不受影响超时后worker.terminate()能真正杀掉。注意 terminate 后要重新创建 Worker因为一个 Worker 被 terminate 后不能复用。5.4 postMessage 序列化失败或消息过大如果收到“DataCloneError”这类报错多半是消息里有不能结构化克隆的值比如函数、Symbol、DOM 节点、或者循环引用的对象。解法是通信前统一做 JSON 安全化或者在沙箱内把数据转换成纯 JSON 值再回传。消息过大的问题则是一次传几十 MB 数据主线程会明显卡顿。我做了两个防线一个是在 adapter 层对结果做大小限制超过阈值直接报错另一个是用 Transferable 传输大二进制对象但这种属于进阶优化业务没到那个量级不用急着上。5.5 沙箱逃逸风险与纵深防御讲沙箱绕不开逃逸。任何单一隔离手段都不是绝对安全的vm 不是安全边界iframe 也可能因为浏览器漏洞被突破Worker 同样存在全局原型污染链的可能性。所以真正常用的是纵深防御不可信代码永远跑在 Worker 或子进程里不要跑在 UI 主线程给沙箱脚本能访问的 API 做最小权限设计用 Proxy 包一层只暴露白名单输出结果必须经过校验和大小限制防止沙箱拿结果通道当攻击面前端沙箱适合防“误操作”不适合防“高水平攻击者”真正高价值场景要在服务端加容器或独立进程做隔离。说句实在话没有“防一切”的沙箱安全是一个组合问题。Sandcastle 这类框架的做法是先把机制搭起来让你有一套可管理的隔离体系然后再通过限定 API、控制资源、监控异常来逐步提升安全水位。5.6 问题速查表现象可能原因快速排查解决方案iframe 白屏sandbox 未加 allow-scripts 或 CSP 拦截看 Console 报错补权限、调整 CSPvm 里没 windowvm 是新 realm没有宿主对象打印 Object.keys(ctx)显式注入白名单 API页面卡死同步死循环跑在主线程恢复后看任务日志改用 Worker 执行DataCloneError消息含不可克隆类型打印待传对象序列化为 JSON结果太大未限制输出大小看数据量设置返回上限任务不超时timeout 没传进 adapter看任务配置用 AbortController timer最后分享一个我实际使用中的体会沙箱编排框架最容易被低估的点不是技术而是“失败策略”。你要提前想清楚任务超时了怎么办、沙箱逃逸了怎么办、队列满了怎么办。这些策略往往比隔离层的技术选型更能决定系统的稳定性。我建议你从最小可用版本开始先跑通一个 adapter加上队列和超时再逐步补充资源限制和生命周期管理不要一上来就追求全功能那样大概率会被复杂度淹没。