世界を動かす技術を、日本語で。

GoのARM64コンパイラにバグを発見しました

概要

  • Cloudflareは Goのarm64コンパイラのバグ を大規模運用中に発見
  • Magic Transit/Magic WAN向け制御サービス で断続的なpanicを観測
  • バグの根本原因は Goランタイムのスタックアンワインド処理 と判明
  • Netlinkライブラリ と非同期プリエンプションの絡みで顕在化
  • 詳細な調査とデバッガ解析で コンパイラの競合状態 が明らかに

Cloudflareの大規模運用でGo arm64コンパイラバグを発見

  • 毎秒 8,400万件のHTTPリクエスト を330都市のデータセンターで処理
  • 極めて稀なバグ でも高頻度で顕在化する規模
  • Magic TransitやMagic WAN向けの カーネル制御サービス で異常なpanicを検知
  • エラーメッセージ「 traceback did not unwind completely」を観測、 スタック破損 の疑い
  • 当初は 稀なスタックメモリ破損 と推測し、深追いせず

異常パターンの再発と調査再開

  • 一時的に panic/recoverの利用停止 で致命的panicが消失
  • しかし 1か月後に再発し、1日最大30件の致命的panic を観測
  • 既存のパターンマッチングや推測が通用せず、 根本原因調査 を決断

2種類のバグパターンを特定

  • 無効メモリアクセス時のクラッシュ

  • 明示的な致命的エラー検出時のpanic

  • いずれも *(unwinder).next でスタックアンワインド時に発生

  • Goランタイムの スケジューラ構造体(g, m, p) の理解が調査の鍵

    • g :goroutine
    • m :カーネルスレッド(machine)
    • p :物理実行コンテキスト(processor)

調査の行き詰まりと突破口

  • スタックアンワインド自体は正常、だが 無効なスタック を処理している
  • Go Netlinkライブラリ の古いバージョンがクラッシュ時に必ず関与
  • ログ調査で 全てのセグメンテーションフォルトがNetlinkSocket.Receiveのプリエンプト時 に発生と判明

Goの非同期プリエンプションの影響

  • Go 1.14以降、 非同期プリエンプション (async preemption)を導入
  • sysmonスレッド が10ms以上実行中のgoroutineをSIGURGで強制プリエンプト
  • signal handlerで スタック・プログラムカウンタを書き換え、asyncPreemptを模倣

根本原因の特定

  • 2つの仮説
    • Go Netlinkのunsafe.Pointer利用による未定義動作
    • GoランタイムのバグでNetlinkSocket.Receiveでのみ顕在化
  • コード監査では決定打が得られず
  • プロダクション環境のcoredumpをデバッガで解析
    • asyncPreempt2 で停止、 NetlinkSocket.Receive 呼び出し中にプリエンプト
    • スタックアンワインド時に スケジューラ構造体mの不正参照 でセグメンテーションフォルト

まとめ

  • Goのarm64コンパイラが生成するコードに競合状態が存在
  • 非同期プリエンプションのタイミングとNetlinkライブラリの組み合わせ でスタック破損が発生
  • Cloudflareの大規模運用が 極めて稀なバグを発見・報告 するきっかけに
  • 問題の根本解決には Goランタイム・コンパイラの修正 が必要

補足

  • 詳細なスタックトレースやメモリアドレスは割愛
  • Goランタイムやプリエンプションの詳細は 公式ドキュメント を参照

Hackerたちの意見

これ、めっちゃ楽しかった!書いてくれてありがとう!

すごい発見だね!アセンブリを見たとき、デバッグの道を一緒に進んでる気分になったよ。面白いことに、これが機能するためにはアセンブリである必要はないんだ。ただ、そこで分岐があっただけ。IRでもできたはずだけど、理由があってそうしてないんだろうね。アームアセンブリが読めるのはまた一つの勝利だね。これが別のやり方になるかはわからないけど、メモリアクセスのコストで命令を節約するために、スタックサイズをプッシュしてからポップするのもありかも?関数のエントリーとエグジットでその動きをしてるだろうし。ガーベジコレクターが何を探してるのかはよくわからないから、もしかしたらうまくいかないかもしれないけど、いろんな意見を聞いてみたいな。

