作者:Nathan Bronson,技术团队成员 OpenAI 的模型和代理日益依赖可扩展的数据基础设施,以便在推理时(即模型思考你的问题时)搜索相关数据。其中一些服务用 C++ 编写,这种语言对系统的底层控制让我们能够最大化性能并最小化内存使用。随着规模扩大,这些效率优势变得重要,但 C++ 缺乏内存安全性意味着错误可能导致程序因写入错误或不存在的内存地址而崩溃。

几个月前,我们在 Rockset 服务中观察到一些崩溃,这是 ChatGPT 数据基础设施中一个定制部分,对许多数据插件和搜索对话至关重要。在每次崩溃中,一个正常的 C++ 函数似乎执行完毕,然后返回一个虚假地址,导致内核停止程序,因为指令指针不再指向代码。有时栈帧中的返回地址槽是 NULL。有时栈指针 CPU 寄存器本身似乎偏移了 8 字节,仿佛 %rsp 在正常执行过程中被错误地递减了。在这两种情况下,崩溃都发生在返回时。

这些不是应用程序代码的正常故障模式。仅落在保存的返回地址上的随机写入是可能的,但极不可能。一个不涉及内联汇编、setcontextlongjmp(我们都不使用)的、使 %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 编译,因此活动栈帧始终可以通过 %rsp 访问,调用者形成一个帧指针链表。

在 Linux x86_64 上,AMD64 System V ABI 还在 %rsp 下方保留了 128 字节作为红区。该区域可供用户空间代码使用,重要的是,内核承诺在传递信号时不会破坏它,这是 ABI 契约的一部分。

红区对我们调试返回后崩溃至关重要,因为它保留了返回前的一些信息。当触发 SIGSEGV 时,folly 的致命信号处理程序在崩溃线程的栈上运行。不再活动的栈帧(因为其函数已返回)将被信号处理程序覆盖,除了最后 128 字节。这就是为什么我们可以说“X 刚弹出的栈帧看起来有效,只是返回地址是 NULL”。红区保留了一些非活动帧,有时只是一个非活动帧的尾部。

我们发现了一次栈对齐错误导致的崩溃,其中涉及的所有函数都非常小。这让我们看到,在执行一个相对简单的函数时,%rsp 变得不对齐,并且之后又成功执行了更多调用。程序只有在活动函数最终尝试返回时才崩溃。这些代码路径都没有使用异常、内联汇编、setcontextlongjmp,因此如果栈指针确实像核心转储所暗示的那样发生了变化,用户空间代码中就没有合理的错误可以解释这个问题。

这让我们将目光转向了内核。

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的实现会胜出;然而,检查运行中的二进制文件后发现并非如此。

推翻最后一个假设

此时,我们的工作假设发生了变化,因为我们放宽了之前认为只有一个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上,每个周期可以执行几条这样简单的指令,因此竞态窗口大约为一百皮秒。

当我们发现这个竞态时,第一反应是它一定非常罕见,无法解释观察到的崩溃率。我们在整个集群中每天看到超过十次返回NULL的崩溃。异常清理过程中的单指令竞态真的能解释这一点吗?

我们转向费米估算。如果易受攻击的窗口大约为$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一直存在,但我们的异常率、信号率和处理器栈使用量的乘积直到最近才跨过阈值,使其在操作上变得可见。

这一机制也解释了为何硬件缺陷和 libunwind 缺陷导致的崩溃大多集中在 DocumentTree::updateDocument 方法中。libunwind 引发的崩溃严重偏向该方法,因为在我们抛出异常以施加摄取背压时,该方法始终处于活跃状态。同时,%rsp 未对齐导致的崩溃也高度集中于此,因为出问题的硬件节点属于我们用于批量摄取的 SKU 型号,其 CPU 大部分时间都花在该方法上。

我们立即采取的缓解措施是将 GNU libunwind 切换为 libgcc 的 unwinder。这本身就是一个很好的权衡:libgcc 的实现得益于大量减少锁竞争的工作,这在扩展到大型虚拟机时尤为重要。

我们还向上游提交了一个独立的复现程序和一个修复方案⁠(在新窗口中打开)给 GNU libunwind,并验证了其他 unwinder 不存在类似问题。

群体诊断的力量

这次调试历程让我们深入了解了动态链接、DWARF 展开元数据、Linux 信号传递、System V ABI 以及 C++ 异常机制的具体细节。但最重要的教训比这些都要简单。

最关键的一步并非巧妙的汇编代码阅读或对细节的深刻理解,而是构建高质量的数据集。如果没有这个数据集,我们就会将两种不同的现象混为一谈,试图在混乱中推理出方向。一旦我们获得了准确且完整的群体数据,问题的结构就变得清晰:一类崩溃属于故障主机,另一类则属于 libunwind 中的竞态条件。数据质量提升后,调试也变得更加容易。

对于像 Rockset 这样的基础设施系统,这一点至关重要。这次调查强化了我们对深度监控、自动化调查以及持续改进运维工具的承诺。可靠性不仅仅在于事后修复缺陷,更在于构建数据、工作流程和技能,从而将看似不可能的问题转变为可诊断、可解决的难题。

本文内容采集自官方网站,排版和翻译可能与原页面存在差异。

阅读官方全文