Day 09 / 共 20 天 · 第 2 周 app_server 编排层

Sandbox 沙箱管理

沙箱是 AI 的"隔离工作间"——安全的地基。今天读它怎么用 Docker 供给/暂停/回收,双容器回调闭环怎么建立,密钥如何"按需下发"永不进镜像。昨天(Day 08)我们看清了"事件如何被存下、被 webhook 推回";今天补上那个"发出 webhook 的沙箱"到底是怎么被拉起来的;这也为明天(Day 10)收官 app_server 装配打好最后一块地基。

📍 第 2 周 后端编排层 · 你在这里
Day6 app_server总览Day7 会话生命周期Day8 事件系统Day9 SandboxDay10 server装配
L01

沙箱是什么(再强调一次)

沙箱(sandbox)= 一个隔离的执行环境(通常是 Docker 容器),里面跑着 agent-server,AI 在其中敲命令、改文件。它和你的宿主机隔离——AI 在里面折腾坏了,删容器重建即可。

💡 本质:沙箱就像一间"隔离实验室"(或你租来做实验的独立厨房)。实验员(AI)在里面尽情操作——打翻试剂、烧糊锅子都行,因为实验室和你家客厅(宿主机)是物理隔离的;出了事故,把这间实验室整个拆掉重建一间就好,绝不会波及你家。本篇后面所有概念,都在这个"租来的实验室"世界观里展开。
🤔 错误驱动:如果没有这层隔离,会出什么事故?假设让 AI 直接在你的宿主机上跑命令。它某一步"自作聪明"执行了 rm -rf /pip install 覆盖了系统库——你的开发机就报废了。真实世界里 AI 的命令并不总是对的,没有隔离 = 拿生产环境给 AI 练手。沙箱就是那道"炸了也不心疼"的隔离墙。
app_server(宿主)
供给/暂停/删除沙箱
记录沙箱身份
— 拉起 / 注入密钥+回调地址 →
← webhook 回推事件 —
sandbox 容器
跑 agent-server
执行 Action
本课对应源码目录:sandbox/(服务与实现)+ sandbox_spec/(镜像模板)。核心实现是 docker_sandbox_service.py
L02

服务抽象与状态

🤔 痛点:为什么不直接在代码里写 docker run,非要抽象一层"服务"?因为 OpenHands 的沙箱不止 Docker 一种——还有 remote(远程集群)、process(本地进程)。如果哪里要用沙箱就现写 docker 命令,那想换成远程执行时得改几十处。
💡 本质:SandboxService 是"沙箱操作的统一遥控器"。它把 search/get/start/resume/pause/delete 这套动作定义成抽象接口(ABC),谁用沙箱只对着这个遥控器按键,不管背后是 Docker 还是远程机器。这和 Day 06 的依赖注入是一套思路——面向接口编程,换实现只换注入的那一个类。

SandboxServicesandbox_service.py:30)抽象了沙箱的全部操作:search / get / start / resume / pause / delete。基类还实现两个通用能力:

# sandbox_service.py
async def wait_for_sandbox_running(self, ...):   # :93 轮询直到 RUNNING,可选校验 /alive
    # 避免"容器起了但 agent-server 没就绪"的竞态
async def pause_old_sandboxes(self, ...):          # :223 超上限时按创建时间暂停最老的
    # 资源回收
读法:wait_for_sandbox_running 解决竞态——容器 docker run 返回不代表里面的 agent-server 已经能接请求,所以要轮询它的 /alive 探活。pause_old_sandboxes 是资源回收——沙箱是重资源(每个一个容器),超过配额就暂停最久没用的那个(像手机自动关掉后台老应用)。

状态枚举 SandboxStatussandbox_models.py:9):STARTING / RUNNING / PAUSED / ERROR / MISSING

L03

SandboxRecord vs SandboxInfo(一个性能取舍)

沙箱有两个模型,区别很重要(sandbox_models.py):

  • SandboxRecord:33):只有 id + owner持久化身份。轻量,存库。
  • SandboxInfo:48):完整实时信息——statussession_api_keyexposed_urls(含 AGENT_SERVER/VSCODE 等命名 URL)。需要查 Docker 才能拼出。
