核心转储流行病学:修复一个18年的旧bug
摘要
OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。
OpenAI工程师通过大规模核心转储分析调试罕见的基础设施崩溃,发现了一个硬件故障和一个长期存在的软件bug。
查看缓存全文
缓存时间: 2026/06/30 18:39
# 核心转储流行病学:修复一个存在 18 年的错误
来源:https://openai.com/index/core-dump-epidemiology-data-infrastructure-bug/
OpenAI 的模型和代理越来越依赖可扩展的数据基础设施,以便在推理时——也就是模型思考你的问题时——搜索相关数据。其中一些服务是用 C++ 编写的,C++ 对系统的底层控制让我们能够最大化性能并最小化内存使用。这些效率优势在我们扩展时很重要,但 C++ 缺乏内存安全性意味着错误可能导致写入错误或不存在的内存地址,从而引发崩溃。
几个月前,我们在 Rockset 服务中观察到一些崩溃。Rockset 是 ChatGPT 数据基础设施的一个定制部分,对许多数据插件和搜索对话记录至关重要。在每次崩溃中,一个正常的 C++ 函数似乎执行完毕,然后返回到一个伪造的地址,导致内核停止程序,因为指令指针不再指向代码。有时栈帧中的返回地址槽是 NULL。有时栈指针 CPU 寄存器本身似乎偏移了 8 字节,就好像 `%rsp` 在正常执行过程中被莫名递减了。在这两种情况下,崩溃都发生在返回时。
这些不是应用程序代码的正常故障模式。仅仅落在保存的返回地址上的随机写入是可能的,但极不可能。一个不涉及内联汇编、`setcontext` 或 `longjmp`(我们都不使用)却导致 `%rsp` 偏移 8 字节的错误更加奇怪,因为编译后的代码只在函数序言和尾声直接调整该寄存器。我们(或 ChatGPT)能想到的每个假设都有强有力的证据反对,所以这个错误似乎不可能存在。
我们最初假设是一个问题,结果变成了两个不相关的错误,巧合地同时被发现。首先,一个 Azure 主机上的静默硬件损坏,CPU 无法正确进行数学运算。其次,GNU libunwind 中一个存在 18 年的竞态条件,是一个广泛使用的开源库中未被注意的错误。
这篇文章讲述了我们如何通过像流行病学家一样思考,并构建关于整个崩溃人群的高质量数据集,来识别和修复看似无法解释的崩溃。
## 第一次调试尝试:仔细检查几个核心转储
首先,让我们深入了解 Rockset。它是一个用于搜索和实时分析的云原生数据系统,我们在 OpenAI 内部用于许多用例,例如同步连接器(Rockset 于 2024 年被 OpenAI 收购)。流式更新用于维护工作区知识库的最新索引,以便 ChatGPT 在回答问题或执行操作时可以搜索相关信息。
Rockset 的执行层是用 C++ 编写的。C++ 语言提供对 CPU 的底层访问,有利于性能和效率,但这意味着应用程序错误可能导致无效的内存访问和段错误。为了帮助追踪这些错误,我们使用 folly 的致命信号处理程序在崩溃发生时记录堆栈跟踪,并将相应的核心转储(程序崩溃时的状态快照)上传到 Azure blob 存储以供后续分析。Rockset 的所有查询处理副本都是复制的,这最大限度地减少了崩溃对客户端的影响。然而,每个段错误都对应一个需要修复的错误,以达到我们的可靠性和质量目标。
我们的最初方法是像处理传统调试问题一样处理这些核心转储:仔细检查几个核心转储,形成假设,然后逐一排除。
大多数崩溃发生在一个名为 `DocumentTree::updateDocument` 的方法中。在这些崩溃中,似乎 `updateDocument` 调用了一个未知函数 X,当 X 活跃时栈被损坏,然后 X 返回到了一个不是可执行代码的地址。在某些情况下,X 刚被弹出的帧看起来有效,只是其保存的返回地址是 NULL。在其他情况下,栈指针本身看起来不对,但下一个有效的帧似乎仍然是 `updateDocument`。
我们不知道栈何时被损坏,这留下了巨大的搜索空间。`updateDocument` 是一个经过大量内联的大型方法,因此 X 的候选数量令人难以招架。
这是我们的 C++ 代码中的错误吗?编译器或链接问题?运行时库的问题?Linux 内核在信号传递或上下文切换方面的错误?甚至更罕见的情况?如果是随机写入,为什么我们的 ASAN 测试环境没有捕获到?
我们尝试使用应用程序级别的日志来识别所有问题实例,但仅从日志中很难对栈损坏错误进行分类,因为记录的栈跟踪本身就被损坏或丢失了。我们无法构造一个既没有误报也没有漏报的日志查询。我们手动检查了更多核心转储,发现了一些额外的例子,但该过程过于劳动密集,无法提供可靠的数据集。
在调查的这个阶段,我们(错误地)排除了硬件错误,因为我们在多个区域和多种硬件类型上都看到了崩溃,所以我们仍然只寻找软件原因。有几天,我们深入分析了一个 `%rsp` 错位的单一崩溃,使用栈和寄存器内容重建了崩溃前的历史。这产生了一些可能的线索,但由于我们没有放弃最初的所有错误都有相同原因的结论,这并没有让我们摆脱困境。
## 来自栈的线索
在进入调查的转折点之前,解释一下我们从核心文件中提取了哪些信息很重要。
Rockset 是用 `-fno-omit-frame-pointer` 编译的,因此活动栈帧总是可以通过 `%rbp` 访问,调用者形成一个帧指针链表。
在 Linux `x86_64` 上,AMD64 System V ABI 还在 `%rsp` 下方保留了 128 字节作为红区。该区域可供用户空间代码使用,重要的是,作为 ABI 契约的一部分,内核承诺在传递信号时不会破坏它。
红区对于调试返回后崩溃至关重要,因为它保留了一些返回前的信息。当触发 `SIGSEGV` 时,folly 的致命信号处理程序在崩溃线程的栈上运行。不再活动的栈帧(因为其函数已返回)会被信号处理程序覆盖,除了最后的 128 字节。这就是为什么我们可以说“X 刚弹出的栈帧看起来有效,除了返回地址是 NULL”。红区保留了一些非活动帧,或者有时只是一个非活动帧的尾部。
我们发现了一个栈错位的崩溃,其中涉及的所有函数都非常小。这让我们看到 `%rsp` 在执行一个相对简单的函数时变得错位,并且后续的调用成功执行了。程序只有当事先函数最终尝试返回时才会崩溃。这些代码路径都没有使用异常、内联汇编、`setcontext` 或 `longjmp`,所以如果栈指针真的像核心转储所示的那样改变,那么用户空间代码中没有一个合理的错误可以解释这个问题。
这让我们把注意力转向了内核。
Rockset 比大多数程序更积极地使用信号。查询执行被分解成许多轻量级任务来交换数据。这对于高效处理高 QPS 工作负载很重要,但它使得每个查询的 CPU 记账变得麻烦,因为许多查询的工作被多路复用到同一个线程池。
我们的解决方案是我们称之为 `coarse_thread_cputime_clock` 的东西,它廉价地近似 `clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)` 以便在每个任务边界采样。`timer_create` API 可以基于多种时间流逝概念(包括 CPU 时间的累积)来调度周期性的信号传递。我们调度一个信号(SIGUSR2)在每个 CPU 时间的几毫秒后传递,此时信号处理程序会更新一个线程局部值。即使许多任务在执行时没有看到粗粒度时钟前进,但所有增量的总和会产生查询实际 CPU 时间的无偏估计。
因为我们如此频繁地传递信号,一个关于上下文切换或信号传递的罕见内核错误似乎是合理的。我们花时间阅读错误报告、内核源代码和 Azure 特定的内核补丁。我们尝试了压力测试。但我们无法找到任何相关的东西。
那时我们决定退一步,尝试不同的方法。
## 医生还是流行病学家?
调试这类问题有两种主要方法。
一种是像医生一样:专注于一个病人,进行大量测试,并尝试根据详细证据诊断一个病例。
另一种是更像流行病学家:查看整个人群,询问是否有单个病例无法揭示的模式。这个错误是在特定版本开始出现的吗?它是否与某个硬件 SKU(特定的 CPU 和服务器型号)、某个区域或某个内核版本相关?在看似一个症候群中是否隐藏着多个不同的集群?
我们之前主要是医生模式。关键的转变是我们决定需要收集高质量的人群数据。
## 清理数据
我们之前自动找到所有问题实例的尝试失败了,因为我们试图使用文本搜索日志。核心转储本身包含更多信息,但手动查看它们无法扩展。我们决定投入精力构建一个可以自动分析核心转储的管道。
我们让 ChatGPT 编写了一个脚本,下载每个核心文件的前缀,提取寄存器,使用日志过滤已知的误报,并自动将崩溃标记为返回空、栈错位或其他。然后我们并行运行该脚本,处理过去一年中所有生产 Rockset 核心转储。
这是转折点。
一旦我们有了干净的数据集,相关性立即显现。我们之前当作一个奇怪错误处理的,实际上是*两个*独立的崩溃人群。
返回空的核心转储分布在许多集群和地理区域。它们的频率最近有所增加,但没有明确的开端日期和清晰的基础设施边界。
栈错位的崩溃看起来完全不同。它们都来自一个区域,有明确的开始日期,并且从未发生在运行了很长时间的节点上。尽管它们涉及多个 Azure VM(云中托管的虚拟机),但模式看起来像是一台物理机器有坏硬件,导致任何恰好落在其上的 VM 出现问题。
那是我们意识到我们一直在心理上混淆了两个错误的时刻。因为我们一直在混合两个错误的反例,所以我们无法找到一个统一的解释。
## 错误 #1:坏主机
有了干净的 Kubernetes 节点和时间戳列表,我们能够将栈错位崩溃追溯到单个物理主机,这很容易被加入黑名单。
我们无法在受控环境中重现该主机上的寄存器损坏,即使在几周的压力测试之后也是如此。然而,一旦有问题的主机被停用,栈错位崩溃就消失了。
移除坏主机并不是一个永久的解决方案,因为它不能防止相同问题的新发生。但是,我们可以更改软件,以便如果类似问题再次发生,可以轻松检测和处理。我们改进了致命信号处理程序,包含寄存器状态,以便仅从日志中检测复发(无需核心转储)。我们更改了控制平面,使得 VM 通常被重用而不是回收,这使得在我们的基础设施堆栈层面更容易检测坏节点。我们还更新了我们的运行手册(以及我们团队的思维模型)以包含这种可能性。
随着坏主机崩溃被分离出来,剩余的返回空核心转储变得更容易推理。之前我们排除了异常展开,因为我们认为有反例:在明确不使用异常的代码路径中崩溃。但这些反例都来自硬件损坏的集群。
一旦我们重新检查剩余的并考虑到这一点,我们发现这个结论完全相反:崩溃都发生在异常展开期间。
## 异常处理是一种动态控制转移
当 C++ 抛出异常时,运行时必须发现哪个 catch 块应该接收它,以及沿途哪些析构函数或清理处理程序应该运行。编译器会发出这些元数据,但实际的匹配在运行时动态发生。
异常展开实际上不是由调用 `throw` 的函数执行的,而是由生成的编译代码调用的辅助函数执行的。这些运行时例程检查栈,获取栈上找到的函数的元数据,动态查找清理处理程序和 catch 块,然后将控制转移到其中一个位置。转移控制包括展开所有中间栈帧(包括辅助函数的帧)。
在操作上,这更接近 `longjmp` 或协程切换,而不是普通的调用和返回。必须恢复调用者保存的寄存器,以及栈帧寄存器 `%rbp` 和 `%rsp`。
我们的二进制文件链接了两个包含执行 C++ 异常展开函数实现的库:libgcc 和 GNU libunwind。动态链接器选择的是 GNU libunwind 的定义。这让我们感到惊讶;我们原本期望 libgcc 实现因为符号版本控制规则而胜出;然而,检查正在运行的二进制文件显示情况并非如此。
## 撤销最后一个假设
此时,我们的工作假设发生了变化,我们放松了另一个我们在认为只有一个错误时做出的假设。
也许我们看到的不是普通函数返回 NULL。也许我们看到的是一个展开转移——实际上是一个 `setcontext` 风格的寄存器恢复——其中目标指令指针在控制转移之前变成了 NULL。换句话说,展开库提供了错误的数据,而不是栈上错误的返回地址槽。
这极大地缩小了问题范围。要么 GNU libunwind 计算了错误的目标状态,要么它计算了正确的状态,但在应用之前被破坏了。
我们阅读了 GNU libunwind 的源代码,发现它在栈上合成一个 `ucontext_t`,为清理处理程序的帧填充所需的寄存器状态,然后将指向该结构的指针传递给一个内部汇编例程:`_Ux86_64_setcontext`。
此时我们有了所有碎片。
合成的 `ucontext_t` 位于由 `_Ux86_64_setcontext` 展开的栈帧之一中,在该函数执行期间。`_Ux86_64_setcontext` 是否在改变 `%rsp` 之后读取该结构?此时该结构不再属于活动栈?这将使其容易受到信号传递(如我们频繁的 `SIGUSR2`)的破坏。
## 错误 #2:libunwind 错误
答案是肯定的。
以下是我们在使用的 GNU libunwind 版本中 `_Ux86_64_setcontext` 的最后六条指令,主要由从内存加载到目标寄存器的 `mov` 指令组成:
(`%rdi` 指向栈分配的 `ucontext_t`,而 `UC_MCONTEXT_*` 宏只是展开为存储特定寄存器的固定偏移量。)
相似文章
Bug Archeology:借助LLM解开一个十年的Swift/C++谜题
一位开发者讲述了如何利用LLM解决一个Swift/C++跨平台音乐应用中存在十年的Bug,展示了AI如何协助调试复杂问题。
@jedisct1: epoll UAF
对 Linux 内核 epoll 子系统中的一个释放后使用(UAF)漏洞的详细分析,该漏洞通过切换到 RCU 修复,以及作者在现代设备上尝试利用该漏洞失败的经过。
Rust与C/C++在内存安全CVE上的差异
分析Rust与C/C++在内存安全CVE报告方式上的不同,论证即使存在错误,Rust的设计也能降低某些类型漏洞的发生。
事故报告:CVE-2024-YIKES
一份讽刺性的事故报告描述了一场灾难性的多阶段供应链攻击,该攻击始于一个被篡改的 JavaScript 依赖项,并在 Rust 和 Python 生态系统中蔓延,最终因一只挖矿蠕虫的“意外介入”而得以解决。
微软修复了 137 个漏洞,但 Azure AI Foundry 的那个最引人注目
微软修复了 137 个漏洞,其中 Azure AI Foundry 中一个值得注意的高严重性权限提升修复突显了 AI 应用基础设施层的安全风险。