跳到主要内容

控制面:从一次 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.pymiddleware/auth.py盖请求号、跨域、API Key 鉴权
② 薄路由api/lifecycle.py只解析/校验 HTTP,然后转调 service
③ 服务层 ABCservices/sandbox_service.py定义 SandboxService 接口 + 公共工具
运行时工厂services/factory.py + services/runtime_resolver.pyruntime.type 选 docker/k8s 实现
④ Docker 后端services/docker/docker_service.pydocker/runtime.py真正建容器、注入 execd、分配端口
配置config.py启动时把 TOML 解析成校验过的模型
装配main.py把上面这些在启动时接线到一起

一个重要事实(诚实说明): 虽然 POST /sandboxes 声明返回 202 Accepted (api/lifecycle.py:68 status_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 的启动/关闭钩子,按顺序做五件事:

顺序做什么代码锚点失败后果
1API 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反向代理复用它
4secure runtime 校验validate_secure_runtime_on_startup(...) main.py:88配了 gVisor/Kata 但运行时不存在 → 启动失败
5renew-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_sandboxesget_sandboxdelete_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 /sandboxescreate_sandbox :77sandbox_service.create_sandbox
GET /sandboxeslist_sandboxes :114sandbox_service.list_sandboxes
GET /sandboxes/{id}get_sandbox :179sandbox_service.get_sandbox
PATCH /sandboxes/{id}/metadatapatch_sandbox_metadata :217sandbox_service.patch_sandbox_metadata
POST /sandboxes/{id}/pausepause_sandbox :282sandbox_service.pause_sandbox
POST /sandboxes/{id}/resumeresume_sandbox :319sandbox_service.resume_sandbox
GET /sandboxes/{id}/endpoints/{port}get_sandbox_endpoint :515sandbox_service.get_endpoint

唯一"稍微多做一点"的是 list_sandboxes:它要把 URL 查询串里的 metadata=k=v&k2=v2parse_qsl(..., strict_parsing=True) 解析成字典(api/lifecycle.py:138-151),解析失败就抛 400。 以及 get_sandbox_endpoint:当同时传了 use_server_proxyexpires 时先挡回 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_sandboxlist_sandboxesget_sandboxdelete_sandboxpause_sandboxresume_sandboxrenew_expirationpatch_sandbox_metadataget_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.typeDocker 参数(OCI runtime)K8s 参数(RuntimeClass)
gvisorrunscgvisor
katakata-runtimekata-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_ADMINSYS_ADMINSYS_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 是怎么"拷"进去的?docker/runtime.pyDockerRuntimeMixin。它的思路很聪明:

  1. _fetch_execd_archive(runtime.py:51):从 execd 镜像里起一个临时容器,get_archive("/execd") 把 execd 二进制、bootstrap.sh、bwrap 都掏出来,缓存在内存里(按平台架构做 key,双检锁), 之后所有沙箱共用这份缓存,不必每次都起临时容器。
  2. _prepare_sandbox_runtime(runtime.py:265):把缓存的 execd 用 container.put_archive 灌进目标 沙箱容器的 /opt/opensandbox/,再灌 bootstrap.sh 和(尽力而为的)bwrap。

这套设计让任意用户镜像(哪怕是干净的 python:3.12)都能被"注入"上 OpenSandbox 的运行时,而 不要求用户镜像预装任何东西。execd 本身干什么见 04 章

7.4 端口约定:44772 与 8080

沙箱容器固定暴露两个端口(docker/docker_service.py:864):

  • 44772 = execd 的控制端口(SDK 连它跑命令/文件,即数据面入口)。
  • 8080 = 沙箱内 HTTP 服务的默认端口。

host 网络模式下(默认,config.py:790)不做端口映射,直接用宿主 IP:端口;在 bridge/自定义 网络下才用 allocate_port_bindings(port_allocator.py:75)给每个容器端口分一个随机宿主端口,并把 映射结果记进 label(docker/docker_service.py:938-944)。get_endpoint(docker/networking.py:160) 读这些 label 拼出对外地址。注意 Docker 后端不支持 expires 签名路由,传了直接 400 (networking.py:178-188)——那是 K8s + 网关的能力,见 06 章

7.5 pause / resume:一层薄薄的转译

对照一下,pause/resume 就朴素得多——它们直接映射到 Docker 原生的 pause/unpause,只在前面加一道 状态检查:

# docker/docker_service.py:1177-1200(节选)
def pause_sandbox(self, sandbox_id):
container = self._get_container_by_sandbox_id(sandbox_id)
if not container.attrs.get("State", {}).get("Running", False):
raise HTTPException(409, ...) # 不在 Running 态,拒绝
container.pause()

resume_sandbox(docker/docker_service.py:1210)对称:非 Paused 态就 409,否则 unpause()。 K8s 的暂停恢复要复杂得多(涉及缩容/快照),见 03 章


8. 鉴权中间件与配置模型(横切关注点)

8.1 AuthMiddleware:一把全局 API Key

AuthMiddleware(middleware/auth.py:34)校验 OPEN-SANDBOX-API-KEY 请求头 (middleware/auth.py:31)。它的判定顺序:

请求进来
├─ 路径在 EXEMPT_PATHS(/health /docs /redoc /openapi.json)? → 放行
├─ 是严格的 proxy 路由 /sandboxes/{id}/proxy/{数字端口}/… ? → 放行
├─ 服务端根本没配 api_key ? → 放行(dev 模式)
├─ 请求头缺 key ? → 401 MISSING_API_KEY
└─ key 不在允许集合 ? → 401 INVALID_API_KEY

两个安全细节值得点出:

  • proxy 路径豁免用的是严格正则(_PROXY_PATH_RE,middleware/auth.py:47),要求端口是纯数字, 且.. 直接判否(middleware/auth.py:49-54)——防路径穿越绕过鉴权。
  • 代理路由为什么能免鉴权?因为它到达沙箱的授权是在 endpoint 解析/签名那层做的,不走全局 key。

8.2 配置模型:启动即校验的 pydantic 树

config.py 用 pydantic 把整个 ~/.sandbox.toml 解析成一棵带校验的模型树,根是 AppConfig (config.py:858)。它做的不只是"读配置",而是在启动就把非法组合挡掉。几个交叉校验的例子 (AppConfig.validate_runtime_blocks,config.py:885):

规则违反时
runtime.type=docker 时不能出现 kubernetes / agent_sandboxValueError
runtime.type=dockeringress.mode 必须是 directValueError
firecracker 安全运行时只能配 kubernetesValueError
agent_sandbox 块要求 workload_provider=agent-sandboxValueError

RuntimeConfig(config.py:716)里 typeexecd_image 都是必填——没有 execd 镜像就没法注入 运行时,所以它是硬性依赖。ServerConfig(config.py:434)、DockerConfig(config.py:787)分别管 服务器和 Docker 后端的参数。API Key 还可被环境变量 OPENSANDBOX_SERVER_API_KEY 覆盖 (_apply_env_overrides,config.py:942-945)。


9. renew-intent:被"访问"就自动续命(可选)

lifespan 装配里那个 renew-intent 消费者(main.py:99)是一个可选的旁路特性,默认关。它解决的 小问题是:沙箱有 TTL 会到期删除,但如果它正在被访问,你多半不想让它死。

机制:当请求打到反向代理路由 /sandboxes/{id}/proxy/...,proxy.py 里的 _schedule_proxy_renew (api/proxy.py:135)会调 ProxyRenewCoordinator.schedule(proxy_renew.py:37),后者把 sandbox_id 非阻塞地丢进 RenewIntentConsumer 的队列(consumer.py:154 submit_from_proxy),由后台 worker 去做"续期"(带最小间隔冷却,避免刷爆)。只有 renew_intent.enabled=true 时整条链才生效 (proxy_renew.py:38consumer.py:106),否则全程静默跳过。细节属于网络平面,见 06 章


10. 客户端视角:SDK 如何调这两平面

绕回使用者。Python SDK(sdks/sandbox/python)的 Sandbox.create(sandbox.py:491)把控制面数据面两次调用串成一个"开箱即用"的对象:

# sdks/.../sandbox.py:571-605(骨架)
response = await sandbox_service.create_sandbox(spec=image, ...) # ① 控制面:POST /sandboxes
sandbox_id = response.id
execd_endpoint = await sandbox_service.get_sandbox_endpoint( # ② 拿数据面地址
response.id, DEFAULT_EXECD_PORT, config.use_server_proxy) # GET .../endpoints/44772
sandbox = cls(
sandbox_id=response.id,
filesystem_service=factory.create_filesystem_service(execd_endpoint), # 绑到 execd
command_service=factory.create_command_service(execd_endpoint),
...)

两步对应本章反复出现的两平面:

SDK 调用打到的端点平面
sandbox_service.create_sandbox(...)POST /v1/sandboxes控制面(本章)
sandbox_service.get_sandbox_endpoint(id, 44772)GET /v1/sandboxes/{id}/endpoints/44772拿到数据面入口
之后 sandbox.commands.run(...)直连 execd 的 44772数据面(04 章)

DEFAULT_EXECD_PORT = 44772(sdks/.../constants.py:20)正是 §7.4 里容器暴露的那个 execd 端口—— 控制面创建容器、暴露 execd 端口,SDK 拿到地址后绕过控制面直连 execd跑真正的活。SDK 的 SandboxesAdapter(adapters/sandboxes_adapter.py:117:349)则是把这些方法映射到生成的 OpenAPI HTTP 客户端。


11. 边界:本章刻意不讲什么

  • Docker 容器操作/网络/卷/OSSFS 的细节、K8s 后端全貌、池化、快照、暂停恢复的完整语义 → 属于 03-runtime-backends。本章只走了 Docker 创建主干和最朴素的 pause/resume。
  • execd 在容器里怎么跑命令、传文件、开 PTY、当代码解释器04-execd-data-plane
  • 容器内再套一层 bubblewrap+overlay 隔离05-nested-isolation(本章只在 _provision_sandbox 里瞥见 bootstrap.execd.isolation=enable 会加 SYS_ADMIN + unconfined, docker/docker_service.py:976-991)。
  • 入站网关路由、签名路由(OSEP-0011 的 expires)、出站策略、凭证保险箱06-networking-and-vault

12. 代码地图(导航索引)

用符号名 grep 比行号更抗漂移。下面每行都是"想看某件事,去哪个文件找哪个符号"。

主题文件路径符号名
启动装配 / lifespanserver/opensandbox_server/main.pylifespanapp
API Key 门禁server/opensandbox_server/startup_guard.pyapi_key_confirm
请求号中间件server/opensandbox_server/middleware/request_id.pyRequestIdMiddleware
鉴权中间件server/opensandbox_server/middleware/auth.pyAuthMiddleware_PROXY_PATH_RE
薄路由server/opensandbox_server/api/lifecycle.pycreate_sandboxget_sandbox_endpoint
代理 catch-all 路由server/opensandbox_server/api/proxy.pyproxy_sandbox_endpoint_request_schedule_proxy_renew
服务层 ABCserver/opensandbox_server/services/sandbox_service.pySandboxServicegenerate_sandbox_id_apply_metadata_patch
运行时工厂server/opensandbox_server/services/factory.pycreate_sandbox_service
secure runtime 解析/校验server/opensandbox_server/services/runtime_resolver.pySecureRuntimeResolvervalidate_secure_runtime_on_startup
Docker 后端server/opensandbox_server/services/docker/docker_service.pyDockerSandboxServicecreate_sandbox_provision_sandbox
建容器 + 注入 execdserver/opensandbox_server/services/docker/container_ops.py_create_and_start_container_base_host_config_kwargs
execd 分发server/opensandbox_server/services/docker/runtime.pyDockerRuntimeMixin_prepare_sandbox_runtime_fetch_execd_archive
端口分配server/opensandbox_server/services/docker/port_allocator.pyallocate_port_bindings
Docker endpoint 解析server/opensandbox_server/services/docker/networking.pyget_endpoint
配置模型server/opensandbox_server/config.pyAppConfigRuntimeConfigload_config
renew-intent 消费者server/opensandbox_server/integrations/renew_intent/consumer.pyRenewIntentConsumerstart_renew_intent_consumer
proxy 续期协调器server/opensandbox_server/integrations/renew_intent/proxy_renew.pyProxyRenewCoordinator
SDK 客户端两平面sdks/sandbox/python/src/opensandbox/sandbox.pySandbox.create