OpenAI 工程师花了数周时间试图解释 Rockset 中神秘的崩溃,Rockset 是为 ChatGPT 的搜索和数据插件提供支持的 C++ 数据基础设施服务。函数似乎返回到错误的内存地址。栈指针似乎在执行过程中偏移了 8 字节。团队提出的每一个假设都有强有力的反证。这个缺陷似乎不可能存在。

他们原以为是一个缺陷,最终发现是两个无关的缺陷,恰好在同一时间被发现。突破并非来自对单个崩溃的深入检查,而是转向了团队所称的流行病学式调试:构建一个管道,自动分析过去一年的所有生产核心转储,然后寻找群体层面上的模式,而不是对单个案例进行推理。

团队让 ChatGPT 编写了一个脚本,该脚本下载每个核心文件的前缀,提取寄存器,过滤已知误报,并将每个崩溃标记为 return-to-null、misaligned-stack 或其他类型。他们在过去一年中所有 Rockset 核心转储上并行运行该脚本。关联性立即显现。看起来像是一种症状,实际上是两个具有完全不同特征的崩溃群体。

所有 misaligned-stack 崩溃都来自一个 Azure 区域,有明确的起始日期,并且从未出现在长期运行的节点上。团队将其追踪到一台物理主机,该主机的 CPU 正在静默地产生错误结果。并非过热,也未抛出机器检查异常,只是安静地算错数学。一旦将该主机从服务中移除,misaligned-stack 崩溃便完全消失了。

在将硬件崩溃分离出去后,剩余的 return-to-null 崩溃变得可追踪。团队之前排除了 C++ 异常展开作为原因,因为他们认为有反例:崩溃发生在未使用异常的代码路径中。但那些反例全部来自硬件损坏集群。一旦去除这种污染,剩余的所有崩溃都发生在异常展开期间。

根本原因是 GNU libunwind 的 _Ux86_64_setcontext 函数中存在一个已持续 18 年的竞争条件。在 C++ 异常展开期间,libunwind 会在栈上合成一个 ucontext_t 结构体,填入期望的寄存器状态,然后调用 _Ux86_64_setcontext 将控制权转移给清理处理程序。问题是:_Ux86_64_setcontext 在完成从旧结构体读取指令指针之前,就已将栈指针 (%rsp) 更新为指向新的栈帧。一旦 %rsp 改变,该结构体就不再属于活动栈,也不再受内核红区保证的保护。如果信号恰好在 %rsp 更新与 %rip 读取之间的窗口内到达,内核就会在该结构体之上构建信号帧,从而破坏指令指针。随后函数跳转到 NULL 或垃圾地址。

该竞争窗口仅有一个指令宽。在现代时钟速度下,这大约相当于 100 皮秒。在大多数程序中它几乎不会触发。OpenAI 的 Rockset 使用 timer_create 每隔几毫秒的 CPU 时间就投递一次 SIGUSR2 信号,用于轻量级的每查询计账,产生的信号投递事件远多于典型应用。这种频率将理论上可能的竞争转变为了生产崩溃。

团队已将修复和自包含的重现程序上游提交给 GNU libunwind,并验证了其他展开器(如 libgcc)不存在相同问题。该修复重新排列了指令顺序,使 %rip 在 %rsp 更新之前被读取,从而彻底消除了该窗口。

团队对教训的总结值得全文重复:

最重要的步骤并非聪明的汇编读取或对细节的深入了解。而是构建一个高质量的数据集。在没有这个数据集的情况下,我们将两种不同的现象混杂成一个故事,并试图通过推理摆脱困惑。一旦我们拥有准确且完整的群体数据,问题的结构就变得显而易见。

对于任何调试难以解释的生产崩溃的团队:请检查是否混淆了多个缺陷。看似与每个假设都不一致的症状,可能根本不矛盾;它们可能与两个不同的假设一致,只是你不小心将它们混在一起了。最快看到结构的路径,不是对单个案例进行更深入的分析,而是对整个失败群体进行完整、带标签的数据收集。

完整的工程博客文章包含详细的栈图、易受攻击的汇编指令,以及揭示这两个群体的崩溃率可视化。

关于作者

Steef-Jan Wiggers