文章摘要
OpenAI工程师排查支撑ChatGPT的Rockset服务神秘崩溃问题时陷入僵局,后采用“流行病学调试”思路,借助AI工具分析核心转储文件,发现是硬件和软件两方面问题。他们找到问题根源并提交修复方案,强调构建高质量数据集的重要性,案例教训值得借鉴。

OpenAI的工程师在排查支撑ChatGPT搜索和数据插件的C++数据基础设施服务Rockset的神秘崩溃问题时,曾花费数周时间陷入僵局:函数返回错误的内存地址,栈指针在执行过程中莫名偏移8字节,团队提出的每一种假设都被有力的反证推翻,这个Bug看起来根本不可能存在。

最终他们发现,所谓的“诡异Bug”其实是两个互不相关的问题同时出现,而找到真相的关键并非对单个崩溃事件的深入排查,而是转向了他们称之为“流行病学调试”的思路:搭建自动化管道,批量分析过去一年生产环境的所有核心转储文件,通过寻找整体规律而非单案例推断来定位问题。

团队借助AI工具编写了脚本,自动提取每个核心文件的寄存器数据,过滤已知的误报结果,将每次崩溃标记为“返回空指针”“栈对齐错误”等类型,并行处理了过去一年的全部Rockset核心转储。分析结果很快显示,原本从症状上看似同类的问题,实际上对应两组特征完全不同的崩溃事件。

第一类是栈对齐错误导致的崩溃,全部源自同一个云服务区域,有明确的起始日期,且从未出现在长期运行的节点上。经过追踪,团队发现问题根源是一台物理主机的CPU在静默地生成错误的计算结果:既没有过热,也没有抛出机器检查异常,只是数学运算默默出现了偏差。将该主机从服务中移除后,这类栈对齐错误的崩溃完全消失。

剔除硬件相关的崩溃问题后,剩余的由“返回空指针”导致的崩溃变得清晰可控。此前团队曾排除C++异常展开的可能性,因为他们在未使用异常的代码路径中发现了崩溃反例,但这些反例全部来自存在硬件损坏的故障集群。排除这些干扰因素后,剩余的所有崩溃都被确认发生在异常展开的过程中。

这类崩溃的根本原因,是GNU libunwind的_Ux86_64_setcontext函数中一个存在了18年的竞争条件。在C++异常展开时,libunwind会在栈上合成一个ucontext_t结构体,填充所需的寄存器状态后,调用_Ux86_64_setcontext将控制权转移给清理处理程序。问题在于,该函数在从旧结构体中读取指令指针的操作尚未完成前,就将栈指针%rsp更新为指向新的栈帧。一旦%rsp发生变化,该结构体便不再属于活动栈的一部分,也不再受内核红区的保护。如果信号恰好在%rsp更新与%rip读取的时间窗口内到达,内核就会在该结构体之上构建信号帧,破坏指令指针,导致函数跳转到空地址或垃圾地址。

这个竞争窗口的宽度仅为一条指令,以现代处理器的时钟频率计算,大约相当于100皮秒。在大多数程序中,这种情况根本不会被触发。但OpenAI的Rockset服务使用了timer_create函数,每隔几毫秒的CPU时间就发送一次SIGUSR2信号,用于实现轻量级的按查询记账,高频的信号发送将仅在理论上可能发生的竞争状况,转化为了生产环境中的真实崩溃。

团队将修复方案和一个可独立复现的示例提交到了GNU libunwind项目,并验证确认其他展开器如libgcc并不存在这个问题。该修复通过重新排序指令,确保在更新%rsp之前先读取%rip,彻底消除了这个危险的时间窗口。

最重要的步骤并非巧妙地解读汇编代码,也不是对细节的深入了解,而是构建一个高质量的数据集。如果没有这个数据集,我们就会把两种截然不同的现象混为一谈,并试图通过推理来理清这种混乱。一旦获得了准确且完整的全量数据,问题的结构便显而易见了。

对于正在排查难以解释的生产环境崩溃问题的团队,这个案例带来的教训值得借鉴:请检查你们是否将多个Bug混为了一谈。那些看似与所有假设都不相符的症状,实际上可能并不矛盾;它们可能对应两个不同的假设,而你却无意中将它们混淆了。洞察问题结构的最快途径,并非对单个案例进行更深入的分析,而是获取涵盖所有故障案例的完整、带标签的数据。

相关的完整工程技术博文还包含详细的栈内存示意图、存在漏洞的汇编指令,以及揭示出两种不同类型故障的崩溃率可视化图表,可供技术团队参考学习。

以上内容不代表本平台立场,仅供读者参考