OpenHands 源码解析系列
第 6 讲:沙箱生命周期 (Docker/K8s/Remote)
基于 OpenHands 源码 · 2026-08-02
一、沙箱在 OpenHands 中的角色
OpenHands 的 Agent 不直接在宿主机上执行命令,而是运行在沙箱 (Sandbox) 中。沙箱提供隔离的执行环境,Agent 的所有文件系统操作、命令执行、代码编译都在沙箱内完成。
📦 源码位置
openhands/app_server/sandbox/
核心文件:17 个 Python 模块,约 3500 行代码
OpenHands 实现了三种沙箱后端,通过统一的 SandboxService 抽象层切换:
🔹 DockerSandboxService — 本地 Docker 容器,适合开发环境
🔹 RemoteSandboxService — K8s 集群远程运行时,适合生产部署
🔹 ProcessSandboxService — 进程级隔离,适合测试/轻量场景
二、SandboxService 抽象层
所有沙箱实现共享同一个抽象基类。源码:
📄 openhands/app_server/sandbox/sandbox_service.py (第 112-173 行)
class SandboxService(ABC):
"""Service for accessing sandboxes in which conversations may be run."""
@abstractmethod
async def search_sandboxes(
self, page_id: str | None = None, limit: int = 100,
) -> SandboxPage:
"""Search for sandboxes."""
@abstractmethod
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
"""Get a single sandbox. Return None if the sandbox was not found."""
@abstractmethod
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
"""Get a single sandbox by session API key."""
@abstractmethod
async def start_sandbox(
self, sandbox_spec_id: str | None = None, sandbox_id: str | None = None
) -> SandboxInfo:
"""Begin the process of starting a sandbox."""
@abstractmethod
async def resume_sandbox(self, sandbox_id: str) -> bool:
"""Begin the process of resuming a sandbox."""
@abstractmethod
async def pause_sandbox(self, sandbox_id: str) -> bool:
"""Begin the process of pausing a sandbox."""
@abstractmethod
async def delete_sandbox(self, sandbox_id: str) -> bool:
"""Begin the process of deleting a sandbox (which may involve stopping it)."""设计要点:
🔹 所有方法都是 async,支持高并发
🔹 search_sandboxes 返回分页结果 SandboxPage
🔹 通过 SandboxServiceInjector 注入,依赖注入框架自动选择后端
三、沙箱状态机
沙箱有五种状态,定义在 SandboxStatus 枚举中:
📄 openhands/app_server/sandbox/sandbox_models.py (第 9-15 行)
class SandboxStatus(Enum):
STARTING = 'STARTING'
RUNNING = 'RUNNING'
PAUSED = 'PAUSED'
ERROR = 'ERROR'
MISSING = 'MISSING'
"""Missing - possibly deleted"""沙箱状态转换图
┌──────────┐ start() ┌──────────┐
│ MISSING │ ──────────► │ STARTING │
└──────────┘ └────┬─────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌────────┐
│ RUNNING │ │ PAUSED │ │ ERROR │
└────┬─────┘ └────┬───┘ └────────┘
│ │
delete() resume()
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ MISSING │◄─│ STARTING │
└──────────┘ └──────────┘每个后端将底层状态映射到统一枚举。Docker 的映射:
📄 openhands/app_server/sandbox/docker_sandbox_service.py (第 119-131 行)
def _docker_status_to_sandbox_status(self, docker_status: str) -> SandboxStatus:
status_mapping = {
'running': SandboxStatus.RUNNING,
'paused': SandboxStatus.PAUSED,
'exited': SandboxStatus.PAUSED, # stop button pressed
'created': SandboxStatus.STARTING,
'restarting': SandboxStatus.STARTING,
'removing': SandboxStatus.MISSING,
'dead': SandboxStatus.ERROR,
}
return status_mapping.get(docker_status.lower(), SandboxStatus.ERROR)四、启动等待与 Agent Server 健康检查
SandboxService 基类提供了通用的启动等待逻辑,解决"沙箱报告 RUNNING 但 Agent Server 尚未就绪"的竞态问题:
📄 openhands/app_server/sandbox/sandbox_service.py (第 175-235 行)
async def wait_for_sandbox_running(
self, sandbox_id: str, timeout: int = 120,
poll_interval: int = 2, httpx_client: httpx.AsyncClient | None = None,
) -> SandboxInfo:
"""Wait for a sandbox to reach RUNNING status with an alive agent server."""
start = time.time()
sandbox: SandboxInfo | None = None
while time.time() - start <= timeout:
sandbox = await self.get_sandbox(sandbox_id)
if sandbox is None:
raise SandboxError(f'Sandbox not found: {sandbox_id}')
if sandbox.status == SandboxStatus.ERROR:
raise _start_failure_error(sandbox_id, sandbox.status_detail)
if sandbox.status == SandboxStatus.RUNNING:
# Optionally verify agent server is alive
if httpx_client and sandbox.exposed_urls:
if await self._check_agent_server_alive(sandbox, httpx_client):
return sandbox
else:
return sandbox
await asyncio.sleep(poll_interval)
raise _start_failure_error(sandbox_id, status_detail)关键设计:双层检查 — 先看沙箱状态,再 实际 HTTP 请求 /alive 端点确认 Agent Server 已就绪。
五、启动失败分类与安全消息
这是一个精巧的设计 — 原始错误信息可能泄露集群内部信息(注册中心地址、Secret 名称、节点标签),所以 OpenHands 做了错误分类,向用户返回安全的通用消息:
📄 openhands/app_server/sandbox/sandbox_service.py (第 35-101 行)
# Known start-failure classes we translate into short, user-safe messages. Raw
# runtime status_detail (k8s pod/scheduling text) can leak internal registry
# hosts, secret/configmap names, node taints/labels, cluster size, etc., so it
# is logged for debugging but never surfaced to end users.
_CAPACITY_MARKERS = (
'Insufficient cpu',
'Insufficient memory',
'Insufficient ephemeral-storage',
'Insufficient pods',
)
_IMAGE_PULL_MARKERS = ('ImagePullBackOff', 'ErrImagePull')
_SCHEDULING_MARKERS = (
'untolerated taint', 'node affinity', 'node selector',
'volume node affinity conflict', 'topology spread', 'unbound',
)
def _classify_start_failure(detail: str | None) -> str | None:
"""Translate a raw runtime status_detail into a user-safe message."""
if any(m in detail for m in _IMAGE_PULL_MARKERS):
return 'The sandbox image could not be pulled...'
if 'CreateContainerConfigError' in detail:
return 'The sandbox failed to start due to a configuration issue...'
resource = next((m for m in _CAPACITY_MARKERS if m in detail), None)
if resource:
return f'The system is at capacity right now ({resource.lower()})...'
...安全设计:原始 status_detail 写入日志供调试,用户看到的是分类后的通用消息 + 沙箱 ID 引用,既保护了内部信息又给了用户排错方向。
六、Docker 沙箱实现
DockerSandboxService 是最常用的本地开发后端。每个沙箱是一个 Docker 容器:
📄 openhands/app_server/sandbox/docker_sandbox_service.py (第 85-109 行)
@dataclass
class DockerSandboxService(SandboxService):
"""Sandbox service built on docker."""
sandbox_spec_service: SandboxSpecService
container_name_prefix: str
host_port: int
container_url_pattern: str
mounts: list[VolumeMount]
exposed_ports: list[ExposedPort]
health_check_path: str | None
httpx_client: httpx.AsyncClient
max_num_sandboxes: int
web_url: str | None = None
use_host_network: bool = False
kvm_enabled: bool = False
docker_client: docker.DockerClient = field(default_factory=get_docker_client)Docker 后端支持两种网络模式:
🔹 Bridge 模式(默认):端口映射,容器端口绑定到宿主机随机端口
🔹 Host 模式:AGENT_SERVER_USE_HOST_NETWORK=true,容器直接使用宿主机网络
每个沙箱暴露四个服务端口:
| 服务名称 | 端口 | 用途 |
|---|---|---|
| AGENT_SERVER | 60000 | Agent 服务器,Agent 执行命令的入口 |
| VSCODE | 60001 | VS Code 远程编辑器 |
| WORKER_1 | 12000 | Web 应用工作节点 1 |
| WORKER_2 | 12001 | Web 应用工作节点 2 |
七、Remote 沙箱 (K8s 集群)
RemoteSandboxService 通过 HTTP 协议与远程 Runtime API 通信,适合 K8s 集群部署。它维护本地数据库副本:
📄 openhands/app_server/sandbox/remote_sandbox_service.py (第 81-103 行)
class StoredRemoteSandbox(Base):
"""Local storage for remote sandbox info.
The remote runtime API does not return some variables we need, and does not
return stopped runtimes in list operations, so we need a local copy. We use
the remote api as a source of truth on what is currently running, not what was
run historically."""
__tablename__ = 'v1_remote_sandbox'
id: Mapped[str] = mapped_column(String, primary_key=True)
created_by_user_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
sandbox_spec_id: Mapped[str] = mapped_column(String, index=True)
session_api_key_hash: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(UtcDateTime, server_default=func.now(), index=True)双源设计:
🔹 Runtime API — 运行时状态真相源(当前运行中的沙箱)
🔹 本地数据库 — 历史记录 + 用户所有权 + session key 哈希
搜索沙箱时批量获取运行时数据,减少 API 调用:
📄 openhands/app_server/sandbox/remote_sandbox_service.py (第 254-286 行)
async def _get_runtimes_batch(
self, sandbox_ids: list[str]
) -> dict[str, dict[str, Any]]:
"""Get multiple runtimes in a single batch request."""
params = [('ids', sandbox_id) for sandbox_id in sandbox_ids]
response = await self._send_runtime_api_request(
'GET', '/sessions/batch', params=params,
)
response.raise_for_status()
batch_data = response.json()
runtimes_by_id = {}
for runtime in batch_data:
if runtime and 'session_id' in runtime:
runtimes_by_id[runtime['session_id']] = runtime
return runtimes_by_id八、Process 沙箱 (轻量级)
ProcessSandboxService 最轻量 — 每个沙箱是一个独立 Python 进程:
📄 openhands/app_server/sandbox/process_sandbox_service.py (第 70-89 行)
@dataclass
class ProcessSandboxService(SandboxService):
"""Sandbox service that spawns separate agent server processes.
Each sandbox is implemented as a separate Python process running the
action execution server, with each process:
- Operating in a dedicated directory
- Listening on a unique port
- Having its own session API key"""
user_id: str | None
sandbox_spec_service: SandboxSpecService
base_working_dir: str
base_port: int = 8000
python_executable: str = sys.executable
agent_server_module: str = 'openhands.agent_server'
health_check_path: str = '/alive'使用 psutil 管理进程生命周期(挂起/恢复/终止):
📄 openhands/app_server/sandbox/process_sandbox_service.py (第 374-423 行)
async def pause_sandbox(self, sandbox_id: str) -> bool:
process_info = _processes.get(sandbox_id)
if process_info is None:
return False
try:
process = psutil.Process(process_info.pid)
if process.is_running():
process.suspend()
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
return False
async def delete_sandbox(self, sandbox_id: str) -> bool:
process_info = _processes.get(sandbox_id)
if process_info is None:
return False
try:
process = psutil.Process(process_info.pid)
if process.is_running():
process.terminate()
try:
process.wait(timeout=10)
except psutil.TimeoutExpired:
process.kill()
process.wait(timeout=5)
# Clean up the working directory
shutil.rmtree(process_info.working_dir, ignore_errors=True)
del _processes[sandbox_id]
return True九、Session API Key 安全机制
Session API Key 是沙箱访问的核心凭证。OpenHands 做了多层保护:
📄 openhands/app_server/sandbox/session_auth.py (第 37-100 行)
async def validate_session_key(session_api_key: str | None) -> SandboxInfo:
"""Validate an X-Session-API-Key and return the associated sandbox.
Security:
This function enforces that session API keys are only valid for RUNNING
sandboxes. This is a critical security measure to prevent leaked keys
from being used to access user secrets after a sandbox has been paused,
stopped, or deleted."""
if not session_api_key:
raise HTTPException(401, 'X-Session-API-Key header is required')
# Use admin context to look up across all users
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with get_sandbox_service(state) as sandbox_service:
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(
session_api_key
)
# Security: Reject session keys for non-running sandboxes
if sandbox_info.status != SandboxStatus.RUNNING:
raise HTTPException(401, 'Sandbox is not running')
return sandbox_infoRemote 后端额外安全:
🔹 Session key 在数据库中存储 SHA-256 哈希,明文不落地
🔹 pause 时立即清除 key hash,失效已泄露的凭证
🔹 resume 时生成全新 key,旧 key 永久作废
🔹 delete 时先清除 key 再停止运行时,防止竞态条件
📄 openhands/app_server/sandbox/remote_sandbox_service.py (第 561-589 行)
async def pause_sandbox(self, sandbox_id: str) -> bool:
"""Pause a running sandbox.
Security: Clears the session_api_key_hash to invalidate any existing
session keys, preventing leaked keys from being used while paused."""
stored_sandbox = await self._get_stored_sandbox(sandbox_id)
if not stored_sandbox:
return False
# Security: Invalidate the session API key hash
stored_sandbox.session_api_key_hash = None
runtime_data = await self._get_runtime(sandbox_id)
response = await self._send_runtime_api_request(
'POST', '/pause', json={'runtime_id': runtime_data['runtime_id']},
)
...十、沙箱规格与镜像管理
沙箱规格 (SandboxSpec) 定义了容器镜像、启动命令、环境变量和工作目录:
📄 openhands/app_server/sandbox/sandbox_spec_models.py (第 8-18 行)
class SandboxSpecInfo(BaseModel):
"""A template for creating a Sandbox (e.g: A Docker Image vs Container)."""
id: str
command: list[str] | None
created_at: datetime = Field(default_factory=utc_now)
initial_env: dict[str, str] = Field(
default_factory=dict, description='Initial Environment Variables'
)
working_dir: str = '/home/openhands/workspace'默认镜像来自 ghcr.io/openhands/agent-server,版本与安装的 SDK 匹配:
📄 openhands/app_server/sandbox/sandbox_spec_service.py (第 17-35 行)
_DEFAULT_REPOSITORY = 'ghcr.io/openhands/agent-server'
@cache
def _bundled_agent_server_version() -> str:
return importlib.metadata.version('openhands-agent-server')
def _bundled_default_image() -> str:
return f'{_DEFAULT_REPOSITORY}:{_bundled_agent_server_version()}-python'
# Prefixes for environment variables that should be auto-forwarded
AUTO_FORWARD_PREFIXES = ('LLM_', 'LMNR_')
def get_agent_server_env() -> dict[str, str]:
"""Auto-forward LLM_*/LMNR_* env vars + explicit OH_AGENT_SERVER_ENV overrides."""
result: dict[str, str] = {}
for key, value in os.environ.items():
if any(key.startswith(prefix) for prefix in AUTO_FORWARD_PREFIXES):
result[key] = value
explicit_env = env_parser.from_env(dict[str, str], 'OH_AGENT_SERVER_ENV')
result.update(explicit_env)
return result环境变量自动转发:所有 LLM_* 和 LMNR_* 前缀的环境变量自动注入到沙箱,确保 LLM 配置在双容器架构中一致。
十一、工作区归档与清理
沙箱删除前,OpenHands Cloud 会自动归档工作区到对象存储:
📄 openhands/app_server/sandbox/workspace_archive.py (第 1-15 行)
"""Archive a remote sandbox's workspace to object storage before deletion.
Pulls a workspace archive from the in-pod agent-server endpoint
(`GET /api/file/archive`) and stores it, plus a small manifest, in object
storage so the agent's work survives sandbox deletion."""
def archive_enabled() -> bool:
return os.getenv('RUNTIME_FILE_ARCHIVE_ENABLED', 'false').lower() in ('true', '1')
def archive_format() -> str:
# Default to 'both' — the compact git-delta AND a self-contained full tar.gz.
return os.getenv('RUNTIME_FILE_ARCHIVE_FORMAT', 'both')归档流程:
🔹 调用 Agent Server 的 GET /api/file/archive 端点
🔹 同时捕获 git-delta(紧凑)和 tar.gz(完整)两种格式
🔹 流式写入临时文件,避免内存 OOM
🔹 上传到 GCS/S3/本地存储,附带元数据清单
🔹 超时默认 660 秒(覆盖大型仓库的 git 操作预算)
十二、架构总结
沙箱系统架构总览
┌─ SandboxService (ABC) ──────────────────────┐
│ search / get / start / pause / resume / delete
│ wait_for_sandbox_running (通用等待逻辑)
│ pause_old_sandboxes (自动清理)
└──────────┬────────────┬──────────┬───────────┘
│ │ │
┌────────▼───────┐ ┌─▼────────┐ ┌▼────────────┐
│DockerSandbox │ │ Remote │ │ Process │
│Service │ │Sandbox │ │ Sandbox │
│ │ │Service │ │ Service │
│ Docker API │ │ HTTP → │ │ subprocess │
│ 容器管理 │ │ Runtime │ │ + psutil │
│ Bridge/Host │ │ API │ │ │
│ 网络模式 │ │ K8s Pods │ │ 本地进程 │
└───────────────┘ └──────────┘ └──────────────┘
│ │ │
┌────────▼────────────▼──────────▼──────────┐
│ SandboxSpecService │
│ DockerSandboxSpecService (pull镜像) │
│ PresetSandboxSpecService (预设列表) │
│ RemoteSandboxSpecService (远程注册) │
└───────────────────────────────────────────┘
│
┌────────▼──────────────────────────────────┐
│ Session Auth + Workspace Archive │
│ validate_session_key() │
│ archive_workspace() → GCS/S3 │
└───────────────────────────────────────────┘关键设计模式:
🔹 策略模式 — 三种后端通过统一接口切换
🔹 依赖注入 — SandboxServiceInjector 根据配置选择实现
🔹 安全分层 — Session key 哈希存储、状态绑定、暂停即失效
🔹 错误隔离 — 原始错误日志记录,用户看到安全分类消息
🔹 流式处理 — 归档流式写入临时文件,避免内存压力
📚 系列导航
← 第 5 讲:事件溯源系统 (Event Log + 3 Backends)
→ 第 7 讲:Agent 类型与 ACP 协议
关注公众号「AI技术推荐官」获取更多源码解析内容
夜雨聆风