2 ポイント 投稿者 GN⁺ 2025-10-09 | 1件のコメント | WhatsAppで共有
  • Cloudflareは、arm64プラットフォームで動作するGoコンパイラで発生するまれな競合状態(レースコンディション)バグを、大規模トラフィックの監視中に発見した
  • このバグは、**スタックアンワインディング(stack unwinding)**の過程で、サービスが予期せずパニック状態になったり、メモリアクセスエラーが発生したりする形で現れる
  • 原因を追跡する過程で、**Goランタイムの非同期preemption(強制プリエンプション)**と、コンパイラが生成した2つのスタックポインタ調整命令の間で問題が発生していることを確認した
  • 最小再現コードにより、このバグがGoランタイム自体の問題であることを証明し、その結果としてスタックポインタが不完全に変更される1命令分の競合状態が存在することを明らかにした
  • この問題はgo1.23.12、go1.24.6、go1.25.0で修正され、新しい方式では即時に変更できないスタックポインタ操作を回避することで、レースコンディションが根本的に遮断された

Cloudflareが見つけたGo ARM64コンパイラバグの分析

Cloudflareのデータセンターは世界330以上の都市で毎秒8,400万件のHTTPリクエストを処理しており、このような大規模トラフィック環境では、まれなバグでさえ頻繁に露出するという特徴がある。この記事では、arm64プラットフォーム向けにGoコンパイラが生成したコードで発生した競合状態の問題を、実例とともに詳しく分析している。

奇妙なパニック現象の調査

  • Cloudflareネットワーク内では、Magic TransitMagic WANのような製品のトラフィック処理をKernelに設定するサービスが稼働している
  • arm64マシンで、**まれだが繰り返し発生するfatal panic(致命的パニック)**メッセージが監視システムで検出された
  • 初期分析の結果、スタックアンワインディングの過程で整合性違反が検出されていた(panic/recoverパターンを使っていた古いコードでパニックが頻発していた)
  • 一時的にpanic/recover構造を除去してパニック頻度を下げたものの、その後疑わしい致命的パニックがさらに頻繁に発生するようになった
  • これにより、単純なパターン追跡以上の、より深い原因分析が必要だと判断した

Goランタイムおよびスケジューラのデータ構造概要

  • Goは軽量なユーザー空間スケジューラとしてM:Nスケジューリング構造を採用している(複数のgoroutineを少数のカーネルスレッドにマッピング)
  • スケジューラの中核となる構造体はg(goroutine)m(マシン/カーネルスレッド)p(プロセッサ)を中心に構成される
  • スタックアンワインディングの失敗やメモリアクセスエラーは、スタックポインタまたはリターンアドレスが異常に変化したときに発生する

スタックアンワインディング中のエラーの構造的原因

  • 複数のバックトレースを分析した結果、すべて(*unwinder).next関数のスタックアンワインディング処理中に発生していた
  • あるケースではreturn addressがnullであるため異常スタックとみなされ致命的エラーで終了し、別のケースでは**スタックフレーム内のGoスケジューラ構造体mのフィールド(incgo)**にアクセスしようとしてセグメンテーションエラーが発生した
  • クラッシュが実際のバグ発生地点からかなり離れた場所で起きており、原因追跡が難しかった

観測されたパターンとGo Netlinkライブラリとの関連

  • Stack traceを確認した結果、いずれもGo NetlinkライブラリNetlinkSocket.Receive関数でpreemptionが発生した時点にクラッシュが集中していることを確認した
  • その後、2つの仮説を立てた
    • Go Netlinkのunsafe.Pointer使用に起因するバグである可能性
    • Goランタイムの非同期preemptionおよびスタックアンワインディング自体で発生するバグである可能性
  • コード監査を行ったが、直接的なメモリ破損パターンなどは見つからず、問題の核心はランタイムとスタック運用戦略にあると推定した

非同期Preemptionと競合状態

  • Go 1.14で導入された非同期preemption機能は、長時間実行されるgoroutineに対してOSスレッドへシグナル(SIGURG)を送り、強制的にスケジューリングポイントを生成する
  • このpreemptionがスタックフレームポインタを調整する2つのアセンブリ命令の間で発生すると、スタックポインタが中間状態にとどまってしまう
  • ガベージコレクション、パニック処理、スタックトレース生成のためにスタックをアンワインドする際、誤った位置を読み取って誤った関数アドレスやデータ解釈が起こる

