10 月 1 日,Perplexity 把 SPACE 开源了。
全称是 Sandboxed Platform for Agentic Code Execution——面向 Agent 代码执行的沙箱平台。Rust 写的,Apache-2.0,我写这篇的时候仓库已经 1400 多 star、两周内还在高频提交。
它一句话的定义是:跑在隔离的 Firecracker microVM 里、给你一个 HTTP API 去创建 Linux 环境、执行命令、收输出、管生命周期。这话听着很普通——不就是一个「跑代码的沙箱」吗?
但我把整个仓库翻了一遍(3857 个文件、crates/ 下上百个 crate、一个 Go 写的 runtime shim、一套 gRPC proto、独立的 Postgres 迁移目录)之后发现,它不像一个「demo 级沙箱」,更像一个把该做的都做进去了的生产级平台。这篇不逐行读代码,只做一件事:把它几个关键设计决策拆开,看它做了什么、为什么这么做、以及哪些值得直接偷师。
先把概念对齐
SPACE 的模型里有五个名词,理解它们就理解了它的骨架:
- Template(模板):起始文件系统和运行时配置,多个沙箱共用一份,不改它。
- Sandbox(沙箱):一个隔离的 microVM,有自己的可写文件系统和生命周期。
- Process(进程):沙箱里跑的程序,同一个沙箱里的进程共享它的环境和文件系统。
- Command session(命令会话):一次命令的连接信息和授权;创建会话才分配进程 ID,attach 才开始跑。
- Operation(操作):一条异步记录,跟踪创建、暂停、挂起、唤醒、删除的请求。
最关键的一句在架构文档开头:一个沙箱、一次生命周期操作、一条命令流,三者生命周期彼此独立。 沙箱可以活得比创建它的那次请求、比承载它输出的那条连接都更久。这一句话就决定了后面所有的设计。
决策一:控制面和数据面,从第一天就切开
大多数「沙箱服务」会把「管理请求」和「命令输出」塞进同一个连接里。SPACE 不。
- 管理面:
应用 → API → PostgreSQL。API 只把「期望状态」写进 Postgres,真正的执行由控制器和工作服务去 reconcile。文档明说:一个被接受的请求不是就绪信号。 - 数据面:
应用 → 数据面 ↔ Guest 进程。命令输出走一条单独的连接,用会话级的 attachment token 授权。
而且是两种 token,不可互换:管理请求用用户会话或服务 bearer token,命令连接用会话级 attachment token。这把「能管沙箱」和「能看某条命令输出」拆成了两个权限,攻击面小了一圈。
数据面的优雅退出也写得很细(这段是生产经验的直接沉淀):收到 SIGTERM 时,replica 不是直接丢掉所有流,而是先关监听(新连接包括就绪探针都拒掉),已 attach 的流继续跑到结束,空闲 keep-alive 关掉;带 --shutdown-grace-secs 时超过期限还在的流才被取消;带 Last-Event-ID 重连的客户端能接回实时流、但拿不到错过的那段输出。这些边界情况,没被生产流量打过是写不出来的。
决策二:Kubernetes 只是「可选项」
这一点很有意思。SPACE 的 substrate(底层编排)是抽象的,有两个适配器:
- direct 适配器:直接调用 worker 服务,不创建 Kubernetes 沙箱 Pod。
- Kubernetes 适配器:本地 Kind 安装用它。
也就是说,K8s 只是它支持的其中一种跑法。文档里那套 Kind 拓扑(API → Postgres → 集群控制器 → 沙箱 Pod → runtime shim → worker → microVM)是「本地指南用的」,不是「每个部署都必需」。
而且这个抽象被 CI 强制了:仓库里有个 cargo arch(源码在 crates/tools/archcheck)做架构守卫。其中一条 pattern ratchet 规定:只允许 substrate 模块里出现 Substrate::as_kubernetes() 调用,别处想用得走一次 review 过的 baseline 登记。换句话说,「哪些代码可以假设自己在 K8s 上」是被机器盯着的,不是靠自觉。
决策三:runtime shim 是一个 containerd shim
这是我翻仓库时最意外的一处。shim/ 目录里是一个 Go 写的程序,main.go 就三行:
func main() {
shim.Run(context.Background(), NewManager("io.containerd.space.v2"))
}
它实现的是 containerd v2 的 runtime shim 接口。翻译一下:SPACE 没有自己造一套节点上的「沙箱 Pod 生命周期协议」,而是借了 containerd / CRI 生态现成的 shim 机制——沙箱在 K8s 里就是一个 containerd task,由这个 shim 负责起 cgroup、配 network namespace(netns_linux.go 里读 Pod 的 netns 拿 IP)、当数据面代理(data_plane_proxy.go)。
这是一个「站在巨人肩膀上」的决策:把「怎么在节点上被调度、被拉起来、被回收」这件脏活交给已经在生产里跑了十年的 containerd,自己只专注 Firecracker microVM 那一段。对任何要做 Agent 沙箱的人来说,这是省掉一大堆节点侧协议设计的最短路径。
决策四:执行环境做「内容寻址」,「装了」不等于「被选中」
SPACE 把「执行环境」当成一等公民,而且给了它一个身份:一个执行环境绑定运行时、宿主需求、guest 平台、启动输入、隔离语义、适配器兼容 profile——它的引用就是这一整份定义的 SHA-256。
第一个正式契约叫 E0:Linux/x86_64 宿主、KVM、Firecracker 1.15.1、Linux/x86_64 guest、内核 6.1.155、硬件 VM 边界、快照格式 9.0.0。它的引用是一串固定的哈希。
规矩写得很硬:「安装本身从不授权选择」(installation alone never authorizes selection)。你把 Firecracker 1.17.0 装上,也不代表节点会用它;默认仍然是编译进去的 E0,非默认绑定要在非生产环境显式选、还得 peer 明确确认同一个引用。而且「任何新的必需含义都要新开一个 schema 版本」——不允许悄悄给旧引用加字段。
运行时目录也是显式钉死了版本,不是「拿最新的」:
| Firecracker | 快照格式 | 内核选项 |
|---|---|---|
| 1.15.1 | 9.0.0 | 6.1.155 |
| 1.16.0 / 1.16.1 | 10.0.0 | 5.10.253、6.1.172 |
| 1.16.2 | 11.0.0 | 5.10.253、6.1.172 |
| 1.17.0 | 12.0.0 | 6.1.186、6.18.44 |
注意 1.16.2 那行:它的快照格式是 11.0.0,不能恢复 1.16.0/1.16.1 的格式 10 快照。文档还专门交代这纠正了目录里一处早先的错误(把三个 patch 版归到一组)。这种「把版本兼容边界写死在目录里、并解释为什么」的做法,是给「快照能不能跨版本恢复」这种坑打的预防针。
决策五:冷启动有三档,不是一个开关
Agent 沙箱体验的胜负手往往就是创建延迟。SPACE 在这上面叠了三层:
| 启动模式 | 做什么 | 什么时候用 |
|---|---|---|
cold(默认) |
完整启动 | 兜底 |
base-snapshot |
从模板的基线内存快照恢复 | 想跳过冷启动 |
pool(温池) |
拿一个已经启动好的沙箱 | 追求最低延迟 |
温池(warm pool)是 K8s 侧的:为某个模板 + 某个 VM 规格常备 N 个已启动的沙箱,创建时直接领走一个,省掉绝大部分创建延迟;池子空了就回落到冷启动,不报错。有意思的是它还支持滚动策略:模板发新版本时,策略自动把最新两个 release 保持温热、把没人用的旧池排干——每个池是 Postgres 里 kubernetes_pool_configs 的一行,槽位在 kubernetes_pool_slots。
单节点没有 K8s 时,用 base snapshot + 节点本地温池。而 suspend(挂起)会快照 guest 内存、运行时状态和根文件系统,然后释放活跃的 VM 资源;默认空闲 6 小时自动挂起——注意是「空闲上限」不是「最长寿命」。
底层还有个容易被忽略的细节:根文件系统用 btrfs copy-on-write 创建,而 cargo arch 里有一条 ratchet 强制所有 rootfs 拷贝走 common-io 的 reflink helper,以保留 NoCoW 和稀疏 extent——因为普通的文件拷贝会悄悄破坏这两个属性、把 CoW 变成实拷贝。这种「把性能关键路径锁进 CI 规则」的做法,很少见。
决策六:出网,是最接近「产品能力」的一层
沙箱隔离了 guest,但真正的产品差异在出网策略。SPACE 在这一层做了几件事:
- 每个沙箱自己的 network namespace;K8s 里 NetworkPolicy 只放行 API 和集群控制器,沙箱出网仅限公网 IPv4 和集群 DNS。
- egress profiles:运维定义的具名策略,federated create 时合成进租户策略;profile 独立提供 allow 和 TLS 豁免。
- TLS 拦截 + 豁免:默认可以终止 TLS 做检查,但
tls_interception_exemptions允许按 SNI 主机名或目标 IP 豁免——豁免的流量中继时不终止 TLS。而且豁免有个约束:用主机名豁免的新连接必须有一个活的、观测到的 DNS 绑定把该主机名映到目标 IP,否则照常拦截。这防的是「拿一个豁免主机名去打别的 IP」。 - credential injection:既然能拦截,就能在流里注入凭据——这是「让 Agent 用上工具但不给它明文 Key」的那类设计。
安全边界文档写得很克制:Firecracker 隔离 guest 执行,但宿主侧服务仍然需要特权去管 VM、存储和网络。它没有假装「一切都是沙箱」。
决策七:用一个兼容层去抢存量客户
SPACE 里有个叫 ECW(E2B Compatibility Wrapper) 的东西——让现有的 E2B 客户端把 SPACE 当后端:它在边缘把 E2B 兼容的 HTTP 请求翻译成 SPACE 操作,测试基线钉死在 e2b==2.21.1 和 e2b-code-interpreter==2.6.2。语义也做了映射:ECW 的 pause 对应 SPACE 的 suspend,resume 对应 restore。
这是一个很清醒的商业判断:沙箱这个品类已经有 E2B 这样的先行者,与其让客户迁移,不如做一层兼容让它「无感换后端」。对后来者,兼容层往往比更好的架构更快拿到用户。
顺便说,它的工程纪律本身就是「精华」
撇开架构,SPACE 仓库里最值得抄的其实是把工程规范变成 CI 门禁这件事:
cargo arch规定单个生产 Rust 文件 ≤1000 行,且 PR 不得给 baseline 加新条目、不得抬高上限。- 禁用 catch-all 的 crate 名:
common、shared、util、utils、helper、misc、core这些词,出现在新 crate 名里直接不过。 - pattern ratchet:像「rootfs 拷贝必须走 reflink」「K8s 访问只能在 substrate 模块」这类规则,被写成可机检的模式,新违规会失败、旧的按 baseline 只许减不许增。
docs/rust/里立了四条原则,第一条是 Parse, don’t validate:每个边界上的领域名词(SandboxId、GuestPath、IdempotencyKey…)解析一次成类型,之后在整条调用链上都是这个类型,字符串只在重新序列化到另一条线时出现。- guest/host 兼容契约:旧版本 guest 要能跑在新版本 host 上,host 变更必须接受
SUPPORTED_SPACED_GUEST_VERSIONS里的每一个 guest,动协议只能增量加字段、保留字段号。
值得偷师的几点
把上面这些收一收,如果你也在做 Agent 沙箱/执行层,我觉得可以直接拿走的判断是:
- 控制面与数据面分离,且两种 token 不互通——把「管沙箱」和「看命令输出」权限切开,是最低成本的安全升级。
- 沙箱生命周期和命令流分开——长连接不该阻塞管理操作,沙箱要能活得比请求和连接都久。
- 执行环境做成内容寻址 + 「安装≠选中」——这是供应链和可复现性的地基,尤其当你有多套 runtime/内核需要并存时。
- 借 containerd shim 而不是另造节点协议——把节点侧调度回收交给成熟生态。
- 冷启动三档(冷启/快照/温池)+ 滚动温池策略——产品体验的胜负手,且要能优雅回落不报错。
- 出网是产品能力,不是配置项——TLS 拦截、SNI/IP 豁免、DNS 绑定校验、凭据注入,这一层决定你能接什么客户。
- 兼容层是抢存量的最短路径——如果赛道已有先行者的 SDK。
- 把规范写成 CI 门禁,而不是写进 wiki 里当口号。
写在最后
SPACE 也有它明确的边界,别把它当万能:生产认证只覆盖 Linux/x86_64 的 E0,ARM builder 产出的仍是「未绑定」模板;跨环境身份不能互相恢复快照(每个快照保留它原来的 VMM 和内核绑定);本地 Kind 是文档默认的跑法;K8s 只是可选项之一。它是一个工程完成度很高、但仍在快速演进的项目。
有意思的是,这不是第一个开源出来的 Agent 沙箱平台。几个月前 DeepSeek 开源了它的弹性计算平台 DSec,号称一天跑三百万沙箱;面向消费级 Agent,OpenAI 和 Meta 还在为「云电脑 vs 安全虚拟机」打架。SPACE 补上的是另一块拼图:一个「拿来就能自建」的、面向代码执行的 microVM 平台。
所以这篇只拆了 SPACE 一家。真正有意思的问题是:当 E2B、Daytona、Modal、DeepSeek DSec 和 SPACE 摆在一起时,它们切的是不是同一块蛋糕? 各自的隔离档位、冷启动路径、出网与凭据模型、以及「自建 vs 托管」的取舍,才是决定谁能活下来的地方。
下一篇,就来把这个横评补齐。