Skip to content

谁有权删掉一个沙箱:在 CubeSandbox 上构建多租户沙箱平台

作者|广晟数科 资深算法工程师 · 冯佳奇

编者按| 创建一个沙箱只需要一次 API 调用,删掉一个沙箱却要回答一串问题:它还有活跃会话吗?这条清理请求会不会是迟到的那一个?崩溃的副本留下的沙箱谁来收?

广晟数科团队有两条产品线(SIN Builder、大晟AI)跑 AI Agent 生成的代码,他们把沙箱能力收敛成了一个独立服务,作为广晟数科 SIN PaaS(AI中台)的沙箱执行层,并用租约、fencing token、三色标记回收和准入控制等实践,回应了上面那串问题。本文记录了这套治理体系从两套各自实现、一次泄漏故障,到统一收敛的完整过程。

问题起点:两套实现与一次故障

选型 Cube Sandbox 之前,我们要解决四个问题:1)两条产品线各写了一套沙箱能力,彼此不通用,生命周期、租约、回收策略互不一致,出了问题难定位也难治理;2)Agent 会话是长连接、有状态的,同一会话需要复用同一个沙箱,这要求按 owner 粒度设计租约和 fencing token,否则一个迟到的清理请求会直接删掉新一代租约,把正在运行的会话干掉;3)多副本部署下配额和回收很难两头兼顾:既不能把正在跑的沙箱当垃圾回收掉,也不能让崩溃副本遗留的沙箱一直泄漏;4)沙箱冷启动延迟会直接变成用户等待时间,而自建 warm pool 遮延迟,维护和一致性成本都很高。

第一个问题维持了很长时间。大晟AI(企业级 AI 助手,内部代号 dasheng-next)从 2026 年 5 月 11 日起就把沙箱能力内嵌进了产品代码;SIN Builder(AI 应用生成平台)的后端 sin-builder-core 到 6 月上中旬才独立接入 Cube Sandbox(以下简称"Cube"),我们自己实现了一套运行时接入和事件解析逻辑。但两条线各自演化、互不相通。真正促成收口的,是 2026 年 6 月 18 日的一次故障。

那次故障的形态是一个死循环。内部排查报告将根因判定为 cube-api / CubeMaster 写路径阻塞:故障发生时,平台上累积了 704 个沙箱,全部处于 running 状态且 endAt 等于 startedAt(从创建后从未续期过);宿主机上 732 个 containerd-shim-cube-rs 运行时进程,合计占用 304 GB 内存,其中 385 个来自空闲预热池,另外 319 个无会话归属,来自 5 个已经不存在的历史实例。读路径 GET /sandboxes 一切正常,秒级返回;但写路径 POST 和 DELETE 全部超时返回 408。回收侧每轮都能识别出 216 个待清理的沙箱,但实际删除数是 0——kill 失败 1359 次,create 请求失败 10214 次,全部 408。这就形成了一个闭环:历史实例遗留的沙箱不断累积,数量压垮了写操作,create 和 kill 都在超时,既删不掉旧的也补不进新的,平台被进一步拖垮,两条产品线同时不可用。

这次故障对后续设计有三个直接影响。其一,385 个泄漏沙箱来自空闲预热池,说明预热池是最大的泄漏源,而一个月后,我们实测 Cube 官方的 hot-start 已经达到亚秒级,warm pool 因此被整体退役。其二,5 个历史 owner 实例与 319 个无归属沙箱,说明当时缺乏单一的记账权威,这也是后来独立出来的新服务要优先解决的问题。其三,事后重建时我们确认了两项待加固:写操作要加熔断、GC kill 不能串行 await。

收敛:唯一的沙箱边界

故障一周后,我们在 Cube Sandbox 之上,建立了 common-sandbox-runner 这个独立的 FastAPI 服务,把沙箱能力收敛。它就是 SIN PaaS 的沙箱执行层组件。

  • SIN Builder 和大晟AI 与 runner 部署在同一个 K8s 集群、不同的命名空间下,只通过一个接口 POST /v1/sandbox-runs 调用 runner(SSE 流式返回 started、progress、completed 三种事件),本身不持有任何沙箱状态,也不直接访问 Cube。
  • runner 是整个系统里唯一能写 Cube 的一侧。目前是单副本部署,pod 内不保存权威状态——租约、准入计数、GC 候选名单这些跨请求都要用到的状态,全部存在 Redis 里;多副本安全目前在设计上已支持。
  • Cube 平台与 runner 部署于同一节点。见下图中虚线处,为平台侧行为:sweeper 依据 endAt 触发 pause,不判断沙箱内是否有命令在执行。

