04 架构
本章导读|预计 20 分钟
前面定义了「沙箱是什么」「要有哪些能力」。这一章讲怎么把它搭出来: 为什么几乎所有生产级沙箱都分成「控制面 + 数据面」?一次执行从 Agent 到结果,中间经过了什么?
后半章再往下走一层,讲控制面里的状态与生命周期怎么实现:快照与分叉、pause / resume、出网控制、凭据保险箱,最后是它们之上的编排与接入。本章讲怎么搭,第 5 章 5.7 讲选型时这四项各看什么。
4.1 全景图
Section titled “4.1 全景图”Agent Loop (LLM) │ tool call: run("python train.py") ▼┌───────────────────────────────┐│ Sandbox Control Plane │ 创建 / 路由 / 鉴权 / 配额 / 快照│ (无状态服务 + 元数据库) │└───────────────────────────────┘ │ 调度:选一个数据面节点 ▼┌───────────────────────────────┐│ 隔离后端 (Container / │ 真正执行(选哪一类见 5.1)│ microVM / gVisor / WASM) ││ (有状态:持有环境与文件) │└───────────────────────────────┘ │ ▼返回 stdout / stderr / exit code / 文件 diff → 回到 Agent4.2 两个面的职责
Section titled “4.2 两个面的职责”| 维度 | 控制面(Control Plane) | 数据面(Data Plane) |
|---|---|---|
| 管什么 | 环境怎么来、活多久、能碰什么 | 真正跑代码 |
| 状态 | 无状态 + 元数据库(环境归属、配额、快照索引) | 有状态(持有文件系统与进程) |
| 关键能力 | 调度、鉴权、配额、快照、路由、回收 | 隔离、执行、IO |
| 常见实现 | API 服务 + 数据库 + 队列 | microVM 池 / 容器池 |
| 扩展方向 | 水平扩展(偏控制逻辑) | 水平扩展(偏算力密度) |
| 失败影响 | 新任务无法创建,旧任务仍在跑 | 该节点上的环境受影响 |
为什么必须分开?
- 优化目标冲突:控制面要「秒级调度」,数据面要「强隔离」。混在一起会互相牵制。
- 故障隔离:数据面被恶意代码打挂,不应带走整个平台;控制面挂了,已有环境还能继续存活。
- 各自扩展:控制面 CPU 密集度低、连接多;数据面内存 / CPU 密集。分开后能独立扩容、独立计费。
- 安全边界:控制面持有凭据与策略,必须永远不进入数据面的执行上下文。
一个常见反例:把 API 服务和执行进程放同一个容器里。结果 Agent 一个
kill -9 -1就能带走 API —— 控制面必须和数据面物理隔离。
4.3 一次执行的完整时序
Section titled “4.3 一次执行的完整时序”Agent 控制面 数据面 │ create ────►│ │ │ │ 鉴权 + 配额检查 │ │ │ 分配/预热 ─────►│ (拿一个 warm 环境) │◄── sandbox_id┤ │ │ exec ──────►│ │ │ │ 路由到同一节点 ─►│ 执行命令 │ │◄── stdout ──────│ │◄── result ───┤ │ │ exec ──────►│ (会话粘性)───►│ 文件还在,继续跑 │◄── result ───┤ │ │ destroy ───►│ │ │ │ 回收/销毁 ─────►│ 环境释放,回到池或销毁关键点:create 之后,控制面必须记录「这个 sandbox_id 在哪个数据面节点」,后续每次 exec 都路由到同一节点——否则第二次执行就看不到第一次写的文件。这就是会话粘性。
4.4 关键设计点
Section titled “4.4 关键设计点”| 设计点 | 说明 | 不做的后果 |
|---|---|---|
| 预热池(warm pool) | 提前起好一批环境,规避冷启动 | 每次执行都等几百 ms~数秒,体验差 |
| 会话粘性 | 同一会话路由到同一环境 | 有状态任务直接失效 |
| 配额与鉴权 | 每个用户 / 会话独立限额 | 一个用户拖垮全平台 |
| 快照 | 支持暂停 / 恢复 / 分叉 | 做不了并行探索与 RL |
| 可观测 | 命令、耗时、资源用量全量记录 | 出事无法追溯与计费 |
| 回收 | TTL + 空闲检测 + 定时清扫 | 僵尸环境堆积、账单失控 |
预热池的直觉:冷启动慢的部分(拉起 microVM、加载镜像)提前做掉。请求进来时只需「从池里取一个 + 注入本次工作区」,把耗时从秒级压到百毫秒级。代价是池里闲置环境占用资源,需要按流量动态调池大小。
4.5 控制面的最小数据模型
Section titled “4.5 控制面的最小数据模型”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.6 快照与分叉:实现与用途
Section titled “4.6 快照与分叉:实现与用途”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 复验。)
用途三件,别混:
- 预热池(避免冷启动):把「已启动、依赖已装好」的状态做成模板,请求进来直接恢复,而不是从零 boot。
- 回溯(backtracking):回到某一步之前重来。arrakis 做的就是这件事——agent 走错路时退回上一个正确节点。
- 分支(fork):同一状态分叉出多份并行尝试。smolvm 主打「branchable VM」;商用方案还会给 fork 上限,这一项怎么看、够不够用,见第 5 章 5.7。
这三件对应三个不同的问题:预热池管「第一次用多快」,回溯管「错了怎么退」,分支管「同时试几种」。一个答案不覆盖另一个。
这是 microVM 系对容器系最大的一处优势。 多数人比较两者时只看隔离强度——谁能挡住逃逸。强度是「不出事的保证」,快照分叉是「能做哪些事」:RL rollout 要把同一初始状态复制上千份,coding agent 要回滚到测试通过的那一刻,这些是容器系给不全的能力。(这是归纳:调研把快照分叉写作「microVM 系对容器系最大的功能优势」,把强度与功能分开这个判断由我承担。)
4.7 快照 ≠ 预热池
Section titled “4.7 快照 ≠ 预热池”这两个词常被当成同一件事,因为「从快照恢复比从零启动快」这句话是真的。但它们存的东西、解决的问题都不一样。
| 快照(snapshot) | 预热池(warm pool) | |
|---|---|---|
| 存什么 | 一台环境某时刻的完整状态(内存 + 设备 + 磁盘差异) | 一批已启动、还没分配的空环境 |
| 什么时候产生 | 运行中途任意时刻 | 启动就绪后,等着被取用 |
| 恢复 / 取用后 | 回到冻结那一刻的原状态,连内存里的变量都还在 | 干净的新环境,注入本次工作区 |
| 解决的问题 | 状态留存、回溯、分叉 | 冷启动延迟 |
| 主要成本 | 快照存储 + 恢复时的读取 I/O | 池内闲置环境占的 CPU / 内存 |
两者的交集正是混淆的来源:预热池可以「从一份预启动快照恢复」来建,这时它用了快照,但目的仍然是省冷启动。反过来,用户中途 pause 出来的快照和池子毫无关系,那是给自己留的存档。抓住这一句就够了——快照是状态机制,预热池是调度机制;快照能用来加速预热,但预热不依赖快照。
4.8 pause / resume 与闲置成本
Section titled “4.8 pause / resume 与闲置成本”常驻 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 的账反而更贵,那时用预热池更合算。(这段取舍是归纳。)
4.9 出网控制三段
Section titled “4.9 出网控制三段”沙箱里能跑代码,就意味着它能往外发请求。控制这件事要在三个不同的位置各做一段,只做其中一段都会漏。
沙箱进程 │ ① 宿主 ↔ 沙箱通道: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 章;本章只讲机制怎么搭。
4.10 凭据保险箱
Section titled “4.10 凭据保险箱”真 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.11 编排与接入
Section titled “4.11 编排与接入”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。把「编排 + 接入」合成第四类配套机制、以及「冷启动是两条耗时串起来」这个拆法,是我的归纳。)
4.12 常见架构反模式
Section titled “4.12 常见架构反模式”| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 控制面与数据面同进程 | 执行把服务带走;凭据暴露给执行上下文 | 物理隔离,控制面永不进入执行态 |
每次 exec 重新选节点 |
会话不粘,文件丢失 | 按 sandbox_id 路由 |
| 无限等待执行返回 | 一条 sleep infinity 卡死连接 |
强制超时 + 异步 / 流式输出 |
| 不做预热 | 冷启动拖垮体验 | warm pool |
| 只删环境不删元数据 | 元数据泄漏、快照残留 | 级联销毁 |
动手练习 4
Section titled “动手练习 4”- 画一张你自己系统的架构图,标出哪部分是控制面、哪部分是数据面。
- 找出你系统里一个「控制面与数据面没分开」的地方,说明风险。
- 给你的常驻 agent 估一笔账:假设它每天被真实使用 30 分钟,比较「一直开着」和「pause / resume」两种做法。说出你还缺哪个数字才能算清。
- 为你的沙箱画出 4.9 的三段图,并指出哪一段你目前完全没有做——那一段就是最可能出事的入口。