为什么要拆成两个? 源码注释点明:鉴权时只需要"这个沙箱属于谁"——用轻量的 Record 就够,不必去调 Docker API 查完整状态(慢)。比如 webhook 进来要验"这个 session_key 对应的沙箱属主是谁",查 Record 即可,毫秒级。只有真要用沙箱(连它、显示状态)时才去查 Docker 拼出 Info。把"轻量身份"和"重量状态"分开,让高频的鉴权路径不被慢的 Docker 查询拖累。这和 Day 07 的 Info/实时 分离是同一种思路。
⚠️ 小白常误以为:一个沙箱就该用一个大对象装所有信息,拆两个是"过度设计"。其实:高频的鉴权路径每秒可能被调很多次,若每次都去问 Docker 要完整状态(可能几十上百毫秒),系统会被拖垮;只查轻量 Record(毫秒级、走数据库)才扛得住。拆开不是炫技,是被性能逼出来的。
L04

SandboxSpec:镜像模板

SandboxSpecInfosandbox_spec_models.py:8)是"创建沙箱的模板"——类比 Docker Image 之于 Container:

class SandboxSpecInfo:
    id: str            # 镜像名
    command: str       # 启动命令
    initial_env: dict  # 初始环境变量
    working_dir: str
默认镜像与版本绑定:默认镜像 = ghcr.io/openhands/agent-server:{agent-server版本}-pythonsandbox_spec_service.py:33)——镜像版本与本地装的 openhands-agent-server 版本绑死。为什么?防止"沙箱里的 agent-server 版本"和"app_server 期望的版本"不匹配导致 SDK 不兼容。get_agent_server_env:154)还会自动把 LLM_*/LMNR_* 前缀的环境变量转发进容器——这样双容器架构下 LLM 配置才能在沙箱内生效。
L05

Docker 供给全流程 start_sandbox

💡 简化版 → 真实版对照如果让你写"起一个沙箱",你大概会写:docker run 镜像 拿到容器就完事。真实的 start_sandbox 比这多了好几步——每一步都在补一个"朴素做法会踩的坑"。
# 如果让你自己写(朴素版):
def start_sandbox_naive(spec):
    c = docker.run(spec.image)      # 起个容器
    return c                        # 完事
# 问题:① 没限配额,容器越起越多把机器占满
#      ② docker.run 一返回就用,但里面 agent-server 还没就绪 → 报错
#      ③ 没身份/回调 → 沙箱产生的事件寄不回来(Day08)
#      ④ 容器里 1 号进程不回收僵尸子进程 → 进程泄漏

真实版 docker_sandbox_service.py:385start_sandbox 把上面这些坑逐一补上:

# start_sandbox 概念流程
# 1. pause_old_sandboxes 先腾配额
# 2. resolve_sandbox_spec 定镜像
# 3. 生成随机 sandbox_id 和 session_api_key = base62(os.urandom(32))
# 4. 组装环境变量,注入两个关键值(见 L06)
# 5. 端口映射、CORS、volumes、KVM device
# 6. docker_client.containers.run(..., detach=True, init=True)   # init=True 用 tini 收僵尸进程
# 7. _container_to_sandbox_info 把容器映射成 SandboxInfo
读法:供给一个沙箱 = 生成身份密钥 → 组装环境和网络 → docker run 起容器 → 把容器信息包成 SandboxInfo。细节体现工程严谨:session_api_keyos.urandom(32) 生成(密码学随机,不可猜);init=True 让容器用 tini 作为 1 号进程回收僵尸进程(长期运行容器的最佳实践)。
resume/pause/delete:518)分别对应 docker unpause+start、pause、stop+remove。状态映射 _docker_status_to_sandbox_status:119)里有个细节——Docker 的 exited 被映射成 PAUSED(因为对 OpenHands 语义来说,退出的容器只是"暂停了",还能 resume 恢复)。
L06

回调闭环的建立(架构精髓)

第 4 步注入的"两个关键值"(docker_sandbox_service.py:418)是双进程架构的命门:

env_vars[SESSION_API_KEY_VARIABLE] = session_api_key   # 'OH_SESSION_API_KEYS_0'
env_vars[WEBHOOK_CALLBACK_VARIABLE] = (                # 'OH_WEBHOOKS_0_BASE_URL'
    f'http://host.docker.internal:{self.host_port}/api/v1/webhooks')
