绕了一圈的弯路转折点:double free第三位主角登场ASAN 终结技几点启示
你有没有遇到过这种情况——测试用例偶尔挂,重跑一次又好了,谁也没当回事?
这几乎是每个后端团队的日常。但Buildkite团队最近遇到的一个"随机挂"的测试,最终揪出了一个藏在 Redis 客户端库深处的 use-after-free 漏洞。整个过程堪称教科书级的线上排障案例,值得每个写代码的人看看。
事情是这样的。Buildkite 的某个工程师在周五下午顺手升级了一下 Redis gem 的版本——看起来就是个常规操作,CI 全绿,谁也没多想。
然后到了下周二,工程经理 David 发现自己的构建挂了。挂的方式很奇怪——不是每次都挂,而是"偶尔挂"。一看 Test Engine 面板,三个测试用例的 reliability 从接近 100% 一夜之间掉到了惨不忍睹。
团队很快拉了 Andon Cord。因为这些测试太不稳定了,直接堵住了 master 分支的部署。先临时排除,再慢慢查。
这时候有个细节值得注意。David 多看了一眼测试可靠性的趋势图,发现三个最严重的"捣蛋鬼"是在 2-4 天前突然变不稳定的——正好和周五下午那个 Redis 升级的时间点吻合。
排查问题的第一步从来不是看代码,而是看变化。谁改了什么、什么时候改的,这些信息往往比代码本身更能帮你缩小范围。
团队起初走了一条很自然的弯路。因为这些测试是 feature test,跑的是一整套环境——RSpec + Selenium 无头浏览器 + Web/API 服务器 + 数据库 + Redis 服务器。在这种多组件环境下,竞态条件(race condition)是导致测试不稳定的常见元凶。
有人提议:"在 test setup 和 assertion 之间加个 sleep 试试?"
这其实不是一个解决方案,而是一个诊断手段——如果加 sleep 后问题消失,说明是 WebSocket 消息延迟导致的竞态。但问题是,就算确认了是竞态,你也不知道具体是哪一段代码的什么问题。
到了周一,更多测试开始随机挂。Patrick(就是升级 Redis 的那位工程师)和另一个前端同事一起,花了三天时间试图复现。他们最终搞出了一个稳定的复现方案,但跑一次要 30 分钟才出问题。
30 分钟才能复现一次——这意味着你在调试循环里,每改一行代码就要等半小时才知道结果。做过排障的人都懂这有多折磨。
第三天晚上,Patrick 精疲力尽,准备收工。就在这时,他看到了一个极不寻常的错误信息:
ruby(29632,0x2a9f1f000) malloc: Double free of object 0x2a9bb6740ruby(29632,0x2a9f1f000) malloc: *** set a breakpoint in malloc_error_break to debug
Double free。这意味着同一块内存被释放了两次。在 C 层面,这种错误会直接损坏内存管理器的内部数据结构,后续的任何 malloc/free 都可能触发不可预测的行为——包括 Segfault、数据损坏、或者表面上什么事也没有(这才是最可怕的)。
但团队当时还没有足够的信息把 double free 和测试失败联系起来。
又过了一天,一个构建触发了 segmentation fault,并且附带了 core dump。
这里有个小插曲:core dump 能自动上传,是因为有个开发之前为了调试另一个问题,写了一个 Buildkite 插件,专门检查构建产物里有没有 core dump,有的话就自动上传。这个"顺手为之"的插件,成了破案的关键。
这时,第三位主角 Rian 登场了。这位老兄在 Buildkite 以修复各种诡异 bug 闻名。通过分析 core dump,他发现崩溃发生在 hiredis 的 C 扩展代码里。具体来说,是 memmove 函数被调用时,size 参数传了一个 0x00a0ffffffffffb6——换算成十进制,大约 45 PB。
一个 memmove 要搬 45 PB 的数据。不用说,这肯定是内存被写坏了。
巧合的是,Rian 年初刚参加过一个关于用 ASAN(Address Sanitizer)排查 C 语言内存安全问题的技术会议。ASAN 是 Google 开发的一款内存错误检测工具,能精确捕获 use-after-free、buffer overflow 这类问题。
Rian 花了几个小时,用 ASAN 编译的 Ruby 跑通了复现脚本。30 分钟内,ASAN 就报告了一个heap use-after-free。
问题出在 hiredis 的并发设计上。为了支持并发执行,hiredis 为每个连接起了两个线程——一个负责读,一个负责写。在某些特定场景下,读线程会释放写线程正在使用的缓冲区。读线程以为这块内存不用了,就 free 掉了,但写线程还在用它——经典的 use-after-free。
修复本身倒是很直接:临时禁用 C 扩展连接,然后向上游提交了 issue。
复盘整个事件,有几个点我觉得特别值得说:
1. 好工具 = 放大器。如果没有 Test Engine 的可靠性趋势图,David 可能根本不会把测试失败和 Redis 升级联系起来。没有 core dump 自动上传插件,崩溃发生一万次也传不上来。写工具就是在给未来的自己铺路。
2. 竞态条件是最容易背锅的。在多线程/多进程环境下,任何偶发的失败都容易被归因于"竞态"。这确实是常见原因,但如果你一开始就默认是竞态,可能错过真正的根因。
3. 内存错误很难排查,但工具箱在变好。Use-after-free、double free 这类问题,在纯 C 代码里排查曾经是噩梦。但随着 ASAN、Valgrind、核心转储分析等工具的普及,很多曾经要花数周的问题,现在可以在几小时内解决。
4. 运气 = 准备 + 机会。Rian 年初去听了那个 ASAN 的分享,纯属巧合。但如果他没去,或者去了没记住,这个机会就浪费了。你永远不知道哪一天,你读过的某篇文章、参加过的某个分享会帮你省下一周的时间。
你在项目里遇到过最诡异的 bug 是什么?评论区聊聊。
热门跟贴