GoからRustへ移行する
(corrode.dev)- GoからRustへの移行は、速度向上よりも
nil、エラー処理、データレース、リソース寿命の問題をコンパイル時保証へ移す選択に近い - Goは高速コンパイル、シンプルなgoroutine、強力なバックエンド生態系が長所だが、Rustは
Option、Result、Send/Syncによって、より多くのミスを型システムで防げる - Rustの 借用チェッカー と
async/awaitは学習曲線と使い勝手のコストを生み、コンパイル時間もGoより明確な後退として受け入れる必要がある - 移行は全面的な書き直しよりも、ホットパスのサービス、ワーカー、ゲートウェイ背後の一部エンドポイントのような境界が明確なコンポーネントから適用する戦略が適している
- 期待される効果は、CPU 20〜60%削減、メモリ 30〜50%削減、より平坦なP99レイテンシ、
nilのデリファレンスやレース起因の障害の減少に要約される
移行の焦点
- GoからRustへの移行は、「Rustのほうが速いか」よりも 正確性の保証、ランタイム上のトレードオフ、開発者体験の違いを見極める問題に近い
- 比較の中心は バックエンドサービス であり、Goが強みを持つ小さな静的バイナリ、ネットワーキング中心の標準ライブラリ、HTTPサーバー・gRPC・データベース生態系を基準にしている
- CLIツール、組み込みファームウェア、ゲームエンジンにも一部は当てはまるが、最適化された対象ではない
- 関連する背景資料として、2017年の “Go vs Rust? Choose Go.” とShuttleチームの “Rust vs Go: A Hands-On Comparison” が挙げられている
- Goは成功した言語だが、
nilの広範な使用、型ではなく規律に依存するエラー処理、長らく存在しなかったジェネリクスのような設計選択は、Rustと比較する際の主要な論点になる - JetBrains Developer Ecosystem Surveyでは、Goは実務開発者の比率が 17〜19% 水準を維持する言語として示され、Rustは着実に成長しているものの、より小さい比率にとどまっている
ツール体系
- GoとRustはいずれも、ビルド、テスト、フォーマット、リンティング、依存関係管理を一貫したインターフェースで提供する バッテリー同梱のツール体系 を備えている
cargoはGoのツールに対応する機能を、一次ツールとしてより広く提供しているgo.mod/go.sum→Cargo.toml/Cargo.lock: プロジェクト設定と依存関係マニフェストgo get/go mod tidy→cargo add/cargo update: 依存関係の追加と解決go build→cargo build: コンパイルgo run .→cargo run: ビルド後に実行go test ./...→cargo test: テストgo vet ./...→cargo clippy: リンターであり、Clippyはvetよりはるかに意見が強いgofmt/goimports→cargo fmt: 設定不要の自動フォーマッタgolangci-lint run→cargo clippy -- -D warnings: 厳格なリンティングモードgo doc→cargo doc --open: APIドキュメントの生成と閲覧pprof→cargo flamegraph/samply: CPUプロファイリングgovulncheck→cargo audit: アドバイザリデータベースに基づく脆弱性検査
- Goでは
golangci-lint、mockgen、air、goreleaserのようなサードパーティーツールで隙間を埋めることが多いが、Rustは一次生態系がより多くの機能を標準的に包含している - 外部クレートが必要な場合でも、
cargo watch、cargo nextestのようにcargo install cargo-nextestを一度実行するだけでインストールでき、cargo nextestのようにネイティブツール同様に動作する gofmtとrustfmtは、細かなスタイルの好みよりも、コードレビューにおけるスタイル論争をなくす利点のほうが大きい- Rob PikeのGo Proverbsからの引用: “Gofmt’s style is no one’s favorite, yet gofmt is everyone’s favorite.”
GoとRustの中核的な違い
- 両言語ともコンパイル言語、静的型付け、単一バイナリ配布、強力な並行モデルを備えているが、違いは コンパイラが保証する範囲 とランタイム動作の制御レベルにある
- 主な比較項目は次のとおり
- 安定版リリース: Goは2012年、Rustは2015年
- 型システム: Goは静的・構造的型付けで1.18からジェネリクスをサポート、Rustは静的・名目的型付けでジェネリクス・トレイト・ライフタイムをサポート
- メモリ管理: Goは並行・低レイテンシGC、Rustは所有権と借用ベースでGCなし
- null安全性: Goでは
nilが広く存在し、Rustにはnullがなく、Option<T>が型レベルの代替となる - エラー処理: Goは
errorインターフェースとif err != nil { ... }、RustはResult<T, E>、?演算子、完全なパターンマッチング - 並行性: GoはgoroutineとチャネルベースのCSP、Rustは
tokio上のasync/await、チャネル、スレッド - キャンセル: Goは慣習ベースの
context.Context、RustはCancellationTokenなど明示的で型検査可能な伝播 - データレース: Goは
-raceでランタイム時に確率的に検出、RustはSend/Syncによりコンパイル時に検出 - コンパイル時間: Goは非常に高速で、Rustは特にクリーンビルドが遅い
- ランタイム: Goは約2MBのランタイムとGCを持ち、Rustは
libcを除けばランタイムがない、あるいはMUSLで完全静的ビルドが可能 - 生態系の規模: Goは約 75万+ モジュール、Rustは 25万+ クレート
- Goで慣習、ツール、ランタイム検出に依存していた
nil処理、エラー伝播、データレース、リソース寿命、キャンセル、ジェネリクスのような検査は、Rustでは型システムの中に入る - Rustの
Mutex<T>は、.lock()で取得したガードを通してのみ内部値にアクセスできるようにし、「ロックを忘れる経路」そのものを型から取り除く - 同じパターンは
Option、Result、&mut T、Send/Sync、RAIIガード全般に繰り返し現れ、慣れてくるとコンパイラが頭の中のチェックを肩代わりする形になる
Rustを検討させるGoの限界
- Goはほとんどのバックエンドワークロードで十分に高速なため、Rustを検討する主な理由は速度よりも、エラー処理の冗長さ、
nilポインタの危険性、enum・traitのような洗練された型システム機能の不足に近い - GoのインターフェースはRustのトレイトを十分に代替するものではなく、標準ライブラリに
Set型がないため、map[T]struct{}のような慣用的な回避策が必要になる -
本番環境での
nilパニック- Goのサービスは何か月も正常に動作した後、特定のコードパスでポインタの
nilチェックを見落として、goroutineのパニックを起こすことがある - 例では
Findが(*User, error)を返し、「not found」ではerrorはnilだが、userの確認は呼び出し側の責務として残される user.Account.Notify()は、userまたはAccountがnilのときにクラッシュする可能性があるnilaway、staticcheckのようなリンターやIDEの検査は一部を検出するが、オプトインであり、確率的で、パッケージ境界を安定して越えられない- Rustの
Option<T>は、Noneのケースを処理しない限りデリファレンスできないようにし、この種の障害を取り除く
- Goのサービスは何か月も正常に動作した後、特定のコードパスでポインタの
-
-raceが捕まえられなかったデータレースgo test -raceは優れたツールだが、ランタイム検出器であるため、テスト中に実際に実行されたレースしか見つけられない- Goでは、2つのgoroutineがロックなしでmapを変更するコードでもコンパイルでき、負荷のかかった本番環境で破綻する可能性がある
- Rustでは、スレッド間で可変状態を共有するには
SendとSyncを実装した型が必要で、通常のHashMapをスレッド間で共有しようとするとコンパイルできない Arc<Mutex<...>>、Arc<RwLock<...>>、チャネルのいずれかを使うことが強制され、レースコンディションは型エラーになる- Paul Dixは、InfluxDB 3.0の書き直しの動機としてデータレースの排除を直接挙げている
- "[The main benefit is] fearless concurrency — eliminating data races essentially, which we had before. Really gnarly bugs in version 1 of Influx due to that."
- 出典: Paul Dix, Founder & CTO, InfluxData, Rust in Production
-
合成しやすいエラー処理
- Goの
if err != nil { return err }は関数の実際のロジックを薄めることがあり、fmt.Errorf("doing X: %w", err)で文脈を包む作業は、コンパイラ規則ではなく規律に依存している - Lobste.rsスレッドでは、熟練したGo開発者たちが、
errcheckとgolangci-lintがエラー処理の見落としの大半を捕捉し、明示的なif err != nilは密な?チェーンより読みやすいと反論している - Peter Bourgonは、Goの明示的なエラー処理を意図された文化的価値として提示している
- "I think that error handling should be explicit, this should be a core value of the language."
- 出典: Peter Bourgon, GoTime #91, Dave CheneyのZen of Goに引用
- Rustの
Result<T, E>は型シグネチャそのものであるため忘れようがなく、thiserror::Errorで定義したenumと#[from]を通じて、エラー変換と網羅性チェックを受けられる - 新しいエラーvariantを追加すると、コンパイラが更新が必要な
matchの箇所を教えてくれる
- Goの
-
ボクシングしないジェネリクス
- Go 1.18のジェネリクスは有用だが、型パラメータを持つメソッドの不在、GC shape stenciling、時に意外な性能特性といった制約がある
- Rustのジェネリクスはモノモーフィゼーションされ、各インスタンス化が特化されたコードを生成し、ランタイムコストがない
- トレイトと組み合わせることで、ゼロコスト抽象化が可能になる
- ハンドラーコードよりも、ミドルウェア、generic repository、decoder、parserのような共有インフラでより重要であり、Goはこうした領域で
interface{}/anyと型アサーションに戻ることが多い
-
予測可能なレイテンシ
- GoのGCは優秀で、並行的かつ低レイテンシで、一般的なサービスワークロード向けによくチューニングされているが、「low-pause」は「no-pause」ではない
- 割り当ての多い状況では、ホットパスで割り当てを行わないRust実装よりもP99レイテンシのテールが悪化する可能性がある
- トレーディング、リアルタイム入札、ネットワークプロキシ、高スループット収集のようなレイテンシに敏感なシステムでは、GC pauseがないことが実際の利点になる
- Stephen Blumは、PubNub規模で必要な価格対性能のキャパシティを得るためにRustが必要だと述べている
- "Go is great at our scale, but we really need something that is going to give us the price-per-dollar performance capacity that we need, and Rust is going to get us there. That’s why basically everything is heading towards Rust these days."
- 出典: Stephen Blum, CTO, PubNub, Rust in Production
Goパターンに対応するRustの書き方
- Rustに慣れる最も速い方法は、すでに知っているGoのパターンをRustの対応パターンにマッピングすること
- 同じバックエンドサービスを両言語で実装した、より長い例は Shuttle comparison にある
-
エラー処理:
if err != nilvsResult<T, E>- Goでは
os.ReadFile(path)やjson.Unmarshalの後でif err != nilにより文脈を付けてラップしたエラーを返す - Rustは
fs::read_to_string(path)?,serde_json::from_str(&data)?,Ok(cfg)で構成される ?演算子はif err != nil { return err }パターンの代わりになり、From<E1> for E2が実装されていれば型変換まで処理するthiserrorの#[from]はこの変換を慣用的にサポートする
- Goでは
-
null:
nilvsOption<T>- Goの
GetUser(id string) *Userはユーザーが見つからなければnilを返し、呼び出し側がfmt.Println(u.Name)を実行するとnilのときパニックが発生する - Rustの
get_user(id: &str) -> Option<User>はSome(User)またはNoneを返す let user = get_user("123"); println!("{}", user.name);はuserがUserではなくOption<User>なのでコンパイルエラーになるmatch get_user("123")でSome(u)とNoneの両方を処理しなければならない- 安全なRustには
nilがなく、参照は null になれない
- Goの
-
インターフェース vs トレイト
- Goのインターフェースは構造的であり、型はインターフェースを暗黙的に満たす
- Rustのトレイトは名目的であり、明示的に実装しなければならない
- Go方式はその場のダックタイピングに向いており、Rust方式はリファクタリングと discoverability に優れ、特定の trait 実装を grep で探せる
fn handle<R: Reader>(r: R)のように trait bound が付いた generic function がほとんどの場合をカバーし、単相化によってランタイムディスパッチは発生しない- ランタイムディスパッチが必要な異種実装の保存には
Box<dyn Trait>またはArc<dyn Trait>を使う
-
Goroutine vs async task
- Goの並行性モデルは
go doWork(ctx, input)のように単純で、goroutine は軽量であり、ランタイムがOSスレッド上でスケジューリングする - Goの大きな利点は 逐次コードと並列コードの間に文法上の区別がないこと である
- Rustはバックエンドサービスでほぼ常に
tokioexecutor 上のasync/awaitを使う - async 関数は
Futureを返し、await されるか spawn されるまでは実行されない - コンパイラは
.awaitの前後でSend/Syncを追跡し、non-Sendな値を await をまたいで保持するとコンパイルエラーを出す - goroutine のような組み込みプリエンプションがないため、CPUバウンドな処理を async task 内で長時間実行すると executor が飢餓状態になる可能性があり、
tokio::task::spawn_blockingまたはrayonに渡す必要がある
- Goの並行性モデルは
-
context.ContextvsCancellationToken- Goではすべての blocking call に
context.Contextを渡す - Rustには組み込みの
context.Contextはなく、キャンセルに最も近い対応物はtokio_util::sync::CancellationTokenである - timeout は
tokio::time::timeout(dur, fut)で future をラップする - deadline や値は、ひとつの context オブジェクトよりも明示的な引数や
tracingspan を通じて渡されることが多い - Dave Cheney の The Zen of Go からの引用:
- “Go doesn’t have a way to tell a goroutine to exit. There is no stop or kill function, for good reason. If we cannot command a goroutine to stop, we must instead ask it, politely.”
- Goではこの「丁寧な依頼」は慣例的に
context.Contextで伝えられ、RustではCancellationTokenまたはwatchチャネルになるが、コンパイラが渡し忘れを知らせてくれる
- Goではすべての blocking call に
-
文字列:
stringvsStringと&str- Goの
stringはUTF-8の byte slice であり、代入時には header がコピーされ、underlying bytes は共有される不変構造である - Rustではこれを2つの型に分けている
String: 所有し、ヒープに割り当てられ、拡張可能&str: 他の文字列データに対する borrowed view であり、多くの場合Goのstringparameter に対応する
- 経験則として、引数には
&strを受け取り、新しいデータを作るときはStringを返す &strとStringの分離は、Rustの「borrow vs own」モデルを縮図として示している
- Goの
Goのジェネリクスに対する評価
- Goは1.18で2022年3月にジェネリクスを導入しており、言語リリース13年後のことだった
- ジェネリクスは有用ではあるが、Rust・Haskell・現代的なC++で期待される利点を十分には提供せず、ジェネリック型システムの欠点のかなりの部分も併せ持つと評価されている
-
標準ライブラリがほとんど使っていない
- ジェネリクス導入から3年後でも、Go標準ライブラリは大半でジェネリクスを避けている
sort.Sliceは依然としてcmp.Orderedconstraint の代わりにfunc(i, j int) boolクロージャを受け取るsync.Mapは依然としてany/anyで型付けされている- 存在する generic helper は、
slices、maps、cmp、sync配下の一部項目のような少数パッケージにあるのみ - Go 1互換性の約束のため既存の non-generic API を改造しにくい点は一部説明になるが、Rustのようにジェネリクスを主たる道具として使ってはいない
- Rustでは初期から
Option<T>、Result<T, E>、Vec<T>、HashMap<K, V>、Iterator、From/Into、すべてのコレクションとスマートポインタにジェネリクスが浸透している
-
トレイトシステムがなく、構造的 constraint しかない
- Rustのジェネリクスは、ad-hoc多相性、supertrait、associated type、blanket impl、coherence を担う trait と結び付いている
- Goのconstraintは、type-set membership のための
~演算子が追加された interface に近い - Goには、Rustの
trait Ord: Eq + PartialOrdのような supertrait hierarchy、Iteratorのtype Item;のような associated type、impl<T: Display> ToString for Tのような blanket impl がない - Goでは型パラメータを持つメソッドを使えないため、
func (s Set[T]) Map[U](<https://corrode.dev/learn/migration-guides/go-to-rust/f func(T>) U) Set[U]のような形は不可能である - 抽象化が「いくつかの演算を持つ任意の
Tに対して動作する関数」を超えた瞬間、Goはany、型アサーション、コード生成、ランタイム reflection に戻ることになる
-
型推論と実装戦略の違い
- Rustは、クロージャ、イテレータチェーン、
?演算子を含む式全体に型情報を伝播させる - Goの推論はより浅く、通常は関数引数から type parameter を推論するが、return-position context では推論できず、呼び出し箇所で明示的な type argument をしばしば要求する
- GoはGCShape stenciling and dictionariesという中間的な道を取り、高速なコンパイル時間を維持するが、type parameter のメソッド呼び出しごとに indirection が入ることがある
- これを示す資料として、PlanetScaleの記事が提示されている
- Rustは
Vec<i32>とVec<String>それぞれに特化した機械語コードを生成し、ランタイムディスパッチがない - 単相化の代償はコンパイル時間であり、両言語は異なる目標を最適化している
- Rustは、クロージャ、イテレータチェーン、
-
型システムの穴を埋められない
- Rustでは、ジェネリクスとトレイトが
Box<dyn Any>やランタイム reflection が必要になる場面の大半を不要にする - Goのジェネリクスは、
any、reflect、ORM・decoder・mock において支配的なコード生成パターンをなくせていない encoding/jsonは依然として reflection を使い、database/sqlは依然としてanyを使い、mockgenは依然としてコードを生成する- Goのジェネリクスは限定的なケースで役立つ新しい道具のように感じられ、Rustのジェネリクスは取り除けば言語が崩れる基盤として機能している
- Rustでは、ジェネリクスとトレイトが
Rustバックエンドエコシステム
- Rustエコシステムでも、一般的なバックエンドサービスについては「デフォルトの選択肢」がある程度収束している
- 代表的な対応関係:
- HTTP server: Go
net/http,chi,gin,echo,fiber→ Rustaxumonhyper - HTTP client: Go
net/http,resty→ Rustreqwest - gRPC: Go
google.golang.org/grpc+protoc-gen-go→ Rusttonic+prost - SQL: Go
database/sql,sqlc,sqlx,gorm→ Rustsqlx,sea-orm,diesel - Migrations: Go
golang-migrate,goose→ Rustsqlx migrate,refinery - JSON: Go
encoding/json,sonic,goccy/go-json→ Rustserde+serde_json - Logging: Go
log/slog,zerolog,zap→ Rusttracing+tracing-subscriber - Metrics: Go
prometheus/client_golang→ Rustmetrics+metrics-exporter-prometheus - Config: Go
viper,koanf→ Rustconfig/ config-rs,figment - CLI: Go
cobra,urfave/cli→ Rustclapderive - Errors: Go
errors,pkg/errors→ Rust ライブラリ向けthiserror、バイナリ向けanyhow - Testing: Go
testing,testify,gomega→ Rust 組み込み#[test],rstest,assert_matches - Mocking: Go
mockgen,moq→ Rust では手書きの fake が慣用的で、mockallも使われる - Background tasks: Go goroutines +
errgroup→ Rusttokio::spawn+JoinSet
- HTTP server: Go
- 典型的なバックエンドサービスでは、
axum+sqlx+tokio+tracing+serde+clapの組み合わせで必要なものの 90% をカバーできるとされる
借用チェッカーと学習曲線
- GoからRustへ移るなら、壁にぶつかることを前提にしておくべき
- Goランタイムはメモリとエイリアシング(aliasing)を肩代わりしてくれるが、Rustはその判断を型システムへ移すため、最初の数週間は「当然動くはず」のコードがコンパイラに拒否されることがある
- Go開発者がよく直面するパターン:
- 長生きする参照: Goではマップから取り出した
*Userを長く保持していても自然だが、Rustではその借用が生きている間はマップの変更が妨げられる - 自己参照構造体: Goではデータとその上のイテレータを同じ構造体に入れられるが、Rustでは
Pin、ouroboros、または再設計が必要になる - goroutine間での可変状態の共有: Goの
mu sync.Mutex; data map[K]Vパターンは、RustではArc<Mutex<HashMap<K, V>>>の形になる - 関数から参照を返す: ライフタイム注釈が登場し、Go開発者にとっては新しい概念になる
- 長生きする参照: Goではマップから取り出した
- 借用チェッカーは邪魔をする「門番」ではなく、実在するバグをあぶり出す仕組みとして捉えるべき
- 値がムーブされた後に再利用されたり、複数スレッドが同じデータを同時に触ったり、null・ダングリングポインタを逆参照したり、参照が値より長生きしたりするケースをコンパイル時に排除してくれる
- 借用の概念を内面化すると、戦う相手ではなく協力者へと変わり、熟練したRust開発者はたいてい4〜12週間ほどで借用チェッカーが助けになったと語る
- PubNub CTOのStephen Blumは Rustacean Station で、最初の1か月を「初めてプログラミングを学んだときのようだった」と表現し、借用チェッカーとライフタイムに強制的に向き合わされたと話している
clapメンテナのEd Pageは Rustacean Station: clap with Ed Page で、借用チェッカーのおかげで高水準の問題に集中でき、自分の分析では見落とした部分まで拾ってくれたと述べている
Rust移行の主な難所
-
コンパイル時間
- Rustのコンパイル時間はGoに比べて明確な後退と受け止めるべきで、中規模サービスのクリーンなリリースビルドでは、Goのほぼ即時なコンパイルと違って数分かかることがある
- インクリメンタルビルドと
cargo checkは妥当な対策であり、コンパイル時間も年々改善されているが、Goとの差は体感できる - 編集ループでは
cargo checkを使い、効果が出るタイミングでワークスペースに分割し、プロシージャルマクロの多いクレートは別クレートとして維持して、変更時だけ再コンパイルされるようにする - 詳しくは Rustのコンパイル時間を短縮するヒント を参照できる
-
asyncの色付け問題
- Rustの
async fn/fnの分離は、Goから移る際の最大級の使い勝手の後退の1つ - async trait はRust 1.75から安定化されたが、動的ディスパッチと組み合わせると今でも荒い部分がある
- 状況によっては、こうした部分を覆い隠すために
async-traitクレートを使うことになる
- Rustの
-
より小さいエコシステム
- Rustのクレートエコシステムは成長中で、ライブラリの品質も全体として高いが、一部のバックエンド周辺領域ではGoが先行している
- Goが先行している領域 には、Kubernetesオペレーター、クラウドプロバイダーSDK、特定のニッチなストレージ向けデータベースドライバが含まれる
- 移行を確定する前に1日ほど時間を取り、依存しているライブラリに使えるRustの代替があるか確認すべき
- チームによっては、放置されたXMLスキーマ検証クレートを更新したり、あまり知られていないプロトコルのクライアントを自作したりする必要があるかもしれない
統合戦略
- GoからRustへの成功した移行は、一度にすべてを書き直すことではなく、戦術的な選択に近い
- Microsoft Principal EngineerのVictor Ciuraは Rust in Production で、「面白半分ですべてをRustに書き直すのではなく、新しいコンポーネントがRustにより適しているならRustでやるという戦術的な選択だ」と語っている
-
1. ホットパスをサービスとして切り出す
- 特定のサービスが継続的に問題を起こしているなら、同じAPI契約の背後に置いたまま、そのサービスだけをRustで書き直すのが最も低リスクな移行になる
- 対象は、CPU使用率が高い、レイテンシに敏感、安定性の問題が繰り返し起きるといったサービスになりうる
- 他のGoサービスは引き続きHTTP/gRPCで通信するため、内部実装言語を意識する必要がない
- Radar CTOのJeff Kaoは Rust in Production で、DiscordのGoからRustへの移行記事 がRadarでも同じ試みを考えるきっかけになったと述べている
-
2. サイドカーやワーカープロセスを置き換える
- バックグラウンドワーカー、キューコンシューマー、収集パイプライン、CPUバウンドなバッチ処理は良い最初の対象になる
- たいていはキューやトピックのような明確な入出力境界があり、システムの他部分とインプロセスの共有状態を持たない
-
3. cgoは可能だがつらい
- Goからcgo経由でRustを呼び出すことはでき、それを扱う良いガイド もある
- ただしバックエンドサービスでは通常おすすめされない
- ビルドの複雑さとFFIオーバーヘッドが、「Rustサービスを立ててネットワーク呼び出しの背後に置く」方式よりも利点を打ち消してしまうことが多い
- ライブラリやCLIツールでは、より現実的な選択肢になりうる
-
4. ゲートウェイの背後でStrangler Patternを適用する
- APIゲートウェイやリバースプロキシがあるなら、特定のエンドポイントだけを新しいRustサービスへルーティングし、それ以外はGoに残せる
- 認証、検索、決済のように、1つの境界づけられたコンテキストが移行単位として適している場合に特によく合う
- このパターンは、新しいサービスが既存サービスの周囲で育ち、最終的に完全に置き換えることから、「strangler fig」 と呼ばれる
実践的な移行のコツ
- 明確な境界を持つサービス から始めるべきで、最も中核的で最も頻繁にデプロイされるサービスを選んではいけない
- システムの他部分との契約が明確に定義されており、影響範囲が小さいサービスを選ぶべき
-
同じAPI契約を維持する
- GoサービスがREST APIを公開しているなら、Rustサービスも同じパス、同じJSON形式、同じエラーラッパーを維持すべき
- クライアントからは移行が見えず、ゲートウェイでトラフィックを段階的に切り替えられる
-
イディオムを文字どおり移植しない
if err != nil { return err }は?になる- リクエストごとのgoroutineパターンは、本当に必要なときだけ
tokio::spawnに置き換える axumはすでにリクエストを並行処理する- メソッド1つだけのインターフェースは、たいてい
Box<dyn Trait>ではなく、ジェネリクスのtrait boundになる
-
コンパイラをペアプログラマのように使う
- Rustコンパイラのエラーは概して品質が高く、ゆっくり読めばほぼ常に正しい答えを示してくれる
- 最も長く苦戦するチームメンバーは、コンパイラを協力者ではなく戦う相手と見なす人たち
-
初期にトレーニングへ投資する
- Rust移行を「片手間で」学びながら進めると、うまく終わらないことが多い
- ワークショップ、オンラインコース、実際のコードベースでのペアセッションのように、学習時間を実際に確保する必要がある
- チームが習熟すれば、先行投資は何倍にもなって返ってくる
Goが引き続き適している領域
- すべてをRustに移す必要はなく、Goが特に向いている領域がある
-
Kubernetesネイティブなツール
- オペレーター、コントローラー、CRDの領域は、エコシステムが圧倒的にGo中心である
-
CLIユーティリティと開発ツール
- コンパイルが速く、クロスコンパイルが容易で、デプロイが単純なことが強みである
-
グルーサービス
- 薄いAPIレイヤー、プロキシ、フォーマット変換器では、Rustのボイラープレート比率は見合わない場合がある
-
チームのスピードが絶対的な正確性保証より重要な場所
- 迅速に動く必要がある領域では、Goが引き続き適している可能性がある
- CanonicalのVP of EngineeringであるJon Seagerは、Rust in Productionで、Goはネットワーキングサービスに非常に良い選択肢であり、CanonicalにはGoが多く、Jujuも巨大なGoコードベースだと述べている
- ハイブリッド戦略は一般的で、多くのチームは「退屈な」サービスにはGoを、安定性と性能が追加の労力を回収できるサービスにはRustを使う多言語バックエンドに行き着く
期待できる改善
- 数値はワークロードによって大きく異なるため、約束ではなくおおまかなガイドとして見るべきである
- GoからRustへの移行で観察された、おおよその改善幅:
- CPU使用量: 20〜60%減少
- Goはすでに効率的なため、PythonからRustへ移すときほど劇的ではない
- GCがないことと、より引き締まったループから利点が生まれる
- メモリ: 30〜50%減少
- 主にGCオーバーヘッドがなく、ランタイムがより小さいためである
- P99レイテンシ: はるかに一貫性が高い
- Rustサービスは、Goサービスで見られるGC起因のジッターが減り、平坦化する傾向がある
- Goでも低レイテンシGCの導入以降かなり改善されたが、高負荷時には差が残っている
- 本番障害: チームが最も積極的に報告する改善領域である
go test -raceを通過して本番まで到達するデータレース、nilデリファレンス、見落とされたエラーパスのようなバグの種類は、Rustではコンパイルされない- Rustへの移行後、オンコール当番は概してとても退屈になる
- CPU使用量: 20〜60%減少
- InfluxDataのStaff EngineerであるAndrew Lambは、Rustacean Station: Rebuilding InfluxDB with Rustで、InfluxDBの再実装後はクラッシュや奇妙なマルチスレッドのレース条件、以前は多くの時間を食っていた問題を追跡する必要がなくなったと述べている
- GoからRustへ移したからといって、PythonからRustへ移したときのようにスループットが10倍改善する可能性は低い
- 実際の利点は、「ばかげたエラー」の減少、より平坦なレイテンシテール、同じ言語で組み込み開発やシステムプログラミングのような他領域まで広げられる能力にある
補足の注意事項
- Rustの型システムはすべての同期ロジックのバグをなくすわけではないが、同期なしでスレッド間共有できない型はコンパイルされない
- 「ロックし忘れた」が静かなデータ破損につながる種類の問題は、Rustの型システムが防げる
- Goの
stringは不変なバイト列で、慣例的にはUTF-8だが、型レベルでは保証されていない - 最も近い対応関係は、読み取り専用ビューの観点ではGoの
string↔ Rustの&str、可変バッファの観点ではGoの[]byte↔ RustのVec<u8>である - Rustの
Stringは&strの所有権を持つ拡張可能なバージョンであり、内容が有効なUTF-8であるという追加の保証がある - 詳しくは Strings, bytes, runes and characters in Go を参照できる
- Go 1.18からジェネリック関数とジェネリック型は利用可能だが、メソッド自体の型パラメータは導入されていない
- Rustの
(0..100).filter(|n| ...).collect()のようなイテレータチェーンはGo開発者にはなじみが薄いかもしれないが、Rustでもforループは使え、一度きりのコードではしばしば正しい選択である
結論
- GoからRustへの移行は、PythonやTypeScriptからRustへ移る場合とは異なる
- Go出身の開発者はすでに静的型付けと言語コンパイルの利点を理解しているため、動的型付けや遅いランタイムを捨てる移行ではない
- 核心となるトレードオフは、
nilを手放す代わりに、より堅牢なコードベース、より少ない落とし穴、コンパイル時により多くのミスを検出する厳格なコンパイラを得ることである - その代わり、学習曲線はより急である
- 基盤サービスのように、組織が依存し、高い可用性が必要で、ビジネス上重要なサービスでは、このトレードオフには明確な価値がある
- 他のサービスでは、Goが依然として正しい答えである可能性がある
- マイグレーションの目的は、各問題を最もよく解決できる言語に配置することである
まだコメントはありません。