协议契约与沙箱生命周期状态机
30 秒导读: OpenSandbox 是给 AI agent 跑「不可信代码」的沙箱平台。它先不谈实现,而是用 四份 OpenAPI 规范 把系统切成几个界面清楚的面:控制面(建/删/暂停沙箱)、数据面(在沙箱里跑命令/文件)、出站面(改网络策略) 、诊断面(捞日志)。这四份契约共同围着一个东西转——沙箱的八态生命周期状态机(Pending → Running → …→ Terminated,外加 Failed)。本章讲这层「地基」:契约怎么分、状态怎么流转、请求/响应长什么样、怎么鉴权。后面 02–06 章讲的每一个后端、每一条数据路径,都是在把这层契约翻译成真实容器行为。
本章是全组的地基章。你可以把它当成一张「坐标系」:后面每章讲某个子系统时,都会回来引用这里定义的状态名、模型名、鉴权头。本章只讲契约与数据模型,不讲某个后端(Docker/K8s)具体怎么把契约变成容器(那是 02 / 03),也不讲 execd 在沙箱里内部怎么执行(那是 04)。
1. 这是什么:先看「契约层」为什么存在(零基础也能懂)
1.1 一句话定义
契约层 = 一组 OpenAPI 规范(.yaml/.yml),它先用文字精确规定「谁能调什么接口、传什么、返回什么、沙箱有哪些状态」,然 后各语言的服务端与 SDK 都照这份契约实现。 它是「说明书先行」:规范是唯一事实来源,代码是它的一种实现。
1.2 为什么要把契约单独拎出来讲
想象你在给 AI agent 造一个「一次性电脑」:agent 说「跑这段 Python」,平台就得凭空变出一个隔离容器、把代码丢进去、跑完再销毁。这里牵涉好几拨完全不同的调用者:
- 编排方(agent 框架 / 用户后端):要「创建、暂停、删除」沙箱——这是管理沙箱本身。
- 沙箱内的执行者(你的代码、代码解释器):要「在已经建好的沙箱里跑命令、读写文件」——这是使用沙箱内部。
- 安全 / 运维:要「改这个沙箱能访问哪些外网」「出问题时捞点日志」。
这三拨人关心的东西、访问的网络位置、鉴权方式都不一样。如果塞进一份大而全的 API,谁都得懂全部,边界还容易糊。OpenSandbox 的选择是:按「面(plane)」切成四份独立规范,每份有自己的 base URL、自己的鉴权头、自己的读者。
1.3 用起来什么样(最小直觉)
编排方创建一个沙箱,本质上就是往控制面发一个 POST:
# 示意:创建一个 python:3.11 沙箱,300 秒后自动过期
curl -X POST http://localhost:8080/v1/sandboxes \
-H "OPEN-SANDBOX-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"image": { "uri": "python:3.11" },
"entrypoint": ["tail", "-f", "/dev/null"],
"timeout": 300,
"resourceLimits": { "cpu": "500m", "memory": "512Mi" }
}'
返回里带一个 id 和 status.state: "Pending"——沙箱开始异步启动。你随后轮询 GET /sandboxes/{id},看它爬到 Running,就能开始用了。这整套「传什么、回什么、经过哪些状态」,就是契约层规定的东西。
2. 顶层全景:四份规范怎么分工
2.1 一张图:四个面、各自的网络位置
先给一句「怎 么读这张图」:从上到下是「离编排方越来越远、离沙箱内部越来越近」。控制面在平台服务器上,数据面/出站面在沙箱内部,诊断面又回到平台服务器。
┌─────────────────────────────┐
编排方 / SDK ───▶ │ 控制面 Control Plane │ 建/删/暂停/恢复/快照/查端点
(你的后端) │ sandbox-lifecycle.yml │ base: http://localhost:8080/v1
│ 鉴权: OPEN-SANDBOX-API-KEY │
└───────────────┬─────────────┘
│ 平台据此变出容器(见 02/03 章)
▼
┌─────────────────────────────────────────────┐
│ 一个具体的沙箱容器 │
│ │
直接连沙箱端点 ──▶ │ 数据面 execd-api.yaml :44772 │ 跑代码/命令/文件/PTY
(跑代码/文件) │ 鉴权: X-EXECD-ACCESS-TOKEN │ (见 04 章)
│ │
直接连沙箱端点 ──▶ │ 出站面 egress-api.yaml :18080 │ 运行时改网络策略
(改网络策略) │ 鉴权: OPENSANDBOX-EGRESS-AUTH(可选) │ (见 06 章)
└─────────────────────────────────────────────┘
▲
编排方 / SDK ─────────────────────┘ 捞诊断日志/事件
(排障) 诊断面 diagnostic-api.yml base: http://localhost:8080/v1
鉴权: OPEN-SANDBOX-API-KEY ← 与控制面同一入口
关键分界(容易搞混,先点破):控制面与数据面在两个不同的网络位置。 控制面是平台对外的统一入口(/v1),你拿 API Key 调它来「管理」沙箱;数据面 execd 跑在每个沙箱容器内部(端口 44772),你不是直接连它,而是先问控制面「这个沙箱的某端口对外端点是什么」(GET …/endpoints/{port}),拿到端点再直连。出站面 egress sidecar(端口 18080)同理——先解析端点、再直连 sidecar(egress-api.yaml 的 Access Model 一节明说了这两步)。
2.2 四份规范各管什么(部件表)
| 规范文件 | 面 | 管什么 | base URL / 位置 | 鉴权头 |
|---|---|---|---|---|
specs/sandbox-lifecycle.yml | 控制面 | 创建/列出/查询/删除沙箱,暂停/恢复,快照,续期,查端点 | http://localhost:8080/v1 | OPEN-SANDBOX-API-KEY |
specs/execd-api.yaml | 数据面 | 在沙箱内跑代码(有状态上下文)、跑 shell 命令、文件 CRUD、指标、SSE 流式输出 | 沙箱内 :44772 | X-EXECD-ACCESS-TOKEN |
specs/egress-api.yaml | 出站面 | 运行时读/改沙箱的出站网络策略(egress sidecar) | 沙箱内 :18080 | OPENSANDBOX-EGRESS-AUTH(可选) |
specs/diagnostic-api.yml | 诊断面 | 尽力而为的排障描述符(日志/事件),内联文本或下载 URL | http://localhost:8080/v1(与控制面同入口) | OPEN-SANDBOX-API-KEY |
引用:各规范的 servers: 与 info.title 在文件头部——specs/sandbox-lifecycle.yml:40(http://localhost:8080/v1)、specs/execd-api.yaml:27(:44772)、specs/egress-api.yaml:30(:18080)、specs/diagnostic-api.yml:37(:8080/v1)。这套分层的官方说明见 docs/api/index.md:8,那里直接点名了这几个 base URL。
一个诚实的澄清: 从
servers:看,诊断面并不是一个独立端口——它和控制面共用http://localhost:8080/v1(diagnostic-api.yml:37),鉴权头也和控制面一样。所以更准确的说法是「三个网络面 + 诊断作为控制面同入口下的一组只读排障接口」。之所以单列一份规范,是因为它刻意不承诺结构化可观测模型:diagnostic-api.yml:6明说它「不是审计日志 API,也不定义 OpenSandbox 的规范可观测 schema」,payload 是给人看的text/plain,客户端不应按稳定 schema 逐行解析。
2.3 为什么用 OpenAPI(3.1.0)而不是直接写代码
四份规范开头都是 openapi: 3.1.0。用规范先行有三个好处,后面各章会反复受益:
- 多实现对齐:同一份
sandbox-lifecycle.yml,既约束 Python 服务端(server/),也生成各语言 SDK(sdks/);server/opensandbox_server/api/schema.py顶部注释就写明「基于 OpenAPI 规范定义数据模型」。 - 契约即测试基准:改了语义就要改规范,评审能盯住(见
specs/AGENTS.md)。 - 状态语义集中定义:整个平台的「沙箱有哪几种状态、能怎么转」这件最核心的事,由
sandbox-lifecycle.yml一处定义(下一节详解),各后端只能实现它、不能各自发明。
3. 核心机制一:沙箱生命周期状态机
这是全平台最该先记住的一张图。所有后端(Docker、K8s)、所有操作(create/pause/resume/delete)、所有轮询逻辑,都是围着这八个状态转。
3.1 它要解决的小问题
沙箱的每一步(拉镜像、起容器、暂停、销毁)都是异步的——你发一个 POST /pause 不会当场变成「已暂停」,而是「开始暂停中」。所以平台需要一套明确命名的中间态,让调用方能轮询「现在到哪一步了」。状态机就是这套命名 + 允许的流转。
3.2 八个状态 + 一张流转图
八个状态里,六个是「稳定/中间」态,Failed 是「任何关键错误的兜底态」,Terminated 是终态。
先给「怎么读这张图」:正常路径是从左到右一条主线;竖直向下的箭头都是异步操作的「进行中」中间态;最下面那条虚线表示几乎任何活动态出错都会掉进 Failed。
create
│
▼
┌────────┐ provisioning done ┌──────────┐
│Pending │ ────────────────────▶ │ Running │◀───────────┐
└────────┘ └──────────┘ │ resume done
│ │ ▲ │
│ pause req │ │ resume req ┌──────────┐
│ ▼ │ │ Resuming │
│ ┌─────────┐ │ └──────────┘
│ │ Pausing │ │ ▲
│ └─────────┘ │ │
│ │ │ pause done │ resume req
│ ▼ │ │
│ ┌─────────┐─┘ ┌──────────┐
│ │ Paused │ ─────────────▶│ Resuming │
│ └─────────┘ └──────────┘
│ │
│ kill / TTL 到期 / error │ kill / TTL 到期
▼ ▼
┌──────────────────────────────────────┐
│ Stopping │ ── 完成 ──▶ ┌────────────┐
└──────────────────────────────────────┘ │ Terminated │ (终态)
└────────────┘
⚠ Failed(兜底):Pending / Running / Paused / Resuming 任一态遇到关键错误 → Failed
3.3 每个状态一句话 + 允许的流转
| 状态 | 含义 | 触发它的动作 |
|---|---|---|
Pending | 正在拉镜像/起容器/从快照恢复,尚未就绪 | POST /sandboxes 后立即进入 |
Running | 已就绪,可接受数据面请求 | provisioning 完成;或 resume 完成 |
Pausing | 正在暂停(异步) | POST /sandboxes/{id}/pause |
Paused | 已暂停,保留状态,不计费/不占算力(取决于后端) | Pausing 完成 |
Resuming | 正在从暂停恢复(异步) | POST /sandboxes/{id}/resume |
Stopping | 正在销毁(异步) | DELETE /sandboxes/{id}、TTL 到期,或 error |
Terminated | 已成功销毁(终态) | Stopping 完成 |
Failed | 遇到关键错误(终态) | 任一活动态出错 |
允许的流转(规范原文):见 specs/sandbox-lifecycle.yml:1041-1048 的 State transitions 列表,以及 info.description 里的生命周期概述 sandbox-lifecycle.yml:13-20。规范特意加了一句「未来可能新增状态值,客户端应优雅处理未知状态」(sandbox-lifecycle.yml:1050)——所以状态是开放字符串,不是封闭枚举(下一节会看到代码里也确实用 str)。
3.4 真实定义处:状态是「字符串」,由运行时实时推导
一个容易踩的认知点:沙箱 状态不是数据库里存的一个字段,而是查询时从底层运行时的真实状态「翻译」出来的字符串。
规范侧,SandboxState 被定义成 type: string(不是 enum),把八个值写在描述里(sandbox-lifecycle.yml:1024)。服务端侧,SandboxStatus.state 也是 str:
# server/opensandbox_server/api/schema.py:365 (SandboxStatus.state)
state: str = Field(
...,
description="Current lifecycle state (Pending, Running, Pausing, Paused, "
"Resuming, Stopping, Terminated, Failed)",
)
这个字符串怎么来?Docker 后端读容器的 State 段实时映射:容器 Running && !Paused → "Running",Paused → "Paused",exited 且退出码非 0 → "Failed"(server/opensandbox_server/services/docker/docker_service.py:535 起的分支)。K8s 后端类似,还会把内部的 "Allocated" 归一成对外的 "Running"(server/opensandbox_server/services/k8s/status_helpers.py:20,_normalize_create_status)。
章节边界: 「Pod/容器的原生状态如何映射成这八个字符串」属于运行时后端的实现,细节在 03-runtime-backends.md。本章只需记住:对外契约是这八个态,后端负责把真实世界翻译成它。
3.5 快照有它自己的一套小状态机
别把快照状态和沙箱状态搞混。快照(Snapshot)是另一条独立生命线,只有四个态:
| 快照状态 | 含义 |
|---|---|
Creating | 快照创建已受理,运行时正在捕获 |
Deleting | 删除已请求,清理中 |
Ready | 可用于恢复沙箱 |
Failed | 创建失败 |
定义处:schema.py:606(SnapshotStatus,state: str,描述列出四态)与规范 sandbox-lifecycle.yml:928-931。注意约束:只有 Running 的沙箱才能打快照(sandbox-lifecycle.yml:482;服务端在 services/snapshot_service.py:482 检查 state == "Running",否则报 SNAPSHOT::INVALID_SOURCE_STATE)。
4. 核心机制二:请求 / 响应数据模型
状态机是「动词」,数据模型是「名词」。控制面的所有名词都定义在一个文件里:server/opensandbox_server/api/schema.py(972 行)。它是 OpenAPI schema 的 Pydantic 实现,一处定义、请求校验和响应序列化共用。下面只列关键类,不全贴——要看全部字段请按引用打开该文件。
4.1 关键模型速查表
| 模型(类) | 定义处 | 作用 | 记住的点 |
|---|---|---|---|
CreateSandboxRequest | schema.py:391 | 创建沙箱的入参 | image 与 snapshotId 二选一;字段用 camelCase 别名 |
Sandbox | schema.py:565 | 沙箱资源的完整表示 | 内含 status: SandboxStatus |
CreateSandboxResponse | schema.py:537 | 创建的精简返回 | 不含 image/updatedAt |
SandboxStatus | schema.py:361 | 状态 + reason + message + 转换时间 | 承载第 3 节的八态字符串 |
Snapshot / SnapshotStatus | schema.py:643 / 606 | 快照资源与其四态 | 独立生命线(见 3.5) |
Endpoint | schema.py:814 | 访问沙箱内服务的对外端点 | 含 endpoint 字符串 + 可选 headers |
SandboxFilter / SnapshotFilter | schema.py:713 / 671 | 列表过滤条件 | state(OR 逻辑)、metadata(AND 逻辑) |
PaginationRequest / PaginationInfo | schema.py:728 / 756 | 分页入参 / 出参 | pageSize 默认 20、上限 200 |
ErrorResponse | schema.py:834 | 所有非 2xx 的统一错误体 | { code, message } 两字段 |