跳转到内容

04 架构

本章导读|预计 20 分钟

前面定义了「沙箱是什么」「要有哪些能力」。这一章讲怎么把它搭出来: 为什么几乎所有生产级沙箱都分成「控制面 + 数据面」?一次执行从 Agent 到结果,中间经过了什么?

后半章再往下走一层,讲控制面里的状态与生命周期怎么实现:快照与分叉、pause / resume、出网控制、凭据保险箱,最后是它们之上的编排与接入。本章讲怎么搭,第 5 章 5.7 讲选型时这四项各看什么。

Agent Loop (LLM)
│ tool call: run("python train.py")
▼
┌───────────────────────────────┐
│ Sandbox Control Plane │ 创建 / 路由 / 鉴权 / 配额 / 快照
│ (无状态服务 + 元数据库) │
└───────────────────────────────┘
│ 调度:选一个数据面节点
▼
┌───────────────────────────────┐
│ 隔离后端 (Container / │ 真正执行(选哪一类见 5.1)
│ microVM / gVisor / WASM) │
│ (有状态:持有环境与文件) │
└───────────────────────────────┘
│
▼
返回 stdout / stderr / exit code / 文件 diff → 回到 Agent
维度 控制面(Control Plane) 数据面(Data Plane)
管什么 环境怎么来、活多久、能碰什么 真正跑代码
状态 无状态 + 元数据库(环境归属、配额、快照索引) 有状态(持有文件系统与进程)
关键能力 调度、鉴权、配额、快照、路由、回收 隔离、执行、IO
常见实现 API 服务 + 数据库 + 队列 microVM 池 / 容器池
扩展方向 水平扩展(偏控制逻辑) 水平扩展(偏算力密度)
失败影响 新任务无法创建,旧任务仍在跑 该节点上的环境受影响

为什么必须分开?

  1. 优化目标冲突:控制面要「秒级调度」,数据面要「强隔离」。混在一起会互相牵制。
  2. 故障隔离:数据面被恶意代码打挂,不应带走整个平台;控制面挂了,已有环境还能继续存活。
  3. 各自扩展:控制面 CPU 密集度低、连接多;数据面内存 / CPU 密集。分开后能独立扩容、独立计费。
  4. 安全边界:控制面持有凭据与策略,必须永远不进入数据面的执行上下文。

一个常见反例:把 API 服务和执行进程放同一个容器里。结果 Agent 一个 kill -9 -1 就能带走 API —— 控制面必须和数据面物理隔离。

Agent 控制面 数据面
│ create ────►│ │
│ │ 鉴权 + 配额检查 │
│ │ 分配/预热 ─────►│ (拿一个 warm 环境)
│◄── sandbox_id┤ │
│ exec ──────►│ │
│ │ 路由到同一节点 ─►│ 执行命令
│ │◄── stdout ──────│
│◄── result ───┤ │
│ exec ──────►│ (会话粘性)───►│ 文件还在,继续跑
│◄── result ───┤ │
│ destroy ───►│ │
│ │ 回收/销毁 ─────►│ 环境释放,回到池或销毁

关键点:create 之后,控制面必须记录「这个 sandbox_id 在哪个数据面节点」,后续每次 exec 都路由到同一节点——否则第二次执行就看不到第一次写的文件。这就是会话粘性。

设计点 说明 不做的后果
预热池(warm pool) 提前起好一批环境,规避冷启动 每次执行都等几百 ms~数秒,体验差
会话粘性 同一会话路由到同一环境 有状态任务直接失效
配额与鉴权 每个用户 / 会话独立限额 一个用户拖垮全平台
快照 支持暂停 / 恢复 / 分叉 做不了并行探索与 RL
可观测 命令、耗时、资源用量全量记录 出事无法追溯与计费
回收 TTL + 空闲检测 + 定时清扫 僵尸环境堆积、账单失控

预热池的直觉:冷启动慢的部分(拉起 microVM、加载镜像)提前做掉。请求进来时只需「从池里取一个 + 注入本次工作区」,把耗时从秒级压到百毫秒级。代价是池里闲置环境占用资源,需要按流量动态调池大小。