部署和调用关系

图 1:部署和调用关系

每个请求都携带 namespace(产品/团队边界)加 env(dev/staging/prod),runner 据此推导出一个统一的 owner_id,写进沙箱的 metadata;后续无论是清理、取消还是排障,都用同一套可预测的 key 格式去查,所有沙箱都能追溯到具体归属。具体规则是:除非调用方显式指定,owner_id 由 namespace、env、项目 ID、会话 run ID 按固定顺序拼接而成,人眼可读;对跑业务的沙箱,runner 会把 namespace、env、owner_idmanaged_by 一起写进沙箱 metadata。收益在排障时最明显:拿到任何一个业务沙箱,不用查任何内部状态,从 metadata 就能直接读出它属于哪条产品线、哪个环境、哪个会话。模板用的是双模板设计,一个主模板加一个轻量模板,调用方可以用 template_id 覆盖,按任务的资源量级选择镜像。Cube 这一侧,我们直接调用官方 SDK 的 create 和 connect,没有再包一层自建抽象;生命周期配置用的是 on_timeout=pauseauto_resume,空闲租约会被转入"软预留"状态而不是直接 kill,会话回来时能直接恢复现场。create、reconnect、活跃 run 的心跳这三个时机,都会调用 set_timeout() 续期并校验返回的 endAt;一旦续期失败,我们会主动终止这次 run,而不是让它带着不确定的存活时间继续跑。

一次 run 从被接受到完成,走的是一套固定的时序:

  • started 之前,通过 Redis Lua 脚本原子获取准入槽——桶级限额和全局上限一并判定,最长等待 30 秒,超时会返回一个可重试的 CAPACITY_EXCEEDED。这一步的关键是把"检查名额"和"占住名额"合并成一次原子操作:一段 Redis Lua 脚本在服务端一次执行完"读计数、比上限、通过则加一"——桶级上限必判,全局上限在全局闸启用时一并判——中间没有任何别的请求能插进来的窗口,多副本并发下也不会出现两个请求同时看到"还剩一个名额"而超卖。名额的释放同样走原子脚本:创建失败当场回滚;已建成的沙箱,要等确认沙箱确实已不存在之后、随 owner 记录一起释放,中途意外残留的计数也会被 GC 清扫兜底回收。拿到名额后,如果命中已有的 owner 租约,就用 connect 复用同一个沙箱,否则才走 create;拿到沙箱之后还要做一次数据面探活(外层 3 次、内层 3 次重试),新建的沙箱从 create 返回到数据面真正可用,中间有数秒到十几秒的激活延迟,探活失败的沙箱会被直接丢弃并重建,不会带着一个不确定能不能用的沙箱继续跑下去。
  • started 到 progress 这一段,是把工作区和上下文写进去,用 nohup 分离方式启动入口进程,短命令会立刻返回。
  • progress 到 completed 这一段,是单循环、严格串行的轮询:扫描产物增量、发送 progress 事件、读 entry.exit 判断是否完成、检查取消标记和 deadline,每 30 秒心跳一次并顺带 rebase endAt;不管这个循环从哪个出口退出,finally 里都会执行一次幂等的 pkill,确保不会有残留进程。

一次 run 的 SSE 时序

图 2:一次 run 的 SSE 时序

这套"分离式启动加严格串行"的设计,是我们在 Cube 上踩过的一个坑换来的。早期的实现是"长命令阻塞加并发轮询",两者同时作用在同一沙箱上会让 envd 互相阻塞,曾导致 app_generation 出现 ReadTimeout 或挂死,当时先默认关闭文件流,改成分离式启动加严格串行之后才重新打开。