正しい修正は、コンパイラが例えば、定数をレジスタに2回のムーブでロードしてから、1回のアドを出力することだと思う。もう1つの命令が増えるけど、その調整はアトミック(つまり1つの命令)になるんだ。他の選択肢としては、一時レジスタで計算してから戻すっていうのもあるね。

アームアセンブリが読めるのはまた一つの勝利だね。 そうだね、でもドルが入ってる変なやつは普通のAArch64アセンブリじゃないよ!この記事で「スタックの移動は一度だけ」ってルールに触れてもよかったかも。

普通、Javaや.NETのようなランタイムには、命令の途中でコンテキストを変更しないようにサーフポイントがあるんだ。

普通は「LDR Rd, =expr」形式を使うよ。直接構築できない即値の場合、即値をPC相対のメモリ位置にコピーして、そこからレジスタにPC相対でロードするんだ。だから、「SPに定数を加える」一連の動作が、即値を構築するための1つと加算するための1つの合計2つの実行可能命令になって、合計8バイトになる。そして、17ビットの即値のための4バイトのデータエリアが必要で、合計12バイトのバイナリになる。これは3つの実行可能命令分だね。

このバグがアセンブラで、RSPへの即時加算の特別なケースとして修正されなかったのはちょっと驚きだね。もしパッチがコンパイラだけに適用されてたら、aarch64アセンブリコードの中に他にも同じバグが潜んでるかもしれないよ。

クラウドフレアのブログはいつも素晴らしい記事だね。魔法のインフラや機械学習なしのエンジニアリング。いつか私も応募するぞ!コンパイラのバグは実際結構よくあるんだ(昔はgccで毎年いくつか見つけてた)。でも著者が言うように、大規模で作業しないと出てこないバグもあって、ほとんどの人はそこまで深くは掘り下げないんだよね。

今日、何があなたを止めてるの?

いつもスタックポインタはアトミックに調整しようね、みんな。

プリエンプションを書いた人たちはX86で作業してたんじゃないかな。そこでは可変長命令のおかげでこんなことは起こらないから、コード生成に頼って原子性を保ってたんだろうね。それでARMポートは、上位レベルから自動的に「分割」して「簡単」にしようとした結果、このバグが生まれたんだ。誰のせいでもないけど、悪い結果になっちゃったね。

待てない人のために、これが修正方法だよ: https://github.com/golang/go/commit/f7cc61e7d7f77521e073137c...

リンクされた問題をレビューしてるときに気づいたんだけど: https://github.com/golang/go/issues/73259#issuecomment-31004... Goチームには自然言語ボットがあるの?それとも単にコメントに「backport」っていうのが含まれてるだけ?

どんなARM64マシン使ってるの?それは何に使うの?去年、AMD EPYCでGen 12サーバーを発表してたけど(https://blog.cloudflare.com/gen-12-servers/)、ARM64については特に言及されてなかったよね。でも今はARM64をフル稼働させてるみたいだね。

ここでの本当の教訓は、シグナルハンドラーでプログラムカウンターをスウィズルしたり、自分でアセンブラを書くのは良いアイデアじゃないってことだね。

これだね。Keith WがDtraceブログで10年前に言ってたよ。 https://wesolows.dtrace.org/2014/12/29/golang-is-trash/ Goは好きだけど、彼らのNIH / すべてを自分たちのもので置き換えようとする姿勢はあまり好きじゃないな。特にアセンブラやリンカーみたいなシステムツールに関しては。

一般的には、こういうことは自分でやらない方がいいって言われてるし、信頼できる実装に頼るべきだよね。でも、時には自分がその信頼できる実装を提供する側になることもあるんだ。コンパイル言語を実装するのは、まさにそういう時の一つだよ。

すごくいい技術ブログだね。ストーリーの流れが良くて、例もピタッとしてるし、説明が明確だから、自分が思ってるより賢くなった気がするよ。最後に真剣にアセンブリを読んだのは何年も前のx86だけどね。それに、マーケティングの目的も果たしてると思う。このチームは、必要に応じてこれをやれるスキルを持ったすごい人たちで、珍しい問題を追いかける品質の規律もあるんだろうな。これってAmpere Altraだよね?ラックを埋めるためにウェブサーバー用に考えてたけど(スペースが必要で、パワーはあまりいらない)、結局はパワーを上げてEpycを使うことにしたよ。

アンワインディングにはフレームポインタを使うと思ってたから、これが問題になるとは思わなかったな。