本番環境のRust:シグナルプロセッサを書き直した理由
シグナル処理エンジンをGoからRustへ書き直しました。その理由、得られた学び、そして達成した4倍のスループット向上について解説します。
6か月前、私たちはシグナル処理エンジン(生のブラウザシグナルを正規化されハッシュ化可能な特徴ベクトルへ変換するコンポーネント)をGoからRustへ書き直すことを決めました。これは軽い気持ちで下した決定ではありません。Goによる実装は動作していました。テストも済んでいました。デプロイもされていました。しかしそれはパイプラインのボトルネックになっており、私たちにはスループットの段階的な飛躍が必要でした。以下はその顛末です。
Goでは手狭になった理由
私たちのシグナルプロセッサは計算負荷の高い処理を行います。JSONペイロードのパース、130+のシグナルへの正規化関数の適用、独自ハッシュの計算、そして識別用ハッシュベクトルの構築です。Goではこの処理はCPUバウンドであり、規模が大きくなるとGoのガベージコレクタが問題になりました。シグナル処理のサイクルごとに中間オブジェクト(パース済みのJSONノード、正規化された文字列値、ハッシュバッファ)が確保され、GCへの圧力が生じていました。
毎秒30Kイベントでは、Go版シグナルプロセッサは数秒ごとに2〜5msのGC一時停止を示していました。この程度の停止は許容範囲でした。毎秒50Kイベントになると、GCの一時停止は8〜15msに拡大し、発生頻度も高まりました。毎秒80Kイベント(Q3に想定していた負荷)では、GCの一時停止によってp99レイテンシがSLAを超過していたはずです。私たちにはサーバの増設(高コスト)か、より効率的な実装のいずれかが必要でした。
なぜRustなのか
私たちは3つの選択肢を評価しました。Go実装の最適化(sync.Pool、アリーナアロケーション、GOGCのチューニング)、C++での書き直し、そしてRustでの書き直しです。Goの最適化は30%の改善をもたらしましたが、GCの問題を根本的には解決しませんでした。C++はセキュリティ上重要なシステムにおけるメモリ安全性への懸念から見送りました。Rustはゼロコスト抽象化、ガベージコレクタの不在、そしてコンパイル時に強制されるメモリ安全性の保証を提供してくれました。
Rustのエコシステムには、私たちが必要とするすべてに対応する成熟したライブラリもありました。JSONパースのためのserde、高性能なハッシュ用crate、非同期I/Oのためのtokioです。学習コストは確かにありました。チームはGoの深い経験を持っていましたが、Rustの経験は限られていました。しかしその性能特性は、まさに私たちが必要としていたものでした。
書き直しのプロセス
私たちはシグナルプロセッサを、パイプラインの他の部分とgRPC経由で通信する独立したサービスとして書き直しました。これによりGo実装と並行してデプロイし、トラフィックを徐々に移行できました。書き直しにはエンジニア3名で4週間を要しました。コア実装に2週間、テスト・ベンチマーク・エッジケース対応に2週間です。
最も難しかったのは言語そのものではなく、Go実装との挙動の一致を保証することでした。私たちは両方の実装を同じ入力に対して実行し、同一の出力が得られることを検証する比較用ハーネスを構築しました。このプロセスの中で14件の微妙な差異を発見しました。その多くは浮動小数点の扱い、Unicode正規化、JSONパースのエッジケースに関するものでした。
パフォーマンスの結果
Rust実装はシグナルを平均0.8msで処理し、Goの3.2msに対して4倍の改善です。同一のワークロードでメモリ使用量は2.1GBから340MBへ減少しました。ガベージコレクタが存在しないため、GCの一時停止はありません。同じスループットにおいてCPU使用率は60%低下し、各サーバが4倍のトラフィックを処理できることを意味します。
毎秒80Kイベントにおいても、Rust実装は一時停止ゼロでp99処理時間1.4msを維持します。この余裕は、当面の間シグナル処理の性能を見直す必要がないことを意味します。CPUとメモリ使用量の削減は、そのままインフラコストの低下にも直結しました。私たちはシグナル処理サーバ12台のうち8台を撤去しました。
得られた教訓
Rustでの書き直しは、私たちの特定のケース、すなわちCPUバウンドで、確保処理が多く、レイテンシに敏感なワークロードにおいては価値がありました。一方でHTTP取り込み層やClickHouseクエリサービスをRustで書き直すことはしないでしょう。これらのコンポーネントはI/Oバウンドであり、Goが効率的に処理するからです。教訓は「すべてをRustで書き直す」ことではなく、「ゼロコスト抽象化と決定論的な性能が最も重要になる場所でRustを使う」ことです。
最大の驚きは、書き直しの過程でRustコンパイラがどれほど多くを検出したかでした。Go実装に潜んでいたいくつかのバグ(共有バッファでの競合状態、ハッシュ計算での整数オーバーフロー、不正な入力に対する境界外アクセス)は、Rustではコンパイル時エラーとして検出されました。コンパイラは厳格ですが、その厳しさは正しさという形で報われます。
正直に言えば、最初の1週間は苦しいものでした。Sarahはホワイトボードに「ボローチェッカーとの格闘」の集計を付けていて、チームが数えるのをやめるまでに47回に達しました。しかし3週目には、コンパイルが通ったコードはそのまま動きました。本番での謎のパニックもなければ、負荷時のデータ競合もありません。ホットパス上にあるものであれば、そのトレードオフは価値があります。