Agent 沙箱的生命周期

摘要真正困难的不是启动一个容器,而是身份、容量预留、文件投影、持久状态、审计与回收怎样在并发故障下保持一致。

文章目录7 节
  1. 先把“沙箱是谁的”说清楚
  2. 容量不是一个 COUNT( )
  3. 文件投影不是共享目录
  4. 持久 REPL 必须活在沙箱里
  5. 回收顺序决定是否安全
  6. 我会固定回归哪些竞态
  7. 沙箱的产品边界

启动一个容器或远程执行环境,只是 Agent 沙箱生命周期的第一步。

我在一个 Agent 平台里负责过沙箱的运行时接入和生命周期收口:把持久解释器放进隔离环境,处理文件投影与回写,修正容量交接竞态,补齐回收、审计和隔离边界。最难的部分从来不是调用一次 create(),而是系统在并发、取消、超时和部分失败下,仍然知道“谁拥有哪个沙箱、哪些数据已经持久化、这个容量到底释放了没有”。

本文所有代码与实验都是为公开复盘重新写的 clean-room 模型,不包含公司源码、供应商、生产配置或容量数字。我也不把平台侧生命周期工作描述成“自研了一个 microVM Runtime”:底层隔离由独立运行时提供,我负责的是 Agent 平台怎样安全、可靠地使用它。

先看一个只有一个容量槽的最小竞争。两个不同 Session 的请求几乎同时到达,都想创建各自的第一代沙箱。切换“检查后占位”和“pending 容量预留”,看看同样的两个请求为什么会得到不同结果。

Clean-room 实验 · 容量交接

最后一个槽位会被谁拿走

容量上限固定为 1;A、B 在同一时刻读取状态。

已占用 / 上限

0 / 1

pending reservation 也必须计入容量。

容量不变量

等待

pending 与 active 都是同一类容量承诺,唯一承诺数不得超过 limit。

