1739 字
9 分钟
幂等键必须带上租户,否则会把别人的工作区交出去

幂等键是调用方自己取的一个字符串。两边各自按一套固定规则生成它——按任务名、按任务内容 hash——很容易撞上。撞上之后,后来的那个请求会 201 拿到前一个正在跑的沙箱,而那个沙箱挂的是前一个调用方的工作目录。我的判断是:幂等匹配的作用域得服务端自己划,不能全托给调用方给的那个字符串。

同一个键两个调用方,摘要带不带租户的两种结果

同一个键,后来的拿到别人的沙箱#

建一个沙箱不是空操作:起一个 pod,占 2 核 4G。响应在回程丢了,客户端判断不出服务端到底建没建——重试可能多出一个沙箱占着资源不放,不重试这次任务就白丢。Idempotency-Key 解的是这个:同一个键重试拿回同一个沙箱。

键由调用方提供,服务端不猜。猜不准反而会把两个本该独立的沙箱合成一个。

原来的摘要是 sha256(key)[:32],只由这个键算出。

工作区是按归属挂进去的。传了 workspace_owner,共享盘上 {盘根}/{owner}/ 下的四个子目录会分别挂到容器的 /workspace 和三个 agent 的状态目录。认领到别人的沙箱,之后每一条命令读写的都是别人那四个目录。

调用方看不出异常:201,拿到一个 sandbox_id,exec 正常返回,文件读得到。只是读到的不是自己的文件。

在此之前,幂等占位表只在进程内存里。多副本时同键重试落到另一个副本,那边查不到占位就当首次,于是建出第二个沙箱。16:53 的那次改动把摘要写进沙箱 metadata(透传成 pod label),占位表没命中就按这个摘要问引擎还有没有活着的沙箱,有就认领。跨副本的幂等这样才成立。

功能上这是对的。代价是这个缺陷的窗口跟着变了:原先它只存在于单进程内存里,进程重启就失效,也只影响这一个副本;写进 metadata 之后,窗口是那个沙箱活着的全程、任意副本。

租户是 19:56 补上的,中间隔三个小时。没有线上碰撞记录,这是改完跨副本认领之后自己复查出来的,实际撞上一次我没有观察到。

作用域得服务端自己划#

摘要改成 sha256(作用域 | 键),作用域里拼进租户。

租户要带长度前缀。不带的话 owner="ab" 配 key="cd"、owner="a" 配 key="bcd",拼出来是同一个串,算出同一个摘要。None 和空串也要能区分——没传归属和传了个空字符串不是一回事。

归属是可选字段,文档支持的默认用法就是不传。不传时不能退回一个共享标记:那样所有不带归属的请求又落进同一个作用域,摘要变回只由调用方那个键算出,等于绕回原来的毛病。改成用整个请求体的指纹兜底——重试发的是同一份请求,匹配得上;别人用同一个键但请求不同,匹配不上。

残留缺口写进了注释:两个调用方的请求逐字节相同、键也相同时仍然会匹配。这种情况服务端确实分不出它们。

这个改动的第一稿只改了一半。「跨副本按 metadata 认领」那条路加了租户,同一个副本内的进程占位表还按调用方给的原始键索引。alice 先建,bob 带自己的归属用同一个键请求,直接等回 alice 那个 Future,201 拿到 alice 的沙箱,引擎那边只收到一次创建。

而生产部署是单副本(部署配置里副本数写的是 1),占位表才是主路径。半截修复正好补在次要的那条上。这条是在复查同一次改动时抓出来的,和租户那一版一起提交。

占位表的键在结果发布之后不删,窗口持续到表 FIFO 淘汰(上限 1 万条)或者进程重启。

下游会过滤,不是自己不校验的理由#

认领要先问引擎:有没有带这个摘要、还活着的沙箱。原来的写法是带上 metadata 过滤参数去列举,拿回来的条目里取第一个活着的就返回。

服务密钥是全集群共用的。不带过滤的 GET /v1/sandboxes 会返回所有调用方的沙箱。过滤参数一旦不生效,这段代码就是无条件认领列表里的第一个。它出错的时候是放人进来,不是把人挡住(fail-open)。

参数真的写错过一个:原先传的 limit 不是引擎认的参数名(应为 pageSize),传了等于没限制。写错的参数名不报错,只是被忽略;过滤条件写错也一样。

认领前自己再核一遍:逐条比对沙箱上记的摘要和归属,对不上就当没查到、照常新建。

这道核对要成立,metadata 就不能由调用方随便写。而沙箱的 metadata 本来就是个自由字典,不剔除的话,谁都能不带幂等键建一个沙箱,把 idem / idem_owner 写成受害者的值——那道「自己再核一遍」核的就是攻击者能控的数据。这几个保留键一律从调用方输入里剔掉,只能由服务端写。

查不动的时候(超时、4xx、应答不是 JSON)一律按没查到处理,继续新建。这同样是放行,但放的是另一个方向:多建一个沙箱浪费 2 核 4G,认错一个是把别人的工作区交出去。两种错不对称,这一边故意往「多建」偏。

没测到的#

两个租户在真实集群上各建一个沙箱、比对各自拿到的是不是自己那个,这条没跑。它要建两个真沙箱,标成 mutating,集群那轮默认不跑。跑过的是假引擎客户端那几条:两个归属带同一个键各建各的、引擎收到两次创建;alice 的沙箱已经在列举结果里时,bob 同键进来必须拿到新建的那个。另有几条是摘要算法的纯函数用例——归属不同摘要不同、长度前缀让不同归属拼不出同一个摘要、同归属同键摘要稳定。

跨副本并发同键仍然可能重复建:两个副本同时走到认领查询、都查不到,各建一个。这条没堵,因为同键并发出现在响应丢失后的重试里,而重试总在首个返回或超时之后。