一个沙箱服务里约 60% 的代码是内置的 7 种 agent 运行框架。全部删掉之后,它只剩五件事。决定删哪些不难,难的是证明没人在用。我的判断是:定一个服务该做什么,先把它现在做的每件事列出来,逐条问「这件事只有我能做吗」——判据不是「这个功能有没有用」。
留下的五件事
原来的服务把权限、会话逻辑、执行框架混在一起。问题不在于做得多:直连引擎、直连沙箱、直接往 Kubernetes 里写沙箱对象,这三条路都不经过它,于是它做的检查全部可以绕开。
第一步,必须过的那几道检查交给 Kubernetes 自己——准入策略、网络策略、配额。这些是谁来建都得过的,发起建沙箱的是我的服务还是别的什么程序,没有区别。
第二步,服务只留五件事:
- 认人。API key 只存哈希,比对的也是哈希。
- 翻译。Kubernetes 的拒绝原样抛给调用方没有意义,要翻成带原因的错误码,并标出能不能重试:是该改请求,还是该等。
- 排队。按真实配额余量算,不写死个数。
- 防重复。幂等键写进沙箱对象的标签,服务重启也找得回。
- 转发。每次转发前核对目标还是就绪时那个 pod。
五条的共同点是绕过去就不成立:不经过服务就没人核对 key,没人算配额余量;幂等键不写进对象,一次重试就是再建一个沙箱。
删掉的那些反过来看:建 pod、维护预建沙箱池、跑 agent 框架,这三件绕过服务照样做得成,而且上游引擎做得比我好。预建池这条补测过:领用 603 次,次次是新沙箱;一个一个领比自写的池慢约 85 毫秒,同时领持平。
按这个结构写的原型是 1818 行标准库 Python,跑通两类负载,12 轮压测串号 0、泄漏 0。转发层自身开销 2.6 毫秒(经服务 9.5 毫秒,直连 6.9 毫秒);10 路并发时两条路一样慢(63.9 对 57.3 次每秒),瓶颈在沙箱里的执行进程,不在转发层。
代价也写进了方案:状态在单进程内存,不能多副本。还有一条没测到——转发前核对 pod 名、uid、IP 能挡住 IP 被复用,这是从机制推出来的,没有构造出那个场景验过。
60% 的代码做的是别人的官方示例
7 种 agent 运行框架,各有各的参数、事件流、会话机制、错误形态。服务把它们包成一套对外一致的接口,使用方不用关心底下装的是哪一种。
这件事有用。使用方确实省掉了装框架这一步。
但它不是只有服务能做。每种框架官方都给了安装示例,使用方在自己的镜像里装一次就有了。服务里那套包装的全部价值,是替使用方省掉那一次。
代价在另一边。加一种框架要改全链路,框架升级要跟着改,7 种各改一遍。更要紧的是,有了这些代码,服务就必须理解题目、会话、训练这些概念。下层知道上层的业务,就没法再做成通用的下层。
删掉之后,服务只处理镜像、资源、挂载、存活时间和调用方身份。
删代码难在举证
加一个功能,证据是有人提了需求,一条消息就够。删一个功能,证据是所有人都没在用,这个要翻日志,而且翻多长的窗口都不构成证明。
小工具接口(有状态代码执行、浏览器、文档转换)查了 15 天的调用量,每项 0–7 次。0 可以删,7 不行——7 次可能是一个真实依赖,也可能是有人在试。所以不直接删,先从 SDK 文档里移除,等一段时间,确认无调用之后再删。方案里写的是「确认无调用后删除」,一个待执行的动作,不是一条已经成立的结论。
个别使用方的专用逻辑(默认挂载、目录布局、版本管理)更难办。它有明确的使用方,日志查不出「没人用」。这类只能交还:使用方自己传挂载路径,服务改成通用挂载参数,不再认识那个路径的含义。服务这边代码少了,使用方那边代码多了,总量没变。
最难删的是自己设计出来、还没人用过的功能。讨论过很多次、改过很多版,看着就像已经定了。
方案定稿前做过一次逐项对照,把方案里的每个功能和使用方实际提过的需求摆在一起,规则是「群里没提到的就不写」,判断权就不在我手上了——自己设计的东西我自己删不动。删掉的有:RL 相关的四项(提前告知下一批题、题目预装环境缓存、按组领用、换全新沙箱判分)、按类别分配 CPU 预留量、专用隔离池、按需加载镜像、分层存储、会话副本。
那四项 RL 设计后来核实过一遍。不只是没人要——主流框架都在答题的同一个沙箱里判分,跟「换全新沙箱判分」正好相反。自己关起门来设计,偏得比我以为的远。