一个沙箱服务的生产副本数从 2 降到 1,原因是建沙箱的幂等表、调度熔断的失败计数、排队人数都存在进程内存里,两个副本各记各的,同一个键的重试落到另一个副本就会再建一个沙箱。当时写进文档的结论是「恢复多副本的前置是把这些状态挪到 Redis」。后来逐件搬完才发现这句话是错的。我的判断是:副本数扩不上去,锁死它的不是缺一个外部存储,是没人把进程内状态逐件列出来过。
降副本是拿可用性换正确性
这个服务对外只做四件事:认证、幂等、排队、把建沙箱和执行命令转给引擎。幂等是其中最硬的一条——调用方的 HTTP 客户端响应丢了会自动重试,带着同一个 Idempotency-Key 再来一次,服务端必须返回上次那个沙箱,不能再建一个。
实现是一张进程内的占位表:键在真正执行之前先占一个 Future,同键的重试等这个 Future 拿同一个结果。这张表只在一个进程里。两个副本,负载均衡把重试扔给没有这张表的那个,它查不到,于是照建不误。
调度熔断同理。引擎等 pod 调度 300 秒仍 Pending 才回 504,每个请求都要白等 5 分钟,所以服务端在滑动窗口里数这种超时,默认 10 分钟内 3 次就熔断 60 秒,期间直接回 503 带原因。计数也在内存里。两个副本各数各的,实际要 6 次才熔断。排队人数上限 500 也一样,两个副本实际放进来 1000。
把生产副本从 2 改成 1,这些问题全部消失。代价是节点 drain、Pod 被驱逐、OOM——任何一件发生,整个服务就是中断,没有第二个副本接。
宁可偶尔整体不可用,也不要悄悄多建一个沙箱、或者把同一条命令跑两遍。但副本数不该一直停在 1。
答案写在沙箱自己身上
搬第一件的时候,问题换了个问法。
建沙箱幂等要回答的是「这个键对应的沙箱是不是已经建过了」。进程内存只是存这个答案的一个地方,而且是最差的一个——进程重启就没了,换个副本也没了。沙箱本身是存在的,它在引擎那里有记录,有 metadata 字段。答案可以写在它身上。
做法是创建时把 sha256(租户作用域 | Idempotency-Key) 的前 32 位写进沙箱的 metadata.idem,租户本身写进 metadata.idem_owner。占位表没命中时,按 metadata=idem=<摘要> 问引擎要存活的沙箱,命中就认领返回。
这一下哪个副本都能问到同一个答案,不用再依赖自己那份内存。
有几个地方不这么写会出事:
摘要必须含租户。键是调用方自己取的,两个调用方各按确定性规则取键很容易撞上。只由键算摘要的话,后来的那个会 201 拿到前一个正在跑的沙箱——而工作区是按 workspace_owner 绑目录的,之后所有读写都落到别人的目录里。窗口不是「一次重试」,是前一个沙箱活着的全程。
认领前要自己再核一遍 metadata.idem 和 metadata.idem_owner。服务密钥全集群共用,不加过滤条件列一遍,所有调用方的沙箱都会回来;只信引擎那边的过滤,等于过滤一失效就无条件认领别人的。
查不动就当没查到。超时、4xx、应答不是 JSON——一律按「没有」处理,多建一个沙箱比让一次正常创建失败好。
第二件是判断工具沙箱还活着没有,要先知道它在哪个 Pod 上。绑定发生在哪个副本,Pod 名就只在那个副本的内存里。引擎接口不暴露 Pod 名,但它的控制器把分配结果写在 CRD 的一条注解里,读那条注解就能定位。
为此加了个 k8s 只读客户端,Role 只有一条 get batchsandboxes——不给 PVC、不给 Pod、不给 Secret。这个 ServiceAccount 的 token,沙箱进程在网络上够得到;多给一条权限,出事的时候能被摸到的东西就多一样。
两件都没用到 Redis。要读的东西一直在那里,只是以前没去读。
列成一张表,不要笼统说「要上 Redis」
搬的过程里真正管用的一步,是把每一项进程内状态列成一行,标注它在多副本下是什么状态。
| 项 | 多副本下 |
|---|---|
| 建沙箱幂等 | 可用。同副本靠占位表、跨副本靠沙箱 metadata,两条路用同一个含租户的摘要索引 |
| 工具沙箱存活判定 | 可用。代价是多一次 k8s 读,可能还要把节点全扫一遍 |
| 调度熔断 / 排队计数 | 会退化。各副本各数各的,阈值和排队上限实际翻倍,配一半可抵消 |
| exec-jobs 幂等 | 仍是纯进程内。同键重试落到另一副本会把命令再执行一遍 |
| agent-package git 目录锁 | 只锁本进程。两副本对同一目录并发提交会撞 index.lock,失败记 warning,下次提交带上这次的改动 |
有了这张表,「要上 Redis」这句话就没法说了。五行里只有两行有缺口,一行只要把配置值改一半就能抵消,另外两行已经不需要外部存储。
列表之前,这些状态在我脑子里是一团「幂等相关的东西」。列完才看清它们的性质完全不同:有的能写进沙箱自己身上,有的只是个数字、翻倍了改配置就行,有的是真缺口。
那句「恢复多副本的前置是把这些状态挪到 Redis」写在文档里,日期是 2026-09-16。9-17 搬完前两件后,我没有删掉它,而是在原处加了一条订正,写明哪部分失效、为什么失效、现状去看哪一节。留着比删掉有用:下次有人想不通为什么不上 Redis,能看到这条路当初是怎么想的。
剩下的两个缺口
exec-jobs 的幂等没有便宜的解法。命令执行不像建沙箱那样产出一个带 metadata 的对象,摘要没地方挂。真要搬,要么引擎侧提供按幂等键查命令的接口,要么确实引入一份共享存储。
agent-package 的 git 目录锁是 dict[Path, asyncio.Lock],锁只在本进程里。影响比 exec-jobs 小:撞上了丢的是一次提交时机,改动并进下一次提交,不会多跑一遍命令。
生产副本数现在仍然是 1,四个环境都是 1。要改,判据是逐条过那张表,不是看 Redis 有没有部署。
这两条缺口我没有测过。没有在两副本下跑过同键重试,也没有跑过并发 commit,表里写的是从代码读出来的行为,没有一条来自观测到的失败。真要扩副本,这两条得先构造出来看一遍。