把崩溃当成流行病看:OpenAI 如何拆开 libunwind 老 Bug 和硬件静默损坏

2026-07-09 30 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

OpenAI 在 ChatGPT 数据基础设施里追一个崩溃问题时,最后发现它不是“一个诡异 Bug”,而是两个互不相关的问题叠在了一起:一台 Azure 主机上的静默硬件损坏,以及 GNU libunwind setcontext 里一个存在了 18 年的竞态条件。真正打开局面的,不是继续盯着单个 core dump,而是把崩溃当成一组群体事件来分析。

这件事对基础设施工程师很有参考价值:当系统规模足够大,单个崩溃现场经常会撒谎;只有把机器、版本、调用栈、时间窗口和硬件位置放到一起看,模式才会浮出来。

两个问题伪装成一个问题

从摘要看,OpenAI 遇到的是典型的大规模系统排障陷阱:症状相似,不代表根因相同。

一个根因来自硬件层面。一台 Azure host 出现静默硬件损坏,这类问题最麻烦的地方在于它不一定立刻触发明确的 I/O error、kernel panic 或硬件告警,而是把上层程序推向看似随机的崩溃。应用团队看到的是进程异常,平台团队看到的可能只是某台机器“有点怪”。

另一个根因在 GNU libunwind。问题藏在 setcontext 函数里,是一个 18 年历史的竞态条件,并且漏洞窗口只有一条指令那么窄。这类 Bug 在单机复现时几乎像抽奖:你可以跑很多次都没撞上,也可能在大规模生产流量里被频繁放大。

这里的关键不是“某个库有 Bug”这么简单,而是:两个互不相关的问题产生了相似的崩溃表象。如果继续围绕某一个 core dump 做显微镜式分析,很容易把所有证据强行解释成同一个故事。

为什么单个 core dump 不够

core dump 对定位内存损坏、非法访问、栈破坏很有用,但它也有天然局限:它是一个死亡瞬间的截面,不是整个人群的病历。

如果只看一个崩溃文件,你会自然地问:

  • 这个线程当时在执行什么?
  • 栈上有哪些帧?
  • 寄存器和内存状态是否一致?
  • 哪个指针最可疑?

这些问题仍然重要,但在大规模基础设施里,还必须补上另一组问题:

  • 崩溃是否集中在某些 host、机型、可用区或内核版本?
  • 同样的调用栈是否跨机器出现?
  • 崩溃是否和部署批次、库版本、CPU 特性或负载峰值相关?
  • 可疑 host 上是否存在多种不同程序的异常?

这就是“像流行病学一样调试”的含义:不要只研究一个病人,而要看发病率、聚类、传播路径和暴露因素。对软件来说,暴露因素可能是 host、容器镜像、libc/libunwind 版本、内核、硬件 SKU、调度策略、流量形态。

可以这样实践:给崩溃事件做群体分析

下面是一个可直接运行的最小示例。它不依赖 OpenAI 内部数据,也不声称复现原问题;它演示的是一种排障方法:把崩溃日志结构化,然后按 host 和 signature 聚类,找出“像硬件问题”和“像软件问题”的不同形态。

把下面内容保存为 crash_epi.py,直接运行:

#!/usr/bin/env python3
from collections import Counter, defaultdict
from dataclasses import dataclass

@dataclass(frozen=True)
class Crash:
    ts: str
    host: str
    binary: str
    kernel: str
    libunwind: str
    signal: str
    top_frame: str
    stack_hash: str

crashes = [
    Crash("2025-02-01T10:01:02Z", "az-host-17", "worker-a", "6.5.0", "1.6", "SIGSEGV", "memcpy", "aaa111"),
    Crash("2025-02-01T10:03:44Z", "az-host-17", "worker-b", "6.5.0", "1.6", "SIGBUS",  "rocksdb::Get", "bbb222"),
    Crash("2025-02-01T10:04:10Z", "az-host-17", "worker-c", "6.5.0", "1.6", "SIGILL",  "python_eval", "ccc333"),
    Crash("2025-02-01T10:10:00Z", "az-host-22", "worker-a", "6.5.0", "1.6", "SIGSEGV", "setcontext", "ctx999"),
    Crash("2025-02-01T10:11:00Z", "az-host-31", "worker-a", "6.5.0", "1.6", "SIGSEGV", "setcontext", "ctx999"),
    Crash("2025-02-01T10:12:00Z", "az-host-44", "worker-a", "6.5.0", "1.6", "SIGSEGV", "setcontext", "ctx999"),
    Crash("2025-02-01T10:13:00Z", "az-host-52", "worker-a", "6.5.0", "1.6", "SIGSEGV", "setcontext", "ctx999"),
]