这两行是整个双进程架构的"脐带" 启动容器时,app_server 往容器环境里塞了:① 一把钥匙(session_api_key)——沙箱以后回来汇报事件时用它证明"我是合法的那个沙箱";② 一个回家地址(webhook URL,用 host.docker.internal 让容器能访问到宿主机的 app_server)。于是沙箱里的 agent-server 每产生一个事件,就拿钥匙、按这个地址把事件 POST 回 app_server(Day 08 的 on_event 接收)。没有这两行,管家和工人就失联了。这就是 Day 05 说的"回推闭环"的建立瞬间。
📝 举个例子app_server 监听在宿主机 3000 端口。启动容器时注入 OH_WEBHOOKS_0_BASE_URL=http://host.docker.internal:3000/api/v1/webhooks 和一把随机钥匙 OH_SESSION_API_KEYS_0=x9f2…。之后沙箱里每产生一个事件,就 POST http://host.docker.internal:3000/api/v1/webhooks 并带上 X-Session-API-Key: x9f2… 头 → app_server 一看钥匙对得上,就收下这个事件。
app_server(宿主机) 监听 :3000 /api/v1/webhooks sandbox 容器 agent-server 干活 ① 注入 钥匙 + 回家地址 ② 带钥匙 POST 回推事件 host.docker.internal = 容器眼里的"宿主机地址"
图:双进程回调闭环——注入"钥匙+回家地址",沙箱凭钥匙把事件寄回宿主机
host.docker.internal 是 Docker 提供的特殊域名,让容器内部能访问到宿主机——因为 app_server 跑在宿主机上,容器要靠它找到回家的路。
L07

密钥按需下发(不进镜像)

Agent 干活常需要密钥(比如访问私有仓库的 token、调某 API 的 key)。这些敏感密钥绝不打进镜像、也不在启动时全塞进容器,而是用时才实时下发sandbox_router.py:157):

# 容器内需要某密钥时,用 session key 回调 app_server 拿:
GET /sandboxes/{id}/settings/secrets/{name}
# app_server 校验 session_key 与该 sandbox 匹配(_valid_sandbox_from_session_key),才返回密钥值
为什么密钥要"按需下发"而不是一次性给全? 安全最小化。如果启动时把所有密钥一股脑塞进容器环境变量,那么:① 密钥会出现在容器的 env 里、可能被日志/子进程看到;② 万一镜像被导出,密钥就泄漏了。"按需下发"= 沙箱真正要用某个密钥时,才凭钥匙来管家单独领取那一个。密钥永远不落在镜像里,容器里也只有当下用到的那个。最小暴露面 = 最小泄漏风险。这就像我们那间隔离实验室的"剧毒试剂"锁在中心库房:实验员要用哪一瓶,才刷卡去领哪一瓶,绝不把整柜钥匙一次塞给每个人。这是处理机密信息的黄金准则。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • wait_for_sandbox_running 解决什么竞态?pause_old_sandboxes 干嘛?
  • SandboxRecord 和 SandboxInfo 为什么要拆开?
  • SandboxSpec 是什么?默认镜像为什么和版本绑定?
  • start_sandbox 注入的"两个关键值"是什么?建立了什么?
  • 密钥为什么"按需下发"?

✋ 动手

grep -n 'wait_for_sandbox_running\|pause_old_sandboxes' openhands/app_server/sandbox/sandbox_service.py
grep -n 'class SandboxRecord\|class SandboxInfo\|SandboxStatus' openhands/app_server/sandbox/sandbox_models.py
grep -n 'def start_sandbox\|SESSION_API_KEY_VARIABLE\|WEBHOOK_CALLBACK\|os.urandom' openhands/app_server/sandbox/docker_sandbox_service.py
明天预告 · Day 10(第2周收官)server 服务装配——FastAPI 的中间件(CORS/缓存/限流)、生命周期钩子与 Alembic 自动迁移、鉴权是怎么加上的,以及 v1_router 如何汇总所有路由。收官 app_server 编排层。
← Day 08 事件 Day 10 · 服务装配 →