Session
├── session_id
├── user_id
├── sandbox_id -> 指向当前环境
└── created_at / expires_at
Sandbox
├── sandbox_id
├── node_id -> 数据面节点(会话粘性靠它)
├── state -> creating | ready | running | paused | destroyed
├── limits -> cpu / mem / disk / net policy
└── quota_usage -> 累计 CPU 时长 / 流量 / 调用次数
Snapshot
├── snapshot_id
├── sandbox_id
└── storage_ref -> 快照存储位置(需加密 + 归属校验)

有了这个模型,配额、回收、计费、快照都有落脚点。

4.5 的模型里有两个字段一直没解释:state: paused 和 Snapshot。这一节把它们补上。

先把两个词摆平。**快照(snapshot)**是把一台正在运行的环境按某个瞬间存下来,之后能原样恢复;**分叉(fork)**是从一个快照恢复出多份,各自独立往下走。前者是「暂停」的能力,后者是「复制」的能力。第 5 章 5.7 把快照与分叉、网络控制、凭据隔离、编排与接入列为四类配套机制,并给出每一项的选型判据;本章 4.6 到 4.11 就是这四类的实现——前三类在这里讲透,第四类(编排与接入)偏产品与生态,本章用 4.11 收口,更细的产品落点见第 8 章。

存的是什么。快照不只是存文件,它要同时落两层:

层 存的内容 常见存法
内存 + 设备状态 进程、页表、寄存器,以及虚拟设备(网卡、时钟、块设备)的状态 Firecracker 直接 dump 成文件,恢复毫秒级
磁盘差异 相对基础镜像被改动过的那部分块 overlayfs / dm-snapshot / reflink 做 COW,只留差异,不整盘拷贝

容器侧走的是另一条路:CRIU(Checkpoint/Restore In Userspace)把进程树冻结到磁盘再读回来。它做得动进程,但够不到「整机」那一层——没有 hypervisor 的虚拟设备,也没有页表级的瞬时冻结。

(公开事实:Firecracker 快照存「内存 + 设备状态」、恢复毫秒级;容器侧靠 CRIU;磁盘靠 overlayfs / dm-snapshot / reflink 做 COW。来源为 Firecracker 文档与各项目 README 原文;快照日期 2026-10-21 采集、2026-10-22 复验。)

用途三件,别混:

  1. 预热池(避免冷启动):把「已启动、依赖已装好」的状态做成模板,请求进来直接恢复,而不是从零 boot。
  2. 回溯(backtracking):回到某一步之前重来。arrakis 做的就是这件事——agent 走错路时退回上一个正确节点。
  3. 分支(fork):同一状态分叉出多份并行尝试。smolvm 主打「branchable VM」;商用方案还会给 fork 上限,这一项怎么看、够不够用,见第 5 章 5.7。

这三件对应三个不同的问题:预热池管「第一次用多快」,回溯管「错了怎么退」,分支管「同时试几种」。一个答案不覆盖另一个。

这是 microVM 系对容器系最大的一处优势。 多数人比较两者时只看隔离强度——谁能挡住逃逸。强度是「不出事的保证」,快照分叉是「能做哪些事」:RL rollout 要把同一初始状态复制上千份,coding agent 要回滚到测试通过的那一刻,这些是容器系给不全的能力。(这是归纳:调研把快照分叉写作「microVM 系对容器系最大的功能优势」,把强度与功能分开这个判断由我承担。)

这两个词常被当成同一件事,因为「从快照恢复比从零启动快」这句话是真的。但它们存的东西、解决的问题都不一样。

快照(snapshot) 预热池(warm pool)
存什么 一台环境某时刻的完整状态(内存 + 设备 + 磁盘差异) 一批已启动、还没分配的空环境
什么时候产生 运行中途任意时刻 启动就绪后,等着被取用
恢复 / 取用后 回到冻结那一刻的原状态,连内存里的变量都还在 干净的新环境,注入本次工作区
解决的问题 状态留存、回溯、分叉 冷启动延迟
主要成本 快照存储 + 恢复时的读取 I/O 池内闲置环境占的 CPU / 内存

