一次看似普通的崩溃排查,最后拆成了两个完全无关的问题:一台 Azure 主机上的静默硬件损坏,以及 GNU libunwind 中 setcontext 函数一个存在 18 年的竞态条件。真正的转折点不是某个神奇调试器,而是 OpenAI 把视角从“盯着单个 core dump”切换到“观察崩溃人群的分布”。
这件事对基础设施团队很有参考价值:当系统规模足够大时,偶发崩溃不再只是单点事故,它们会形成统计信号。你需要的不是更用力地看一个样本,而是把所有样本放到同一张图上。
两个 bug 伪装成一个故障
来源摘要里最关键的一点是:OpenAI 最初看到的是 ChatGPT 数据基础设施里的崩溃现象,但背后其实有两个问题。
一个问题来自硬件层:某台 Azure host 出现了静默硬件损坏。所谓“静默”,麻烦就在这里。它不一定立刻报错,不一定留下清晰日志,应用层看到的可能只是“某些进程偶尔崩了”。如果只看单个 core dump,很容易把它误判成软件 bug。
另一个问题在软件层:GNU libunwind 的 setcontext 函数存在一个历史很久的 race condition,而且漏洞窗口只有一条指令。这类问题非常难抓,因为它需要线程调度、信号、寄存器状态和极窄时间窗口同时踩中。复现率低,堆栈也可能不稳定。
这两个问题叠在一起,就会制造一种危险假象:你以为自己在追一个 bug,实际上是在把两个不同分布的崩溃混在一起分析。
为什么单个 core dump 会误导你
传统崩溃排查常常从一个 core dump 开始:打开 gdb,看 backtrace,检查寄存器,读 disassembly,然后试图还原现场。这仍然重要,但在大规模基础设施里,它不够。
原因很简单:单个样本没有告诉你“它是否代表整体”。
如果 1000 次崩溃里有 990 次集中在某个库函数附近,而 10 次只发生在同一台物理主机上,你的处理方式应该完全不同。前者可能指向软件缺陷,后者可能指向机器、内存、CPU、虚拟化层或底层存储异常。
OpenAI 的突破来自 population-level crash analysis,也就是把崩溃当成群体事件看:
- 哪些崩溃共享相同的栈签名?
- 是否集中在某个 host、AZ、内核版本、库版本或二进制构建?
- 崩溃地址是否落在同一个指令附近?
- 时间上是否有聚集?
- 同样 workload 在其他机器上是否健康?
这很像流行病学:不要只问“这个病人怎么了”,还要问“同样症状的人集中在哪里”。
可以这样实践:给 core dump 建一个崩溃“病例表”
下面是一个可改造的最小示例:用 gdb 批量提取 core dump 的 backtrace,生成归一化签名,再按 host 和签名聚合。它不是 OpenAI 的内部工具,只是把这次事件里的方法论落到一个小项目里。
假设你有如下目录:
crashes/
host-a/
app.123.core
app.124.core
host-b/
app.777.core
bin/app
保存下面脚本为 cluster_cores.py,把 BINARY 和 CORE_ROOT 改成你的路径:
#!/usr/bin/env python3
import hashlib
import json
import os
import re
import subprocess
from collections import Counter, defaultdict
from pathlib import Path
BINARY = "bin/app"
CORE_ROOT = "crashes"
FRAME_RE = re.compile(r"^#\d+\s+(?:0x[0-9a-fA-F]+\s+in\s+)?([^\s(]+)")
ADDR_RE = re.compile(r"0x[0-9a-fA-F]+")
def run_gdb(core_path: Path) -> str:
cmd = [
"gdb",
"--batch",
"-q",
BINARY,
str(core_path),
"-ex",
"thread apply all bt",
]
return subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout
def normalize_backtrace(gdb_output: str) -> list[str]:
frames = []
for line in gdb_output.splitlines():
match = FRAME_RE.match(line.strip())
if not match:
continue
fn = match.group(1)
fn = ADDR_RE.sub("0xADDR", fn)
frames.append(fn)
return frames[:12]
def signature(frames: list[str]) -> str:
raw = "|".join(frames).encode("utf-8", errors="replace")
return hashlib.sha256(raw).hexdigest()[:16]
def main() -> None:
rows = []
for core in Path(CORE_ROOT).glob("*/*.core"):
host = core.parent.name
output = run_gdb(core)
frames = normalize_backtrace(output)
sig = signature(frames)
rows.append({"host": host, "core": str(core), "signature": sig, "top_frames": frames[:5]})
by_sig = Counter(row["signature"] for row in rows)
by_host_sig = defaultdict(Counter)
for row in rows:
by_host_sig[row["host"]][row["signature"]] += 1
print("Top crash signatures:")
for sig, count in by_sig.most_common(10):
sample = next(row for row in rows if row["signature"] == sig)
print(f"\n{sig} count={count}")
for frame in sample["top_frames"]:
print(f" {frame}")
print("\nHost distribution:")
print(json.dumps({host: dict(counter) for host, counter in by_host_sig.items()}, indent=2))
if __name__ == "__main__":
main()
运行:
python3 cluster_cores.py
你要看的不是某一行 backtrace 多么“像答案”,而是分布:
- 如果某个签名跨很多 host 出现,优先怀疑共享软件路径、库版本或部署变更。
- 如果多个不同签名集中在一台 host,优先隔离机器,排查硬件、虚拟化层或宿主机异常。
- 如果签名都落在
setcontext、unwind、signal handler、协程切换附近,要特别警惕极窄窗口的竞态。
在 Kubernetes 或云环境里,还可以把 pod、node、image digest、kernel version、instance id 一起打进崩溃元数据。可以这样给进程启动脚本加一份环境快照:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p /var/log/myapp
cat > /var/log/myapp/runtime-metadata.json <<EOF
{
"hostname": "$(hostname)",
"kernel": "$(uname -r)",
"image": "${IMAGE_DIGEST:-unknown}",
"node": "${K8S_NODE_NAME:-unknown}",
"pod": "${HOSTNAME:-unknown}",
"started_at": "$(date -Is)"
}
EOF
exec /app/my-service
这类元数据平时看起来啰嗦,出事时就是区分“软件群体感染”和“单机硬件异常”的关键字段。
一条指令窗口意味着什么
摘要提到 GNU libunwind 的 setcontext 竞态窗口只有 one-instruction vulnerability window。这个描述很重要:它解释了为什么问题能隐藏 18 年。
很多竞态并不是“每次并发都会错”,而是只在某条指令前后状态不一致时出错。线程或信号处理如果刚好插入到这个点,程序才会进入损坏状态。这样的 bug 有几个典型特征:
- 复现脚本不稳定,甚至在开发机上完全复现不了。
- core dump 的局部现场看起来合理,但大量样本会指向同一个低层机制。
- 更换编译器、优化级别、CPU、内核调度行为后,概率会变化。
- 业务代码往往只是触发器,真正缺陷在 runtime、unwind、上下文切换或系统库。
所以排查这类问题时,不要只问“哪段业务代码传了坏参数”。还要把 unwind、signal、fiber/coroutine、exception handling、green thread runtime 等低层机制纳入嫌疑范围。
落地建议:把崩溃排查产品化
这次事件的工程启示很直接:崩溃分析不要停留在手工艺阶段。单个 core dump 是显微镜,群体分析是雷达;两者都需要。
可以从一份短清单开始:
- 为每次崩溃记录 host、node、image、binary build id、kernel、library version、timestamp。
- 对 backtrace 做归一化签名,去掉地址噪声,保留函数序列。
- 按签名、host、版本、时间窗口做聚合,不要只看最新一个 core。
- 对“多签名集中在单机”的情况建立硬件隔离流程。
- 对“同签名跨机器扩散”的情况建立软件回滚、符号化和最小复现流程。
- 对
setcontext、unwind、signal handler 这类底层栈帧保持敏感,尤其是崩溃窗口极窄时。
边界也要讲清楚:群体分析不能替代底层调试,它只是帮你把搜索空间缩小。真正修复 GNU libunwind 这样的竞态,仍然需要读汇编、理解 ABI、验证上下文恢复语义。但如果没有先把两个问题拆开,你可能会在错误的 core dump 上花掉几周。