一个从未运行的功能,只因一个服务缺少了密钥
某个功能自发布之日起,每次请求都失败了。四个部署目标中的一个缺少凭据,而本地环境掩盖了这个问题。
一个功能上线了,从那天起,所有对它的请求都因 503 错误而失败。四天里都没人注意到。原因不是该功能中的 bug。共享同一个容器镜像的四个部署目标之一缺少一个 API 密钥,而本地开发环境总是提供该密钥,因此再多的本地测试也无法发现这个问题。本文将介绍该故障的表现形式、为何仅凭响应时间就能定位到故障层面,以及本可以避免此问题的核对清单。
1.4 毫秒的故障能告诉你什么
故障端点的访问日志如下所示:
14:20:07 503 /api/v1/extract/preview 0.001846s
14:20:06 503 /api/v1/extract/preview 0.001463s
14:20:06 503 /api/v1/extract/preview 0.001414s
一天内十九个请求,全部是 503 错误,全部在两毫秒以内。这个数字就是完整的诊断结果。
该端点会解析两个上传的文件,通过语言模型运行两次,成功时需要 30 到 60 秒。一个在 1.4 毫秒内返回的失败,说明它根本没有尝试执行任何这些操作。它没有读取请求体。它在入口处就被拒绝了。
在你阅读任何一行代码之前,响应时间就已经将故障分层归类了:
| 故障延迟 | 含义 |
|---|---|
| 几毫秒以内 | 在执行任何工作前,因前置条件检查而被拒绝 |
| 与正常延迟相似 | 在执行了大部分实际工作后,在内部失败 |
| 达到平台超时时间 | 因某个从未响应的依赖项而挂起 |
| 波动极大 | 资源争用或不健康的实例池 |
一个耗时与成功请求相当的 503 错误,意味着某个依赖项在处理中途宕机了。一个立即返回的 503 错误,则是由守卫子句(guard clause)导致的。这是两种不同的错误,需要不同的修复方法,而时间戳列在你打开编辑器之前就已经告诉你属于哪一种了。
在这个案例中,守卫子句是位于处理程序顶部的一个能力检查:
if s.embedder == nil || s.primaryLLM == nil || s.secondaryLLM == nil {
writeError(w, http.StatusServiceUnavailable, "models are not available")
return
}
自从该功能发布之日起,那三者之一在每个实例、每个请求上都为 nil。
为什么本地开发会隐藏缺失的凭据
这个 nil 值是第二个语言模型客户端,它仅在 API 密钥存在时才会被构建:
if cfg.OpenAIKey != "" {
s.secondaryLLM = newClient(cfg.OpenAIKey)
} else {
log.Warn("secondary model disabled: API key not set")
}
该警告在每次实例启动时都会打印,持续了四天。没有人去查看一个看起来运行正常的服务的启动日志,因为它确实运行正常。应用程序中的所有其他页面都能正常工作。
该服务是基于同一个容器镜像构建的四个部署目标之一:一个公共 API、一个预发布副本、一个夜间批处理作业和一个内部审查工具。其中三个有密钥。内部工具没有。
| 部署目标 | 密钥存在 |
|---|---|
| Public API | 是 |
| Staging | 是 |
| Nightly batch job | 是 |
| 内部审查工具 | 否 |
那个目标之所以不同,其原因值得一提,因为它具有普遍性。这个内部工具一直以来只需要一个数据库连接。它是一个带有编辑按钮的表格查看器。它的部署配置列出了一个数据库 URL 和一个缓存密码,这个列表在几个月里都是正确的。然后,该工具中上线了一个调用语言模型的功能,配置列表背后的假设悄然不再成立。代码变了。部署规范没有变。
本地开发使得这个问题无法被发现。在开发人员的机器上,密钥来自 .env 文件或共享的配置加载器,因此客户端总是能被构建,保护性子句也永远不会触发。该功能的每次本地运行都成功了。针对本地服务器的集成测试也成功了。这个故障只存在于代码现在所需与某个部署实际所提供的内容之间的差距中,而在笔记本电脑上运行的任何东西都无法看到这个差距。
正是这个特性使得凭证漂移比普通错误更棘手。大多数错误至少会在你关注的一个环境中失败。而这个错误除了在生产环境中,在其他任何地方都能成功,并且生产环境中的失败是静默的,除非有人打开那个特定的屏幕。
丢失了自身消息的错误
服务器并未含糊其辞。它回答了一个特定的句子:
{"detail": "models are not available"}
浏览器显示 Request failed (503)。
将失败响应转换为消息的客户端辅助程序读取了三个键,而服务器实际使用的那个键并不在其中:
async errText(r) {
const d = await r.json();
return d.error || d.message || `Request failed (${r.status})`;
}
API 统一使用 detail 字段。前端的辅助函数是根据一个不同的约定编写的,之后也从未更新过。每个路由返回的每个错误体都被获取、解析,然后丢弃,只留下一个状态码。修复只需一个标识符:
return d.detail || d.error || d.message || `Request failed (${r.status})`;
这种不匹配在正常流程中是不可见的,在对状态码进行断言的测试中也是不可见的。它只在有人试图根据截图进行调试时才会出现,而这恰恰是你最需要它的时候。错误路径上的契约不匹配一开始没什么成本,直到它耗费你数天时间。
另外两个较小的改动使得剩余的诊断可以自助完成。现在,卫语句会指明缺少哪个功能,而不是用一句话来概括其中三个:
func (s *Server) missingCapabilities() []string {
var miss []string
if s.embedder == nil { miss = append(miss, "embeddings") }
if s.primaryLLM == nil { miss = append(miss, "primary model") }
if s.secondaryLLM == nil { miss = append(miss, "secondary model (API key)") }
return miss
}
并且,拒绝信息会连同该列表一起被记录下来,因此答案会出现在响应正文和日志行中,而不仅仅是出现在四天前就已滚屏消失的启动警告里。
添加外部依赖的检查清单
修复本身只用了一条命令。注入缺失的密钥只花了几秒钟,端点在第一次尝试时就从 1.4 毫秒内返回 503 变成了 30.5 秒内返回 200。有趣的部分是,有五项检查本可以让这四天的时间变得毫无必要。
- 列出运行此镜像的每一个部署目标。 不是你正在测试的那个。服务、预发环境副本、批处理作业、内部工具。相同的二进制文件意味着相同的新需求。
- 更新创建脚本,而不仅仅是线上服务。 对正在运行的服务进行一次性注入,对于下一个从脚本重新创建它的人来说是不可见的。如果你的重新部署只是替换镜像,那么注入的内容会保留下来,而这恰恰是没人注意到脚本现在已经出错的原因。
- 让启动警告显式失败,或者让健康检查知晓。 对于一个在其他方面能正常启动的服务来说,一条
log.Warn日志并不是一个信号。要么在已声明的功能无法工作时拒绝启动,要么在有人查看的地方暴露其能力状态。 - 返回缺失项的名称。 将多个能力组合在一条消息之后,当其中一个缺失时,会让你在诊断上付出全部代价。
- 假设本地测试无法验证第 1 步和第 2 步。 这是令人不安的一点。你的机器上有凭据。你在本地运行的任何测试都无法证明部署环境中有它。
第 1 步和第 2 步是真正能防止此类故障的步骤,但它们也是任何测试套件都不会为你做的两件事。它们是一种习惯,而不是一个工具。
常见问题
当凭据缺失时,服务是否应该拒绝启动?
这取决于该依赖是核心的还是可选的。如果缺少它会导致整个服务都无法使用,那么就应该在启动时快速失败,这样部署就会明显失败并回滚。如果它只为众多功能中的一个提供支持,那么启动是正确的,但此时能力状态必须在启动日志以外的地方可见。一个看起来健康但已降级的服务是两者中最糟糕的情况。
部署后的冒烟测试能发现这个问题吗?
只有当冒烟测试运行了那个特定功能时才行,但对于一个耗时 30 秒的语言模型调用来说,在每次部署时都运行它成本太高。一个成本更低的版本是设置一个就绪端点,用于报告哪些可选功能是活动的,并增加一个部署步骤,将该报告与发布版本所期望的状态进行比较。你测试的是配置,而不是行为,所以它可以很快。
这只是一个基础设施即代码(infrastructure as code)的案例吗?
声明式的基础设施有帮助,因为部署规范与需要它的代码放在一起,审查者可以在同一个变更中看到两者。但这并不能消除问题。仍然需要有人将新的密钥添加到正确的目标中,而且一个内容错误的声明式文件会可靠地部署错误的内容。它带来的好处是,错误在差异(diff)中可见,而不是只存在于控制台中。
如何找到这种偏差的现有实例?
枚举共享同一镜像的部署目标,并对它们各自的环境和密钥列表进行差异比较。差异并不一定就是错误的,因为一个批处理作业理所当然地比一个公共 API 需要更少的配置。但每一个差异都应该有一个人能说出原因。本次事件中的那个差异就没有任何原因。它只是比需要它的那个功能更旧而已。