コアダンプ疫学:18 年前のバグを修正
OpenAI は、システム障害の根本原因調査に不可欠な「コアダンプ」機能に 18 年間存在していた重大なバグを発見し、その修正方法を発表した。
キーポイント
長年の潜伏バグの発見
システム障害時のデバッグに広く利用されている「コアダンプ」機能において、18 年前から存在する重大な欠陥が OpenAI によって特定された。
調査プロセスへの影響
このバグにより、過去に収集されたコアダンプデータの一部が信頼性のないものであった可能性があり、過去のシステム障害分析の見直しが迫られている。
修正と再評価の発表
OpenAI はバグの修正方法を公開し、今後より正確な障害調査が可能になるよう、既存のインフラやプロセスの更新を促している。
影響分析・編集コメントを表示
影響分析
本ニュースは、インフラの根幹を支えるデバッグツールの信頼性に対する懸念を示しており、大規模システム運営における「見えないリスク」の重大さを浮き彫りにしています。OpenAI が自社の発見を公開したことは、技術的透明性の向上と業界全体のセキュリティ・安定性への貢献と言えますが、即座に業界構造を変えるほどの画期的な革新というよりは、運用基盤の維持管理における重要な教訓です。
編集コメント
18 年もの間見過ごされていたバグが、大規模 AI モデルの運用基盤においても存在し得るという事実は、技術的負債の蓄積がいかに危険かを如実に示しています。
OpenAI のモデルやエージェントは、推論時に(つまり、モデルがあなたの質問について考えている際に)関連データを検索するために、スケーラブルなデータインフラストラクチャにますます依存しています。これらのサービスの一部は C++ で書かれており、そのシステムへの低レベルの制御により、パフォーマンスを最大化しメモリ使用量を最小化できます。この効率性のメリットはスケールする上で重要ですが、C++ にはメモリの安全性がないため、バグが誤ったまたは存在しないメモリアドレスへの書き込みによってクラッシュを引き起こす可能性があります。
数ヶ月前、ChatGPT のデータインフラストラクチャの独自部分であり、多くのデータプラグインや会話内検索に不可欠な Rockset サービス内部からいくつかのクラッシュを観察しました。これらの各クラッシュにおいて、通常の C++ 関数が終了した後に偽のアドレスへ戻ろうとし、その結果、命令ポインタがもはやコードを指さなくなったためカーネルがプログラムを停止させました。場合によっては、スタックフレーム内の戻りアドレススロットが NULL になっていました。また、場合によっては、スタックポインタ CPU レジスタ自体が 8 バイトずれているように見え、%rsp が通常の実行の途中で何らかの理由で減算されたかのように見えました。どちらの場合も、クラッシュは関数からの戻り時に発生しました。
これらはアプリケーションコードにおける通常の故障モードではありません。保存された戻りアドレスのみに誤って書き込まれる stray write は起こり得ますが、極めて稀です。インラインアセンブリ、setcontext、longjmp(いずれも使用していません)を伴わずに %rsp を 8 バイトずらすバグはさらに奇妙です。なぜなら、コンパイルされたコードがそのレジスタを直接調整するのは関数のプロローグとエピローグのみだからです。私たちが(あるいは ChatGPT が)考え得たすべての仮説には強い反証があり、このバグは不可能に見えました。
我々が一つの問題だと考えていたものは、実は同時に偶然発見された二つの無関係なバグでした。第一に、ある Azure ホストでの静かなるハードウェア破損で、CPU が正しく計算を行っていなかったこと。第二に、広く使われているオープンソースライブラリである GNU libunwind(GNU libunwind)における 18 年前の競合条件(race condition)であり、見過ごされていたバグでした。
この投稿は、疫学者のように考え、すべてのクラッシュの母集団に関する高品質なデータセットを構築することで、説明不能に見えるクラッシュを特定し修正した物語です。
最初のデバッグ試行:いくつかのコアダンプの慎重な検討
まず、Rockset について詳しく見ていきましょう。Rockset は検索とリアルタイム分析のためのクラウドネイティブデータシステムで、OpenAI では多くの内部ユースケースに使用されています。例えば同期コネクタなどです(Rockset は 2024 年に OpenAI に買収されました)。ストリーミング更新は、ワークスペースのナレッジベースのインデックスを最新の状態に保つために用いられ、ChatGPT が質問への回答やアクション実行時に関連情報を検索できるようにしています。
Rockset の実行層は C++ で書かれています。C++ は CPU への低レベルアクセスを提供するため、パフォーマンスと効率性に優れていますが、その反面、アプリケーションのバグがメモリへの不正なアクセスやセグフォルト(segfault)を引き起こす可能性があります。これらの問題を特定するために、クラッシュ発生時にスタックトレースをログ出力する folly の致命的シグナルハンドラを使用し、対応するコアダンプ(プログラムがクラッシュした時点の状態のスナップショット)を Azure Blob Storage にアップロードして後で分析しています。Rockset のクエリ処理のすべてのレイヤーはレプリケーションされており、これによりクライアントへのクラッシュの影響を最小限に抑えています。しかし、各セグフォルトは信頼性と品質目標を達成するために修正すべきバグに対応しています。
当初のアプローチは、これらのコアファイルを従来のデバッグ問題として扱うことでした:いくつかのコアダンプを非常に詳しく検査し、仮説を立てて、一つずつ除外していくという方法です。
クラッシュの大部分は DocumentTree::updateDocument というメソッド内で発生していました。これらのクラッシュでは、updateDocument が不明な関数 X を呼び出し、X 実行中にスタックが破損し、その後 X が実行可能なコードではないアドレスにリターンしたように見えました。場合によっては、直前にポップされた X のフレームは有効に見えたものの、保存された戻り先アドレスが NULL でした。他のケースではスタックポインタ自体がおかしいように見えましたが、次の有効なフレームはまだ updateDocument であるようでした。
スタックがいつ破損したのか分からず、調査範囲は非常に広範でした。updateDocument は大規模なメソッドであり、多くのインライン展開が行われるため、X の候補となるものが膨大すぎて対応しきれませんでした。
これは C++ コード内のバグでしょうか?コンパイラやリンケージの問題でしょうか?ランタイムライブラリのいずれかの不具合でしょうか?シグナル配信やコンテキストスイッチに関する Linux カーネルのバグでしょうか?あるいはもっと稀な事象でしょうか。もしこれが誤った書き込み(stray write)なら、なぜ ASAN 検証環境で検出されなかったのでしょうか。
アプリケーションレベルのログを使用して問題の全発生箇所を特定しようと試みましたが、スタック破損バグはログだけでは分類が困難です。記録されたスタックトレース自体が破損していたり欠落しているためです。偽陽性と偽陰性の両方を含んでいないログクエリを構築することはできませんでした。手動でさらに多くのコアファイルを調査し追加の事例を見つけましたが、このプロセスは信頼できるデータセットを得るにはあまりにも労力が必要でした。
調査のこの段階では、複数のリージョンや複数のハードウェアタイプでクラッシュが発生していることから(誤って)、ハードウェアバグを除外しました。そのため、ソフトウェア固有の原因を探し続けていました。数日間は単一の整列ミスによる%rsp クラッシュに徹底的に取り組み、スタックおよびレジスタの内容を用いてクラッシュ前の履歴を再構築しました。これによりいくつかの手がかりが得られましたが、すべてのバグに共通する原因があるという当初の結論から離れなかったため、事態は進展しませんでした。
スタックからの手がかり
調査の転換点に到達する前に、コアファイルからどのような情報を抽出していたのかを説明しておくことが重要です。
Rockset は -fno-omit-frame-pointer オプションでコンパイルされているため、アクティブなスタックフレームは常に %rbp を通じてアクセス可能であり、呼び出し元はフレームポインタの連結リストを形成します。
Linux x86_64 において、AMD64 System V ABI はまた、%rsp の下に 128 バイトをレッドゾーンとして予約しています。この領域はユーザー空間コードに利用可能であり、重要なのは、ABI の契約の一部として、カーネルがシグナルを配信する際にこれを上書きしないことを約束している点です。
レッドゾーンは、戻り後のクラッシュのデバッグにおいて中心的な役割を果たしました。なぜなら、それは戻りの前の情報をいくつか保持しているからです。SIGSEGV がトリガーされると、folly の致命的なシグナルハンドラがクラッシュしたスレッドのスタック上で実行されます。その関数が既に終了しているためもはやアクティブではないスタックフレームは、シグナルハンドラによって上書きされてしまいますが、最後の 128 バイトは例外です。そのため、「X の直前にポップされたスタックフレームは有効に見えたが、NULL の戻りアドレスを除いて」といったことが言えるのです。レッドゾーンは、いくつかの非アクティブなフレームを保持するか、あるいは単に一つの非アクティブなフレームの末尾部分を保持します。
関与するすべての関数が非常に小さかった 1 つのスタックアライメントミスによるクラッシュを見つけました。これにより、比較的単純な関数の実行中に %rsp がアライメントを失ったこと、その後にさらに多くの呼び出しが成功したことがわかりました。プログラムは、アクティブな関数が finally 戻ろうとしたときにのみクラッシュしました。これらのコードパスのいずれも例外、インラインアセンブリ、setcontext、または longjmp を使用していませんでした。したがって、スタックポインタがコアファイルが示す通りに実際に変更されたのであれば、ユーザー空間コードにその問題を説明する妥当なバグは存在しません。
これが私たちをカーネルへと向かわせました。
Rockset は、多くのプログラムよりもはるかに積極的にシグナルを使用しています。クエリ実行はデータを交換する多数の軽量タスクに分割されています。これは高 QPS ワークロードを効率的に処理するために重要ですが、多くのクエリの作業が同じスレッドプールに多重化されるため、クエリごとの CPU アカウンティングが awkward になります。
私たちの解決策は、coarse_thread_cputime_clock と呼ばれるものであり、これはタスク境界ごとにサンプリングするに十分なほど安価に clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) を近似するものです。timer_create API は、CPU 時間の蓄積を含む時間の経過に関するいくつかの概念に基づいて、周期的なシグナル配信をスケジュールするために使用できます。私たちは、CPU 時間が数ミリ秒ごとに SIGUSR2 というシグナルが配信されるようにスケジュールし、その時点でシグナルハンドラーがスレッドローカル値を更新します。多くのタスクは実行中に粗いクロックが進むのを認識しないものの、すべてのデルタを合計することで、クエリの実際の CPU 時間に対する不偏推定量を得ることができます。
私たちが非常に頻繁にシグナルを配信するため、コンテキストスイッチやシグナル配信に関する稀なカーネルバグが妥当であるように思われました。私たちはバグレポート、カーネルソースコード、および Azure 固有のカーネルパッチを読むのに時間を費やし、ストレステストも行いました。しかし、関連すると思われるものは何も見つかりませんでした。
その時点で、私たちは一旦立ち止まり、異なるアプローチを試すことに決めました。
ドクターか疫学者か?
このような問題をデバッグするには、主に 2 つの広い方法があります。
1 つは、ある種の医師のように振る舞うことです:一人の患者に焦点を絞り、多くのテストを実行し、詳細な証拠から単一の症例を診断しようとします。
もう一つの手法は、疫学者のように振る舞うことです:個々の症例では見えないパターンがあるかどうかを調べるために、集団全体を見渡すのです。このバグは特定のリリースで始まったのでしょうか?特定のハードウェア SKU(特定の CPU とサーバーモデル)、特定のリージョン、あるいは特定のカーネルバージョンと相関関係があるのでしょうか?一見すると一つの症候群に見えるものの内部に、複数の異なるクラスターが隠れているのでしょうか?
私たちは主に医師モードで活動していました。重要な転換点は、高品質な集団データを収集する必要があると決断したことです。
データのクリーニング
以前の問題のすべてのインスタンスを自動的に見つけようとした試みは失敗しました。その理由は、ログ上でのテキスト検索を使おうとしていたからです。コアダンプ自体にはより多くの情報が含まれていますが、それらを人手で確認する方法ではスケーラビリティが確保できませんでした。そこで、コアダンプを自動的に分析できるパイプラインを構築するために努力を投資することにしました。
ChatGPT に、各コアファイルのプレフィックスをダウンロードし、レジスタを抽出し、ログを用いて既知の偽陽性をフィルタリングし、クラッシュを「null への復帰」「スタックの不整合」、あるいはその他に自動的にラベル付けするスクリプトを作成させました。その後、過去一年間に発生したすべての本番環境における Rockset のコアダンプに対して、このスクリプトを並列実行しました。
これが転換点でした。
クリーンなデータセットが整った瞬間、相関関係が即座に浮かび上がりました。これまで私たちは一つの奇妙なバグとして扱っていたものが、実際には*二つ*の独立したクラッシュ集団であったことが判明したのです。
null への復帰コアは多くのクラスターや地理的領域に広がっていました。その頻度は最近増加していましたが、明確な開始日やクリーンなインフラの境界線はありませんでした。
スタックの不整合によるクラッシュは全く異なる様子を示していました。これらはすべて一つの地域から発生し、明確な開始日を有しており、長時間稼働しているノードでは決して発生しませんでした。複数の Azure VM(クラウド上でホストされる仮想マシン)が関与していましたが、そのパターンは不良ハードウェアを備えた単一の物理マシンが、たまたまそこに割り当てられたどの VM にも問題を引き起こしているように見えました。
これが、私たちが二つのバグを精神的に混同していたことに気づいた瞬間でした。両方のバグからの反例を混ぜて分析していたため、単一の整合性のある説明を見つけることができなかったのです。
バグ #1: 不良ホスト
クリーンな Kubernetes ノードとタイムスタンプのリストを手に入れたことで、スタックの不整合クラッシュを単一の物理ホストに遡って追跡することができました。これは容易に除外リストに登録できるものでした。
そのホスト上で制御された環境においてレジスタ破損を再現することはできず、数週間にわたるストレステストを行っても同様でした。しかし、問題のあるホストが運用から外されると、スタックの不整合クラッシュは消滅しました。
悪いホストを除去するだけでは永続的な解決策にはなりません。なぜなら、同様の問題の再発を防ぐものではないからです。しかし、ソフトウェアを変更することで、同様の問題が再発した場合でも容易に検出・処理できるようにすることは可能です。致命的なシグナルハンドラーを改善し、レジスタ状態を含めることで、ログのみから再発を検出可能にし(コアダンプは不要)、制御プレーンを変更して通常は VM を再利用するようにし、リサイクルしないようにしました。これにより、インフラストラクチャスタックの当レベルでの悪いノード検出が格段に容易になりました。また、この可能性を考慮したランブック(およびチームのメンタルモデル)も更新しました。
悪いホストによるクラッシュを分離したことで、残りの null へのリターンコアは推論しやすくなりました。以前は例外の巻き戻しを除外しましたが、例外が確実に使用されていないコードパスでのクラッシュという反例があると考えたためです。しかし、その反例はすべてハードウェア破損クラスターからのものでした。
その点を踏まえて残りのコアを見直したところ、この結論は完全に逆であったことが分かりました:すべてのクラッシュは例外の巻き戻し中に発生していました。
例外処理は動的な制御転送です
C++ で例外がスローされると、ランタイムはどの catch ブロックがそれを受け取るべきか、またその過程でどのデストラクタやクリーンアップハンドラーを実行すべきかを発見する必要があります。コンパイラはこのメタデータを生成しますが、実際のマッチングは実行時に動的に行われます。
例外の巻き戻しは、throw を呼び出す関数自体によって行われるのではなく、生成されたコンパイルコードによって呼び出されるヘルパー関数によって実行されます。これらのランタイムルーチンはスタックを検査し、スタック上に見つかった関数に関するメタデータを取得し、動的にクリーンアップハンドラとキャッチブロックを探し出し、その後制御をそれらの場所のいずれかに移します。制御の移行には、中間のすべてのスタックフレーム(ヘルパー関数のフレームを含む)の巻き戻しも含まれます。
運用上、これは通常の呼び出しと復帰よりも、longjmp やファイバー切り替えに非常に近いです。キャリー保存レジスタを復元するだけでなく、スタックフレームレジスタ %rbp と %rsp も復元する必要があります。
私たちのバイナリは、C++ 例外の巻き戻しを実行する関数の実装を含む 2 つのライブラリとリンクされています:libgcc と GNU libunwind です。GNU libunwind の定義が動的リンカーによって選択されました。これは私たちを驚かせました;シンボルバージョン管理規則のため、libgcc の実装が勝つだろうと考えていたからです。しかし、実行中のバイナリを検査した結果、そうではないことがわかりました。
最後の仮定を取り消す
現時点で、私たちが 1 つのバグしかないと考えていたときに設定していたもう一つの仮定を緩和したため、私たちの作業仮説は変更されました。
おそらく、通常の関数呼び出しの戻り先が NULL になっていたわけではありません。むしろ、unwind transfer(アンワインド転送)、つまり setcontext スタイルのレジスタ復元の一環として、制御が転送される前に宛先の命令ポインタが NULL 化していた可能性があります。言い換えれば、スタック上の戻りアドレススロット自体の誤りではなく、unwind ライブラリからの不正確なデータだったのです。
これにより問題の範囲は劇的に絞り込まれました。GNU libunwind が間違った宛先状態を計算しているのか、あるいは正しい状態を計算したものの、適用される前にそれが破損していたのかのどちらかです。
私たちは GNU libunwind のソースコードを読み込み、そこでスタック上に ucontext_t を合成し、クリーンアップハンドラのフレームに対する必要なレジスタ状態を埋め込んだ後、その構造体へのポインタを内部のアセンブリルーチンである _Ux86_64_setcontext に渡していることを発見しました。
この時点で、すべての手がかりが揃いました。
合成された ucontext_t は、_Ux86_64_setcontext 関数の実行中に、その関数によってアンワインドされるスタックフレームのいずれかに存在します。_Ux86_64_setcontext が %rsp を変更した後にこの構造体を読み込んでいたのでしょうか?もしそうであれば、その時点で構造体はもはやアクティブなスタックの一部ではなくなり、頻繁に発生する SIGUSR2 などのシグナル配信によって上書きされる脆弱性を抱えていたことになります。
Bug #2: the libunwind bug
答えは「はい」でした。
以下は、私たちが使用していたバージョンの GNU libunwind における _Ux86_64_setcontext の最後の 6 つの命令です。これらは主にメモリから宛先レジスタへデータをロードする mov 命令で構成されています:
(%rdi はスタック割り当てされた ucontext_t を指しており、UC_MCONTEXT_* マクロは特定のレジスタが保存される固定オフセットに展開されます。)
最初の命令は競合ウィンドウの開始点です。これは %rsp を更新して、アクティブなスタックの新しい底部を指すようにします。この瞬間から、%rdi が指す構造体はもはやアクティブなスタック(またはレッドゾーン)の一部ではなく、カーネルによってアクセス可能になります。
通常これは問題を引き起こしませんが、信号がちょうどよい(あるいは悪い?)タイミングで到着した場合、カーネルは %rsp-128 にシグナルフレームを構築します。これにより、%rdi が指すメモリを上書きする可能性があります。
これが次の命令で UC_MCONTEXT_GREGS_RIP(%rdi) を読み出す前に発生すると、復元された命令ポインタが破損する可能性があります。私たちのクラッシュでは、それが NULL になりました。
それがバグです。
なぜコアダンプが通常の不良リターンとして隠蔽されていたのか
このアセンブリは、私たちが混乱させた観察結果の一つも説明しています:なぜ関数 X の前のスタックフレームの戻りアドレススロットに NULL が含まれていたのかという点です。
setcontext は %rdi を含むすべてのレジスタを復元するように記述されているため、制御転送の最終段階で UC_MCONTEXT_GREGS_RIP(%rdi) を読み取るためにそのレジスタを使用することはできません。代わりに、値をより早く読み取り、それをスタックに保存し、さらにいくつかのレジスタを復元した後、retq 命令を使用して保存された値を読み取り、制御を転送します。
コアファイル上で「関数が NULL に戻った」と見えた現象は、実際には「アンワインダーがスタック上にターゲットの戻り先アドレスを合成したが、その転送が完了する前にターゲットが破損していた」ものでした。私たちは、戻り先アドレスのスロットに対する破損は必ずその場で起こるものだと仮定していました。なぜなら、(破損可能な)データを意図的に戻り先アドレスのスロットに書き込む場所が存在しないことを知らなかったからです。
A single-instruction race window
このバグがどれほど不合理に見えるかは、この競合状態のウィンドウがいかに狭いかによって決まります。このような競合状態では、外部イベント(シグナル)が別のスレッドが行う 2 つのステップの間で発生する必要があります。その 2 つのステップが互いに近ければ近いほど、競合状態が発生する可能性は低くなります。
この場合、脆弱なウィンドウは文字通り 1 命令幅しかありません!シグナルは %rsp が変更された後に、次の命令が %rip を読み込む前に配信されなければなりません。現代のスーパースカラーアウト・オブ・オーダー CPU では、このような単純な命令を 1 サイクルあたり複数実行できるため、競合ウィンドウはおよそ 100 ピコ秒です。
この競合状態を発見したとき、私たちの最初の反応は、観測されたクラッシュ率を説明するには稀すぎてありえないというものでした。私たちは fleet 全体で 1 日に 12 件以上の戻り先 NULL クラッシュを検出していました。例外クリーンアップ中に 1 命令分の競合が本当にそのような頻度を引き起こすことができるのでしょうか?
フェルマー推定に頼りました。脆弱なウィンドウが 10 のマイナス 10 乗秒のオーダーであり、SIGUSR2 が CPU 時間の 10 のマイナス 2 乗秒ごとに到着すると仮定すれば、各例外クリーンアップハンドラまたは catch ブロックが競合に負ける確率はおよそ 10 のマイナス 8 乗となります。
Rockset では、内部のインジェストバックプレッシャー機構の一部として例外を使用しています。単一の過負荷ホストでは、1 秒あたり 10 の 4 乗個程度の例外をスローする可能性があります。これは、バックプレッシャーを使用しているホストにおける障害間の平均時間が 10 の 4 乗秒、つまり数時間に一度クラッシュすることを意味します。フリート規模全体で見れば、これは観測されたクラッシュ頻度を十分に説明するのに十分な量です。
なぜ今になって libunwind のバグが現れたのか?
GNU libunwind のバグは古く、18 年以上前に存在し、C++ 例外のアンワインディングをサポートした最初の x86_64 バージョンから含まれています。
では、なぜそれが今になって現れたのでしょうか?
クラッシュ率は、スローされる例外の数と配信されるシグナルの数にほぼ比例します。また、シグナルハンドラが消費するスタックの量にも依存しています。
Rockset はこれら 3 つの軸すべてにおいて特異です。通常の過負荷制御の一部として高頻度で例外をスローし、coarse_thread_cputime_clock のせいで SIGUSR2 を非常に頻繁に配信しており、さらに今年初めにマージされたシグナルを処理できるように timer_getoverrun 関数を呼び出すことで、SIGUSR2 ハンドラがより多くのスタックを使用するように変更しました。
その最後の修正は重要だったようです。ハンドラが使用するスタック量が十分に少なければ、古い ucontext_t メモリに到達して上書きするのを防げる可能性があります。この変更以前には、これらのクラッシュを全く観測していませんでした。変更後、クラッシュ発生率は低く維持されていましたが、バックプレッシャーメカニズムに負荷をかけるいくつかのユースケースで負荷を増加させたところ、状況が変わりました。
つまり、libunwind のバグは常に存在していたのですが、例外発生率、シグナル発生率、およびハンドラのスタック使用量の積が、運用上検出可能な閾値に達したのは最近のことだったのです。
このメカニズムは、ハードウェアのバグと libunwind のバグの両方が主に DocumentTree::updateDocument 内でクラッシュするという偶然の一致も説明しています。libunwind に起因するクラッシュはこのメソッドに強く偏っていました。なぜなら、インジェストバックプレッシャーを適用するために例外をスローする時点で、このメソッドは常にアクティブな状態にあるからです。また、%rsp のアライメントミスによるクラッシュも同様に強く選択されていました。その理由は、問題のあるハードウェアノードがバッチインジェストに使用される SKU であり、その CPU 時間の大部分をこのメソッドで消費しているためです。
私たちの即座の対策は、GNU libunwind から libgcc のアンワインダ(unwinder)へ切り替えることでした。これ自体も良好なトレードオフでした。libgcc の実装はロック競合を削減するための多くの作業によって改善されており、大規模な VM へのスケーリングにおいて重要な要素となっています。
また、自己完結型の再現手順と 修正(新しいウィンドウで開く) を GNU libunwind にアップストリームし、他のアンワインダーには同様の問題がないことを確認しました。
集団レベルでの診断の力
このデバッグの旅は、動的リンク、DWARF アンワインドメタデータ、Linux シグナル配信、System V ABI、および C++ 例外機構の詳細について多くの教訓を与えてくれました。しかし、最も重要な教訓はそれらよりも単純でした。
最も重要だったのは、巧妙なアセンブリコードの読み込みや詳細に関する深い知識ではありません。それは高品質なデータセットを構築することでした。このデータセットがなければ、私たちは2つの異なる現象を1つの物語に混ぜ合わせ、混乱から抜け出すために推論しようとしていました。正確で完全な集団データが揃ったことで、問題の構造は明白になりました:一方のクラッシュ集団は不良ホストに属し、もう一方は libunwind 内の競合状態に属していました。データが改善されるにつれて、デバッグも容易になりました。
Rockset のようなインフラストラクチャシステムにとって、これは非常に重要です。この調査は、詳細な計測、自動調査、および運用ツールの継続的な改善への当社のコミットメントを強化しました。信頼性は、バグが発生した後に修正するだけのことではありません。それは、不可能に見える問題を診断可能で解決可能な問題に変えるためのデータ、ワークフロー、そしてスキルを構築することなのです。
原文を表示
OpenAI’s models and agents increasingly rely on scalable data infrastructure in order to search for relevant data at inference time: when the models are thinking about your question. Some of these services are written in C++, whose low-level control of the system lets us maximize performance and minimize memory usage. Those efficiency benefits are important as we scale, but C++’s lack of memory safety means that bugs can cause crashes by writing to incorrect or non-existent memory addresses.
A few months ago we observed some crashes from inside the Rockset service, a bespoke part of our ChatGPT data infrastructure which is key to many data plugins and to searching over conversations. In each of these crashes, a normal C++ function seemed to finish and then return to a bogus address, causing the kernel to stop the program because the instruction pointer no longer pointed at code. Sometimes the return address slot in the stack frame was NULL. Sometimes the stack pointer CPU register itself seemed to be off by 8 bytes, as if %rsp had somehow been decremented in the middle of normal execution. In both cases the crash happened on return.
These are not normal failure modes for application code. A stray write that lands only on a saved return address is possible, but extremely unlikely. A bug that misaligns %rsp by 8 without involving inline assembly, setcontext, or longjmp (none of which we use) is even stranger, because compiled code only adjusts that register directly in the function prologue and epilogue. Every hypothesis we (or ChatGPT) could think of had strong evidence against it, so the bug seemed impossible.
What we assumed was one problem eventually turned out to be two unrelated bugs, coincidentally discovered at the same time. First, silent hardware corruption on one Azure host, where the CPU just didn’t do math correctly. Second, an 18-year-old race condition in GNU libunwind, an unnoticed bug in a widely used open source library.
This post is the story of how we identified and fixed seemingly inexplicable crashes by thinking like an epidemiologist and building a high-quality data set about the entire population of crashes.
First debugging attempt: carefully examining a few core dumps
First, let’s go deeper on Rockset. It’s a cloud-native data system for search and real-time analytics that we use for many internal use cases at OpenAI, such as sync connectors (Rockset was acquired by OpenAI in 2024). Streaming updates are used to maintain an up-to-date index of a workspace’s knowledge base so that ChatGPT can search for relevant information when answering questions or performing actions.
Rockset’s execution layer is written in C++. The C++ language provides low-level access to the CPU, which is good for performance and efficiency, but it means that application bugs can lead to invalid memory accesses and segfaults. To help track these down we use folly’s fatal signal handler to log a stack trace when a crash happens, and we upload the corresponding core dumps (a snapshot of the state of the program when it crashed) to Azure blob storage for later analysis. All of Rockset’s query processing leaves are replicated, which minimizes the client impact of a crash. However, each segfault corresponds to a bug that needs to be fixed to meet our reliability and quality goals.
Our initial approach was to treat these cores like a conventional debugging problem: inspect a few core dumps very closely, form hypotheses, and rule them out one by one.
Most of the crashes occurred in a method called DocumentTree::updateDocument. In these crashes it appeared that updateDocument had called some unknown function X, the stack had become corrupted while X was active, then X had returned to an address that wasn’t executable code. In some cases X’s just-popped frame looked valid except that its saved return address was NULL. In other cases the stack pointer itself looked wrong, but the next valid frame still seemed to be updateDocument.
We didn’t know when the stack was getting corrupted, which left a huge search space. updateDocument is a large method that undergoes a lot of inlining, so the number of candidates for X was overwhelming.
Was this a bug in our C++ code? A compiler or linkage issue? A problem in one of our runtime libraries? A Linux kernel bug around signal delivery or context switching? Something even rarer? If this was a stray write, why wasn’t it caught by our ASAN staging environment?
We tried to use our application-level logs to identify all occurrences of the problem, but stack-corruption bugs are hard to classify from logs alone because the logged stack traces are themselves corrupted or missing. We weren’t able to construct a log query that didn’t have both false positives and false negatives. We manually inspected more cores and found some additional examples, but that process was too labor-intensive to give us a trustworthy data set.
At this stage of the investigation, we (incorrectly) ruled out a hardware bug, because we saw crashes across multiple regions and multiple hardware types, so we were still looking for software-only causes. For a few days, we went super-deep on a single misaligned-%rsp crash, reconstructing the pre-crash history using stack and register contents. This produced some possible clues, but because we didn’t let go of our initial conclusions that all of the bugs had the same cause, this didn’t get us unstuck.
Clues from the stack
Before getting to the turning point of our investigation, it’s important to explain what kind of information we were extracting from the core files.
Rockset is compiled with -fno-omit-frame-pointer, so the active stack frame is always reachable through %rbp, and callers form a linked list of frame pointers.
On Linux x86_64, the AMD64 System V ABI also reserves 128 bytes below %rsp as the red zone. That region is available to userspace code and, importantly, the kernel promises not to clobber it when it delivers a signal, as part of the ABI contract.
The red zone was central to our debugging of a post-return crash, because it preserves some information from before the return. When a SIGSEGV is triggered, folly’s fatal signal handler runs on the crashing thread’s stack. Stack frames that are no longer active (because their function has returned) will get clobbered by the signal handler, except for the last 128 bytes. That’s why we can say things like “X’s just-popped stack frame looked valid, except for a NULL return address.” The red zone preserves some of the inactive frames, or sometimes just the tail of one inactive frame.
We found one misaligned-stack crash in which all of the functions involved were very small. That let us see that %rsp had become misaligned during execution of a relatively simple function, and that more calls had succeeded afterward. The program only crashed when the active function finally tried to return. None of those code paths used exceptions, inline assembly, setcontext, or longjmp, so if the stack pointer truly changed in the way the core suggested, no plausible bug in userspace code explained the issue.
That pushed us toward the kernel.
Rockset uses signals more aggressively than most programs. Query execution is broken into many lightweight tasks that exchange data. This is important for handling high-QPS workloads efficiently, but it makes per-query CPU accounting awkward as work for many queries is multiplexed onto the same thread pool.
Our solution is something we call coarse_thread_cputime_clock, which approximates clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) cheaply enough to sample at every task boundary. The timer_create API can be used to schedule a periodic signal delivery based on several notions of the passage of time, including the accumulation of CPU time. We schedule a signal (SIGUSR2) to be delivered every few milliseconds of CPU time, at which point the signal handler updates a thread-local value. Even though many tasks don’t see the coarse clock advance while they are executing, summing all of the deltas produces an unbiased estimate of the actual CPU time for a query.
Because we deliver signals so often, a rare kernel bug around context switching or signal delivery seemed plausible. We spent time reading bug reports, kernel source code, and the Azure-specific kernel patches. We tried stress tests. We weren’t able to find anything that seemed related.
At that point we decided to step back and try a different approach.
Doctor or epidemiologist?
There are two broad ways to debug a problem like this.
One is to act like a doctor of sorts: focus on one patient, run lots of tests, and try to diagnose a single case from detailed evidence.
The other is to act more like an epidemiologist: look at the entire population and ask whether there are patterns that a single case cannot reveal. Did the bug start at a specific release? Does it correlate with one hardware SKU (the specific CPU and server model), one region, or one kernel version? Are there multiple distinct clusters hiding inside what looks like one syndrome?
We had mostly been in doctor mode. The key shift was deciding that we needed to gather high-quality population data.
Cleaning the data
Our previous attempts to automatically find all of the instances of the problem failed because we were trying to use text searches over the logs. The core dumps themselves have a lot more information, but looking at them manually didn’t scale. We decided to invest the effort to build a pipeline that could automatically analyze the core dumps.
We had ChatGPT write a script that downloaded a prefix of each core file, extracted the registers, filtered known false positives using the logs, and automatically labeled the crash as return-to-null, misaligned-stack, or other. Then we ran that script in parallel over every production Rockset core dump from the previous year.
This was the turning point.
Once we had a clean data set, correlations appeared immediately. What we had been treating as one weird bug was actually *two* separate crash populations.
The return-to-null cores were spread across many clusters and geographic regions. Their frequency had increased recently, but there was no crisp start date and no clean infrastructure boundary.
The misaligned-stack crashes looked completely different. They all came from one region, had a clear start date, and never happened on nodes that had been running for a long time. Even though they involved multiple Azure VMs (virtual machines hosted in the cloud), the pattern looked like one physical machine with bad hardware causing problems for whichever VM happened to land on it.
That was the moment we realized we had been mentally conflating two bugs. Because we had been mixing counterexamples from both bugs, we couldn’t find a single coherent explanation.
Bug #1: the bad host
Armed with a clean list of Kubernetes nodes and timestamps, we were able to trace the misaligned-stack crashes back to a single physical host, which was easy to denylist.
We were not able to reproduce the register corruption on that host in a controlled environment, even after several weeks of stress testing. Once the problematic host was taken out of service, however, the misaligned-stack crashes disappeared.
Removing the bad host isn’t a permanent solution, in the sense that it doesn’t prevent a new occurrence of the same problem. We can, however, change the software so that if a similar issue recurs, it is easily detected and handled. We improved our fatal signal handler to include register state so that we can detect recurrence only from the logs (no core dump needed). We changed the control plane so that VMs are usually reused instead of recycled, which makes bad-node detection much easier at our level of the infrastructure stack. We also updated our runbooks (and our team’s mental models) to include this possibility.
With the bad-host crashes separated out, the remaining return-to-null cores became much easier to reason about. Earlier we had ruled out exception unwinding because we thought we had counterexamples: crashes in code paths where exceptions were definitely not used. But those counterexamples were all from the hardware-corruption cluster.
Once we revisited the remaining cores with that in mind we found that this conclusion was exactly backward: the crashes were all happening during exception unwinding.
Exception handling is a dynamic control transfer
When C++ throws an exception, the runtime has to discover which catch block should receive it and which destructors or cleanup handlers should run along the way. The compiler emits this metadata, but the actual matching happens dynamically at runtime.
Exception unwinding is not actually performed by the function that invokes throw, but by helper functions called by the resulting compiled code. Those runtime routines examine the stack, fetch metadata about the functions found on the stack, dynamically look for cleanup handlers and catch blocks, and then transfer control to one of those locations. Transferring control includes unwinding all of the intervening stack frames (including those of the helper functions).
Operationally, this is much closer to a longjmp or a fiber switch than to a normal call and return. Callee save registers must be restored, as well as the stack frame registers %rbp and %rsp.
Our binary links against two libraries that contain implementations of the functions that perform C++ exception unwinding: libgcc and GNU libunwind. GNU libunwind’s definitions were the ones chosen by the dynamic linker. That surprised us; we had expected the libgcc implementation to win because of symbol versioning rules; however, inspecting running binaries showed that wasn’t the case.
Undoing one last assumption
At this point our working hypothesis changed, as we relaxed another assumption that we had made when we thought there was only one bug.
Maybe we were not seeing an ordinary function return to NULL. Maybe we were seeing an unwind transfer—effectively a setcontext-style register restore—where the destination instruction pointer had become NULL before control was transferred. In other words, incorrect data from the unwind library rather than an incorrect return address slot on the stack.
That narrowed the problem dramatically. Either GNU libunwind was computing the wrong destination state, or it was computing the right state and something was corrupting it before it could be applied.
We read the GNU libunwind source and found that it synthesizes a ucontext_t on the stack, fills in the desired register state for the cleanup handler’s frame, and then hands a pointer to that struct to an internal assembly routine: _Ux86_64_setcontext.
At this point we had all of the pieces.
The synthesized ucontext_t lives in one of the stack frames that is unwound by _Ux86_64_setcontext, during that function’s execution. Was _Ux86_64_setcontext reading from the struct after it changed %rsp, at which point the struct was no longer part of the active stack? That would make it vulnerable to being clobbered by a signal delivery, such as our frequent SIGUSR2.
Bug #2: the libunwind bug
The answer was yes.
Here are the last six instructions of _Ux86_64_setcontext in the version of GNU libunwind we were using, which consist mostly of mov instructions that load from memory to a destination register:
(%rdi points at the stack-allocated ucontext_t, and the UC_MCONTEXT_* macros just expand to the fixed offset at which a particular register is stored.)
The first instruction is the beginning of the race window. It updates %rsp to point to the new bottom of the active stack. As soon as this happens, the struct pointed to by %rdi is no longer part of the active stack (or red zone), and it’s no longer off-limits to the kernel.
Usually this doesn’t cause problems, but if a signal arrives at exactly the right (wrong?) moment, the kernel will build the signal frame at %rsp-128. That can overwrite the memory pointed to by %rdi.
If that happens before the next instruction reads UC_MCONTEXT_GREGS_RIP(%rdi), then the restored instruction pointer can be corrupted. In our crashes, it became NULL.
That’s the bug.
Why the cores masked as ordinary bad returns
This assembly also explains one of the observations that confused us: why function X had a NULL in the return address slot of the preceding stack frame.
setcontext was written to restore all registers, including %rdi, so it can’t use that register to read UC_MCONTEXT_GREGS_RIP(%rdi) at the final moment of the control transfer. Instead, it reads the value earlier, saves it to the stack, restores a few more registers, then uses retq to read the saved value and transfer control.
What looked in the cores like “a function returned to NULL” was actually “the unwinder synthesized a target return address on the stack, but that target had been corrupted before the transfer completed.” We had assumed that corruption of the return address slot must happen in-place, because we didn’t know of any places where (corruptible) data was written to the return address slot on purpose.
A single-instruction race window
What makes this bug seem absurd is how narrow this race window is. In this kind of race condition, the external event (the signal) needs to happen in between two steps taken by another thread. The closer those steps are to each other, the less likely the race condition is to happen.
In this case the vulnerable window is literally one instruction wide! A signal must be delivered after %rsp has been changed, but before the next instruction loads %rip. Several simple instructions like this can be run per cycle on a modern super-scalar out-of-order CPU, so the race window is roughly a hundred picoseconds.
When we found this race, our first reaction was that it must be too rare to explain the observed crash rate. We were seeing more than a dozen return-to-null crashes per day across the fleet. Could a one-instruction race during exception cleanup really account for that?
We turned to Fermat estimation. If the vulnerable window is on the order of 10−1010^{-10} seconds and SIGUSR2 arrives every 10−210^{-2} seconds of CPU time, then each exception cleanup handler or catch block has a roughly 10−810^{-8} probability of losing the race.
Rockset uses exceptions as part of its internal ingest backpressure mechanism. A single overloaded host can throw on the order of 10410^{4} exceptions per second. That implies the mean time between failures of a host using backpressure is 10410^{4} seconds, or one crash every few hours. At fleet scale, that is more than enough to explain the observed crash frequency.
Why did the libunwind bug appear now?
The GNU libunwind bug is old—more than 18 years old, present in the first x86_64 version that supported C++ exception unwinding.
So why did it show up now?
The crash rate is roughly proportional to how many exceptions are thrown and how many signals are delivered. It’s also dependent on how much stack the signal handler consumes.
Rockset is unusual on all three axes. We throw exceptions at high rates as part of normal overload control; we deliver SIGUSR2 unusually often because of coarse_thread_cputime_clock; and earlier this year we made the SIGUSR2 handler use more stack by adding a call to timer_getoverrun, so we could account for merged signals.
That last change seems to have been important. If the handler uses little enough stack, it may not reach and overwrite the stale ucontext_t memory. Before that change, we do not observe these crashes at all. After the change the rate remained low until we ramped up load for some use cases that stressed the backpressure mechanism.
In other words, the libunwind bug has always been there, but the product of our exception rate, signal rate, and handler stack usage had only recently crossed the threshold where it became operationally visible.
This mechanism also explains the coincidence that both the hardware bug and the libunwind bug crashed mostly inside DocumentTree::updateDocument. Crashes from libunwind were heavily biased toward this method, because it’s always active at the point we throw an exception to apply ingest backpressure. It was also heavily selected for the %rsp-misalignment crashes because the bad hardware node was of a SKU that we use for bulk ingest, which spends the majority of its CPU time in that method.
Our immediate mitigation was to switch from GNU libunwind to libgcc’s unwinder. That was a good trade on its own: libgcc’s implementation has benefited from a lot of work to reduce lock contention, which matters when scaling to large VMs.
We also upstreamed a self-contained reproducer and a fix(opens in a new window) to GNU libunwind, and verified that the other unwinders don’t have a similar issue.
The power of a population-level diagnosis
This debugging journey taught us a lot about the specific details of dynamic linking, DWARF unwind metadata, Linux signal delivery, the System V ABI, and C++ exception machinery. But the main lesson was simpler than any of that.
The most important step was not the clever assembly reading or deep knowledge of the details. It was building a high-quality data set. In the absence of this data set, we were mixing two distinct phenomena into one story and trying to reason our way out of the confusion. Once we had accurate and complete population data, the structure of the problem became obvious: one crash population belonged to a bad host, and the other belonged to a race in libunwind. Once the data got better, the debugging got easier.
For infrastructure systems like Rockset, that matters a lot. This investigation reinforced our commitment to deep instrumentation, automated investigations, and continual improvements in our operational tooling. Reliability is not just about fixing bugs after they happen—it’s about building the data, workflows, and skills that turn impossible problems into diagnosable and solvable ones.
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み