跳到主要内容

控制通道:DO 怎么跟容器说话

本章是全项目工程含量最高的一支。先讲清「DO 和容器之间有几条线、默认走哪条」, 再讲那条线上最巧妙的设计:怎么知道容器到底忙不忙,从而决定沙箱什么时候可以睡。

1. 三种传输,一个抽象

Sandbox DO 有一个字段 client,类型是 SandboxClient | ContainerControlClient (packages/sandbox/src/sandbox.ts:951)。具体是哪个,由 transport 配置决定:

transport 值选中的 client通道定位
rpc(主)ContainerControlClientcapnweb RPC over /rpc WebSocket主控制面,新能力默认走这里
websocketSandboxClient自定义 WebSocket /ws路由式兼容
httpSandboxClientHTTP :3000路由式兼容(最朴素)

选择发生在 createClientForTransport(sandbox.ts:1131):

// packages/sandbox/src/sandbox.ts:1131 —— 真实源码(节选)
private createClientForTransport(transport: SandboxTransport) {
if (transport === 'rpc') {
return new ContainerControlClient({ stub: this, port: 3000, ... });
}
return this.createSandboxClient(); // http / websocket
}

transport 可以来自 env(SANDBOX_TRANSPORT,sandbox.ts:1216)、DO 存储,或运行时 setTransport(sandbox.ts:1540)。切换传输时旧 client 会 disconnect()、tunnels handler 被丢弃由 lazy getter 重建(sandbox.ts:1558+)。

两个 client 公开形状一致——文件底部用一行编译期断言钉死:

// packages/sandbox/src/container-control/client.ts:763 —— 真实源码
void (0 as unknown as PublicKeys<ContainerControlClient> satisfies PublicKeys<SandboxClient>);

ContainerControlClient 必须拥有 SandboxClient 的每一个顶层键。换传输对上层透明。

2. 主通道:capnweb RPC

默认传输是 rpc,走 capnweb(Cloudflare 的 RPC 库)。它的好处是: 容器把每个域(commands、files、processes…)暴露成嵌套 RpcTarget, SDK 拿到的是带类型的桩,直接 rpc.commands.execute(...),没有逐方法的样板代码

DO 侧 容器侧
ContainerControlClient SandboxControlAPI (extends RpcTarget)
.commands ─────/rpc WebSocket──────▶ get commands → CommandsRPCAPI(processService)
.files get files → FilesRPCAPI(fileService)
.processes ...
  • 容器侧:SandboxControlAPI(packages/sandbox-container/src/control-plane/api.ts:79)。
  • 连接建立:Bun 在 /rpc 升级时 newBunWebSocketRpcSession(ws, app.controlPlaneAPI) (server.ts:204),并把对端的 main 桩(DO 侧的 SandboxControlCallback)记下来, 让容器能反向回调 DO(例如「cloudflared 死了」通知,server.ts:212)。
  • DO 侧每个域 getter 都用 wrapStub 包一层 Proxy,把 capnweb 抛回的错误翻译成结构化 SandboxError,并在调用开始时触发 onActivity(container-control/client.ts:660+)。

错误翻译的诚实之处

translateRPCError(client.ts:172)要应付两种线格式(新容器用 capnweb 传播的 error 属性,旧容器用 JSON-in-message),还要把传输层错误(Peer closed WebSocket: 1006WebSocket connection failed.)按 error.name / 消息逐字匹配分类成 RPCTransportErrorKind (buildTransportErrorResponse,client.ts:262)。注释坦白这些字符串「pinned to the exact messages emitted by capnweb's WebSocketTransport」——一旦上游改文案就得跟着改。

3. 最妙的一招:用「会话忙闲」决定何时休眠

这是本章的核心,也是整个 SDK 最值得带走的设计。

3.1 要解决的小问题

Sandbox 是个 Durable Object,会在「一段时间没活动」后睡掉(省钱),由 sleepAfter 控制。 HTTP 传输下这很简单:每个 containerFetch() 开头 inflightRequests++finally--, 请求计数归零就能睡。

RPC 传输把所有调用复用在一条 WebSocket 上,而且——这是关键—— 一个返回 ReadableStream 的调用,它的 Promise 在毫秒级就 resolve 了,但容器还要往那个流里 写好几秒。 用「per-call promise 生命周期」根本测不准「容器到底干完没」。

commands.executeStream("long-running")
Promise resolve ✓ (几毫秒,拿到一个 stream 引用)
───────────────── 但容器在这几秒里持续往流里写 ─────────────────▶
如果按「promise 已 resolve = 空闲」来判断 → 会在流写到一半时把沙箱睡掉!