by_host = Counter(c.host for c in crashes)
by_signature = Counter((c.signal, c.top_frame, c.stack_hash) for c in crashes)
host_to_signatures = defaultdict(set)
signature_to_hosts = defaultdict(set)

for c in crashes:
    sig = (c.signal, c.top_frame, c.stack_hash)
    host_to_signatures[c.host].add(sig)
    signature_to_hosts[sig].add(c.host)

print("Hosts with multiple unrelated crash signatures:")
for host, count in by_host.most_common():
    if len(host_to_signatures[host]) >= 3:
        print(f"  {host}: {count} crashes, {len(host_to_signatures[host])} signatures")

print("\nCrash signatures spread across multiple hosts:")
for sig, count in by_signature.most_common():
    hosts = signature_to_hosts[sig]
    if len(hosts) >= 3:
        signal, frame, stack_hash = sig
        print(f"  {signal} at {frame}, stack={stack_hash}: {count} crashes on {len(hosts)} hosts")

运行:

python3 crash_epi.py

你会看到两种不同信号:

Hosts with multiple unrelated crash signatures:
  az-host-17: 3 crashes, 3 signatures

Crash signatures spread across multiple hosts:
  SIGSEGV at setcontext, stack=ctx999: 4 crashes on 4 hosts

这就是排障方向的分叉点:

  • 一台 host 上多个程序、多个栈签名一起出事,更像硬件、内核、虚拟化层或宿主机问题。
  • 同一个 setcontext 签名跨多台机器重复出现,更像共享软件依赖、运行时、编译器或系统库问题。

生产系统里可以把这个思路接到 S3、ClickHouse、BigQuery、Elasticsearch 或日志平台上。关键字段不需要一开始就很完美,但至少要有:host_idinstance_idbinaryimage_digestkernel_versionlibrary_versionssignaltop_framestack_hashtimestamp

采集侧别只存 core dump

如果崩溃只留下 core 文件,后续分析会很重。可以在进程崩溃处理链路里额外生成一个轻量 JSON 事件,供群体分析使用。

一个可改造的 systemd-coredump 后处理脚本可以长这样:

#!/usr/bin/env bash
set -euo pipefail

COREDUMP_FILE="${1:?usage: collect-crash.sh /path/to/core /path/to/binary}"
BINARY="${2:?usage: collect-crash.sh /path/to/core /path/to/binary}"
HOST_ID="$(hostname)"
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

TOP_FRAME="$(
  gdb -batch -ex "thread apply all bt 3" "$BINARY" "$COREDUMP_FILE" 2>/dev/null \
    | awk '/#0 / {print $4; exit}'
)"

STACK_HASH="$(
  gdb -batch -ex "thread apply all bt 10" "$BINARY" "$COREDUMP_FILE" 2>/dev/null \
    | sha256sum | awk '{print substr($1,1,16)}'
)"

cat <<JSON
{
  "timestamp": "$TS",
  "host_id": "$HOST_ID",
  "binary": "$BINARY",
  "kernel": "$(uname -r)",
  "top_frame": "${TOP_FRAME:-unknown}",
  "stack_hash": "$STACK_HASH"
}
JSON

运行前需要把 COREDUMP_FILEBINARY 换成你的实际路径。例如:

chmod +x collect-crash.sh
./collect-crash.sh /var/lib/systemd/coredump/core.worker.1234 ./worker

这段脚本只是起点。真实环境里还要处理符号文件、PIE 地址、容器路径映射、隐私脱敏和上传失败重试。但它体现了一个重要选择:core dump 用来深挖,结构化事件用来找模式。

落地时的判断清单

这次案例的工程启发很明确:遇到低概率崩溃,不要过早把它归为“随机”。随机只是你还没找到分组维度。

可以按这个顺序推进:

  • 先给每个崩溃生成稳定 signature,例如 signal、top frame、归一化调用栈哈希。
  • 按 host 聚合,看是否有单机异常密度过高,尤其是不同 binary、不同栈一起失败。
  • 按 signature 聚合,看是否跨 host、跨实例、跨部署批次重复出现。
  • 把系统库版本、内核版本、镜像 digest、硬件位置纳入事件字段。
  • 对单 host 异常,优先隔离、迁移负载、查硬件和宿主机指标。
  • 对跨 host 同签名异常,优先查共享依赖、运行时、编译参数和上游补丁。

边界也要讲清楚:群体分析不能替代底层调试。它帮你缩小嫌疑范围,告诉你该看硬件还是软件、该看某台机器还是某个库。真正修复 GNU libunwind 这样的竞态,仍然需要能读汇编、理解上下文切换和验证补丁的人。

但在大规模系统里,先看群体再看个体,往往能少走很多弯路。一个 core dump 是证词,一组崩溃事件才是地图。


相关推荐