乐于分享
好东西不私藏

OpenHands 源码-沙箱生命周期 (Docker/K8s/Remote)

OpenHands 源码-沙箱生命周期 (Docker/K8s/Remote)

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_SERVER60000Agent 服务器,Agent 执行命令的入口
VSCODE60001VS Code 远程编辑器
WORKER_112000Web 应用工作节点 1
WORKER_212001Web 应用工作节点 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_info

Remote 后端额外安全:

🔹 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技术推荐官」获取更多源码解析内容