捕获发布后立即崩溃的应用
9 月 25 日,有三次发布被标记为成功,但应用的 API 在每次启动后几秒钟就崩溃了。由于这些发布“成功”了,因此没有运行回滚;几分钟后,后台检查将它们判定为失败,整个网站则返回了 404。健康检查统计崩溃次数时,所用的数据来自托管平台的事件列表——该列表每台机器只保留最近五条事件,因此一旦发生崩溃循环,列表就会被填满,计数也就永远不会增长。现在,健康检查改为比较最新一次崩溃的时间。如果发布出现崩溃循环,它就会失败,并恢复到之前的版本。
发布在等待什么
当你在 Mythex 上发布一个带后端的应用时,新版本会部署到我们托管平台的机器上,然后由健康检查来判定这次发布是成功上线还是失败。如果失败,回滚会把之前的版本恢复回来。
难点在于,“已启动”和“正在提供服务”并不是同一回事。平台只要机器启动了,就会将其报告为已启动。而一个在启动后一秒钟就崩溃的应用,会被立即重启,过不了多久又会被显示为已启动。因此,随着时间推移,健康检查逐渐叠加了几层逻辑:
- 多看几次。 机器显示为已启动之后,稍等片刻再看一次。启动时的崩溃,往往会在第二次检查时暴露出来。
- 留意崩溃循环。 如果平台报告某台机器反复崩溃,并且它已经放弃重启,就判定失败。
- 统计本次发布开始以来的崩溃。 两次“已启动”的检查可能恰好跨越一次重启。因此,如果本次发布开始之后发生过任何崩溃,就再看第三次;如果第三次看到的崩溃数量比第二次多,就说明应用并没有稳定运行。
这里失效的正是第三项检查。
为什么计数始终不变
崩溃信息来自每台机器的事件列表:包括启动、退出和重启。平台每台机器只保留最近五条事件。
设想一个应用在每次启动后 2.5 秒就崩溃,其中一个应用正是如此。每个周期都会新增一次崩溃和一次启动。过了几个周期,列表就满了。从那时起,每个新事件都会把一条旧事件挤出去。列表是向前滑动的,但它总是保持大致相同的组合:几次崩溃,几次启动。
因此,“本次发布开始以来的崩溃次数”在每次检查时都返回相同的数字。第二次检查看到的可能是两次崩溃,第三次检查看到的也是两次——虽然是不同的崩溃,但数量相同。检查于是得出结论:崩溃已经停止,并放行了这次发布。
9 月 25 日,这种情况在两个应用上发生了三次。每一次都是这样:
- 发布被标记为已上线,
- 没有运行回滚,因为没有任何失败被记录,
- 我们的对账器(一个事后检查部署状态的后台任务)几分钟后将该部署判定为失败,
- 整个网站返回了 404。
一次能及时失败的发布是可以恢复的:回滚会把之前的版本恢复回来。而一次几分钟后才失败、并且先前已被宣布为成功的发布,则已经跳过了它的回滚。
我们如何发现它
我们在本地复现了这个问题:拿一个正常工作的 API,让它进入休眠,然后用一个会在启动时崩溃的改动重新发布。健康检查将其标记为已上线。
这与生产环境的情况一致:在这三次发布中,应用在每次启动后几秒钟就会死亡,然而健康检查所比较的崩溃计数,在两次检查之间从未增加。
修复方案:比较最新一次崩溃,而不是计数
现在,健康检查不再统计崩溃次数,而是查看本次发布开始以来最近一次崩溃的时间。
如果第二次检查显示本次发布开始之后发生过任何崩溃,检查就会等待并进行第三次查看。如果第三次看到的最新崩溃时间晚于第二次看到的最新崩溃时间,说明机器在两次检查之间又崩溃了。它并没有稳定运行,发布会失败,并给出提示:机器自发布以来反复崩溃,并且仍在不断重启。
无论事件列表有多短,这种方式都有效。一个只保留五条事件的滑动窗口,可能掩盖崩溃的总次数,但无法掩盖刚刚又发生了一次崩溃——因为最新的事件始终会被保留。
修复之后,本地复现的情况表现正常:发布失败,之前的镜像被恢复。我们还添加了一个测试来模拟平台的行为——每次检查都会新增一次崩溃和一次启动,并把列表裁剪为五条——并验证结论是失败,而不是通过。
我们的收获
- 了解你所统计的数据的结构。 对一个有上限的列表做计数,一旦列表被填满,计数就不再有意义。这个上限并非隐藏的;只是我们当初没有围绕它来设计检查。
- 优先问“有没有发生新的事情”,而不是“发生了多少次”。 最新的时间戳在截断之后依然有效,而总数则不然。
- 迟到的失败比早到的失败更糟。 同一个有问题的构建,如果发布失败并回滚,危害不大;但如果它先被宣布为成功,就会让整个网站宕机。
- 在真实条件下复现。 这个 bug 需要一台机器已经崩溃了足够多次,以至于事件列表被填满。在测试中只崩溃一次,是发现不了它的。
这项修复与同一天的另一项修复相配合,后者确保回滚会把旧版本恢复回来,而不是将其删除:失败的重新发布永远不会删除正常运行的后端。如果你自己的应用因为启动时崩溃而无法发布,发布故障排查文档,以及我们关于调试 AI 构建应用的指南,是很好的起点。