OpenAI 的模型和智能体越来越依赖可扩展的数据基础设施,以便在推理时搜索相关数据:也就是模型思考你的问题时。其中一些服务用 C++ 编写;C++ 对系统的底层控制让我们能够最大化性能并最小化内存使用。随着规模扩大,这些效率优势很重要,但 C++ 缺乏内存安全性,意味着 bug 可能因写入错误或不存在的内存地址而导致崩溃。
几个月前,我们观察到 Rockset 服务内部发生了一些崩溃。Rockset 是我们 ChatGPT 数据基础设施中的一个定制部分,对许多数据插件和会话搜索都很关键。在这些崩溃中,一个普通的 C++ 函数似乎执行结束后返回到了一个无效地址,导致内核停止程序,因为指令指针不再指向代码。有时,栈帧中的返回地址槽为 NULL。有时,栈指针 CPU 寄存器本身看起来偏移了 8 个字节,就像 %rsp 在正常执行过程中不知为何被递减了。这两种情况下,崩溃都发生在返回时。
这些并不是应用代码的正常失败模式。一次误写只命中保存的返回地址是可能的,但极不可能。如果没有内联汇编、setcontext 或 longjmp(我们都没有使用),一个 bug 让 %rsp 偏移 8 字节就更奇怪了,因为编译后的代码只会在函数序言和尾声中直接调整该寄存器。我们(或 ChatGPT)能想到的每个假设都有强有力的反证,所以这个 bug 看起来不可能存在。
我们以为是一个问题,最后却发现是两个无关的 bug,只是碰巧同时被发现。第一,是一台 Azure 主机上的静默硬件损坏:CPU 就是算不对。第二,是 GNU libunwind 中一个存在 18 年的竞态条件,也就是一个广泛使用的开源库中一直未被注意到的 bug。
本文讲述我们如何像流行病学家一样思考,并为整个崩溃群体建立高质量数据集,从而识别并修复这些看似无法解释的崩溃。
首先,让我们更深入了解 Rockset。它是一个用于搜索和实时分析的云原生数据系统,我们在 OpenAI 的许多内部场景中使用它,例如同步连接器(Rockset 于 2024 年被 OpenAI 收购)。流式更新用于维护工作空间知识库的最新索引,让 ChatGPT 在回答问题或执行操作时能够搜索相关信息。
Rockset 的执行层用 C++ 编写。C++ 语言提供对 CPU 的底层访问,有利于性能和效率,但也意味着应用 bug 可能导致无效内存访问和段错误。为了帮助追踪这些问题,我们使用 folly 的致命信号处理函数在崩溃发生时记录栈跟踪,并将对应的核心转储(程序崩溃时状态的快照)上传到 Azure blob 存储以供后续分析。Rockset 的所有查询处理叶节点都有副本,从而最大限度降低崩溃对客户端的影响。不过,每个段错误都对应一个需要修复的 bug,才能达到我们的可靠性和质量目标。
我们最初把这些 core 当作传统调试问题来处理:非常仔细地检查几个核心转储,提出假设,再逐一排除。
大多数崩溃发生在一个名为 DocumentTree::updateDocument 的方法中。在这些崩溃中,看起来像是 updateDocument 调用了某个未知函数 X,X 活动期间栈被损坏,然后 X 返回到了一个不是可执行代码的地址。在一些情况下,X 刚弹出的栈帧看起来有效,只是保存的返回地址为 NULL。另一些情况下,栈指针本身看起来不对,但下一个有效栈帧似乎仍是 updateDocument。
我们不知道栈是在什么时候被损坏的,这留下了巨大的搜索空间。updateDocument 是一个很大的方法,其中发生了大量内联,因此 X 的候选数量多到难以处理。
这是我们 C++ 代码中的 bug 吗?编译器或链接问题?某个运行时库的问题?Linux 内核在信号递送或上下文切换方面的 bug?还是更罕见的东西?如果这是一次误写,为什么没有被我们的 ASAN 预发布环境捕获?
我们试图用应用级日志识别该问题的所有出现实例,但仅凭日志很难分类栈损坏 bug,因为日志中的栈跟踪本身就已损坏或缺失。我们无法构造一个既没有假阳性也没有假阴性的日志查询。我们手动检查了更多 core,并找到了一些额外示例,但这个过程太耗费人力,无法得到可信的数据集。
调查到这个阶段,我们(错误地)排除了硬件 bug,因为我们在多个区域和多种硬件类型上都看到了崩溃,所以仍在寻找纯软件原因。有几天,我们对一次 %rsp 未对齐崩溃进行了极深入分析,利用栈和寄存器内容重建崩溃前的历史。这带来了一些可能的线索,但由于我们没有放下“所有 bug 都有同一原因”的初始结论,仍然没能走出僵局。
在讲到调查的转折点之前,有必要先解释我们从 core 文件中提取的是哪类信息。
Rockset 使用 -fno-omit-frame-pointer 编译,因此始终可以通过 %rbp 到达活动栈帧,调用者则形成一条帧指针链表。
在 Linux x86_64 上,AMD64 System V ABI 还会将 %rsp 下方 128 字节保留为红区。该区域可供用户空间代码使用;更重要的是,作为 ABI 契约的一部分,内核承诺在递送信号时不会覆盖它。
红区是我们调试返回后崩溃的关键,因为它保留了返回前的一些信息。当触发 SIGSEGV 时,folly 的致命信号处理函数会在崩溃线程的栈上运行。不再活动的栈帧(因为其函数已经返回)会被信号处理函数覆盖,但最后 128 字节除外。这就是为什么我们能说“X 刚弹出的栈帧看起来有效,只是返回地址为 NULL”。红区保留了一些非活动帧,有时也可能只是某个非活动帧的尾部。
我们发现一次栈未对齐崩溃,其中涉及的所有函数都非常小。这让我们看到,%rsp 是在执行一个相对简单的函数期间变得未对齐的,而且之后还有更多调用成功完成。程序直到活动函数最终尝试返回时才崩溃。这些代码路径都没有使用异常、内联汇编、setcontext 或 longjmp,所以如果栈指针真的按 core 所显示的方式改变了,就没有合理的用户空间代码 bug 可以解释。
这把我们的怀疑推向了内核。
Rockset 使用信号的方式比大多数程序更激进。查询执行会被拆分成许多交换数据的轻量级任务。这对高效处理高 QPS 工作负载很重要,但也让按查询统计 CPU 变得棘手,因为许多查询的工作会复用到同一个线程池上。
我们的解决方案叫 coarse_thread_cputime_clock,它以足够低的成本近似 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...),从而能在每个任务边界采样。timer_create API 可用于基于多种时间流逝概念安排周期性信号递送,其中包括 CPU 时间的累积。我们安排每经过几毫秒 CPU 时间就递送一个信号(SIGUSR2),此时信号处理函数会更新一个线程本地值。即便许多任务执行期间看不到粗粒度时钟前进,把所有增量相加也能得到某个查询实际 CPU 时间的无偏估计。
由于我们递送信号非常频繁,围绕上下文切换或信号递送的罕见内核 bug 看起来是可能的。我们花时间阅读了 bug 报告、内核源代码以及 Azure 专用内核补丁。我们尝试了压力测试。但没能找到任何看起来相关的东西。
此时,我们决定退一步,尝试另一种方法。
调试这样的问题大致有两种方式。
一种像某种医生:聚焦一个病人,做大量检查,并尝试根据详细证据诊断单个病例。
另一种更像流行病学家:观察整个群体,询问是否存在单个病例无法揭示的模式。这个 bug 是从某个特定版本开始的吗?它是否与某个硬件 SKU(具体 CPU 和服务器型号)、某个区域或某个内核版本相关?在看似同一种症状之下,是否隐藏着多个不同的聚类?
我们此前基本处在医生模式。关键转变在于,我们决定必须收集高质量的群体数据。
我们之前尝试自动找出该问题的所有实例都失败了,因为我们试图对日志做文本搜索。核心转储本身包含的信息多得多,但手动查看无法扩展。我们决定投入精力构建一条能自动分析核心转储的流水线。
我们让 ChatGPT 编写脚本:下载每个 core 文件的前缀,提取寄存器,利用日志过滤已知假阳性,并自动将崩溃标记为返回到 null、栈未对齐或其他。然后,我们并行运行该脚本,处理过去一年里生产环境中的每个 Rockset 核心转储。
这就是转折点。
一旦有了干净的数据集,相关性立刻显现。我们一直当作一个奇怪 bug 处理的问题,其实是两个独立的崩溃群体。
返回到 null 的 core 分布在许多集群和地理区域。它们的频率最近有所上升,但没有明确的开始日期,也没有清晰的基础设施边界。
栈未对齐崩溃看起来完全不同。它们全部来自同一个区域,有明确开始日期,而且从未发生在已长期运行的节点上。尽管涉及多个 Azure VM(云中托管的虚拟机),但这种模式看起来像是一台硬件故障的物理机器在影响任何碰巧落到它上面的 VM。
那一刻我们意识到,我们在脑中把两个 bug 混为一谈了。由于我们把来自两个 bug 的反例混在一起,就找不到单一且一致的解释。
有了干净的 Kubernetes 节点列表和时间戳,我们得以将栈未对齐崩溃追溯到一台物理主机,并很容易将其加入拒绝列表。
即使经过数周压力测试,我们也没能在受控环境中复现那台主机上的寄存器损坏。不过,一旦这台问题主机停止服务,栈未对齐崩溃就消失了。
移除故障主机并不是永久解决方案,因为它不能防止同一问题再次发生。不过,我们可以修改软件,使类似问题再次出现时易于检测和处理。我们改进了致命信号处理函数,使其包含寄存器状态,这样只凭日志就能检测复发(无需核心转储)。我们更改了控制平面,使 VM 通常被复用而不是回收,这让我们在基础设施栈的这一层更容易检测故障节点。我们还更新了运行手册(以及团队的心智模型),将这种可能性纳入其中。
把故障主机崩溃分离出来后,剩下的返回到 null 的 core 就容易推理得多。此前我们排除了异常展开,因为我们以为有反例:某些崩溃发生在肯定没有使用异常的代码路径中。但这些反例全都来自硬件损坏聚类。
一旦带着这个认识重新审视剩余 core,我们发现结论正好相反:崩溃全都发生在异常展开期间。
当 C++ 抛出异常时,运行时必须发现哪个 catch 块应接收它,以及沿途应运行哪些析构函数或清理处理函数。编译器会发出这些元数据,但实际匹配是在运行时动态发生的。
异常展开实际上不是由调用 throw 的函数执行,而是由生成的编译代码所调用的辅助函数执行。这些运行时例程会检查栈,获取栈上函数的元数据,动态查找清理处理函数和 catch 块,然后将控制权转移到其中一个位置。转移控制权包括展开中间所有栈帧(包括辅助函数的栈帧)。
从操作上看,这更接近 longjmp 或纤程切换,而不是普通调用和返回。必须恢复被调用者保存寄存器,以及栈帧寄存器 %rbp 和 %rsp。
我们的二进制文件链接了两个库,它们都包含执行 C++ 异常展开的函数实现:libgcc 和 GNU libunwind。动态链接器选择的是 GNU libunwind 的定义。这让我们很意外;我们原以为由于符号版本规则,libgcc 的实现会胜出;然而检查运行中的二进制文件后发现并非如此。
此时,随着我们放宽另一个在以为只有一个 bug 时作出的假设,工作假设发生了变化。
也许我们看到的不是普通函数返回到 NULL。也许我们看到的是一次展开转移——实际上类似 setcontext 的寄存器恢复——其中目标指令指针在控制权转移前变成了 NULL。换句话说,问题来自展开库中的错误数据,而不是栈上错误的返回地址槽。
这大大缩小了问题范围。要么 GNU libunwind 计算出了错误的目标状态,要么它计算出了正确状态,但在应用之前被某些东西损坏了。
我们阅读 GNU libunwind 源码,发现它会在栈上合成一个 ucontext_t,填入清理处理函数栈帧所需的寄存器状态,然后把指向该结构体的指针交给一个内部汇编例程:_Ux86_64_setcontext。
到这里,我们已经拼齐了所有线索。
合成的 ucontext_t 位于 _Ux86_64_setcontext 在执行期间会展开的某个栈帧中。_Ux86_64_setcontext 是否在更改 %rsp 之后仍从该结构体读取,而此时该结构体已不再属于活动栈?这会使它容易被信号递送覆盖,例如我们频繁递送的 SIGUSR2。
答案是肯定的。
下面是我们所用 GNU libunwind 版本中 _Ux86_64_setcontext 的最后六条指令,主要由从内存加载到目标寄存器的 mov 指令组成:
(%rdi 指向栈上分配的 ucontext_t,而 UC_MCONTEXT_* 宏只是展开为特定寄存器存储位置的固定偏移。)
第一条指令就是竞态窗口的开始。它将 %rsp 更新为指向活动栈的新底部。一旦发生这一步,%rdi 指向的结构体就不再属于活动栈(或红区),也不再是内核不得触碰的区域。
通常这不会造成问题,但如果信号在恰到好处(或者说恰好糟糕?)的时刻到达,内核会在 %rsp-128 处构建信号帧。这可能覆盖 %rdi 指向的内存。
如果这发生在下一条指令读取 UC_MCONTEXT_GREGS_RIP(%rdi) 之前,那么恢复后的指令指针就可能被损坏。在我们的崩溃中,它变成了 NULL。
这就是那个 bug。
这段汇编还解释了一个曾让我们困惑的观察结果:为什么函数 X 在前一个栈帧的返回地址槽中是 NULL。
setcontext 的设计是恢复所有寄存器,包括 %rdi,所以在控制转移的最后时刻,它不能用该寄存器读取 UC_MCONTEXT_GREGS_RIP(%rdi)。相反,它会提前读取该值,保存到栈上,再恢复几个寄存器,然后用 retq 读取保存的值并转移控制权。
在 core 中看起来像“一个函数返回到了 NULL”的情况,实际上是“展开器在栈上合成了一个目标返回地址,但该目标在转移完成前被损坏了”。我们原以为返回地址槽的损坏必然发生在原位置,因为我们不知道哪里会有意把(可被损坏的)数据写入返回地址槽。
这个 bug 之所以显得荒唐,是因为这个竞态窗口极其狭窄。在这种竞态条件中,外部事件(信号)需要发生在另一个线程执行的两个步骤之间。这两个步骤越接近,竞态条件发生的可能性就越低。
在这个例子中,脆弱窗口实际上只有一条指令宽!信号必须在 %rsp 已被更改之后、下一条指令加载 %rip 之前递送。在现代超标量乱序 CPU 上,每个周期可以运行多条这样的简单指令,因此竞态窗口大约是一百皮秒。
当我们发现这个竞态时,第一反应是它一定太罕见,不足以解释观察到的崩溃率。我们在整个集群中每天看到十几次返回到 null 的崩溃。异常清理期间的单指令竞态真的能解释这一点吗?
我们转向费米估算。如果脆弱窗口量级为 秒,而 SIGUSR2 每 秒 CPU 时间到达一次,那么每个异常清理处理函数或 catch 块输掉竞态的概率约为 。
Rockset 将异常作为内部摄取背压机制的一部分。一台过载主机每秒可抛出约 个异常。这意味着使用背压的主机平均故障间隔为 秒,也就是每隔几小时崩溃一次。在整个集群规模上,这足以解释观察到的崩溃频率。
GNU libunwind 的这个 bug 很老,已有 18 年以上,存在于首个支持 C++ 异常展开的 x86_64 版本中。
那它为什么现在才显现?
崩溃率大致与抛出异常的数量和递送信号的数量成正比。它还取决于信号处理函数消耗多少栈。
Rockset 在这三个维度上都不寻常。作为正常过载控制的一部分,我们以高频率抛出异常;由于 coarse_thread_cputime_clock,我们异常频繁地递送 SIGUSR2;今年早些时候,为了统计合并信号,我们又添加了对 timer_getoverrun 的调用,使 SIGUSR2 处理函数使用更多栈。
最后这项变化似乎很重要。如果处理函数使用的栈足够少,它可能不会触及并覆盖陈旧的 ucontext_t 内存。在那项更改之前,我们完全没有观察到这些崩溃。更改之后,直到我们为一些给背压机制造成压力的用例提高负载,崩溃率才从低位上升。
换句话说,libunwind bug 一直存在,只是我们的异常率、信号率与处理函数栈使用量的乘积最近才跨过了使其在运行中可见的阈值。
这一机制也解释了一个巧合:硬件 bug 和 libunwind bug 的崩溃大多都发生在 DocumentTree::updateDocument 内部。来自 libunwind 的崩溃严重偏向这个方法,因为在我们抛出异常以施加摄取背压时,它总是处于活动状态。对于 %rsp 未对齐崩溃,它也被严重选中,因为那个故障硬件节点属于我们用于批量摄取的 SKU,而该方法占用了其大部分 CPU 时间。
我们的即时缓解措施是从 GNU libunwind 切换到 libgcc 的展开器。这本身就是一个不错的取舍:libgcc 的实现受益于大量减少锁竞争的工作,而这在扩展到大型 VM 时很重要。
我们还向 GNU libunwind 上游提交了一个自包含复现程序和一个修复(在新窗口中打开),并验证其他展开器没有类似问题。
这段调试历程让我们深入了解了动态链接、DWARF 展开元数据、Linux 信号递送、System V ABI 和 C++ 异常机制的具体细节。但主要教训比这些都简单。
最重要的一步不是聪明地阅读汇编,也不是对细节有深厚知识。而是构建一个高质量数据集。没有这个数据集时,我们把两种不同现象混成一个故事,并试图靠推理走出混乱。一旦有了准确完整的群体数据,问题的结构就变得很明显:一个崩溃群体属于一台故障主机,另一个属于 libunwind 中的竞态。数据变好后,调试也变容易了。
对于 Rockset 这样的基础设施系统来说,这一点非常重要。这次调查强化了我们对深度检测、自动化调查和持续改进运维工具的投入。可靠性不只是事后修复 bug,还要构建数据、工作流和能力,把看似不可能的问题变成可诊断、可解决的问题。
作者
By Nathan Bronson、Member of Technical Staff


