数据截至 (上游 commit 415259e527d2)
控制面:从一次 create 请求到容器落地
30 秒导读: OpenSandbox 的 Lifecycle Server 是一个 FastAPI 应用,对外只做一件事——把 "帮我起一个沙箱""暂停它""给我它的访问地址"这类 HTTP 请求,翻译成对底层容器运行时 (Docker 或 Kubernetes)的操作。这一章沿着一次
POST /sandboxes请求,端到端走一遍 它经过的四层:薄路由 → 服务层抽象 → 运行时解析 → Docker 后端,看它最终怎么变成一个 注入了 execd 的运行容器。
本章聚焦"请求怎么变成容器"这条主干。更深的 Docker/K8s 容器操作、网络、快照、暂停恢复细节 留给 03-runtime-backends;execd 在容器里到底干什么留给 04-execd-data-plane;协议契约与状态机见 01-protocol-and-lifecycle。
1. 先建立直觉:控制面是什么
一句话定义: 控制面(control plane)= 那个"发号施令"的进程。它自己不跑用户代码,而是 接收 API 请求、决定"该起哪个镜像、给多少 CPU、放到哪个运行时",然后指挥 Docker/K8s 去把 容器真正拉起来。
拿一个熟悉的类比:
- 控制面像餐厅前台 + 后厨调度——接单、记单、安排哪个灶台做,但不亲自炒菜。
- 数据面(execd,见 04 章)像灶台上的厨师——真正在沙箱里跑命令、读写文件。
对使用者来说,控制面就是一组 REST 端点。最小的一次交互长这样:
POST /v1/sandboxes
{ "image": { "uri": "python:3.12" }, "timeout": 600 }
→ 202 Accepted
{ "id": "b1c2...-uuid", "status": { "state": "Running" }, ... }
拿到 id 后,再问它"给我 44772 端口的地址",就能连上沙箱里的 execd 干活:
GET /v1/sandboxes/b1c2.../endpoints/44772
→ 200 { "endpoint": "192.168.1.10:44772" }
这一章要回答的就是:从那行 POST /v1/sandboxes 进来,到一个容器真的在跑,中间发生了什么。
2. 顶层全景:一次 create 请求的四层旅程
先看大盘。请求从左边进来,自上而下穿过四层,最右边落成一个容器。怎么读这张图:从上到下 是调用栈深度,每一层只做自己那件事,然后把活交给下一层。
HTTP POST /v1/sandboxes
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ① 中间件链 RequestId → CORS → Auth │
│ 盖请求号 · 跨域 · 校验 OPEN-SANDBOX-API-KEY │
│ middleware/request_id.py · middleware/auth.py │
└─────────────────────────────────────────────────────────────┘
│ (通过鉴权)
▼
┌─────────────────────────────────────────────────────────────┐
│ ② 薄路由 api/lifecycle.py create_sandbox() │
│ 只做:校验 extensions,然后 delegate 给 service │
└─────────────────────────────────────────────────────────────┘
│ sandbox_service.create_sandbox(request)
▼
┌─────────────────────────────────────────────────────────────┐
│ ③ 服务层抽象 services/sandbox_service.py SandboxService(ABC)│
│ 定义接口 + 公共工具(生成 id、metadata 合并、端口校验) │
│ 实例由 services/factory.py 按 runtime.type 选出 │
└─────────────────────────────────────────────────────────────┘
│ (docker 分支)
▼
┌─────────────────────────────────────────────────────────────┐
│ ④ Docker 后端 services/docker/docker_service.py │
│ _provision_sandbox():建 label/env → 拉镜像 → 分端口 │
│ → 创建容器(停)→ 注入 execd/bootstrap → 启动容器 │
│ docker/runtime.py 负责把 execd 拷进容器 │
└─────────────────────────────────────────────────────────────┘
│
▼
Docker daemon:一个跑着 execd 的沙箱容器 ✅
每一层的职责,一句话:
| 层 | 文件 | 干什么 |
|---|---|---|
| ① 中间件 | middleware/request_id.py、middleware/auth.py | 盖请求号、跨域、API Key 鉴权 |
| ② 薄路由 | api/lifecycle.py | 只解析/校验 HTTP,然后转调 service |
| ③ 服务层 ABC | services/sandbox_service.py | 定义 SandboxService 接口 + 公共工具 |
| 运行时工厂 | services/factory.py + services/runtime_resolver.py | 按 runtime.type 选 docker/k8s 实现 |
| ④ Docker 后端 | services/docker/docker_service.py、docker/runtime.py | 真正建容器、注入 execd、分配端口 |
| 配置 | config.py | 启动时把 TOML 解析成校验过的模型 |
| 装配 | main.py | 把上面这些在启动时接线到一起 |
一个重要事实(诚实说 明): 虽然
POST /sandboxes声明返回202 Accepted(api/lifecycle.py:71status_code=status.HTTP_202_ACCEPTED),但 Docker 后端的create_sandbox是同步 provision 的——它把_provision_sandbox丢进 daemon 线程、 在请求内awaitfuture 等容器起来,返回时status.state已经是Running(docker/docker_service.py:678、:933)。K8s 后端才 是真正的异步 provision。这一章走 Docker 主线。
3. 启动装配:main.py 怎么把这些接线起来
在任何请求进来之前,main.py 先在模块导入和lifespan两个阶段把整台机器装好。看懂装配
顺序,后面每一层为什么"拿得到"自己需要的东西就清楚了。
3.1 导入期:先加载配置,再加载路由
main.py 顶部有一个刻意的导入顺序:先 load_config(),再 import 各路由(注意 # noqa: E402,
表示"我知道这不在文件顶部,是故意的")。
# main.py:39-45(节选)
app_config = load_config() # 先解析 TOML 配置
_log_config = configure_logging(app_config.log)
from opensandbox_server.api.devops import router as devops_router # noqa: E402
from opensandbox_server.api.lifecycle import router, sandbox_service, snapshot_service # noqa: E402
为什么必须先加载配置?因为 api/lifecycle.py 在模块级就实例化了服务单例
sandbox_service = create_sandbox_service()(api/lifecycle.py:59),而工厂要读 runtime.type
才知道该造 Docker 还是 K8s 实现——配置没就位,import 就会炸。
3.2 lifespan:五件启动大事
lifespan(main.py:56-117)是 FastAPI 的启动/关闭钩子,按顺序做五件事:
| 顺序 | 做什么 | 代码锚点 | 失败后果 |
|---|---|---|---|
| 1 | API Key 确认 | api_key_confirm(...) main.py:59 | 无 key 且未确认 → os._exit(1) |
| 2 | 放大线程池 | current_default_thread_limiter().total_tokens = ... main.py:64-66 | 默认 anyio 只有 40,会卡住并发的同步路由 |
| 3 | 共享 HTTP 客户端 | app.state.http_client = httpx.AsyncClient(...) main.py:68 | 反向代理复用它 |
| 4 | secure runtime 校验 | validate_secure_runtime_on_startup(...) main.py:88 | 配了 gVisor/Kata 但运行时不存在 → 启动失败 |
| 5 | renew-intent 消费者 + proxy 协调器 | start_renew_intent_consumer(...) main.py:99;ProxyRenewCoordinator(...) main.py:106 | 关掉时(enabled=false)返回 None,静默跳过 |
第 1 件:API Key 门禁(fail-fast 安全设计)。
api_key_confirm(startup_guard.py:55)的逻辑是:如果 server.api_key 是空的,就不允许静默
裸奔——要么设置环境变量 OPENSANDBOX_INSECURE_SERVER=YES 明确承认风险(startup_guard.py:74),
要么在交互式 TTY 里手动输入 YES(30 秒超时,startup_guard.py:85),否则拒绝启动。这是一个
"不让你不小心开个没鉴权的服务"的防呆闸。
第 2 件:线程池放大是个真实的性能坑。
FastAPI 里同步路由函数(如 list_sandboxes、get_sandbox、delete_sandbox 都是 def 不是
async def)会被丢进 anyio 的线程池执行。anyio 默认只给 40 个 token,高并发下一堆阻塞的 Docker
调用会把池子占满、后续请求排队。所以启动时把它抬到 thread_pool_size(默认 200,
config.py:522)。
3.3 中间件顺序:反直觉但有讲究
中间件的注册顺序和执行顺序是反的——后注册的最先执行(最外层)。main.py:134-146 的注释把
这点讲得很清楚:
# main.py:136-146(节选)
app.add_middleware(AuthMiddleware, config=app_config) # 先加 → 内层
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(RequestIdMiddleware) # 后加 → 最外层
于是真实执行顺序是 RequestId(最外)→ CORS → Auth(最内)→ 路由。这样安排的目的:即使
AuthMiddleware 返回 401,响应也已经被最外层的 RequestIdMiddleware 包过,带上 X-Request-ID
且日志里有请求号(main.py:144-146 注释原话)。
3.4 路由注册顺序陷阱:catch-all 要放最后
# main.py:148-158(节选,附原注释)
# IMPORTANT: devops_router and pool_router MUST be registered before proxy_router
# because proxy_router contains catch-all routes that would swallow diagnostics paths.
app.include_router(router) # lifecycle
app.include_router(devops_router) # /sandboxes/{id}/logs 等诊断
app.include_router(pool_router) # /pools ...
app.include_router(proxy_router) # /sandboxes/{id}/proxy/{port}/{path:path} —— catch-all
app.include_router(router, prefix="/v1") # 再挂一遍到 /v1
...
proxy_router 里有形如 /sandboxes/{sandbox_id}/proxy/{port}/{full_path:path} 的贪婪路径参数
(api/proxy.py:578-582)。FastAPI 按注册顺序匹配路由,所以具体路由必须排在 catch-all 之前,
否则诊断/池化路径会被代理路由吞掉。每套路由都在根路径和 /v1 前缀各挂一遍,让新旧客户端都能用。
4. 薄路由约定:路由只搬运,不做业务
第二层是 api/lifecycle.py。它的铁律是:路由函数只做三件事——声明 HTTP 契约(状态码、
响应模型)、做输入层校验、然后 delegate 给 service。任何真实业务逻辑都不许写在这里。
以 create_sandbox 为例,整个函数体就两行实质代码:
# api/lifecycle.py:77-99(节选)
async def create_sandbox(request: CreateSandboxRequest, ...) -> CreateSandboxResponse:
validate_extensions(request.extensions) # 输入校验
return await sandbox_service.create_sandbox(request) # 直接转调 service
其它端点是同一个模子:
| 端点 | 路由函数 | 转调的 service 方法 |
|---|---|---|
POST /sandboxes | create_sandbox :77 | sandbox_service.create_sandbox |
GET /sandboxes | list_sandboxes :114 | sandbox_service.list_sandboxes |
GET /sandboxes/{id} | get_sandbox :179 | sandbox_service.get_sandbox |
PATCH /sandboxes/{id}/metadata | patch_sandbox_metadata :217 | sandbox_service.patch_sandbox_metadata |
POST /sandboxes/{id}/pause | pause_sandbox :282 | sandbox_service.pause_sandbox |
POST /sandboxes/{id}/resume | resume_sandbox :319 | sandbox_service.resume_sandbox |
GET /sandboxes/{id}/endpoints/{port} | get_sandbox_endpoint :515 | sandbox_service.get_endpoint |
唯一"稍微多做一点"的是 list_sandboxes:它要把 URL 查询串里的 metadata=k=v&k2=v2 用
parse_qsl(..., strict_parsing=True) 解析成字典(api/lifecycle.py:141-154),解析失败就抛 400。
以及 get_sandbox_endpoint:当同时传了 use_server_proxy 和 expires 时先挡回 400
(api/lifecycle.py:555-565),再把 service 返回的 endpoint 改写成服务器代理 URL
(api/lifecycle.py:570-591)。这些都是HTTP 层的形状转换,不是业务决策——分寸拿捏得很干净。
5. 服务层抽象:SandboxService ABC
第三层是 services/sandbox_service.py 里的 SandboxService(sandbox_service.py:42),一个抽象基类
(ABC)。它定义了所有运行时都必须实现的接口,同时把几样"跟运行时无关的公共逻辑"用
@staticmethod 沉淀在基类里,让 Docker 和 K8s 两个子类共享。
5.1 抽象方法 = 契约
create_sandbox、list_sandboxes、get_sandbox、delete_sandbox、pause_sandbox、
resume_sandbox、renew_expiration、patch_sandbox_metadata、get_endpoint 等都是
@abstractmethod(sandbox_service.py:109-367)。子类不实现就没法实例化——这保证了
"换一个运行时,路由层一行都不用改"。