两者的交集正是混淆的来源:预热池可以「从一份预启动快照恢复」来建,这时它用了快照,但目的仍然是省冷启动。反过来,用户中途 pause 出来的快照和池子毫无关系,那是给自己留的存档。抓住这一句就够了——快照是状态机制,预热池是调度机制;快照能用来加速预热,但预热不依赖快照。

常驻 agent 场景的命门是闲置成本:沙箱不销毁,内存就一直占着。一个用户挂着 2GB 的工作区、每天只用 10 分钟,剩下 23 小时 50 分钟都在为这段内存付钱。

传统上只有两个选择,都不好:不销毁,状态全在,但内存账单一直烧;销毁,账单停了,状态没了,下次回来从头冷启动。

pause / resume 是第三条路:把「保留状态」和「释放算力」拆开。pause 时把内存(必要时连同磁盘)冻到存储,占着的 CPU 与内存配额还给机器;resume 时读回来接着跑。(公开事实:OpenSandbox 的 pause/resume 宣称保留内存与磁盘,E2B 也提供 pause/resume;来源为 OpenSandbox README 与 E2B 文档,2026-10-22 复验。)

代价要说清:resume 不是免费的,读回内存镜像本身有开销,只是比冷启动小。所以它适合「几小时不用、几分钟内要回来」的节奏。如果间隔只有几十秒,pause 再 resume 的账反而更贵,那时用预热池更合算。(这段取舍是归纳。)

沙箱里能跑代码,就意味着它能往外发请求。控制这件事要在三个不同的位置各做一段,只做其中一段都会漏。

沙箱进程
│ ① 宿主 ↔ 沙箱通道:TAP/NAT 或 vsock (有没有网)
▼
egress 代理 + 域名白名单
│ ② 按协议分层过滤:HTTP CONNECT / SOCKS5 / DNS (能去哪)
▼
公网
▲
│ ③ 每沙箱专属子域名反向代理(入站) (外面怎么进来)
外部浏览器 / 回调

① 通道:有没有网。 给沙箱一个网络接口,或一条与宿主之间的专用通道。TAP/NAT 是把它接进虚拟网络、由宿主做地址转换;vsock 是宿主与 guest 之间的专用通道,不占公网地址。这一段只决定「能不能出去」,不做任何策略。

② 出网过滤:能去哪。 沙箱默认不通外网,所有出站请求先到 egress 代理,由它按域名和协议决定放不放行。三层过滤缺一不可:

  • HTTP CONNECT:拦 HTTPS 隧道,看目标 host:port;
  • SOCKS5:拦非 HTTP 的协议(git、数据库、任意 TCP);
  • DNS:拦域名解析本身。

DNS 这层最容易漏。如果只在 CONNECT / SOCKS5 层放行了 api.openai.com,沙箱仍然可以先解析出它的 IP、再直连那个 IP,白名单就被绕过了。所以域名策略必须同时挡在解析入口。(公开事实:E2B 文档把出网控制写成按 CIDR / hostname;2026-10-22 复验。)

③ 入站:外面怎么进来。 每个沙箱给一个专属子域名,反向代理按子域名把请求转进对应沙箱。E2B 的 3000-{id}.e2b.app 就是这个——3000 是沙箱内监听的端口,{id} 是 sandbox_id。(公开事实,E2B 文档。)用子域名而不是直接暴露 IP:port,是为了让代理统一处理端口冲突、鉴权、TLS 与回收,沙箱自己不用管这些。

三段各答一个问题:有没有网 / 能去哪 / 外面怎么进来。出网策略被攻击者利用的方式(例如借 prompt injection 把数据带出去)属于威胁分析,放在第 6 章;本章只讲机制怎么搭。

真 key 不进沙箱。做法是三步:

vault(存真 key 的明文)
│ 出网时代理取出真值
egress 代理 ← 沙箱发来的请求在这里被改写
│ 注入真值 header
目标 API
▲
沙箱内:只放占位符(如 OPENAI_API_KEY=__PROXY_INJECTED__)

沙箱里的代码照常读环境变量、照常发请求,但它拿到的是占位符;真值只存在于代理进程里,由代理在出网那一刻替换进 header。这样即使沙箱被 prompt injection 攻陷、代码被读走,泄出去的也只是假 key。

