Core dump epidemiology:OpenAI 如何修复一个 18 年的 libunwind Bug

Core dump epidemiology:OpenAI 如何修复一个 18 年的 libunwind Bug

解读 OpenAI 工程博客 Core dump epidemiology:Rockset 的诡异崩溃如何从单点 core dump 调试转向群体数据分析,并最终拆出一个坏宿主机和 GNU libunwind 的 18 年竞态。

OpenAI 这篇工程博客最值得读的地方,不是「一个 18 年老 Bug 被修了」这件事本身,而是他们先承认一个更尴尬的事实:几天里,团队把两个毫不相关的问题当成了同一个问题。一个是 Azure 物理宿主机的静默硬件错误,另一个才是 GNU libunwind 里存在多年的竞态。只有把所有 core dump 做成可查询的数据集之后,这两个群体才被分开。1
先纠正一个容易误读的口径:这不是 OpenAI 修了一个 Linux 内核补丁。文章里确实一度怀疑过内核的 signal delivery 或 context switching,但最终修复落在 GNU libunwind 的 _Ux86_64_setcontext,也就是 C++ 异常展开链路里的用户态 unwinder。Linux ABI、red zone 和高频信号只是让这个旧问题暴露出来的环境。1

事故表象:函数正常返回,却跳到了空地址

出问题的是 Rockset,OpenAI 在 2024 年收购的云原生搜索与实时分析系统。OpenAI 把它用于 ChatGPT 的数据插件、对话搜索和工作区知识库检索等场景,执行层有大量 C++ 代码。C++ 给了系统很低的控制成本和内存开销,但代价也直接:写错内存时,程序可能以很怪的方式崩掉。1
这次崩溃最怪的地方在返回路径。OpenAI 看到一些 core dump 显示,一个普通 C++ 函数似乎已经执行完,随后返回到一个不是代码的地址;有时保存返回地址的位置是 NULL,有时 CPU 的栈指针 %rsp 像是被平白错开了 8 个字节。文章明确说,相关代码路径没有 inline assembly、setcontextlongjmp,所以它不像普通业务代码能轻易制造出的故障。1
团队一开始走的是传统调试路线:挑几个 core dump 深挖,重建栈、寄存器和返回前状态,再逐个排除假设。问题是,栈损坏类故障本来就很难从日志里自动分类,因为栈追踪本身可能已经坏了;人工多看几个 core dump 能增加线索,但很难得到可信的总体分布。1

转折:别再只看单个病人,要看整个人群

OpenAI 用了一个很好的类比:调试可以像医生,也可以像流行病学家。医生式调试盯住一个病例,把所有细节查到底;流行病学式调试先看整体分布,问这些崩溃是否和发布时间、硬件 SKU、区域、内核版本或某个集群相关。1
真正的转折来自一条自动分析管线。团队让 ChatGPT 帮忙写脚本,批量下载每个 core file 的前缀,抽取寄存器,结合日志过滤已知误报,再把崩溃自动标成 return-to-null、misaligned-stack 或 other。随后他们把这套脚本并行跑过上一年所有生产环境 Rockset core dump。1
结果很快变清楚:
崩溃群体分布特征后来的根因
return-to-null分散在多个集群和地理区域,近期频率上升,但没有清晰的基础设施边界。1GNU libunwind 在异常展开时的竞态。1
misaligned-stack都来自一个区域,有清晰起始时间,而且不会发生在已长期运行的节点上。1单台物理宿主机疑似静默硬件损坏;下线后该类崩溃消失。1
这个表比任何单个 core dump 都重要。之前的假设之所以互相打架,是因为证据来自两个不同病种。把两个群体分开之后,硬件问题可以被 denylist,剩下的 return-to-null 才指向异常展开。1

真正的老 Bug:异常展开遇上高频信号