最小再現コードの作成

  • スタックフレーム割り当てサイズを調整し、明示的にスタックが調整される関数(big_stack)と、常時ガベージコレクション呼び出しコードを記述することで競合状態を再現した
  • 実際にアセンブリコードで2つのADD命令によりスタックポインタが調整され、その間に非同期preemptionが発生すると、スタックアンワインディング過程でクラッシュが起こる
  • この欠陥は純粋な標準ライブラリコードだけでも再現可能であり、Goコンパイラが生成するコードに内在する命令単位(1インストラクション分)の脆弱性であることを証明した

ARM64コンパイラレベルの競合ウィンドウの原因

  • ARM64アーキテクチャの固定長命令および即値制限のため、スタックポインタ調整には2つ以上の命令が必要になる場合がある
  • Goの内部中間表現(IR)ではこうした即値長を認識しておらず、実際のマシンコード変換時にのみ分割命令が挿入される
  • このため、スタックフレームの返却(ADD RSP, RSP)に2命令が使われ、preemptionに脆弱な単一インストラクションのウィンドウが生じる
  • アンワインダはスタックポインタの正確性を絶対的に必要とするため、命令の途中で停止すると誤った値の解釈や致命的失敗を招く
  • 実際のクラッシュフローは次のように構成される:
    1. 2つのADD命令の間で非同期preemptionが発生
    2. GCまたはその他の理由でスタックアンワインディングルーチンが動作
    3. 特異なスタックポインタ位置を探索し、誤った関数アドレスを解釈
    4. ランタイムクラッシュ

バグ修正と根本的改善

  • Cloudflareチームは最小再現コードと詳細な分析内容を基にGo公式リポジトリへ報告し、この問題は迅速にパッチ適用・リリースされた
  • go1.23.12、go1.24.6、go1.25.0以降では、一時レジスタに全オフセットを先に計算したうえで単一命令でスタックポインタを変更し、preemptionの脆弱性を解消している
  • これによりスタックポインタは常に有効な状態が保証され、競合状態は構造的に遮断された
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET

結論と示唆

  • このバグは、特定アーキテクチャにおけるコンパイラのコード生成と**並行性管理(非同期preemption)**が想定外の形で衝突した事例である
  • 大規模環境でのみ顕在化する非常にまれな命令レベルの競合状態を、実運用データと科学的推論によって追跡した点が印象的な事例だ
  • 最新のGo環境およびARM64アーキテクチャベースのサービスを運用しているなら、該当Goバージョンへのアップグレードが重要になる

