1462 字
7 分钟
取消请求和回收资源是两件事

客户端超时断开,服务端那边的创建不会跟着撤销。我的判断是:取消请求和回收资源是两件事,取消只管得住自己的等待,已经建出来的东西得有人专门去删。

放弃之后,服务端留下了什么

50 毫秒就放弃,16.4% 照样建成#

实验集群只有 1 台 8 核 15 GiB 的真机器,另登记 50 台 KWOK 模拟节点(只在 Kubernetes 里存在,不下载镜像也不跑容器),把并发拉到千级。

客户端照常握手、照常把请求发完,只把「等响应」这一段设成 50 毫秒,模拟 SDK 等不及先断开。放弃之后再等二三十秒,数服务端实际建出来多少。

建法放弃次数服务端仍建成比例
直接建 pod97524925.5%
经开源引擎建97716016.4%

两组各跑 1 次。唯一的变量是写进去的东西:一边直接写 pod,一边写引擎自己的沙箱对象,再由它的控制器去建 pod。

这组数第一次测错了。最早把超时设成 30 毫秒,还把 TLS 握手一起算进这 30 毫秒,结果 927 次在握手阶段就断了,只建出 4 个。看着像「几乎不漏」,实际是请求根本没送到服务端。「客户端放弃」要说清在哪一步放弃,数才能用。重做一遍,握手和发送都走完、只掐等响应,才是真实场景。

断开为什么拦不住创建#

过了向 Kubernetes 发写请求这一步,就收不回来了。

写进去的是一份期望状态,不是一次函数调用。记录落库之后,调度器和控制器照着它往下执行,和当初那条 HTTP 连接再没有关系。客户端断开只做成一件事——服务端那边的 context 结束了,它不再等响应。已经写进去的记录不会因为调用方走了而消失。

直接建 pod 这条路短,请求更快冲过这一步,漏掉的就多;经引擎多一跳转发,取消有更长的窗口追上创建,于是少一些。9 个百分点就是这一跳换来的。少掉的那些具体卡在哪一步——引擎接口还没发出写请求,还是别的地方——我没查。

这一跳也让漏掉的东西变多:引擎的沙箱对象漏掉一个,它的控制器照样把下面的 pod 建出来,删的时候也要连着底下的 pod 一起收。

一晚 49 个#

线上一个集群的节点在某个晚上被占满。查下来是平台自己的测试套件留下的:一晚 49 个沙箱,没人在用也没人删,其中 26 个的成因没查清。

建沙箱是同步请求,引擎要等 pod 分到机器、拿到 IP 才返回沙箱编号,最长等 300 秒;节点的 pod 名额用满时,排队时间就接近这个数。调用方设的超时更短,客户端先断开,服务端照建,编号无处返回。当时也没有「按请求编号查创建结果」的接口,重试不带同一个编号就是再建一个。

这些沙箱活到寿命上限才消失,这段时间一直占着 pod 名额。后面的请求排得更久、更容易超时、留下更多没人删的沙箱。所以这个数不会停在某个水平上,集群越接近满载涨得越快——49 个就出现在节点刚好占满的那一晚。

还有一种更难防:引擎接口返回了错误,沙箱其实已经建好。实验里遇到过 1 次,按「失败了就不用管」处理,它一直占着资源到有效期(当时设 30 分钟)满,最后手动删掉。返回码说的是调用方收到了什么,不是远端发生了什么。

补删、对账、收到报错也删#

三条各管一段,少一条就有一类没人删。

同一个请求编号只建一次。 把编号映射成对象名字,重名就撞 409 AlreadyExists;撞上说明这个请求已经建过,把原来那个拿回来。唯一性交给存储层去保证,比在应用层维护一张幂等表可靠。这条管的是不再多建。

放弃之后自己补一次删除。 在实验原型上人为插 2 秒延迟把窗口拉宽,试了 28 次每次都删掉了,从放弃到沙箱消失 0.2 到 6.1 秒。

定期拿实际存在的和该存在的对一遍,多出来的删掉。 前两条漏下的只能靠它。原型最初的清理规则是「内存里的记录不是已删除就当还在用」,对 16 个没人要的沙箱一个也没删——服务重启后内存里没有记录,这些沙箱对它来说等于不存在。把判据换成「该不该存在」,另一轮的 15 个都在建成后 29.8 到 38.2 秒内删掉。

收到报错的那种归在第三条,按「可能已经建成」再删一次。补删的触发条件不能只写「没收到回复」。

清理本身不贵。按标签删 274 个 pod,调用 2.75 秒返回、2.69 秒全部消失;删 183 个引擎沙箱对象只要 1.64 秒,但它们底下的 pod 靠垃圾回收收尾,又花 8.45 秒。经引擎这条路删得慢,和建的时候多一跳是同一个原因。

没测到的几项:两组放弃实验各只跑 1 次;补删、定期对账、滚动升级三项只在实验原型上验过,正式服务没测;「回了报错但已建成」只遇到 1 次,没有发生率。