C++ 抛异常时,运行时要沿着调用栈查找 catch block,并运行中间的析构或清理逻辑。这个过程不是普通函数调用返回,而是一次动态控制转移:unwinder 要恢复寄存器、栈帧和目标指令地址,行为更像 longjmp 或 fiber 切换。1
OpenAI 的二进制同时链接了 libgcc 和 GNU libunwind 两套异常展开实现,最终动态链接器选中了 GNU libunwind。继续读源码后,团队发现 GNU libunwind 会在栈上合成一个 ucontext_t,里面放目标寄存器状态,再把这个结构体指针传给 _Ux86_64_setcontext。问题就藏在最后几条汇编里。1
旧实现先从 ucontext_t 里读出新的 %rsp,把当前栈指针切到新栈;随后又继续通过 %rdi 指向的旧 ucontext_t 读取目标 %rip,也就是下一步要跳去哪里。这个顺序非常危险:一旦 %rsp 已经切走,旧 ucontext_t 就不再属于当前活动栈,也不在 red zone 保护范围内。如果这时信号正好送达,内核会在新 %rsp - 128 附近构造 signal frame,可能覆盖旧结构体里的内容。1
在 OpenAI 的崩溃里,被覆盖的就是目标指令地址。core dump 看起来像某个函数「返回到了 NULL」,但真实机制是 unwinder 计划跳转的目标地址在控制转移完成前被信号处理路径踩坏了。1

为什么 18 年后才炸出来

这个竞态窗口窄到离谱。OpenAI 说,信号必须在 %rsp 被更新之后、下一条读取 %rip 的指令之前送达,窗口大约是百皮秒量级。直觉上这应该稀有到可以忽略。1
但 Rockset 的运行环境把概率乘了起来。它会用异常做内部 ingest backpressure;一个过载 host 每秒可能抛出约 10^4 次异常。它还用 coarse_thread_cputime_clock 高频发送 SIGUSR2,用来低成本估算每个 query 的 CPU 时间。OpenAI 估算,如果脆弱窗口约 10^-10 秒,信号约每 10^-2 秒 CPU 时间送达一次,每个 exception cleanup handler 或 catch block 输掉竞态的概率约 10^-8。在 fleet 规模下,这足以解释每天十几次 return-to-null 崩溃。1
还有一个触发条件:OpenAI 今年早些时候让 SIGUSR2 handler 调用了 timer_getoverrun,用于统计被合并的信号,这让 handler 消耗更多栈空间。handler 栈用得更深,才更可能踩到已经失去保护的 ucontext_t。负载上来、异常变多之后,18 年老问题终于变成生产事故。1

修复点:不要切栈后再读旧栈

OpenAI 的直接缓解是切回 libgcc unwinder。长期修复则是给 GNU libunwind 上游提交补丁。补丁的核心思路很朴素:在调整 %rsp 之前,先把恢复后的 RIP 和 RDI 暂存到恢复后的栈位置;等真正切到新栈后,从新栈里取回这些值,再 retq。这样 _Ux86_64_setcontext 就不会在切换 %rsp 之后继续从旧栈读取关键控制流数据。12
这也是这篇文章对基础设施团队最有用的一点:有些问题不是靠「再聪明一点」就能推出来。OpenAI 不是一开始就读出了汇编里的竞态,而是先把错的总体假设拆掉。只要硬件坏点和 libunwind 竞态还混在同一个样本池里,任何单点推理都会被另一个群体提供的反例污染。

读者该带走什么

第一,core dump 不只是事后验尸材料。只要能批量抽取寄存器、栈形态、节点、区域、时间和二进制版本,它就可以变成生产事故的群体数据。对大规模系统来说,这类数据集有时比单个专家连续盯几天更有效。
第二,高频信号、异常、动态链接和 C++ unwinding 这几件事单看都正常,叠在一起才危险。Rockset 的设计选择有现实理由:C++ 为性能服务,异常用于 backpressure,SIGUSR2 用于低成本 CPU 计量。但系统事故经常发生在这些「单项都合理」的边界交汇处。
第三,18 年老 Bug 不一定是没人认真用过。更常见的情况是,它一直在,只是还没有遇到足够高的异常率、信号率和栈消耗。OpenAI 这次把它打出来,不是因为场景普通,而是因为场景太极端:大规模 C++ 数据系统、密集异常、高频 signal 和 fleet 级样本量同时出现。下一次遇到「不可能」的崩溃,先别急着证明某个假设,多半该先问一句:我们看到的是一个问题,还是两个问题长得太像了。

Contenido relacionado

  • Inicia sesión para comentar.
More from this channel