1918 字
10 分钟
静默失败比报错难查,所以宁可显式拒绝

一个参数写错了,从入口到调度器没有一层报错,表现出来只是「创建卡住了」。另一个参数拼错一个字母,接口照常返回 200,拿回来的是全量数据,不是筛过的。两次都一样,出错的地方和发现的地方隔得很远。我的判断是,错了不报错、只是结果不对的失效,排查成本比直接报错高一个数量级,所以宁可在入口显式拒绝。

静默失败和显式拒绝,从出错到被发现隔了多远

cpu 写成 500M,每一层都说可以#

Kubernetes 的资源量纲里,小写 m 是毫(milli),大写 M 是 SI 的兆(10^6)。500m 是 0.5 核,500M 是 5 亿核,一个字母差 10 的 9 次方倍。

这个值本身合法。API server 的格式校验过得去,服务端的参数校验过得去,调度器拿到它也不报错——它只是找不到能装下的节点,把 pod 放在 Pending 里等。

真集群复现过一次:创建请求挂住 120 秒零响应,pod 停在 Pending,describe 出来是 req: 62500m 和 Insufficient cpu。62500m 是 62.5 核,和 5 亿核对不上,中间哪一层把它压下来的我没追。结论不受影响,整条链路上没有任何一层说这个值有问题。

调用方看到的只有「卡住了」。从这个症状往回推,要依次怀疑网络、镜像、调度器水位、配额,最后才轮到自己填的那个字符串。而能一眼看出问题的地方在最前面:接参数的那一层本来就知道 500M 不是任何人想要的值。

做法是在入口拒绝并说明原因,上限定成 64 核 / 512Gi。这个数比现在任何一种用法都宽——线上实测 2 核 4Gi 的 147 个、1 核 2Gi 的 5 个,核对下来用大写 SI 量纲的 pod 是 0 个,所以这次改动对线上的影响是零。

代价是入口要复制一份下游的单位规则。下游改了规则,入口不会自动知道。

同一条判断在另外四个地方重复出现#

统一字段在部分运行时被静默丢掉。 对外承诺「所有运行时都收同一套字段」,但有些字段在某些运行时根本没有对应的机制。最差的做法是收下来然后吞掉,调用方以为生效了,行为和没传一样。改成分三种情况处理:接得上原生机制的就真接上;接不上的降级,但回一条 notice 说明;还有一类,这一轮直接换到能让它生效的那条执行路径上去跑。「哪些字段在常驻路径上读不到」写成代码里的一个常量,路由走哪条、notice 怎么写都读它,两边不会说不同的话。

未声明的查询参数被静默忽略。 FastAPI 默认丢弃路由没声明的 query 参数。把 owner 拼成 owners 不会报错,筛选条件被无声丢掉,调用方拿到全量数据还以为在筛。改成返回 422,列出未知项和合法集;校验挂在 router 级依赖上,合法集取自框架自己递归收集的那份参数声明,写一处,全部端点都生效,新增端点不用各自记得加。

这条的代价在另一头。同一个服务里请求体也收紧成拒绝额外字段之后,前端多传一个字段就触发 422,而那个接口的请求体里带凭证 header。422 的错误处理器必须先按敏感键掩码再截断,否则凭证明文会进日志平台长期留存。收紧校验会多出一类错误响应,而错误响应要把收到的东西回显出去,这是新多出来的一处泄露。

从 JSON 解析换成 Map 列直取。 从时序表取标签值,原来每行跑一次 JSON 解析函数,等于全表解析;改成 Map 列直接取键之后,集群实测结果逐项完全一致,服务端总耗时从 1.27s 降到 0.63s。代价是「某个键落在哪个 Map 列」只能靠实测:pod、namespace、node、container.name 都在 resource_attrs,唯独网络的 direction 在 attrs。放错列不报错,取到空值,过滤永不命中,查询返回空结果。这份对应关系因此只写在一个地方,不许调用点各写一遍。

下游枚举映射不上时静默透传。 上游传进来的模型名什么都可能填,带厂商前缀、带日期后缀、空字符串都有,而每个下游运行时只认自己的枚举。做法是先精确命中白名单,再宽松匹配常见写法,认不出一律回退到本运行时的默认值并打 warning。回退必须打日志,否则用户不知道实际跑的是哪个模型。认不出也不透传,透传非法值给下游会被直接拒掉。

为什么静默贵一个数量级#

报错的时候现场还在。请求参数、调用栈、上下游状态都在,日志里有一行指着出错的位置。

静默失败要等到结果不对才会被发现,而「结果不对」的发现时间取决于有没有人去核对结果——可能是几分钟,也可能是下个季度盘点。等到那时,那次请求的现场早没了,剩下一个和预期不符的数字,链路上每一层的日志都写着成功。

排查的起点也不同。报错给的是一个位置,静默给的是一个症状,从症状倒推到成因,中间隔着整条链路。

「一个数量级」是我按这两次的排查过程估的,没真去量过两类问题的平均定位时间。

我在几份设计文档里重复写过这条,原话是「静默失败比报错难发现,宁可显式拒绝」。配套还有一句:错了不报错、只是数字不对的约束,必须由代码固定。

后一句给的是「某个约束该写进文档还是写进代码」的判据,按违反时会不会报错来分。会报错的可以靠约定,写进文档,调用方第一次踩到就改了。不会报错的必须写死在代码里并集中到一处,靠「调用方记得」一定会漏,漏了还没人知道。

阈值要写清是真跑不起来,还是防手误#

入口拒绝就要定阈值,阈值会被后人改,改之前他得知道这个数是哪来的。

显存下限 512 MiB 的理由在文档里被推翻过一次。原来记的是「CUDA context 本身要占数百 MiB,低于这个数建不起来」。后来拿裸 pod 实测,1、16、64、128、256 MiB 全部能建起上下文,原解释不成立。

512 MiB 真正拦的是量纲误写,拦的是把 8 GiB 写成 8。它防的是手误,不是物理上建不起来。

两种数的改法完全不同。真跑不起来的那种,改了就坏;防手误的那种,改了只是拦得松一点。写成「因为 CUDA context 要占数百 MiB」,后人以为动不得;写成「拦量纲误写」,他至少知道这个数可以按实际业务调。

实测只做到「256 MiB 能建起上下文」这一步,没测跑真实模型要多少显存,所以这条下限对真实负载是不是太松,我不知道。