OpenAI在Rockset服务中发现两类看似相同的崩溃,通过系统化分析所有核心转储,最终定位到两个独立原因:一个硬件错误和一个存在18年的GNU libunwind竞争条件。这个故事展示了人口级数据诊断在复杂调试中的关键作用。
OpenAI 的模型和智能体越来越依赖可扩展的数据基础设施,以便在推理时搜索相关数据。这些服务部分用 C++ 编写,其底层系统控制能最大化性能、最小化内存占用。但 C++ 缺乏内存安全性,bug 可能导致写入错误或不存在的内存地址,从而引发崩溃。
几个月前,我们在 Rockset 服务(ChatGPT 数据基础设施的一部分,支撑许多数据插件和对话搜索)中观察到一些崩溃。每次崩溃中,一个正常的 C++ 函数看似执行完毕,却返回了一个无效地址,导致内核终止程序,因为指令指针不再指向代码。有时栈帧中的返回地址是 NULL,有时栈指针寄存器本身偏移了 8 字节,就像在执行过程中被错误减少。这些都不是应用程序代码的正常故障模式。
我们最初假设这是一个问题,结果发现是两个不相关的 bug 同时出现:一是某台 Azure 主机上的无声硬件损坏(CPU 计算错误),二是 GNU libunwind 中一个 18 年历史的竞争条件。
Rockset 是一个云原生数据系统,用于搜索和实时分析,被 OpenAI 用于内部用例,如同步连接器(Rockset 于 2024 年被 OpenAI 收购)。它的执行层用 C++ 编写,我们使用 folly 的致命信号处理器记录崩溃时的堆栈,并将核心转储上传到 Azure blob 存储。
大多数崩溃发生在一个名为 DocumentTree::updateDocument 的方法中。在这些崩溃中,updateDocument 调用了某个未知函数 X,栈在 X 执行期间被损坏,然后 X 返回到了一个不是可执行代码的地址。有些情况下 X 刚弹出的帧看起来正常,只是返回地址是 NULL;其他情况下栈指针错误,但下一个有效帧仍是 updateDocument。
我们不知道栈何时被损坏,留下了巨大的搜索空间。updateDocument 是一个有大量内联的大方法,X 的候选者太多。
Rockset 使用 -fno-omit-frame-pointer 编译,因此活动栈帧总是通过 %rbp 可达,调用者形成帧指针链表。Linux x86_64 的 AMD64 System V ABI 在 %rsp 下方保留了 128 字节作为红区,内核在传递信号时不会覆盖它。
红区对调试很有用,因为它保留了返回前的一些信息。当 SIGSEGV 触发时,信号处理器运行在崩溃线程的栈上。已返回函数的栈帧会被信号处理器覆盖,但最后 128 字节得以保留。这让我们能断言“X 刚弹出的帧除了 NULL 返回地址外看起来很有效”。
我们发现一个栈错位的崩溃,其中所有涉及的函数都很小。这让我们看到 %rsp 在执行一个相对简单的函数期间错位,之后还有更多调用成功。程序只在活动函数最终尝试返回时才崩溃。这些代码路径都不使用异常、内联汇编、setcontext 或 longjmp,因此如果栈指针真的如核心转储所示变化,用户空间代码没有合理解释。
这让我们把怀疑转向内核。Rockset 比大多数程序更积极地使用信号。查询执行被分解为许多轻量级任务,我们的 coarse_thread_cputime_clock 通过 timer_create 每几毫秒 CPU 时间发送一次 SIGUSR2 信号来估计 CPU 时间。由于信号如此频繁,少见的内核 bug 似乎可能。但我们阅读了内核源码、Azure 特定补丁并做了压力测试,没有发现相关的问题。
调试有两种方式:一种是像医生一样,关注一个病例,做大量测试;另一种是像流行病学家一样,观察整个群体,寻找模式。我们之前一直是医生模式,关键转变是决定收集高质量的人口数据。
我们让 ChatGPT 编写了一个脚本,自动下载每个核心转储的前缀、提取寄存器、过滤误报,并自动分类为 return-to-null、misaligned-stack 或其他。然后对过去一年所有 Rockset 核心转储并行运行该脚本。
这是转折点。
有了干净的数据集,相关性立即显现。我们之前视为一个奇怪 bug 的事情,实际上是两个独立的崩溃群体。return-to-null 核心分布广泛,频率近期增加但没有清晰起点。misaligned-stack 崩溃全部来自一个区域,有清晰起始日期,从不在长运行节点上发生。尽管涉及多个 Azure 虚拟机,模式指向一台有坏硬件的物理机。
我们意识到自己混淆了两个 bug。
凭借清理后的 Kubernetes 节点和时间戳,我们将 misaligned-stack 崩溃追溯到单一物理主机,并轻松将其加入黑名单。移除该主机后,misaligned-stack 崩溃消失。我们改进了信号处理器的寄存器状态记录,并修改了控制平面以便检测类似问题。
剩下的 return-to-null 核心变得更容易分析。之前我们排除了异常展开,因为我们以为有反例(不使用异常的代码路径),但那些反例都来自硬件损坏集群。重新审视后,我们发现崩溃都发生在异常展开期间。
C++ 异常处理通过运行时例程展开栈,恢复栈帧寄存器。我们的二进制链接了 libgcc 和 GNU libunwind,后者在动态链接器中胜出。我们假设 GNU libunwind 可能在计算目标状态时出错,或者计算正确但状态被破坏。
阅读 GNU libunwind 源码后,发现它在栈上合成一个 ucontext_t,填充目标寄存器状态,然后将其指针传给内部汇编例程 _Ux86_64_setcontext。该例程的最后六条指令中,第一条更新 %rsp 指向新栈底。此时 ucontext_t 不再属于活动栈或红区,如果此时信号到达,内核在 %rsp-128 构建信号帧,可能覆盖该结构,破坏后续读取的指令指针。在崩溃中,指令指针变成 NULL。
这就是 bug。
这个竞争窗口只有一条指令宽!信号必须在 %rsp 更改之后、下一条指令加载 %rip 之前到达。现代 CPU 上这样简单的指令每周期可执行多条,窗口大约百皮秒。
我们起初认为这太罕见,无法解释观察到的崩溃率。但通过费米估算:窗口约 10^{-10} 秒,信号每 10^{-2} 秒 CPU 时间到达,每个异常清理处理程序或 catch 块有约 10^{-8} 概率丢失竞争。Rockset 使用异常作为内部背压机制,单台过载主机每秒可能抛出 10^4 次异常,平均无故障时间约 10^4 秒(几小时一次)。在集群规模下,这足以解释观察到的频率。
GNU libunwind bug 存在超过 18 年,出现在第一个支持 C++ 异常展开的 x86_64 版本中。崩溃率与异常抛出率、信号传递率和信号处理器栈使用量成正比。Rockset 在这三方面都不寻常:异常率高、SIGUSR2 频率高、今年早期我们增加了信号处理器的栈使用(添加了 timer_getoverrun 调用)。这个改变似乎很重要:如果处理器栈使用太少,可能无法触及并覆盖过时的 ucontext_t 内存。在改变之前,我们没有观察到这些崩溃;改变后速率很低,直到某些用例的背压机制被压测。
两种崩溃都主要发生在 DocumentTree::updateDocument 中,因为它是背压异常抛出的活跃点,而坏主机节点也正好用于批量摄入,大部分 CPU 时间花在该方法上。
我们的即时缓解措施是切换到 libgcc 的展开器,并向上游提交了复现程序和修复补丁。
这次调试教会我们具体细节,但最重要的教训是:构建高质量的数据集。在缺乏数据集时,我们将两个不同现象混合成同一个故事。一旦有了准确完整的人口数据,问题结构就变得清晰。对于 Rockset 这类基础设施系统,这非常重要——可靠性不仅是修复 bug,更是构建数据、工作流和技能,将不可能的问题变为可诊断和可解决。
免费获取企业 AI 成熟度诊断报告,发现转型机会
关注公众号

扫码关注,获取最新 AI 资讯
3 步完成企业诊断,获取专属转型建议
已有 200+ 企业完成诊断