RustからGoへのバックエンド移行と、ビルド時間が決め手となった理由
Rustは、我々がめったに必要としなかった安全性と、毎日戦っていたコンパイルループをもたらしました。これが、あるサービスをGoに移行させることになった、数値に基づく正直なトレードオフです。
私たちは本番環境のバックエンドをRustからGoに書き直しました。その決定要因は、実行速度、メモリ使用量、あるいは安全性ではありませんでした。それはコンパイルループでした。1時間に10回も変更するようなサービスは、その変更をどれだけ速く確認できるかにかかっています。これは、私たちが何を諦め、何を得たのか、そしてシステムのどの部分をRustのままにしておくかをどのように決定したのかについての全記録です。
これ以降に続く内容は、Rustに対する不満ではありません。Rustは、その約束通りのことをしてくれました。要点はもっと絞られます。Rustが最も得意とすることは、この特定のサービスが最も必要としていたことではなく、Rustが課す代償は、私たちが編集のたびに支払っていたものだったのです。
フィードバックループは仕事の大部分を占める
バックエンドの仕事のほとんどは、白紙の状態から新しいコードを書くことではありません。1行変更し、実行し、エラーを読み、そして再び変更することです。それを10回、20回、50回と連続して繰り返します。そのラウンドトリップに40秒かかるとしたら、午前中のデバッグはほとんどが待ち時間になります。もし4秒で済むなら、問題に集中し続けることができ、何をテストしていたかという短期記憶も維持されます。
その最後の部分は、単なる秒数以上に重要です。10秒あたりに閾値があり、それを超えるとビルドを待つのをやめ、コンテキストスイッチングを始めてしまいます。alt-tabを押し、メッセージを読み、思考の糸が途切れてしまいます。戻ってきたときには、何をしていたかの全体像を頭の中で再構築しなければなりません。4秒のビルドなら、椅子に座ったままでいられます。40秒のビルドは、すべてのテストを小さな中断に変えてしまい、その中断は足し算ではなく、掛け算で影響します。
絶えず編集されるサービスにとって、コンパイラはバックグラウンドツールではありません。それは、1日の中で最もよく使われる部分です。私たちはその速度を些細な不便として扱っていましたが、実際にはそれが私たちのすべての仕事のペースを決めていたのです。
私たちが出荷したバグはRustが防ぐバグではなかった
Rustの型システムと借用チェッカーは、データ競合、use-after-free、イテレータの無効化、null参照外しといった、欠陥のカテゴリー全体を排除します。これらは実在し、高くつくバグであり、適切なコードベースにおいては、その保証はほとんどどんな代償を払ってでも価値があります。
問題のサービスは、そのようなコードベースではありませんでした。それはI/Oバウンドなグルーコードでした。HTTPハンドラ、データベース呼び出し、翻訳キュー、JSONを入力してJSONを出力するといったものです。数ヶ月にわたって本番環境で実際に発生した障害を見ると、そのリストは一貫していました:
- 間違ったフィルター形状を持ち、誤った行を返したクエリ。
- ある場所ではオプショナルであり、別の場所では必須であったJSONフィールド。
- ページネーションにおけるoff-by-oneエラー。
- ローカルでは正しかったものの、デプロイされたリージョンでは間違っていたタイムゾーンの想定。
これらのどれ一つとして、メモリ安全性のバグではありません。Rustはそれらのすべてをコンパイルしてしまったでしょう。ほぼすべてがリクエストごとでステートレスであり、競合の対象となる共有可変状態がほとんどないサービスで、私たちはデータ競合に対する保証の対価を支払っていたのです。その保険は本物でしたが、私たちは抱えていないリスクに対して保険をかけていたのです。
実際に時間がどこで費やされたか
苦痛だったのはコールドビルドではありません。その代償は、おそらく1日に1回程度で、コーヒーを淹れに行くことができます。苦痛だったのは、1行編集した後のインクリメンタルビルドでした。なぜなら、それこそが一日中代償を払い続けるものだからです。
その様子を、正確なベンチマークではなく範囲として以下に示します。なぜなら、正確な数値はあなたのマシンと依存関係グラフに依存するからです。
| 変更 | Rust (インクリメンタル) | Go |
|---|---|---|
| ハンドラーの本体を1つ編集 | 数十秒 | 約1秒 |
| 共有されている型に触れる | 大規模な再コンパイル | 数秒 |
| 依存関係を追加 | 数分 | 数秒 |
| 完全なクリーンビルド | 数十分 | 数秒 |
Rustの数値を押し上げた要因は2つあります。1つ目は単相化(monomorphization)です。ジェネリックコードは、それが使用される具象型ごとに新たにコンパイルされ、これにより高速なバイナリと遅いビルドが生まれます。2つ目は依存関係グラフです。手続きマクロ(procedural macros)に依存するクレートをいくつか取り込むと、ビルド時間のかなりの部分が、あなたが読んだことのないコードの展開とコンパイルに費やされます。広く使われている型に対する1行の変更が、キャッシュの大部分を無効にし、長い再コンパイルを引き起こす可能性があります。
繰り返しますが、これはRustが不正な動作をしているわけではありません。単相化(Monomorphization)は、ランタイムが高速である理由です。手続きマクロ(Proc macros)は、エルゴノミクスが良い理由です。これらは、コンパイルループよりも生成されるバイナリを優先するという意図的なトレードオフです。私たちが一日中編集していたサービスにとって、私たちはそのトレードオフの不利な側にいました。
Goが何を犠牲にし、なぜそれに価値があったのか
Goはより小さく、より無骨な言語であり、それに移行することは現実のものを失うことを意味しました。その損失について正直になることが、トレードオフを信頼できるものにする唯一の方法です。
私たちは適切な合計型を失いました。タグ付き共用体に対するGoの答えはインターフェースと型スイッチですが、コンパイラはその網羅性をチェックしません。私たちは ? 演算子を失い、感覚的には3行に1度は if err != nil を書くことになりました。私たちは、ある種の並行処理の誤りをコンパイル時に検出できたであろう借用チェッカーを失い、代わりに競合検出器とコードレビューに頼らざるを得なくなりました。Goにジェネリクスが導入されたのは遅く、今なおRustのトレイトほど表現力豊かではありません。
これらの損失と引き換えに得たものは次のとおりです。
- ビルドについて考える必要がなくなるほど高速なコンパイラ。編集-実行-確認のループが、思考が中断される閾値を下回り、その状態を維持しました。
- ほとんどのことに対して、明白なやり方が1つだけあること。ループを書く方法はほぼ1つ、エラーを処理する方法も1つ、そして誰も文句を言わないツールによって強制されるフォーマット標準も1つです。その統一性は、聞こえる以上に重要です。特に、コードの大部分がAIアシスタントによって書かれたり編集されたりする場合、もっともらしく見えるが普通ではない構文が紛れ込む余地が少なくなるためです。
- 私たちが必要としていたHTTPサーバー、JSON、コンテキスト伝播、タイムアウトをすでに備え、それぞれが依存関係ツリーを引き連れてくることのない標準ライブラリ。
冗長なエラー処理は、予想よりも小さなコストであることが判明しました。それには特定の理由があります。それは、局所的でざっと読み飛ばせるということです。
// The whole error style in one function. Boring to write, fast to read.
func (s *Server) renderHome(ctx context.Context, lang string) (*Page, error) {
posts, err := s.store.ListPublishedByLang(ctx, ListQuery{Lang: lang, Limit: 20})
if err != nil {
return nil, fmt.Errorf("list posts for %s: %w", lang, err)
}
count, err := s.store.CountPublishedByLang(ctx, lang)
if err != nil {
return nil, fmt.Errorf("count posts for %s: %w", lang, err)
}
return s.buildPage(lang, posts, count), nil
}
すべてのエラー箇所は、それを生成した呼び出しのすぐ隣にあり、何が失敗したかを示すコンテキストでラップされています。本番環境で何かが壊れたとき、エラー文字列は「何が起こっていたか」のスタックのように読めます。対照的に、遅いビルドは局所的ではありません。それは変更のたびにチーム全体をブロックし、ざっと読み飛ばしてやり過ごすことはできません。
移行は意図的に退屈なものにした
私たちはビッグバンリライトを行いませんでした。サービスはインターフェースを介して分割され、Go版が同じトラフィックを同じ出力で処理できるようになるまでRust版を稼働させ続けながら、境界づけられたまとまりを一度に1つずつ移行しました。移行の目標は、退屈であることです。面白い部分にこそ、バグが潜んでいるのです。
2つのことが、この移植を容易にしました。1つ目は、データモデルは変更されず、その周りのコードだけが変更されたことです。そのため、同じデータベースに対して2つの実装を比較し、そのレスポンスの差分を取ることができました。2つ目は、Goの標準ライブラリが必要な範囲をカバーしていたため、ほとんどのハンドラは再設計というよりは、ほぼ機械的な変換で済んだことです。機械的でなかった部分、主にエラー処理と並行処理は、まさしく時間をかけて手作業で行う価値のある部分でした。
それでも私たちがRustを選ぶとき
これはトレードオフであり、最終的な結論ではありません。それを結論として扱うことで、人々は二度も間違ったツールを選ぶことになってしまうのです。Rustは、適切な場所で使えば、そのコンパイル時間に見合う価値を何倍にもして取り戻します。
- すべてのアロケーションとすべての分岐が重要になる、CPUバウンドなコア。
- データ競合が理論上のリスクではなく現実的なリスクとなる、本物の共有ミュータブルな状態と真の並行性を持つコード。
- パーサー、コーデック、数値計算のホットパスなど、微妙なメモリバグが発生しやすく、かつ致命的になりうるあらゆるもの。
私たちは、まさにそれらの部分をRustのままにしました。メディアのトランスコーディングといくつかの数値計算のホットなルーチンは移行しませんでした。なぜなら、それらにとっては、コンパイラが実行のたびに元を取ってくれるからです。それらを取り巻くサービスレイヤー、つまり主にネットワークとデータベースを待っている部分こそが、Goの居場所なのです。
私たちがたどり着いたルール
言語は、バグのリスクがあってほしいと願う場所ではなく、それが実際に存在する場所に合わせる。
セーフティクリティカルでCPUバウンドなコードには、コンパイラにコストを払い、人間が見逃すものを見つけさせます。一日中編集するI/Oバウンドなサービスコードでは、主なリスクはデータ競合ではなく、目の前にあるロジックのバグを見つけるのを妨げる、遅いフィードバックループです。そこでは、そのループを取り戻します。
バグのリスクはどこに存在するのか、というその一つの問いが、このサービスをGoに移行させ、そのホットコアをRustに留めた理由です。あなたのシステムでは、その答えは同じではないでしょう。問いは同じであるべきです。