这套流程解决了"一次 run 怎么跑完",但还有一个更基础的问题:**同一个会话怎么保证每次都能找到、也只能找到自己那个沙箱。这靠的是租约机制。**这两个接口是我们自己设计的,语义刻意收得很窄。获取侧:POST /v1/leases 带上 owner_id(连同 namespace 和 env),调用方还可以为每一代会话附一个自己生成的 lease_id,作为条件释放用的代次(epoch)标记;如果这个 owner 已有存活的租约、且模板兼容,接口会返回同一个 sandbox_id 并标记 reused=true——会话续聊时拿回的就是原来那个沙箱。释放侧:POST /v1/lease-releases 必须同时带 owner_idlease_id,只有这一对与服务端当前持有的那一代(epoch)完全匹配,才会真正 kill 沙箱并释放名额(清理异步完成);不匹配时只把这个过期代次封存作废,绝不会回退成"删当前租约"。这条规则解决的正是前面提到的问题——迟到的清理请求可能撞上旧租约已过期、新一代租约已接管同一会话的情况,如果不校验就删,删掉的会是新一代正在用的沙箱。引入 epoch 匹配后,这个问题彻底消失。

安全回收:三色标记与官方生命周期的分工

我们重写后的回收器设计,参照了 JVM 和 Go 里三色标记清扫加代际宽限的思路。

  • 黑色是根——心跳保持新鲜的 run、租约、操作锁,永不回收;
  • 灰色是宽限期内的沙箱,禁止删除;
  • 白色——连续两个确认周期无根引用、且 generation(代次编号)没有变化的沙箱,才会真正被清扫。

三色标记清扫示意

图 3:三色标记清扫示意

清扫前会对每个沙箱先取分布式锁,再复核一次 generation 和根集合,确认 kill 成功或查询返回 404 之后,才删除 owner 元数据、释放对应的准入配额。

四项参数里只有 30 秒心跳是自定节拍,其余三个都由它派生:黑根判定 120 秒(4 倍心跳,容忍连续丢失 3 次)、新生宽限 180 秒(3 倍 GC 周期,覆盖数据面激活延迟)、确认周期连续 2 轮(单次清单不可信,平台可能漏掉 paused 沙箱)。

回收器四项参数与取值依据

表 1:回收器四项参数与取值依据

我们这套回收器,是被三轮同一个根因的事故反复打磨出来的,并非一次设计到位:

第一轮是 2026 年 7 月 13 日的生产事故:sweeper 失效导致 100 个僵尸沙箱(93 个处于 paused,endAt 逾期 23 到 41 小时)占满配额,依赖沙箱的功能全部不可用,催生了 endAt 逾期兜底——超过 1800 秒宽限就 best-effort kill 并释放记账。

**第二轮问题的根因和第一轮相同,但换了个形态复发。**我们的 GC 里有一条规则:"只要一个沙箱还持有租约,就当成根对象、永不回收"。这条规则本意是保护正在使用的沙箱,但它没有考虑租约本身也可能已经过期作废。叠加 Cube 平台把 paused 状态的沙箱也放进沙箱清单里返回(导致我们的强杀逻辑走不到这些沙箱),最终结果是租约早已过期的 paused 沙箱一直被判为"根对象、不能删",两条产品线分别积压了 85 个和 173 个过期僵尸。我们的修复思路是,把"要不要回收"这件事从"机制"层拆到"策略"层去做,让每个资源桶显式声明自己的保留期(单位:秒),只有当桶的保留期大于 0、沙箱确实处于 paused 状态、并且已经超过保留期这三个条件同时满足,才判定为可回收,其他情况仍然保留原来的"永不回收"行为。

**第三轮是一个更隐蔽的自锁问题。**我们的 GC 依赖"最近一次成功获取到的清单"作为对账基准。如果获取失败或者结果异常(比如清单突然从有变没),GC 会跳过这一轮扫描不做任何动作,等下一轮再重试。但有一种边界情况会让这套保护变成陷阱:当系统里最后一个沙箱正常触发 auto-pause 时,清单会从"有一个"直接降到"零个"。这个数据形态和"Cube 平台彻底联系不上、拿不到清单"长得一模一样,两种情况在我们眼里没法区分。原逻辑遇到这种"疑似失联"的情况就一直跳过对账,配额耗尽后反而没有新沙箱能进来解锁这个状态。修复办法是给"跳过"设一个上限(默认连续 3 轮),超过就强制恢复对账并记录日志;中间那段"看不清"的窗口,靠单独查询单个沙箱状态来兜底。

同一轮改动里还补了一个反向测试:让一个空闲租约经历两个确认周期,然后断言"这期间续期次数必须为 0",防止 GC 扫描时顺手给空闲沙箱续了期。一旦续期,这个沙箱就永远不会被官方 autoPause 处理。

