控制面:从一次 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:68status_code=status.HTTP_202_ACCEPTED),但 Docker 后端的create_sandbox是同步 provision 的——它在请求内直接调_provision_sandbox并等容器起来, 返回时status.state已经是Running(docker/docker_service.py:660、:1017)。异步 pending 那套(_async_provision_worker)在 Docker 后端主要给快照恢复等路径用;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:56),而工厂要读 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:478)。
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:406-410)。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:138-151),解析失败就抛 400。
以及 get_sandbox_endpoint:当同时传了 use_server_proxy 和 expires 时先挡回 400
(api/lifecycle.py:551-561),再把 service 返回的 endpoint 改写成服务器代理 URL
(api/lifecycle.py:566-573)。这些都是HTTP 层的形状转换,不是业务决策——分寸拿捏得很干净。
5. 服务层抽象:SandboxService ABC
第三层是 services/sandbox_service.py 里的 SandboxService(sandbox_service.py:41),一个抽象基类
(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:105-321)。子类不实现就没法实例化——这保证了
"换一个运行时,路由层一行都不用改"。
5.2 三样公共工具(值得记住的设计)
① 生成沙箱 id —— 就是一个 UUID4。
# sandbox_service.py:49-57
@staticmethod
def generate_sandbox_id() -> str:
"""Returns: str: A RFC4122-compliant UUID4 string (with hyphens)"""
return str(uuid4())
沙箱 id 不带任何语义,纯随机 UUID。这让 id 天然全局唯一、不可猜测(安全)、也不暴露顺序或规模。
② metadata 的 JSON Merge Patch(RFC 7396)。
_apply_metadata_patch(sandbox_service.py:219)实现了标准的合并补丁语义:非 null 值 = 新增/覆盖,
null = 删除,不出现的键 = 保持不变。它还有两条护栏:
- 拒绝写入
opensandbox.io/前缀的系统保留标签(_is_system_label,sandbox_service.py:215), 返回 400。 - 只校验传入的补丁值,不去校验已存在的标签,避免历史脏数据卡住合法更新
(
sandbox_service.py:235-238)。
路由层的 docstring 还诚实标注了一个已知局限:这是读-改-写、无乐观锁,并发 PATCH 可能丢更新
(api/lifecycle.py:224-226)。
③ 端口校验。
validate_port(sandbox_service.py:92)转调 ensure_valid_port,把端口限制在 1–65535。
6. 运行时选择:工厂 + secure runtime 解析
服务层是抽象的,那"到底造哪个实现"由谁定?两个协作的小模块:services/factory.py(选后端类)
和 services/runtime_resolver.py(选安全运行时)。
6.1 工厂:一张 runtime.type → 类 的注册表
# services/factory.py:33-72(节选)
def create_sandbox_service(service_type=None, config=None) -> SandboxService:
active_config = config or get_config()
selected_type = (service_type or active_config.runtime.type).lower()
implementations = {
"docker": DockerSandboxService,
"kubernetes": KubernetesSandboxService,
# "containerd": ... # 预留扩展位
}
if selected_type not in implementations:
raise ValueError(f"Unsupported sandbox service type: {selected_type}. ...")
return implementations[selected_type](config=active_config)
就是一张字典。想加新后端,往 implementations 里塞一行即可——典型的注册表模式,扩展点清晰。
6.2 secure runtime 解析:把"gvisor"翻译成后端参数
runtime.type 决定的是 docker vs k8s;而安全容器运行时(gVisor / Kata / Firecracker)是
另一个正交维度,由 SecureRuntimeResolver(runtime_resolver.py:40)处理。它的活是"把配置里的
抽象类型名,翻译成各后端听得懂的参数":
配置 secure_runtime.type | Docker 参数(OCI runtime) | K8s 参数(RuntimeClass) |
|---|---|---|
gvisor | runsc | gvisor |
kata | kata-runtime | kata-qemu |
firecracker | (不适用,仅 K8s) | kata-fc |
默认映射见 runtime_resolver.py:51-60(DEFAULT_DOCKER_RUNTIMES / DEFAULT_K8S_RUNTIME_CLASSES);
用户在配置里显式写了 docker_runtime / k8s_runtime_class 就用显式值,否则回落默认映射
(get_docker_runtime,runtime_resolver.py:81-103)。
启动时 fail-fast 校验。 validate_secure_runtime_on_startup(runtime_resolver.py:130)在
lifespan 里被调用:Docker 后端会查 docker.info()["Runtimes"] 里有没有那个运行时
(runtime_resolver.py:194-203),K8s 后端会 read_runtime_class 查 RuntimeClass 存不存在
(404 → 报错,runtime_resolver.py:243-248)。配错了就别想启动,而不是等第一个沙箱创建时才崩。
7. Docker 后端主路径:一个容器怎么诞生
终于到最底层。DockerSandboxService(docker/docker_service.py:135)通过多个 mixin 组合而成
(runtime / volumes / networking / container_ops / diagnostics …),把职责拆散到各文件。本节只追
创建主干。
7.1 create_sandbox:先校验,再 provision
# docker/docker_service.py:613-663(骨架)
async def create_sandbox(self, request) -> CreateSandboxResponse:
# Docker 不支持池化,poolRef 直接拒
if (request.extensions or {}).get("poolRef", "").strip():
raise HTTPException(400, ...)
request = resolve_sandbox_image_from_request(request) # 解析镜像
ensure_entrypoint(...); ensure_metadata_labels(...) # 一串前置校验
ensure_platform_valid(...); ensure_timeout_within_limit(...)
self._ensure_secure_access_support(request)
self._ensure_network_policy_support(request)
self._validate_network_exists()
sandbox_env, egress_env = split_egress_env(request.env) # 拆出 egress 专用 env
pvc_inspect_cache, auto_created_volumes = self._validate_volumes(request)
sandbox_id, created_at, expires_at = self._prepare_creation_context(request)
return self._provision_sandbox(sandbox_id, request, created_at, expires_at, ...)
注意 _prepare_creation_context(docker/docker_service.py:602)在这里生成 sandbox_id(调基类的
generate_sandbox_id)并按 timeout 算出 expires_at。校验全过,才进 _provision_sandbox。
7.2 _provision_sandbox:六步落地
_provision_sandbox(docker/docker_service.py:790)是真正干活的地方。抽掉 egress/windows/isolation
等分支,主干是这样:
① 建 labels + env
_build_labels_and_env() container_ops.py:287
把 sandbox_id、expiresAt、platform、metadata 都写成容器 label
② 解析镜像 & 拉取
_resolve_image_auth() container_ops.py:316 → _ensure_image_available()
③ 算资源 & host_config
_resolve_resource_limits() → mem / cpu / gpu
_base_host_config_kwargs() container_ops.py:338
注入安全默认:no-new-privileges、drop capabilities、pids_limit、secure runtime
④ 分配端口(仅非 host 网络)
allocate_port_bindings(["44772","8080"]) port_allocator.py:75
⑤ 创建容器(先不启动)→ 注入 execd → 启动
_create_and_start_container() container_ops.py:380
⑥ 记 expiration 定时器 + 组装响应(state=Running)
_schedule_expiration(); return CreateSandboxResponse(...)
默认安全姿态在第 ③ 步。 _base_host_config_kwargs(container_ops.py:338-378)默认加上
no-new-privileges:true、丢弃一串危险 capability(NET_ADMIN、SYS_ADMIN、SYS_PTRACE…,默认清单在
config.py:805-820)、限制进程数 pids_limit(默认 4096,config.py:837),并在配了 secure runtime
时把 runtime 塞进 host_config。这是一层"先关紧,再按需开"的默认。
7.3 关键一步:先建停着的容器,再注入 execd,最后才启动
_create_and_start_container(container_ops.py:380)藏着整章最精巧的一手——容器先创建但不启动,
趁它"停着"把 execd 二进制拷进去,再启动:
# container_ops.py:421-470(骨架)
response = self.docker_client.api.create_container(...) # 创建,未 start
container = self.docker_client.containers.get(container_id)
...
self._prepare_sandbox_runtime(container, sandbox_id, runtime_platform) # 注入 execd
with self._docker_operation("start sandbox container", sandbox_id):
container.start() # 现在才启动
容器的 entrypoint 被强制改成 [BOOTSTRAP_PATH](即 /opt/opensandbox/bootstrap.sh,
container_ops.py:416-417),这样容器一启动就跑 bootstrap 脚本,由它拉起 execd。
execd 是怎么"拷"