1件のコメント

 
GN⁺ 2025-10-09
Hacker Newsの意見
  • 本当にすごい発見だと感じたし、アセンブリコードを見た瞬間にデバッグの経路を追っていけた。実際、このやり方は必ずしもアセンブリでしかできないわけではなく、IR段階でも可能かもしれないが、いろいろな理由でそうなっていない。ARMアセンブリを読めることは大きな強みだ。命令数を減らすためにスタックサイズを push や pop してみる方法も考えたが、GCが正確に何を確認しているのかわからないので確信はない。ほかの意見も聞いてみたい。
    • 一般的には LDR Rd, =expr というARMの疑似命令を使う。即座に生成できない定数の場合、PC相対位置に定数を置き、PCを基準にレジスタへロードする。これにより「SPに定数を足す」処理を2つの実行命令に置き換えられ、8バイトのコードと4バイトのデータ領域(17ビット定数用)で合計12バイトが必要になる。関連文書: LDR pseudo-instruction の説明
    • 即値をRSPに加える特殊ケースとして、アセンブラでこのバグが特別扱いされていなかったのは意外だ。パッチがコンパイラ側にしか適用されていないなら、aarch64アセンブリのほかの箇所にも同じ問題が残っているかもしれない。
    • ARMアセンブリ構文のドル記号入りの奇妙な表現はAArch64標準アセンブリではないし、記事では「スタックは一度だけ移動しなければならない」という規則にも触れてほしかった。
    • Javaや.NETのようなランタイムでは safepoint を明確に設け、命令列の途中でコンテキストが切り替わらないようにしている。
    • コンパイラが定数を2回に分けてレジスタに入れ、その後 add を1回行ってSPを原子的に調整するのが正しい解決策だと思う。もちろん命令は1つ増えるが、原子性は確保できる。あるいは一時レジスタで演算してから戻す方法もある。
  • 急いでいる人向けに修正コミットのリンクを共有する: golang/go コミットリンク
    • issue を見ていて、Goチームが自然言語ボットを使っているのか、それともコメント内の単なる backport というキーワードだけをチェックしているのか気になった。関連コメント: github issue comment
  • 技術的に非常に優れたブログで、説明がとても明快なので理解しやすく、むしろ少し賢くなった気分になった。x86アセンブリ以来ひさしぶりにアセンブリに触れたのに追いやすかったし、こういうチームならいつでもこの種の問題を解決できるだけの能力と品質管理があるという信頼まで生まれる。サーバー拡張のために Ampere Altra も検討したが、スペースに余裕があったので結局 Epyc を使った。
  • Goにすべての命令をシングルステップ実行し、各命令ごとにGC割り込みを発生させるモードがあれば、この種のバグはもっと見つけやすくなると思う。
  • ARM64サーバーをどこで使っているのか気になる。昨年はAMD EPYCベースのGen 12サーバーを投入すると言っていたがARM64への言及はなかったし、今はARM64が本番環境で使われているのだろうか。
    • 私はCloudflareの社員ではないが、ブログをよく読んでいる限りでは、セキュアブートなども考慮してすでに数年前から Ampere を AMD と並行して配備していた。用途はエッジ効率のために見えるが、ほかの目的もあるかもしれない。詳しくは エッジサーバー設計の記事Ampere Altra vs AWS Graviton2 、それに Qualcomm ARM評価 などを参照。
    • Cloudflareが一部の non-edge コンピューティングをパブリッククラウドでホスティングしているという話を覚えている。たとえばコントロールプレーンなどで、そういう可能性はある。
  • Cloudflareは最近 100% Rust と x86(EPYC)だけを使っているのかと思っていたが、GoとARMも使っているのは興味深い。
  • 毎回Cloudflareのブログは、インフラやMLの魔法に頼らずエンジニアリングの本質を伝えてくれる素晴らしい内容だと思う。いつかここに応募してみたい。コンパイラバグは思ったよりよくあるが(昔は gcc で毎年いくつか見つけていた)、記事のように大規模でないと表面化しない珍しいケースが多い。たいていの人はそこまでの規模に到達しない。
    • 今日応募しない理由が気になる。
  • スタックポインタは常に原子的に調整しなければならないことを強調したい。
    • プリエンプションを書いた人たちはx86前提(こちらでは命令が定数を保持できるので原子的に行える)でコードを書き、ARMへの移植時に高レベルで自動分割されてこのバグが生じたように見える。誰かの過失というわけではないが、よい結果ではなかった。
    • まさにその考えが最初に浮かんだ。
  • マシンスレッドがどうやって2つの命令の途中で止められたのか、いまひとつ理解できない。ベアメタルでそんなことが可能なのか疑問だ。
    • Go はGC通知のために割り込みを使う。
    • signals
  • 「とても面白い問題だった」という記事の表現について、こういう根本的な問題を解決できたときは確かに爽快だっただろうが、未解決の間はまったく楽しくなかったはずだと思う。こういうバグは神経を全部持っていくような体験だ。標準ライブラリやコンパイラが原因だとは誰も思わないので、開発者は自分のコードばかり疑い続ける文化がある。私も一度標準ライブラリのバグを見つけたことがあるが、SDK側が問題だと疑うのは最後の最後だった。そのせいで見当違いの場所に時間を使い切るし、しかも今回のようにレースコンディションだと再現も難しく、消えたと思ったらまた現れる。
    • このコメントは自分の似た体験談を付け加えてはいるものの、わざわざ著者が感じた楽しさに反論を立てていて、かえって感動が薄れた感じがする。何を面白いと感じるかは人それぞれだ。
    • 人によっては、他人なら苦しみそうな非常に特殊なデバッグをむしろ喜ぶこともある。ある人の挫折が別の人の楽しみになる。
    • たぶん著者が言いたかったのは「面白い(funny)」ではなく「満足感がある(satisfying)」ということだったのだと思う。私も締め切りに追われながら Ubuntu GCC ARM ツールチェーンの sscanf バグを追ったことがあるが、その最中は楽しくなかったものの、問題を正確に突き止めて回帰テストまで書いた後は本当に満足感があった。
    • 深い欠陥を解決できたときの解放感はものすごい。私もコンパイラやCPU周りのバグを解決したときに、いちばん大きな喜びを感じたことが何度もある。
    • 管理言語で Unsafe 系をまったく使っていないのに segfault が出たら、自分のコードが原因ではない可能性が高いというシグナルとして受け取るようにしている。