resume 对准入计数的影响:resume-then-kill 目前只在分支上,还没合入 main。背景是 Cube v0.5.1 拒绝对 paused 沙箱执行 DELETE(错误码 130593),connect() 唤醒正常情况下有效但是 best-effort——一次瞬时失败会让 kill 持续返回 500,该沙箱可能跨越每一轮 GC。这里可以直接回答一个关键问题:resume 不会导致准入计数超限,因为计数以 owner 记录为单位,paused 沙箱从未释放槽位,唤醒后 kill 不会重复计数,槽位只在 kill 成功或确认 404 后释放。但物理资源会短暂占用——每沙箱 2C/2G,若把当前积压的 paused 沙箱逐个唤醒再 kill,瞬时会占用 212 vCPU / 217 GB,这也是我们希望平台直接支持对 paused 沙箱 DELETE 的原因。

整体演进路线

表 2:整体演进路线

使用 Cube Sandbox 后的三项成效

回收机制解决的是沙箱用完之后怎么安全释放的问题,但在沙箱被创建之前,还需要一道闸门来控制同时能开多少个。我们靠 Redis 支撑的准入计数来实现准入控制:按 namespace 和 env 分桶限额加全局闸门,测试和生产环境默认容量分开,每次申请最长等待 30 秒,多个副本执行和释放的是同一份计数。

这套机制上线后,有三项比较直接的成效:

第一,沙箱启动延迟大幅下降。 烤模板 hot-start 实测中位数 180 ms、P95 下 210 ms,冷启动也能稳定在一秒以内。正因为官方启动已经足够快,我们把自建的 warm pool 整体下线。

第二,架构复杂度大幅下降。 warm pool 退役加沙箱脚本改成预烤方式,这一轮净删除约 1350 行代码(新增 193 行、删除 1542 行),沙箱运行时的核心文件单独减少 650 行,WarmPool、PoolRegistry、池分支逻辑、skip_pool 开关,整条有状态链路都被去掉了。两条产品线原来各写一套的沙箱逻辑,收敛成 1 个共用服务,用 198 个测试用例守住这条边界。

第三,admission 逻辑上限从 200 放宽至 500 沙箱,解除僵尸租约导致的配额死锁,而且没有增加任何额外机器资源。

这里也说明一下,"500"这个数字,第一,500 是 runner 自己 admission 闸的逻辑上限,不是 Cube 单个 Cubelet 节点的真实容量上限,后者我们没有测试过。第二,我们没有做过压测,500 并非压测得出,也没有做过 CPU、内存、网络、调度层面的分层压测,给不出真正的瓶颈数据。已知的口径只有每个沙箱 2C/2G,真正的上限取决于 Cube 集群的物理容量,但我们至今没有逼近过——Cube 侧线上全部容量类报错都是 runner 准入闸抛出的 CapacityExceeded,根因是僵尸租约占用记账,不是 Cube 报资源不足;前面提到的 106 个僵尸沙箱、212 vCPU / 217 GB,在集群上最长停留 32 天,平台侧没有任何告警。当前是单副本部署,日常规模在千个沙箱以内,500 这个上限从未被真正逼近过。

总结

回过头看,我们两条产品线各自实现自己的沙箱逻辑持续五周半,一次泄漏故障,让我们两天内把服务抽出来,又用两周半完成整体切换,最后花四天集中重构生命周期管理。

当前线上真实状态(数据截至 2026-08-13)是:106 个沙箱全部 paused,没有一个在 running,最长的一个已停留 32 天;GC 运行正常,但真正符合回收条件的候选只有 4 个——原因不是 GC 坏了,而是按桶保留期默认为 0(永不回收,为的是不影响老用法),这批沙箱所在的桶恰好没声明过保留期,累积到了配额上限。这个"默认永不回收"的保守选择,代价是攒下了一个月的僵尸沙箱。

沙箱平台真正难的地方从来不是怎么创建沙箱,而是怎么安全地删掉它。每一次"删还是不删"的判断,都要在"误杀正在用的会话"和"放任泄漏"这两种风险之间,找到一条站得住的边界。

感谢广晟数科冯佳奇老师及其团队,对以上内容的贡献。

如果你有关于 CubeSandbox 的文章想贡献,欢迎提交 PR!前往 GitHub 贡献 →