核心转储流行病学:修复一个18年的bug
Core dump epidemiology: fixing an 18-year-old bug
OpenAI 的 ChatGPT 数据基础设施组件 Rockset 在 C++ 执行层出现两种看似不可能的崩溃:返回地址为 NULL 和栈指针错位。通过构建自动分析所有生产核心转储的管道,团队将此前混为一谈的崩溃分离为两个独立 bug。其一为 Azure 主机上的静默硬件损坏,导致 CPU 数学运算错误;其二为 GNU libunwind 中存在 18 年的竞态条件,在异常展开期间,信号恰好在单指令窗口内破坏栈上合成的 `ucontext_t`,使恢复的指令指针变为 NULL。团队通过切换到 libgcc 展开器并向上游提交修复解决了问题。
OpenAI 的模型和 agent 越来越依赖可扩展的数据基础设施,以便在推理时(即模型思考你的问题时)搜索相关数据。其中一些服务是用 C++ 编写的,这种语言对系统的底层控制让我们能够最大化性能并最小化内存使用。随着规模扩大,这些效率优势变得重要,但 C++ 缺乏内存安全性意味着 bug 可能因写入错误或不存在的内存地址而导致崩溃。
几个月前,我们在 Rockset 服务内部观察到一些崩溃。Rockset 是我们 ChatGPT 数据基础设施中一个定制化的部分,对许多数据插件以及搜索对话功能至关重要。在每次崩溃中,一个正常的 C++ 函数似乎执行完毕,然后返回到了一个虚假的地址,导致内核停止程序,因为指令指针不再指向代码。有时栈帧中的返回地址槽是 NULL。有时栈指针 CPU 寄存器本身似乎偏移了 8 字节,就好像 %rsp 在正常执行过程中被意外递减了。在这两种情况下,崩溃都发生在返回时。
这些对于应用程序代码来说并非正常的故障模式。一种只命中保存的返回地址的 stray write 是可能的,但可能性极低。一个在不涉及内联汇编、setcontext 或 longjmp(这些我们都没用)的情况下将 %rsp 错位 8 字节的 bug 则更加奇怪,因为编译后的代码只在函数序言和尾声直接调整该寄存器。我们(或 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,以满足我们的可靠性和质量目标。
我们最初的方法是将这些核心转储视为传统的调试问题:非常仔细地检查几个核心转储,形成假设,然后逐一排除。
大多数崩溃发生在一个名为 DocumentTree::updateDocument 的方法中。在这些崩溃中,看起来 updateDocument 调用了某个未知函数 X,当 X 活跃时堆栈被破坏,然后 X 返回到了一个不是可执行代码的地址。在某些情况下,X 刚被弹出的栈帧看起来是有效的,只是其保存的返回地址是 NULL。在其他情况下,栈指针本身看起来是错误的,但下一个有效的栈帧似乎仍然是 updateDocument。
我们不知道堆栈是什么时候被破坏的,这留下了巨大的搜索空间。updateDocument 是一个很大的方法,经历了大量的内联,因此 X 的候选者数量巨大。
这是我们的 C++ 代码中的 bug 吗?编译器或链接问题?运行时库中的问题?关于信号传递或上下文切换的 Linux 内核 bug?还是更罕见的问题?如果这是一个 stray write,为什么我们的 ASAN 预发布环境没有捕获到?
我们尝试使用应用程序级日志来识别所有问题发生的情况,但堆栈破坏 bug 很难仅从日志中分类,因为记录的堆栈跟踪本身已被破坏或丢失。我们无法构建一个既没有误报也没有漏报的日志查询。我们手动检查了更多的核心转储,并发现了一些额外的例子,但这个过程过于劳动密集,无法给我们一个可信的数据集。
在调查的这个阶段,我们(错误地)排除了硬件 bug,因为我们在多个区域和多种硬件类型上都看到了崩溃,所以我们仍在寻找纯软件原因。有几天,我们深入研究了单个 %rsp 错位的崩溃,使用堆栈和寄存器内容重建了崩溃前的历史。这产生了一些可能的线索,但由于我们没有放弃最初认为所有 bug 都有相同原因的结论,这并没有让我们摆脱困境。
来自堆栈的线索
在进入我们调查的转折点之前,有必要解释一下我们从核心文件中提取了哪些信息。
Rockset 使用 -fno-omit-frame-pointer 编译,因此活跃的栈帧总是可以通过 %rbp 访问,并且调用者形成一个帧指针链表。
在 Linux x86_64 上,AMD64 System V ABI 还在 %rsp 下方保留了 128 字节作为红区。该区域可供用户空间代码使用,重要的是,作为 ABI 契约的一部分,内核承诺在传递信号时不会破坏它。
红区对于我们调试返回后崩溃至关重要,因为它保留了返回前的一些信息。当触发 SIGSEGV 时,folly 的致命信号处理程序在崩溃线程的堆栈上运行。不再活跃(因为其函数已返回)的栈帧将被信号处理程序破坏,但最后 128 字节除外。这就是为什么我们可以说“X 刚被弹出的栈帧看起来是有效的,只是返回地址是 NULL”。红区保留了一些不活跃的栈帧,或者有时只是一个不活跃栈帧的尾部。
我们发现了一个堆栈错位的崩溃,其中涉及的所有函数都非常小。这让我们看到,%rsp 是在执行一个相对简单的函数期间变得错位的,并且之后更多的调用成功了。程序只有在活跃函数最终尝试返回时才崩溃。这些代码路径都没有使用异常、内联汇编、setcontext 或 longjmp,因此如果栈指针确实像核心转储所暗示的那样发生了变化,那么用户空间代码中没有合理的 bug 可以解释这个问题。
这促使我们将注意力转向内核。
Rockset 比大多数程序更积极地使用信号。查询执行被分解成许多交换数据的轻量级任务。这对于高效处理高 QPS 工作负载很重要,但它使得每个查询的 CPU 记账变得棘手,因为许多查询的工作被多路复用到同一个线程池。
我们的解决方案是我们称之为 coarse_thread_cputime_clock 的东西,它近似于 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...),成本足够低,可以在每个任务边界进行采样。timer_create API 可用于根据多种时间流逝概念(包括 CPU 时间的累积)来调度周期性信号传递。我们调度一个信号(SIGUSR2)每几毫秒的 CPU 时间传递一次,此时信号处理程序更新一个线程局部变量。即使许多任务在执行期间没有看到粗略时钟前进,对所有增量的求和也会产生查询实际 CPU 时间的无偏估计。
由于我们如此频繁地传递信号,一个关于上下文切换或信号传递的罕见内核 bug 似乎是有可能的。我们花时间阅读了 bug 报告、内核源代码和 Azure 特定的内核补丁。我们尝试了压力测试。我们没能找到任何看起来相关的东西。
那时我们决定退一步,尝试不同的方法。
医生还是流行病学家?
调试这样的问题有两种广泛的方法。
一种是像医生一样行事:专注于一个病人,进行大量测试,并尝试从详细的证据中诊断单个病例。
另一种是更像流行病学家:观察整个人群,并询问是否存在单个病例无法揭示的模式。这个 bug 是在特定版本开始出现的吗?它与某个硬件 SKU(特定的 CPU 和服务器型号)、某个区域或某个内核版本相关吗?在看似一个综合征的内部是否隐藏着多个不同的集群?
我们之前主要处于医生模式。关键的转变是决定我们需要收集高质量的人群数据。
清理数据
我们之前尝试自动查找所有问题实例的尝试失败了,因为我们试图对日志进行文本搜索。核心转储本身包含更多信息,但手动查看它们无法扩展。我们决定投入精力构建一个可以自动分析核心转储的管道。
我们让 ChatGPT 编写了一个脚本,该脚本下载每个核心文件的前缀,提取寄存器,使用日志过滤已知的误报,并自动将崩溃标记为返回空、堆栈错位或其他。然后我们并行地对过去一年中 Rockset 的每个生产核心转储运行该脚本。
这是转折点。
一旦我们有了干净的数据集,相关性立即显现出来。我们一直当作一个奇怪 bug 来处理的东西,实际上是两个独立的崩溃群体。
返回空的核心转储分布在许多集群和地理区域中。它们的频率最近有所增加,但没有明确的开始日期和清晰的基础设施边界。
堆栈错位的崩溃看起来完全不同。它们都来自一个区域,有明确的开始日期,并且从未发生在运行了很长时间的节点上。尽管它们涉及多个 Azure VM(云中托管的虚拟机),但模式看起来像是一台物理机器硬件损坏,导致问题出现在恰好落在其上的任何 VM 上。
那一刻我们意识到,我们一直在心理上将两个 bug 混为一谈。因为我们一直在混合来自两个 bug 的反例,所以我们无法找到一个单一的、连贯的解释。
Bug #1:坏主机
有了干净的 Kubernetes 节点和时间戳列表,我们能够将堆栈错位的崩溃追溯到单个物理主机,这很容易将其加入黑名单。
我们无法在受控环境中在该主机上重现寄存器损坏,即使在进行了数周的压力测试之后也是如此。然而,一旦有问题的主机被停止服务,堆栈错位的崩溃就消失了。
移除坏主机并不是一个永久的解决方案,因为它不能防止相同问题的新发生。但是,我们可以更改软件,以便如果类似问题再次发生,可以轻松检测和处理。我们改进了致命信号处理程序以包含寄存器状态,这样我们就能仅从日志中检测到复发(无需核心转储)。我们更改了控制平面,以便 VM 通常被重用而不是回收,这使得在我们基础设施堆栈的这一层检测坏节点变得容易得多。我们还更新了我们的 runbook(以及我们团队的思维模型)以包含这种可能性。
将坏主机的崩溃分离出来后,剩余的返回空核心转储变得更容易推理了。之前我们排除了异常展开,因为我们认为有反例:在绝对不使用异常的代码路径中发生崩溃。但这些反例都来自硬件损坏集群。
一旦我们带着这个想法重新审视剩余的核心转储,我们发现这个结论完全反了:崩溃都发生在异常展开期间。
异常处理是一种动态控制转移
当 C++ 抛出异常时,运行时必须发现哪个 catch 块应该接收它,以及沿途哪些析构函数或清理处理程序应该运行。编译器会发出这些元数据,但实际的匹配是在运行时动态发生的。
异常展开实际上不是由调用 throw 的函数执行的,而是由生成的编译代码调用的辅助函数执行的。这些运行时例程检查堆栈,获取在堆栈上找到的函数的元数据,动态查找清理处理程序和 catch 块,然后将控制权转移到其中一个位置。转移控制权包括展开所有中间的栈帧(包括辅助函数的栈帧)。
在操作上,这更接近于 longjmp 或 fiber 切换,而不是普通的调用和返回。被调用者保存的寄存器以及栈帧寄存器 %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)的破坏。
Bug #2:libunwind bug
答案是肯定的。
以下是我们使用的 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 读取保存的值并转移控制权。
在核心转储中看起来像“一个函数返回到了 NULL”的东西,实际上是“展开器在堆栈上合成一个目标返回地址,但该目标在转移完成之前已被破坏。”我们假设返回地址槽的破坏一定是就地发生的,因为我们不知道有任何地方会故意将(可破坏的)数据写入返回地址槽。
单指令竞态窗口
使这个 bug 看起来荒谬的是这个竞态窗口有多窄。在这种竞态条件下,外部事件(信号)需要发生在另一个线程执行的两个步骤之间。这些步骤彼此越接近,竞态条件发生的可能性就越小。
在这种情况下,脆弱窗口实际上只有一条指令宽!信号必须在 %rsp 被更改之后,但在下一条指令加载 %rip 之前传递。在现代超标量乱序 CPU 上,每个周期可以运行几条这样简单的指令,因此竞态窗口大约为一百皮秒。
当我们发现这个竞态时,我们的第一反应是它一定太罕见了,无法解释观察到的崩溃率。我们在整个集群中每天看到超过十几个返回空崩溃。异常清理期间的单指令竞态真的能解释这个吗?
我们转向了费米估算。如果脆弱窗口大约为 $10^{-10}$ 秒,并且 SIGUSR2 每 $10^{-2}$ 秒的 CPU 时间到达一次,那么每个异常清理处理程序或 catch 块大约有 $10^{-8}$ 的概率输掉竞态。
Rockset 使用异常作为其内部摄取背压机制的一部分。一个过载的主机每秒可以抛出大约 $10^{4}$ 个异常。这意味着使用背压的主机的平均故障间隔时间约为 $10^{4}$ 秒,即每几小时崩溃一次。在集群规模下,这足以解释观察到的崩溃频率。
为什么 libunwind bug 现在出现?
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 这样的基础设施系统,这一点非常重要。这次调查强化了我们对深度 instrumentation、自动化调查以及持续改进运营工具的承诺。可靠性不仅仅是修复发生后的 bug——而是构建数据、工作流程和技能,将不可能的问题转变为可诊断和可解决的问题。