Session A尚未开始
Session B尚未开始

    先运行错误版本,再切到 pending 容量预留比较。

    先把“沙箱是谁的”说清楚

    如果系统只用 user_id 找沙箱,很快会遇到冲突:同一个用户可以有多个 Agent、多个会话、多个并发任务;旧实例失败重建后,新旧实例还会在一段时间内同时存在。

    我最后采用的心智模型不是“一个用户一个容器”,而是三种不能混用的所有权凭据:

    @dataclass(frozen=True)
    class SandboxHandle:
        workspace_id: str
        session_id: str
        generation: int
        runtime_id: str
    
    @dataclass(frozen=True)
    class ExecutionLease:
        generation: int
        fencing_token: str
        expires_at: float
    
    @dataclass(frozen=True)
    class CapacityReservation:
        scope: str
        owner_token: str

    workspace_id + session_id 决定业务归属,generation 区分同一 Session 的重建代次;execution fencing token 防止并发执行和旧代迟到写回;capacity owner token 只允许创建者释放自己取得的 pending reservation。

    这能挡住一个常见的 ABA 问题:

    1. generation 1 超时,清理任务开始。
    2. 会话很快创建了 generation 2。
    3. generation 1 的迟到清理继续执行。
    4. 如果清理只按 session_id 删除目录或释放容量,它会误伤新实例。

    三者的校验边界不同:删除依赖 Session 生命周期锁、generation 与 durable 记录标识;写回依赖 generation 和 fencing token;pending 释放才使用 capacity reservation 的 owner token。不能把它们合并成一个通用 lease_token,更不能把所有动作都笼统写成 compare-and-delete。

    容量不是一个 COUNT(*)

    错误版本通常长这样:

    if await count_active() < limit:
        runtime = await create_sandbox()
        await mark_active(runtime)

    检查和提交之间隔着一次慢创建。两个 Worker 可以同时读到相同计数,然后都穿过上限。仅仅给 create_sandbox() 外面加 Semaphore 也不够:进程内 Semaphore 约束不了其它 Worker,重启后还会丢状态。

    我当时真正落地并验证的是双存储交接:durable active 记录是长期事实,pending reservation 保护慢创建窗口。公开伪代码可以抽象成:

    async with admission_lock(scope):
        active = await count_active(scope)
        reservation = await reserve_pending(
            scope=scope,
            remaining=limit - active,
        )
    
    runtime = None
    try:
        runtime = await create_sandbox()
        await commit_active(runtime)
        async with admission_lock(scope):
            await release_pending_if_owner(
                reservation.owner_token,
            )
    except Exception:
        await best_effort_kill(runtime)
        await best_effort_release(reservation)
        raise

    慢创建和 active 提交在 admission 边界外执行,因此 pending 要一直保留。active 可见后,pending 的 owner 才重新取得同一 admission 边界并移除自己的占位。竞争者无论先后拿到边界,要么看见 pending,要么看见 active,不会落进“两边都没看见”的空窗。

    这里的容量不变量按唯一创建承诺计数,而不是把短暂重叠的 active 和 pending 机械相加;它们在交接期表示同一个 Session。释放必须校验 owner token,绝不能先删 pending 再提交 active。

    我不把这段写成一个已经完成的取消安全协议。当时的创建调用没有幂等键,异常路径只会即时 best-effort 尝试终止远端实例;失败可以留下日志,但没有持久化 orphan 登记。普通 Exception 捕获也不能覆盖 asyncio.CancelledError,一次可取消的 finally 更不能替代持久对账。完整方案还需要持久化 reconciliation intent、独立 supervisor 和可恢复的幂等创建。本文声称完成的是并发容量交接,不声称取消后的后台闭环已经落地。

    文件投影不是共享目录

    Agent 需要读写项目文件,但把宿主业务目录原样可写挂进沙箱,会把隔离边界变成一个 mount 参数。

    我处理这部分时,把文件分成两类:

    • 持久事实:平台确认存在、可下载、可审计的文件与版本。
    • 工作投影:某个 sandbox generation 为执行任务得到的临时副本。

    创建或执行前,平台把允许读取的文件投影到这一代专属目录;工具执行后,只通过受控 manifest 识别变化并写回持久层。投影目录本身不是数据真源,生命周期结束后可以整代回收。

    这个边界需要同时防几件事:

    • 路径在规范化后必须仍位于允许根目录。
    • 拒绝符号链接越界,不追随指向宿主其它位置的链接。
    • .git、缓存目录和运行时元数据不能因为“文件有变化”就自动写回。
    • 长任务期间源文件可能变化,执行前要有明确的刷新策略。
    • 本地与远端投影传输需要文件数和字节上限、整体摘要校验与暂存提交;传输中断时必须 fail closed,并能安全地从头重试。

    如果平台的文件列表读的是持久事实,而下载接口读的是工作投影,用户会看到“列表里是一个版本、下载到另一个版本”。所以读路径也必须统一,不只是把写回做安全。

    持久 REPL 必须活在沙箱里

    持久 Python 工具希望跨多次调用保留变量。最省事的实现,是在应用进程旁边养一个本地 Python 子进程;但这样一来,普通执行进了沙箱,持久执行却绕回宿主,隔离模式形同虚设。

    我做过的收口方式,是让持久解释器本身驻留在 sandbox generation 内:

    1. 沙箱内启动一个只使用标准库的常驻解释器。
    2. 每次工具调用通过本地 IPC 发送代码与接收结果。
    3. namespace、工作目录和生成文件都绑定当前 generation。
    4. generation 结束时,解释器与状态一起销毁。

    在这条持久 Python 路径中,平台侧只负责协议、超时、输出上限和生命周期,不在应用容器执行用户代码。长命令也不能持有数据库事务等待;应先完成元数据读取和准入,再执行远端命令,最后用短事务提交结果。

    回收顺序决定是否安全

    “空闲一段时间就 kill”只是触发条件,不是完整回收。我落地的空闲回收不会取消仍持有有效 execution lease 的任务,也不会到回收阶段才临时补写文件;允许持久化的变化应在每次执行结束、释放 execution lease 之前完成 reconcile。回收顺序是:

    扫描空闲候选
    → 获取 Session 生命周期锁
    → 重新读取状态
    → 再次确认仍然 active、空闲且没有有效 execution lease
    → 标记正在回收
    → 终止 runtime
    → 成功后标记 killed / inactive
    → 删除这一代 projection
    → 记录 cleanup 指标与结果

    每一步都可能失败。我的处理原则不是把它们塞进一个无法回滚的大事务,而是让步骤幂等,并为迟到任务保留代次与所有权校验:

    • runtime 终止失败:恢复成仍可继续对账的 active 状态并保留 projection;不能在实例可能仍运行时删除它的工作视图。即时补偿之外仍需要独立 reconciliation,本文不声称后台闭环已经完整实现。
    • projection 删除失败:生命周期不回滚,但失败必须可观测;是否存在自动重试,需要单独验收而不能从这里推定。
    • 审计失败:安全拒绝决定无法持久化时继续 fail closed;普通生命周期审计失败则记录错误和指标,不能因此触发本地回退或第二次危险执行。
    • 数据库提交失败:远端可能已经创建,必须执行补偿或登记孤儿。

    把 working copy 定义成可丢弃投影非常重要。只有确认允许保留的变化已经进入持久事实,projection 才能在生命周期终点整目录删除。

    我会固定回归哪些竞态

    沙箱测试不能只覆盖“创建成功、执行成功”。真正有区分度的是负路径:

    场景必须守住的不变量
    两个 Worker 抢最后一个槽位唯一容量承诺数不超过上限
    同一会话两个工具并发进入一个执行,另一个得到可重试 busy
    generation 1 清理迟到不得删除 generation 2 的 projection 或改写其 durable 状态
    创建成功但数据库提交失败即时终止补偿被执行;失败可观测且不误报已收口
    长命令超过超时不持有数据库事务,不泄漏执行槽
    文件里出现越界 symlink不读取、不写回、不跟随删除
    网络策略或能力探测失败实例不进入 ready,失败原因可观测
    projection 回收失败生命周期仍收敛,失败有指标且不会被误报为已清理

    我还会跑跨生命周期的负载,而不是只测单次命令:反复创建、执行、取消、空闲回收、重建,再核对宿主目录、远端实例、pending reservation 和数据库记录是否一致。资源泄漏往往不会在第一次运行里出现,而会在几个小时或几天后把节点拖满。

    沙箱的产品边界

    容器、microVM 或远程执行服务解决的是隔离原语;Agent 平台仍要承担身份、准入、文件、状态、审计和人工接管。底层隔离更强,不会自动修复上层把凭据全部投进去、把宿主目录可写挂载、或用错误身份释放容量。

    这次工作让我把“Agent 沙箱”重新定义成了一个有状态的分布式子系统:

    实现过程中,代码 Agent 帮助我做过跨模块扫描、测试矩阵补齐和机械性改写;我负责的是不变量、失败语义、所有权边界、代码审查与最终验证。文章只陈述这部分可以由个人提交和测试支撑的责任。

    创建 API 只是起点;只有当每一代资源都有明确所有者、持久化边界和可验证终点,沙箱才真正进入平台。