Sandbox 沙箱管理
沙箱是 AI 的"隔离工作间"——安全的地基。今天读它怎么用 Docker 供给/暂停/回收,双容器回调闭环怎么建立,密钥如何"按需下发"永不进镜像。昨天(Day 08)我们看清了"事件如何被存下、被 webhook 推回";今天补上那个"发出 webhook 的沙箱"到底是怎么被拉起来的;这也为明天(Day 10)收官 app_server 装配打好最后一块地基。
沙箱是什么(再强调一次)
沙箱(sandbox)= 一个隔离的执行环境(通常是 Docker 容器),里面跑着 agent-server,AI 在其中敲命令、改文件。它和你的宿主机隔离——AI 在里面折腾坏了,删容器重建即可。
rm -rf / 或 pip install 覆盖了系统库——你的开发机就报废了。真实世界里 AI 的命令并不总是对的,没有隔离 = 拿生产环境给 AI 练手。沙箱就是那道"炸了也不心疼"的隔离墙。供给/暂停/删除沙箱
记录沙箱身份
← webhook 回推事件 —
跑 agent-server
执行 Action
sandbox/(服务与实现)+ sandbox_spec/(镜像模板)。核心实现是 docker_sandbox_service.py。服务抽象与状态
docker run,非要抽象一层"服务"?因为 OpenHands 的沙箱不止 Docker 一种——还有 remote(远程集群)、process(本地进程)。如果哪里要用沙箱就现写 docker 命令,那想换成远程执行时得改几十处。SandboxService 是"沙箱操作的统一遥控器"。它把 search/get/start/resume/pause/delete 这套动作定义成抽象接口(ABC),谁用沙箱只对着这个遥控器按键,不管背后是 Docker 还是远程机器。这和 Day 06 的依赖注入是一套思路——面向接口编程,换实现只换注入的那一个类。SandboxService(sandbox_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 是资源回收——沙箱是重资源(每个一个容器),超过配额就暂停最久没用的那个(像手机自动关掉后台老应用)。
状态枚举 SandboxStatus(sandbox_models.py:9):STARTING / RUNNING / PAUSED / ERROR / MISSING。
SandboxRecord vs SandboxInfo(一个性能取舍)
沙箱有两个模型,区别很重要(sandbox_models.py):
SandboxRecord(:33):只有id + owner的持久化身份。轻量,存库。SandboxInfo(:48):完整实时信息——status、session_api_key、exposed_urls(含 AGENT_SERVER/VSCODE 等命名 URL)。需要查 Docker 才能拼出。
SandboxSpec:镜像模板
SandboxSpecInfo(sandbox_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版本}-python(sandbox_spec_service.py:33)——镜像版本与本地装的 openhands-agent-server 版本绑死。为什么?防止"沙箱里的 agent-server 版本"和"app_server 期望的版本"不匹配导致 SDK 不兼容。get_agent_server_env(:154)还会自动把 LLM_*/LMNR_* 前缀的环境变量转发进容器——这样双容器架构下 LLM 配置才能在沙箱内生效。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:385 的 start_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_key 用 os.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 恢复)。回调闭环的建立(架构精髓)
第 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')
host.docker.internal 让容器能访问到宿主机的 app_server)。于是沙箱里的 agent-server 每产生一个事件,就拿钥匙、按这个地址把事件 POST 回 app_server(Day 08 的 on_event 接收)。没有这两行,管家和工人就失联了。这就是 Day 05 说的"回推闭环"的建立瞬间。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 一看钥匙对得上,就收下这个事件。host.docker.internal 是 Docker 提供的特殊域名,让容器内部能访问到宿主机——因为 app_server 跑在宿主机上,容器要靠它找到回家的路。密钥按需下发(不进镜像)
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 里、可能被日志/子进程看到;② 万一镜像被导出,密钥就泄漏了。"按需下发"= 沙箱真正要用某个密钥时,才凭钥匙来管家单独领取那一个。密钥永远不落在镜像里,容器里也只有当下用到的那个。最小暴露面 = 最小泄漏风险。这就像我们那间隔离实验室的"剧毒试剂"锁在中心库房:实验员要用哪一瓶,才刷卡去领哪一瓶,绝不把整柜钥匙一次塞给每个人。这是处理机密信息的黄金准则。今日小结 + 动手
🧠 今天你应该能回答
- 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