---
url: https://cubesandbox.com/zh/blog/posts/2026-09-03-guangdong-rising.md
description: >-
  创建一个沙箱只需要一次 API
  调用，删掉一个沙箱却要回答一串问题：它还有活跃会话吗？这条清理请求会不会是迟到的那一个？崩溃的副本留下的沙箱谁来收？广晟数科团队把两条产品线的沙箱能力收敛成
  SIN PaaS 的独立沙箱执行层，用租约、fencing token、三色标记回收和准入控制回应了这串问题。
---

# 谁有权删掉一个沙箱：在 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，不判断沙箱内是否有命令在执行。

![部署和调用关系](./assets/2026-09-03-guangdong-rising/01-deployment-topology.jpg)

*图 1：部署和调用关系*

每个请求都携带 namespace（产品/团队边界）加 env（dev/staging/prod），runner 据此推导出一个统一的 `owner_id`，写进沙箱的 metadata；后续无论是清理、取消还是排障，都用同一套可预测的 key 格式去查，所有沙箱都能追溯到具体归属。具体规则是：除非调用方显式指定，`owner_id` 由 namespace、env、项目 ID、会话 run ID 按固定顺序拼接而成，人眼可读；对跑业务的沙箱，runner 会把 namespace、env、`owner_id` 和 `managed_by` 一起写进沙箱 metadata。收益在排障时最明显：拿到任何一个业务沙箱，不用查任何内部状态，从 metadata 就能直接读出它属于哪条产品线、哪个环境、哪个会话。模板用的是双模板设计，一个主模板加一个轻量模板，调用方可以用 `template_id` 覆盖，按任务的资源量级选择镜像。Cube 这一侧，我们直接调用官方 SDK 的 create 和 connect，没有再包一层自建抽象；生命周期配置用的是 `on_timeout=pause` 加 `auto_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 时序](./assets/2026-09-03-guangdong-rising/02-run-sse-sequence.jpg)

*图 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_id` 和 `lease_id`，只有这一对与服务端当前持有的那一代（epoch）完全匹配，才会真正 kill 沙箱并释放名额（清理异步完成）；不匹配时只把这个过期代次封存作废，绝不会回退成"删当前租约"。这条规则解决的正是前面提到的问题——迟到的清理请求可能撞上旧租约已过期、新一代租约已接管同一会话的情况，如果不校验就删，删掉的会是新一代正在用的沙箱。引入 epoch 匹配后，这个问题彻底消失。

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

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

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

![三色标记清扫示意](./assets/2026-09-03-guangdong-rising/03-tri-color-marking.jpg)

*图 3：三色标记清扫示意*

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

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

![回收器四项参数与取值依据](./assets/2026-09-03-guangdong-rising/04-gc-parameters.jpg)

*表 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 的原因。

![整体演进路线](./assets/2026-09-03-guangdong-rising/05-evolution-roadmap.jpg)

*表 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（永不回收，为的是不影响老用法），这批沙箱所在的桶恰好没声明过保留期，累积到了配额上限。这个"默认永不回收"的保守选择，代价是攒下了一个月的僵尸沙箱。

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

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