主题
沙箱资源管理:隔离键、生命周期与配额
一句话:沙箱不是“给每个会话开一个容器”这么简单——执行隔离按会话、数据隔离按用户、配额按租户加用户、网络身份按会话派生;四把钥匙分开,才不会在持久化、并发与安全之间三选一。
状态与快照的存储选型见 状态、记忆与沙箱,个人助理的实例形态见 个人助理 Hub;本篇只回答“沙箱怎么开、给谁开、开多久、开多少”。
为什么需要
沙箱的坑通常不在“能不能跑”,而在“跑完留下什么”:
- 同用户并发串文件:两个标签页同时让 Agent 改同一份报告,一个会话读到另一个未提交的半成品,最后互相覆盖;
- 热池残留:从池里领到的容器还留着上一个用户的环境变量、临时文件或缓存的 Token,新用户一进来就能读到;
- 快照失败静默冷启动:对象存储抖了一下,恢复失败被当成“没有快照”,用户看到的是空工作区,还以为是模型忘了;
- 配额被多副本放大:每个副本各记一份内存计数,水平扩容三倍,实际允许的并发也翻了三倍。
这些问题的共同点:把“隔离”当成一个开关。实际要分别回答四个问题。
核心机制
一、四个维度,四把钥匙
| 维度 | 问题 | 建议键 | 选错的后果 |
|---|---|---|---|
| 执行隔离 | 谁和谁共享内核、进程、文件系统 | session_id,一次运行一个沙箱 | 同用户并发会话互相看到未提交文件 |
| 数据隔离 | 工作区、记忆、产物归谁 | user_id(租户内) | 用户换会话就丢文件,或跨用户串数据 |
| 配额归属 | 谁被限流、谁付费 | tenant_id + user_id | 一个人打满共享池,或租户账单算不清 |
| 网络与凭据 | 以谁的身份出网、调工具 | 会话绑定用户主体 | 长期凭据驻留,泄漏后无法定位 |
AgentScope Java 2.x 的 IsolationScope 正好提供这几种粒度:USER 是 v2 默认值,同一 userId 的会话共用一个沙箱,没有 userId 时回退到 SESSION;SESSION 一会话一沙箱;AGENT / GLOBAL 跨用户共享,必须配分布式锁。四档里没有 TENANT:租户隔离不能靠 scope,要靠存储前缀、K8s 命名空间与桶策略三层同时表达。默认值不是推荐值:面向个人的助理,建议显式设成 SESSION 执行 + 用户卷持久化,而不是直接吃 USER 默认;USER 只共享工作区与依赖缓存,对话上下文仍按 SESSION 存,两者不要混成一个键。
二、三种形态,按“执行”和“数据”拆开
| 形态 | 执行沙箱 | 工作区 | 隔离强度 | 成本/延迟 | 适用 |
|---|---|---|---|---|---|
| 会话沙箱 + 用户卷 | 每会话新建 | 挂载用户卷,会话内 overlay | 高 | 中,热池可缓解 | 默认形态 |
| 用户沙箱 | 每用户长期 | 用户独占 | 中 | 低,复用依赖 | 个人开发环境、长任务 |
| 会话沙箱 + 临时盘 | 每会话新建 | 不持久化 | 最高 | 中 | 不可信代码、一次性计算 |
默认选第一种:沙箱按会话创建,用户卷按用户挂载,会话结束提交 diff,未提交内容丢弃。这样既保证并发会话互不可见,又让用户下次打开还能看到自己的文件。用户沙箱只在“明确是个人开发环境”时启用,并限制同一用户并发会话数——否则两个会话同时写一个目录,隔离就名存实亡。
三、生命周期:状态、超时与四分支恢复
三个超时要分开设,不能只设一个:
- 排队超时:等不到资源就快速失败,返回可重试的 429/503,不要让请求无限挂起;
- 空闲超时:建议 5–15 分钟,先快照再停,用户下一轮还能续;
- 硬 TTL:建议 2–12 小时,长任务靠 checkpoint 续期,不能靠“一直活着”。
恢复有四条分支,必须显式处理:容器还在且工作区完整 → 直接复用;容器还在但工作区丢了 → 从快照恢复;容器没了但有快照 → 新容器水合;没有快照 → 冷启动;快照读取或恢复失败 → 报错,不能静默冷启动。AgentScope 用 SandboxManager 加生命周期钩子把这些动作挂在 PreCall / PostCall / Error 上,默认快照规格是 Noop,多副本生产必须换成远端快照规格并配分布式状态存储,否则换节点就是空工作区。
四、池化:热池按规格分,不按用户分
Kubernetes 侧的 SandboxTemplate、SandboxWarmPool、SandboxClaim 把池化拆成三步:模板定义镜像与资源,热池维持一批就绪 Pod,领取时用 Claim 绑定。要盯住四个点:
- 分池维度:按镜像、规格、运行时类别、租户类别分,不按用户预分配;用户凭据在 claim 之后注入;
- 领取原子性:CAS 加 fencing token,避免两个请求领到同一个沙箱,也避免迟到的清理杀掉已复用的沙箱;
- 水位与补货:池低于水位补货,高于水位回收;命中率与冷启动 SLO 一起看;
- 残留清理:回收时杀进程、清环境变量、删临时目录、卸载卷,并验证无凭据残留。
shutdownTime 这类字段要看清语义:它是绝对过期时间,不是“空闲多久后关闭”;空闲回收是另一套策略。把两者混用,会出现“用户刚回来,沙箱已经到期”的体验问题。
五、配额与准入:先预留,再创建
配额至少六个维度:并发沙箱数、CPU/内存/磁盘、PID 数、出网流量、月度沙箱时长、快照存储。作用层级是租户总量 → 用户上限 → 单会话上限,后者只能收窄。
准入必须是原子预留:查余额和扣减在一个原子操作里完成(Redis Lua 或数据库行锁),拿到租约后再创建;创建失败要释放,租约要有 TTL,防止孤儿沙箱永久占额。多副本部署时,计数器必须在共享存储,不能放进程内存——否则副本数就是配额的隐藏倍数。
公平性靠两件事:租户加权队列 + 单用户并发上限。长任务单独池,交互式请求优先;超时任务强制中止并释放槽位,不能让它一直占着。
六、文件、快照与迁移
- 工作区要 POSIX 语义(随机读写、重命名、锁),选 NAS/CephFS;纯产物与快照走 S3 兼容对象存储;两者不要互相替代。
- 挂载模式分三种:用户卷读写、知识库只读、临时盘 tmpfs;只读挂载能显著降低“Agent 改坏共享资料”的概率。
- 快照一致性:先停写或冻结进程,fsync 后打包,排除缓存与临时目录,记录 checksum;大工作区做增量,不要每次全量 tar。
- 跨节点迁移:快照加水合;同一沙箱的并发恢复必须用分布式锁挡住,恢复要幂等。
- 依赖缓存:按锁文件哈希做只读共享缓存,写缓存按用户隔离;否则一个用户装错版本,污染所有人。
七、网络、镜像与逃逸
- 默认拒绝出网,按需开域名白名单;解析后校验实际 IP,禁止内网段、云元数据地址与 DNS 重绑定;限制重定向次数与响应体大小。
- 镜像固定 digest、签名校验、最小化;禁止 privileged、禁止挂载宿主目录;seccomp + 用户命名空间是底线,不可信代码用 gVisor/Kata 或独立节点。
- 凭据短时注入:热池镜像里不能有用户凭据,claim 之后再注入;凭据不进环境变量快照、不进日志、不进模型上下文。
- 沙箱到控制面、沙箱到其他租户存储要 mTLS + 网络策略双重限制;“能连到端口”和“有权调用”必须分开。
八、成本与可观测
按 tenant/user/session/agent/version 打标签,才能回答“谁在用、哪个版本贵、热池值不值”。核心指标:启动延迟、热池命中率、活跃/排队数、OOM 与驱逐次数、快照大小与耗时、失败原因分布、单会话成本。闲置回收和热池是同一枚硬币的两面:池太小冷启动多,池太大闲置成本高,用命中率与 P95 启动延迟一起调。
九、边界场景:十个最容易漏的决策
等待与恢复的通用契约见 22-架构评审与任务闭环 §三、§十二;这里只保留沙箱侧的决策。
| 场景 | 决策 | 验收 |
|---|---|---|
| 共享账号 / 值班号 | 默认 SESSION;确需共享工作区时按 (subject, actor) 再切一层 | 两个使用者工作区与记忆互不可见,审计能区分 actor |
| 未登录访客 | 发放 per-browser 临时 subject,禁用长期记忆与凭据注入,过期回收 | 两个浏览器 subject 不同,过期后工作区与快照被清理 |
| 定时任务 / Cron | 需累积状态用 cron:{jobId} 加单飞锁,一次性任务用 cron:{jobId}:{fireId};以服务身份运行 | 重叠触发被锁挡住或排队,状态可从外部存储恢复 |
| 长任务跨设备 | 沙箱生命周期绑 taskId 而非浏览器 session,心跳续租,结果写产物存储 | 关闭页面十分钟后重连,任务仍在跑或已完成,产物可下载 |
| 审批等待两小时 | 默认快照后释放沙箱;只有审批对象必须活在进程里才保留短租约,并标 resumable=false | 审批结束能按 task_id 恢复;租约过期明确失败,不假装能恢复 |
| 同用户多设备 | 会话分开、工作区按 USER 共享,文件冲突用版本 CAS,不用“最后写入者胜” | 两端并发改同一文件产生冲突提示或合并结果,不静默丢失 |
| 配额漂移 | 创建与释放都写事件,定时用实际沙箱清单校正占用,月度时长按秒聚合 | 人为制造一次释放丢失,对账后占用恢复,差异有审计 |
| 快照指针 | 先写对象存储拿到不可变版本号,再 CAS 更新 SandboxState 指针 | 人为中断上传,恢复时仍指向上一份完整快照 |
| 时钟偏移 | 节点强制 NTP;签名允许分钟级容差;租约与版本比较尽量用单调计数 | 人为偏移节点时钟,鉴权与锁在容差内保持正确 |
| 路径逃逸 | 文件 API 先 realpath 再校验是否在 workspace 根内,拒绝符号链接穿越,容器不以 root 运行 | ../../etc/passwd 与指向外部的软链接均被拒绝并记审计 |
| 镜像升级 | 会话创建时固定 digest,新会话用新 digest,旧热池停止补货并排空 | 老会话 digest 不变,新 claim 全为新 digest,回滚只改发布指针 |
再补两条运维判据:启动就绪探针要跑真实命令(shell、tar、python/目标语言、工作区可写),Running 不等于 Ready;平台错误与用户代码错误分开统计,镜像、节点、快照失败计入平台 SLO,用户代码非零退出只计任务失败,否则真正的平台故障会被大量用户报错淹没。
十、运行态分级矩阵(R1–R5 统一控制器,G2)
六条链路不是六种实现,而是同一个运行态控制器的五个等级配置。控制器职责固定:创建 → 注入凭据 → 执行循环(PreCall/PostCall/Error 钩子)→ 回收;等级只是参数组合。
| 等级 | 资源语义 | 隔离键(IsolationScope) | 快照策略 | 回收策略 | 恢复策略 |
|---|---|---|---|---|---|
R1 shared | 模型连接池(共享) | SESSION/USER | Noop | 不回收(连接池复用) | 会话重放 |
R2 workspace | 隔离工作区+进程 | 租户/会话 | optional | 会话结束即回收 | 快照 Fork/Reset |
R3 dedicated | 独享实例 | 身份+实例 | mandatory | 闲置检测→快照→释放 | 冷/热恢复(水合) |
R4 shared-workspace | 主工作区+受限子区 | 子任务 scope | optional | 随主任务 | 子任务重跑+join |
R5 remote | 远端实例 | 协议契约 | n/a | 远端自管 | 协议幂等重放 |
判级函数(一个函数,链路切换不换实现):
text
runtimeClass = judge(链路特征):
Skill 声明脚本 → R2 # 质变点 A(脚本执行→工作区)
独享身份/常驻偏好 → R3 # 质变点 B(独享实例→回收恢复)
subagent 委派 → R4
跨端协议接入 → R5 # 质变点 C(状态权威→协议)
否则 → R1链路 1/2"执行资源共享"成立前提是零文件副作用(47 篇 §1.2-1)——该判定写入 judge 的资源分配分支:出现文件副作用则强制升 R2,防止共享态泄漏工作区。
创建契约:create(tenant, runtimeClass, context) → 原子预留配额 → 领取热池/新建 → 注入凭据 → 返回 (sandboxId, lease, fencingToken)。R1–R5 任一等级统一走此入口,ConflictPolicy.FAIL 显式配置(24 篇 §6.5.4-1),恢复重放的状态合并统一走 FAIL。
十一、闲置回收与事件唤醒(G1,链路 4 收口)
回收是资产回收(快照落盘 + 释放容器),不是删状态;全程可审计。
回收决策(复用 §三 IDLE/SNAPSHOTTING/STOPPED 状态 + 空闲超时,补齐审计与唤醒):
IDLE超空闲时 →SNAPSHOTTING:冻结进程 → fsync → 增量快照 → checksum → 写对象存储(不可变版本,先写存储再 CAS 更新指针);STOPPED:释放容器/归还热池;写回收审计事件(actor=scheduler,入agent_task_event);- 任务侧联动:任务在
waiting_*(22 篇 §三)时沙箱可提前进 IDLE,恢复时水合续跑(22 篇 §十二Suspend/Resume已定)。
事件唤醒流程(渠道事件 → 恢复):
- 渠道事件到达(13 篇入口)→ 查关联 task 的沙箱状态;
STOPPED+ 有快照 → 冷启动水合(新容器挂快照)或热池领取;STOPPED无快照 → 冷启动(显式提示,不静默);- 唤醒成功后续租 + 事件入账(
actor=event-bridge)。
验收判据:空闲超时 → 快照落盘 → 释放容器 → 事件唤醒冷/热启动,全程可审计(47 篇 G1)。
原生对齐(§6.5.6/6.5.7 复核):agentscope-service 原生协调器提供租约/心跳/工作项——builder_coord_lease(lease_kind/lease_key/owner_id/instance_id/acquired_at/expires_at)、builder_coord_worker(worker_id/last_seen_at/capabilities_json)、builder_coord_work(lease_id/session_id/environment_id/claimed_by/work_dir)——本节"闲置检测/回收/唤醒"的租约与存活检测直落原生(lease_kind 可承载 run/worker/hitl 多类租约);快照落盘/水合/事件唤醒流程(SNAPSHOTTING→STOPPED→冷/热启动)为平台/沙箱层补充(E2B TAR / workspace 卷承载快照)。
最小上手
先做“会话沙箱 + 用户卷 + 一个远端快照”,不碰专属节点:
text
1. 显式设置 IsolationScope.SESSION,工作区按 tenant/user 前缀挂载;
2. 配置远端快照规格与分布式状态存储,禁用 Noop;
3. 设三层超时:排队 30s、空闲 10min、硬 TTL 4h;
4. 配额先做“每用户并发 2、每租户并发 20”,用 Redis 原子预留;
5. 用两个账号、同一账号两个会话并发写同一文件,验证互不可见;
6. 杀掉一个运行中的沙箱,验证从快照恢复或明确报错。验收判据:并发会话不串文件;换节点能恢复;快照失败不静默冷启动;配额并发申请不超卖;热池领取的沙箱无残留;沙箱访问内网与元数据地址被拒。
生产动作
- 默认 SESSION,显式覆盖:不要依赖框架默认值;个人助理用会话执行 + 用户卷,开发环境才用用户沙箱。
- 快照失败 fail-closed:宁可让用户重试,也不要给一个空工作区。
- 配额租约化:预留带 TTL,创建失败释放,孤儿沙箱定时回收。
- 热池做残留检测:回收后检查环境变量、临时目录、凭据与网络连接,抽检加自动巡检。
- 长任务单独池:与交互式请求隔离,避免长任务把热池吃光。
- 镜像按 digest 锁定:升级只影响新会话,旧会话跑完再回收。
- 成本看板按人:至少能回答“哪个租户、哪个 Agent 版本最费资源”。
边界
- 容器不是强隔离:共享内核就有逃逸面;强隔离要 VM、独立节点或 gVisor/Kata。
- 热池不是免费:闲置资源也是钱;命中率低时,热池比冷启动更贵。
- 快照不是备份:它保的是“继续跑”,不是“误删可恢复”;备份要独立做。
- 用户沙箱不是默认:它省启动时间,但把并发隔离的责任交给了应用层。
- 配额不是限流:配额管“同时能占多少”,限流管“单位时间能来多少”,两者要叠加。
讲清楚
沙箱要分四把钥匙:执行按会话、数据按用户、配额按租户加用户、网络身份按会话派生。默认形态是“会话沙箱 + 用户卷”,不是“一人一个容器”。
生命周期要设三层超时和四条恢复分支;快照失败必须报错,不能静默冷启动。热池按规格分池、CAS 领取、fencing 防迟到清理,领取后再注入凭据。
判断方案是否成立,看六条:并发会话不串文件、换节点能恢复、快照失败不静默、配额不超卖、热池无残留、沙箱出网被限。
适用:AgentScope Java 2.x + Kubernetes agent-sandbox / Docker | 初稿 2026-10