(公开事实:OpenSandbox 有 Credential Vault,microsandbox 的 README 写着「secrets never enter the VM」;2026-10-22 复验。下面这半句是推断:要在 HTTPS 上改写 header,代理必须先终止 TLS、用沙箱信任的证书解密,才看得见请求内容——这是这套方案的主要实现代价。)

一句话收口:沙箱里能拿到的一切都可能泄,所以真 key 从不进去;保险箱要做的是把「能不能用凭据」和「能不能看到凭据」拆开。(这是归纳。)

4.4 到 4.10 讲的都是「一台环境怎么用」;这一节跳到上一层——一群环境怎么管起来,以及 Agent 怎么碰得到它们。第 5 章 5.7 把编排与接入列为四类配套机制里的第四类,理由是它和边界选型绑在一起:边界越重,这一层越重。

编排:让池子转起来。 主要落在 microVM 一档(WASM 一档没有 VM 要编,进池子靠的是编译与部署管线,走的是另一套)。要做的三件事:

要做的事 做法 不做的后果
生命周期 把「沙箱」建模成一等资源,用 CRD + controller 声明式地管创建、回收与 TTL 创建 / 回收各写一遍,池化与清扫散落在业务代码里
选后端 用 K8s 的 RuntimeClass 把「这个 Pod 用哪类隔离」变成调度属性 换后端要改业务代码,5.6 的升级路径走不通
压冷启动 镜像懒加载(overlaybd / Nydus)按需取块,而不是先拉全量镜像 4.4 的预热池被镜像拉取拖住,池开得再大也快不起来

第三件事最容易漏。预热池解决的是「环境启动」的耗时,镜像分发解决的是「镜像就位」的耗时——两个耗时串在冷启动这一条路上,只优化其中一个,另一个照样把请求卡住。

接入:让 Agent 碰得到。 这一层各档通用,挑一套暴露出去即可:

  • REST / gRPC:控制面的主接口,也就是 4.3 时序里的 create / exec / destroy 那一组;
  • E2B 兼容 API:多家自托管实现以「能替换 E2B」为卖点(8.2.1 的 Dormice 直接这么写),接入侧因此有了一个事实上的形状;
  • MCP server:把沙箱当成 Agent 的一个工具,做法见第 10 章;
  • PTY over WebSocket:交互式终端;
  • CDP / VNC、RDP:分别是浏览器与桌面 computer-use 场景的接入面。

为什么这两件事合成一节。 因为它们总在同一个地方出错:编排把池子管得很好,接入只丢给调用方一个 sandbox_id 让它自己拼命令——Agent 侧就得自己处理重连、超时、进程残留;反过来,接入做得再顺手,编排没管住回收,池子迟早被僵尸环境撑爆。编排负责「环境活着且被管着」,接入负责「Agent 不需要知道这些」。

(公开事实:kubernetes-sigs/agent-sandbox 用 CRD + controller 管生命周期、用 RuntimeClass 把后端选择交给调度;镜像懒加载由 overlaybd / Nydus 提供各自实现;Dormice 以 E2B 兼容为卖点。来源见附录 D。把「编排 + 接入」合成第四类配套机制、以及「冷启动是两条耗时串起来」这个拆法,是我的归纳。)

反模式 问题 正确做法
控制面与数据面同进程 执行把服务带走;凭据暴露给执行上下文 物理隔离,控制面永不进入执行态
每次 exec 重新选节点 会话不粘,文件丢失 按 sandbox_id 路由
无限等待执行返回 一条 sleep infinity 卡死连接 强制超时 + 异步 / 流式输出
不做预热 冷启动拖垮体验 warm pool
只删环境不删元数据 元数据泄漏、快照残留 级联销毁
  1. 画一张你自己系统的架构图,标出哪部分是控制面、哪部分是数据面。
  2. 找出你系统里一个「控制面与数据面没分开」的地方,说明风险。
  3. 给你的常驻 agent 估一笔账:假设它每天被真实使用 30 分钟,比较「一直开着」和「pause / resume」两种做法。说出你还缺哪个数字才能算清。
  4. 为你的沙箱画出 4.9 的三段图,并指出哪一段你目前完全没有做——那一段就是最可能出事的入口。