核心转储流行病学修复十八年旧漏洞
First debugging attempt: carefully examining a few core dumpsOpenAI 的模型和智能体在推理时—也就是模型思考你的问题的时刻—越来越依赖可扩展的数据基础设施来搜索相关信息。其中一些服务是用 C++ 编写的,这种语言对系统的底层控制让我们
OpenAI 的模型和智能体在推理时—也就是模型思考你的问题的时刻—越来越依赖可扩展的数据基础设施来搜索相关信息。其中一些服务是用 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。
这篇文章讲述的是我们如何通过像流行病学家一样思考、构建关于所有崩溃的高质量数据集,来识别并修复那些看似无法解释的崩溃。
First debugging attempt: carefully examining a few core dumps
首先,让我们深入了解一下 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 都有相同原因”的初步结论,这并没有让我们走出困境。
Clues from the stack
在进入调查的转折点之前,有必要解释一下我们从核心文件中提取了哪些信息。
Rockset 编译时使用了 -fno-omit-frame-pointer,所以活跃的栈帧总是可以通过 %rbp 访问,调用者形成一个帧指针链表。
在 Linux x86_64 上,AMD64 System V ABI 还在 %rsp 下方保留了 128 字节作为红区。该区域可供用户态代码使用,重要的是,作为 ABI 契约的一部分,内核承诺在传递信号时不会破坏它。
红区对我们调试返回后的崩溃至关重要,因为它保留了返回之前的一些信息。当触发 SIGSEGV 时,folly 的致命信号处理器会在崩溃线程的栈上运行。不再活跃的栈帧(因为它们的函数已经返回)会被信号处理器覆盖,除了最后 128 字节。这就是为什么我们可以说“X 刚刚弹出的栈帧看起来有效,只是返回地址是 NULL”。红区保存了一些不活跃的帧,或者有时只是某个不活跃帧的尾部。
我们找到了一个栈错位的崩溃,其中涉及的所有函数都非常小。这让我们看到, 这把我们推向了内核的方向。 Rockset 使用信号比大多数程序更频繁。查询执行被分解为许多交换数据的轻量级任务。这对于高效处理高 QPS 工作负载很重要,但它使得每个查询的 CPU 核算变得棘手,因为许多查询的工作被复用到同一个线程池上。 我们的解决方案是称为 因为我们如此频繁地传递信号,一个关于上下文切换或信号传递的罕见内核 bug 似乎可信。我们花时间阅读 bug 报告、内核源代码和 Azure 特定的内核补丁。我们尝试了压力测试。但我们没能找到任何相关的东西。 那时,我们决定退后一步,尝试不同的方法。 调试这类问题有两种大致的方法。 一种是像医生一样:专注于一个病人,进行大量测试,并尝试从详细的证据中诊断单个病例。 另一种是更像流行病学家:查看整个人群,并询问是否存在单个病例无法揭示的模式。这个 bug 是在特定发布版本开始的吗?它与某个硬件 SKU、某个区域或某个内核版本相关吗?在看起来像单一综合征的里面是否隐藏着几个不同的集群? 我们大部分时间都处于医生模式。关键的转变是决定我们需要收集高质量的人群数据。 我们之前尝试自动找到问题的所有实例都失败了,因为我们是尝试在日志上使用文本搜索。核心转储本身有更多信息,但手动查看它们不可扩展。我们决定投入精力构建一个能够自动分析核心转储的管道。 我们让 ChatGPT 编写了一个脚本,下载每个核心文件的前缀,提取寄存器,使用日志过滤已知的假阳性,并自动将崩溃标记为返回-null、栈错位或其他类别。然后我们在过去一年所有生产环境 Rockset 核心转储上并行运行该脚本。 这是个转折点。 一旦我们有了干净的数据集,关联性立刻显现出来。我们一直认为的一个奇怪 bug 实际上是两个独立的崩溃人群。 返回-null 的核心分散在许多集群和地理区域中。它们的频率最近有所增加,但没有明确的开始日期,也没有干净的基础设施边界。 栈错位的崩溃看起来完全不同。它们都来自一个区域,有明确的开始日期,并且从未发生在运行时间很长的节点上。尽管它们涉及多个 Azure VM,但这个模式看起来像是一台物理机硬件故障,对其上运行的任何 VM 都造成了问题。 那一刻我们意识到,我们一直在心里把两个 bug 混为一谈。因为一直在混合使用两个 bug 的反例,我们找不到一个统一的合理解释。 有了干净的 Kubernetes 节点和时间戳列表,我们得以将栈错位崩溃追溯到一台物理主机,很容易将其列入黑名单。 即使经过数周的压力测试,我们也无法在受控环境中重现那台主机上的寄存器损坏。然而,一旦把那台有问题的主机停用,栈错位崩溃就消失了。 移除有问题的宿主并不是一个永久的解决方案,因为它并不能防止同样的问题再次出现。不过,我们可以修改软件,这样如果类似问题再次发生,就能被轻松检测和处理。我们改进了致命信号处理器,使其包含寄存器状态,这样我们就能仅凭日志检测复发(无需核心转储)。我们更改了控制平面,使得 VM 通常被重用而不是回收,这在我们基础设施栈的层面大大简化了坏节点的检测。我们还更新了运行手册(以及团队的思维模型),将这种可能性纳入其中。 将坏主机崩溃分离出去后,剩下的返回-null 核心变得更容易推理了。之前我们排除了异常展开,因为我们认为有反例:在明确不使用异常的代码路径中发生了崩溃。但那些反例都来自硬件损坏的集群。 一旦我们带着这个想法重新审视剩下的核心,我们发现这个结论完全颠倒了:崩溃都发生在异常展开过程中。 当 C++ 抛出异常时,运行时必须发现哪个 catch 块应该接收它,以及沿途哪些析构函数或清理处理程序应该运行。编译器会发出这些元数据,但实际的匹配在运行时动态发生。 异常展开实际上不是由调用 操作上来说,这更接近于 我们的二进制文件链接到了两个包含执行 C++ 异常展开函数的库:libgcc 和 GNU libunwind。动态链接器选择了 GNU libunwind 的定义。这让我们很惊讶;由于符号版本控制规则,我们原本期望 libgcc 的实现会胜出;然而检查运行中的二进制文件表明并非如此。 此时,我们的工作假设发生了变化,我们放松了另一个在认为只有一个 bug 时所做的假设。 也许我们看到的不是普通的函数返回 NULL。也许我们看到的是展开转移——本质上是 这大幅缩小了问题范围。要么 GNU libunwind 计算了错误的目标状态,要么它计算了正确的状态,但某些东西在应用它之前破坏了它。 我们阅读了 GNU libunwind 的源代码,发现它在栈上合成一个 此时,我们已经有了所有拼图。 合成的 答案是肯定的。 以下是我们使用的 GNU libunwind 版本中 174:movUC_MCONTEXT_GREGS_RSP(%rdi),%rsp275:376:/* push the return address on the stack */477:movUC_MCONTEXT_GREGS_RIP(%rdi),%rcx578:push %rcx679:780:movUC_MCONTEXT_GREGS_RCX(%rdi),%rcx881:movUC_MCONTEXT_GREGS_RDI(%rdi),%rdi982:retq ( 第一条指令是竞态窗口的开始。它将 通常这不会造成问题,但如果信号恰好在一个“完美”的时刻到达,内核会在 如果这在下一条指令读取 这就是 bug。 这段汇编也解释了一个让我们困惑的观察:为什么函数 X 在前一个栈帧的返回地址槽中有一个 NULL。 核心转储中看起来像是“一个函数返回到了 NULL”的东西,实际上是“展开器在栈上合成一个目标返回地址,但该目标在转移完成之前已被损坏”。我们假设返回地址槽的损坏是就地发生的,因为我们不知道任何有目的地将(可损坏的)数据写入返回地址槽的地方。 让这个 bug 看起来荒谬的是这个竞态窗口有多窄。在这种竞态条件中,外部事件(信号)需要在另一个线程执行的两个步骤之间发生。步骤越接近,竞态条件发生的可能性就越小。 在这种情况下,漏洞窗口实际上只有一条指令宽!信号必须在 当我们发现这个竞态时,第一反应是它一定太罕见了,无法解释观察到的崩溃率。我们在整个集群中每天看到超过十几个返回-null 的崩溃。异常清理过程中一个指令的竞态真的能解释这一切吗? 我们转向了费米估算。如果漏洞窗口大约是 秒,而 SIGUSR2 每 秒 CPU 时间到达一次,那么每个异常清理处理程序或 catch 块大约有 的概率输掉这场竞态。 Rockset 将异常作为其内部 ingest 反压机制的一部分。单个过载主机每秒可能抛出 个异常。这意味着使用反压的主机的平均故障间隔时间是 秒,即每隔几小时一次崩溃。在集群规模上,这完全足以解释观察到的崩溃频率。 GNU libunwind bug 很老了——超过 18 年,存在于第一个支持 C++ 异常展开的 那么为什么它现在才出现? 崩溃率大致与抛出的异常数和传递的信号数成正比。它还取决于信号处理程序消耗的栈空间。 Rockset 在这三个方面都不寻常。我们在正常过载控制中高速抛出异常;由于 最后一个更改似乎很重要。如果处理程序使用的栈足够少,它可能不会到达并覆盖过时的 换句话说,libunwind bug 一直存在,但我们的异常率、信号率和处理程序栈使用率的乘积直到最近才跨过变得操作上可见的阈值。 这个机制也解释了为什么硬件 bug 和 libunwind bug 都主要崩溃在 我们的即时缓解措施是从 GNU libunwind 切换到 libgcc 的展开器。这本身就是一个好交易:libgcc 的实现受益于大量减少锁竞争的工作,这在扩展到大型 VM 时很重要。 我们还向上游提交了一个独立的复现程序和修复,并验证了其他展开器没有类似问题。 这次调试旅程让我们学到了很多关于动态链接、DWARF 展开元数据、Linux 信号传递、System V ABI 和 C++ 异常机制的具体细节。但主要的教训比这些都要简单。 最重要的步骤不是聪明的汇编阅读或对细节的深入了解。而是构建一个高质量的数据集。如果没有这个数据集,我们就会将两个不同的现象混为一谈,并试图推理出困惑的出路。一旦我们有了准确完整的人群数据,问题的结构就变得显而易见了:一个崩溃人群属于一个坏主机,另一个属于 libunwind 中的竞态。数据变好了,调试就容易了。 对于像 Rockset 这样的基础设施系统,这很重要。这次调查强化了我们对深度工具、自动化调查和持续改进运维工具的承诺。可靠性不仅仅是问题发生后的 bug 修复——更是构建数据、工作流程和技能,将看似不可能的问题变成可诊断和可解决的。 %rsp 在一个相对简单的函数执行过程中发生了错位,并且之后更多的调用都成功了。只有当活跃函数最终试图返回时,程序才崩溃。这些代码路径都没有使用异常、内联汇编、setcontext 或 longjmp,所以如果栈指针真的像核心转储所示那样发生了变化,则没有用户态代码中的合理 bug 能解释这个问题。coarse_thread_cputime_clock 的东西,它近似于 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...),成本低到可以在每个任务边界采样。timer_create API 可用于基于几种时间流逝的概念(包括 CPU 时间的累积)来调度周期性信号传递。我们安排一个信号(SIGUSR2)每几毫秒 CPU 时间传递一次,此时信号处理器更新一个线程本地值。尽管许多任务在执行时没有看到粗粒度时钟前进,但将所有增量求和会产生一个对查询实际 CPU 时间的无偏估计。Doctor or epidemiologist?
Cleaning the data
Bug #1: the bad host
Exception handling is a dynamic control transfer
throw 的函数执行的,而是由生成的编译代码调用的辅助函数执行的。这些运行时例程检查栈,获取栈上函数的元数据,动态查找清理处理程序和 catch 块,然后将控制权转移到其中一个位置。转移控制权包括展开所有中间的栈帧(包括辅助函数的栈帧)。longjmp 或 fiber 切换,而不是普通的调用和返回。被调用者保存的寄存器必须恢复,栈帧寄存器 %rbp 和 %rsp 也是如此。Undoing one last assumption
setcontext 风格的寄存器恢复——其中目标指令指针在控制权转移之前变成了 NULL。换句话说,是展开库提供了错误的数据,而不是栈上错误的返回地址槽。ucontext_t,填入清理处理程序帧所需的寄存器状态,然后将指向该结构体的指针交给一个内部汇编例程:_Ux86_64_setcontext。ucontext_t 位于被 _Ux86_64_setcontext 展开的栈帧中,在该函数执行期间。是 _Ux86_64_setcontext 在改变 %rsp 之后读取了该结构体吗?这时结构体已不再是活跃栈的一部分,容易受到信号传递(比如我们频繁的 SIGUSR2)的破坏。Bug #2: the libunwind bug
_Ux86_64_setcontext 的最后六条指令,主要由从内存加载到目标寄存器的 mov 指令组成:Plain Text
%rdi 指向栈上分配的 ucontext_t,UC_MCONTEXT_* 宏只是展开为存储特定寄存器的固定偏移量。)%rsp 更新为指向活跃栈的新底部。一旦发生这种情况,%rdi 指向的结构体就不再是活跃栈(或红区)的一部分,也不再是内核不能触碰的区域。%rsp-128 处构建信号帧。这可能会覆盖 %rdi 指向的内存。UC_MCONTEXT_GREGS_RIP(%rdi) 之前发生,那么恢复的指令指针就可能被损坏。在我们的崩溃中,它变成了 NULL。Why the cores masked as ordinary bad returns
setcontext 被编写为恢复所有寄存器,包括 %rdi,所以它不能在控制转移的最后时刻使用该寄存器来读取 UC_MCONTEXT_GREGS_RIP(%rdi)。相反,它更早地读取该值,将其保存到栈上,恢复几个更多的寄存器,然后使用 retq 读取保存的值并转移控制权。A single-instruction race window
%rsp 改变之后、下一条指令加载 %rip 之前传递。在现代超标量乱序 CPU 上,几个像这样简单的指令可以每个周期执行,所以竞态窗口大约是几百皮秒。Why did the libunwind bug appear now?
x86_64 版本中。coarse_thread_cputime_clock,我们异常频繁地传递 SIGUSR2;今年早些时候,我们通过添加对 timer_getoverrun 的调用,让 SIGUSR2 处理程序使用了更多栈,以便能够考虑合并的信号。ucontext_t 内存。在那个更改之前,我们根本没有观察到这些崩溃。更改之后,频率保持较低,直到我们为某些使用场景增加了负载,这些场景使反压机制承受了压力。DocumentTree::updateDocument 内部。来自 libunwind 的崩溃强烈偏向于这个方法,因为在我们抛出异常以施加 ingest 反压时,它总是活跃的。它也强烈地选择了 %rsp 错位的崩溃,因为有问题的硬件节点是我们用于批量 ingest 的 SKU,该节点将大部分 CPU 时间花在那个方法上。The power of a population-level diagnosis
你是一名 AI 行业编辑,请围绕下面这条热点输出一份资讯解读:
热点:核心转储流行病学修复十八年旧漏洞要求:
1. 先用一句话解释这条热点在讲什么
2. 再总结它为什么重要
3. 说明会影响哪些 AI 产品或内容方向
4. 最后给出 3 个适合资讯站使用的标题
游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。
相关热点在招聘这个行业中,数据录入的繁琐程度相信大家都有切身体会。每天需要从各类网页、社交平台、招聘站点中搜寻候选人信息,再手动一条条录入系统,既耗时费力又容易出错。今天要介绍的这款Kwal Chrome插件,正是为了彻底解决这一痛点而设计的。什么是 Kwal Chrome 扩展程序 插件?该插件的定位十分
网红经济正在进化——Twinning AI带来的玩法是:粉丝可以直接跟你的人工智能分身聊天,而你,每次互动都能收到真金白银。它集成了专业的声音克隆、文本和语音消息,以及数据分析能力,让粉丝互动变得既有趣又能变&现。 什么是Twinning AI? 简单来说,Twinning AI允许网红创建一个属于
在跨境电商和全球业务快速发展的今天,发票与财务管理工具的重要性日益凸显。AI技术的加入,让这些原本繁琐的流程实现了质的飞跃。Invoicemint 正是这样一款专注全球企业的智能发票与财务管理软件——它不只是一个简单的发票生成器,而是一套覆盖从开票、对账到税务合规、催款的全链路解决方案。 什么是In
想象一下,你随时都能找到一个倾听者——不带任何偏见,不会感到疲惫,而且完全匿名。这听起来像科幻小说里的情节,但现在已经成为现实。MyWhy 就是这样一款 AI 心理治疗应用,它将专业的情感支持装进你的口袋,让心理健康服务不再是奢侈品,而是像打开手机一样触手可及。什么是MyWhy?简单来说,MyWhy
- 日榜
- 周榜
- 月榜
热点快看
