Agent 沙箱的生命周期
摘要真正困难的不是启动一个容器,而是身份、容量预留、文件投影、持久状态、审计与回收怎样在并发故障下保持一致。
启动一个容器或远程执行环境,只是 Agent 沙箱生命周期的第一步。
我在一个 Agent 平台里负责过沙箱的运行时接入和生命周期收口:把持久解释器放进隔离环境,处理文件投影与回写,修正容量交接竞态,补齐回收、审计和隔离边界。最难的部分从来不是调用一次 create(),而是系统在并发、取消、超时和部分失败下,仍然知道“谁拥有哪个沙箱、哪些数据已经持久化、这个容量到底释放了没有”。
本文所有代码与实验都是为公开复盘重新写的 clean-room 模型,不包含公司源码、供应商、生产配置或容量数字。我也不把平台侧生命周期工作描述成“自研了一个 microVM Runtime”:底层隔离由独立运行时提供,我负责的是 Agent 平台怎样安全、可靠地使用它。
先看一个只有一个容量槽的最小竞争。两个不同 Session 的请求几乎同时到达,都想创建各自的第一代沙箱。切换“检查后占位”和“pending 容量预留”,看看同样的两个请求为什么会得到不同结果。
Clean-room 实验 · 容量交接
最后一个槽位会被谁拿走
容量上限固定为 1;A、B 在同一时刻读取状态。
已占用 / 上限
pending reservation 也必须计入容量。
容量不变量
pending 与 active 都是同一类容量承诺,唯一承诺数不得超过 limit。
先运行错误版本,再切到 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 问题:
- generation 1 超时,清理任务开始。
- 会话很快创建了 generation 2。
- generation 1 的迟到清理继续执行。
- 如果清理只按
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 内:
- 沙箱内启动一个只使用标准库的常驻解释器。
- 每次工具调用通过本地 IPC 发送代码与接收结果。
- namespace、工作目录和生成文件都绑定当前 generation。
- 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 只是起点;只有当每一代资源都有明确所有者、持久化边界和可验证终点,沙箱才真正进入平台。