3.2 思路:问 capnweb「你手上还攥着几个引用?」

capnweb 每条会话维护两张表:imports(对端暴露给我们的引用)和 exports(我们暴露给对端的)。 getStats() 返回两者的实时计数。空闲基线是各 1(每侧的 bootstrap main 桩)。 各种在途工作都会顶高这两个数:

在途工作表现
一个 pending RPC 调用imports = 2(返回值占一个 import 槽)
一个活着的返回流exports = 2(直到流结束/取消才释放,即使 promise 早已 resolve)
跨线传递的桩 / RpcTarget各加一项直到两侧释放

所以判忙闲的真正信号不是 promise,而是 getStats()——文件头注释把这点讲得淋漓尽致 (container-control/client.ts:15-70)。

3.3 原理演示(示意,非源码)

// 示意,非源码:轮询会话忙闲,把休眠闹钟往后推
const IDLE = 1; // 空闲基线:imports/exports 各 1
setInterval(() => {
const { imports, exports } = conn.getStats();
const busy = imports > IDLE || exports > IDLE; // 流还活着 → exports > 1 → busy
if (busy) {
renewActivityTimeout(); // 每个 tick 都续命,顶住 sleepAfter
} else if (wasBusy) {
onSessionIdle(); // 忙→闲:还原 inflight 计数,安排断开空闲 WS
}
}, 1000); // 1Hz;必须远小于最小 sleepAfter(E2E 最小 3s,留 3× 余量)

3.4 真实实现

核心轮询是 pollBusyState(container-control/client.ts:554):

// packages/sandbox/src/container-control/client.ts:570 —— 真实源码(节选)
const { imports, exports } = conn.getStats();
const isBusy =
imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD;
if (isBusy) {
if (!this.busy) { this.busy = true; this.onSessionBusy?.(); }
this.onActivity?.(); // 每个忙 tick 续命 —— 这是流活过 sleepAfter 的关键
this.clearIdleTimer();
} else if (this.busy) {
this.busy = false; this.onSessionIdle?.(); this.scheduleIdleDisconnect();
}

DO 侧把这三个回调接到自己的 inflight 计数上(sandbox.ts:1160-1182): onSessionBusy → inflightRequests++onSessionIdle → inflightRequests-- + 续命、 onActivity → renewActivityTimeout(),精确复刻 HTTP 传输 containerFetch 的 inflight 语义

3.5 关键细节 / 坑

  • 双保险。 光靠 1Hz 轮询会漏掉「两次 tick 之间起止」的快调用,所以每个 RPC 方法调用还会 同步触发一次 onActivity(wrapStub 里的 onCallStarted,client.ts:386)。
  • 轮询频率有硬约束。 BUSY_POLL_INTERVAL_MS 注释立了不变量:必须远小于最小可配置的 sleepAfter,否则可能在两次续命之间被闹钟睡掉(client.ts:111-131)。
  • WS 掉线要主动收尾。 容器崩了时 setInterval 在 DO 空闲期不一定触发,所以连接的 onClose 回调会同步 destroyConnection()(client.ts:500),并在 busy 状态下补发 onSessionIdle,防止 inflight 计数永久泄漏。

4. 路由式传输的看家本领:503 重试

兼容路径(SandboxClient + clients/transport/)也有一招值得记:容器冷启动时返回 503, BaseTransport.fetch 用一个重试预算反复轮询直到容器就绪 (clients/transport/base-transport.ts:53,shouldRetry: r => r.status === 503, 默认 120s 预算,至少剩 15s 才再试)。这个预算由 computeRetryTimeoutMs(sandbox.ts:1098) 按容器启动超时算出来,RPC 与路由式共用。

5. 代码地图

主题文件符号名
RPC 客户端 + 忙闲轮询packages/sandbox/src/container-control/client.tsContainerControlClientpollBusyStatewrapStub
RPC 错误翻译packages/sandbox/src/container-control/client.tstranslateRPCErrorbuildTransportErrorResponse
选传输packages/sandbox/src/sandbox.tscreateClientForTransportsetTransport
inflight 回调接线packages/sandbox/src/sandbox.tsonSessionBusy/onSessionIdle/onActivity(在 createClientForTransport 内)
容器侧 RPC 入口packages/sandbox-container/src/control-plane/api.tsSandboxControlAPI
capnweb 会话建立packages/sandbox-container/src/server.tsnewBunWebSocketRpcSession(在 websocket open)
503 启动重试packages/sandbox/src/clients/transport/base-transport.tsBaseTransport.fetch
重试预算packages/sandbox/src/sandbox.tscomputeRetryTimeoutMs