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が依然として正しい答えである可能性がある
- マイグレーションの目的は、各問題を最もよく解決できる言語に配置することである
4件のコメント
Rust の成長が停滞しているのか、新天地のように、Go を使う人たちが Rust へたくさん移っているかのような文章を書き回っているようだ。これは、20年前に Python ユーザーが Perl ユーザーに対してやっていたことだ。そして、それは成功していた。
tokio は、そろそろ標準ライブラリに入ってもいい頃なんじゃないかという気もしますね。
標準の非同期ランタイムを置かないこと自体が、むしろRustの設計だという話もあるようです。
Hacker Newsのコメント
ほぼRustしか使わないが、最後にRustでWebサーバー周りの作業をしたときは、Goを使えばよかったと感じた
原文はGoのエラー処理構文が冗長だと指摘しているが、これはその通り。Rustにも同じ問題があり、エラーならエラー値を返す
?構文が追加された。Goのエラー処理はたいていこれを展開して書いた形になっているRustには統一されたエラー型がなく、
io::Error、thiserror、anyhowのような主要なエラー体系があり、呼び出しチェーンに沿って上位へ伝播させるときに煩雑だ新しい言語では、最初に入っておらず後から付け足しにくいものがある。定数型、ブール型、エラー型、多次元配列型、2/3/4要素のベクトル・行列型と標準演算などがそうだ。初期に標準化しないと、同じ概念の複数の表現をすり合わせるのに多くの時間を使うことになる
エラー処理を除けばWeb開発では影響は小さいが、数値計算・グラフィックス・モデリングでは数値配列に標準演算を適用する必要があるため、大きな苦痛になる
GoがWebサービスで持つ利点は2つある。1つ目は原文が述べた goroutine で、2つ目は原文であまり触れられていないライブラリだ。GoにはWebサービスに必要なライブラリの大半があり、Google内部でも使われているものなので、非常に過酷な環境に耐えてきた。一方でRustのcrateは成熟度が低く、公式な品質保証がない場合が多い
またRustはGoと比べて、いまだに多くのC/C++ライブラリに依存しており、クロスコンパイルや再現可能なビルド、静的バイナリ生成が問題になりやすい
Goの欠点はガベージコレクタが単純すぎる点だ。レイテンシのスパイクが発生すると、つらい書き直し以外に対処手段があまりない
列挙されているものはそれを使う一般的な方法にすぎず、
Boxだけを使ってもまったく問題ない。これはおおむねanyhow::Errorがやっていることに近いただし標準ライブラリに関しては、GoはRustよりはるかによくできていると思う
Rustという言語は好きで、組み込みファームウェアやPCアプリケーションに使っているが、WebバックエンドはいまだにPythonを使っている。RustにはDjangoやRails級のツール群がないからだ
Flaskに似たものはあるが、Flaskの堅牢なエコシステムはない。Goの経験は少ないが、WebバックエンドならRustよりGoを選ぶと思う。理由はライブラリとフレームワークの エコシステム だ
また一般によく言われる理由から、Async Rustはあまり好きではない。RustのWebエコシステムはほぼすべて非同期利用が必須に近い
io::Errorは、これを実装している複数の型のうちの1つにすぎず、特別なものではない。thiserrorで定義したエラーもこの trait を実装する。anyhowは、関数が返しうるエラー型をAPI契約として細かく書きたくないときに、「何らかの Error」と気軽に表現できるようにしてくれるだけだRust は Go よりもコードを決定論的にしやすく、決定論的シミュレーションテストやプロパティベーステストが必要なときに非常に有用です
最近、Go で Postgres-to-Iceberg データミラーリングツール https://github.com/polynya-dev/pg2iceberg を書きましたが、Go ランタイムと格闘せずに決定論的シミュレーションテストを行いたくて Rust に移植しました
ただし、そのドメインがそこまでのテストを正当化するほど重要でないなら、いつでも Rust より Go を選びます
関連記事: https://www.polarsignals.com/blog/posts/2024/05/28/mostly-ds...
ありきたりで繰り返しに聞こえるかもしれないが、Rust に対する最大の不満は パッケージ管理 の状況で、これは完全に開発者の考え方の結果だと思う。
Rust 側の使い勝手は気に入っている。データ型に対する関数型アプローチは美しい。だが今、Rust プロジェクトと Go プロジェクトを並行して作業していて、依存関係ツリーがまったく別物だ。
Go プロジェクトはたいてい標準ライブラリで片付くが、Rust プロジェクトでは
rusqlite(sqlite)、clap(CLI)、ratatui(TUI)、tauri(GUI) だけを入れたつもりでも、依存関係が 400 個を超えているように見える。特にtauriが圧倒的な主犯で、それを外してもほぼ 100 個あるので狂っていると感じる。依存関係を妥当に扱う、よく整備された Rust crate の代替があればずっとましなのだが、まだ見つけられていない。システムに shai hulud を持ち込みたいわけではないだけなのに、Rust の Web 側の人たちはその点で
cargoをnpmのようなものにしたがっているように見える。多くの Rust ライブラリは複数の crate に分割されていて、それらがすべて依存グラフに入ることを考慮すべきだ。
だから依存関係の数が実際より多く見える。別 crate であっても、メンテナが同じで、同じ upstream Git リポジトリの一部であることが多い。
それでも全体的な感覚には同意する。Rust には 0.x バージョンで半ば放置された crate も多く、より良い代替がないこともよくある。
標準ライブラリ は良いアイデアが死にに行く場所だと思う。
そうして
httplib3の次にhttplib4が出てくる。つまり、自分は Rust のアプローチをずっと好む。標準ライブラリに依存しようが、別の依存関係に依存しようが、自分にとって大きな違いはない。どのみち依存関係だ。
標準ライブラリだから品質が高いとか、よりよく保守されると考えるのは、別の概念だ。
結局はすべてリソース次第だ。もちろん標準ライブラリのほうが多くのリソースを得られることはあるが、その一方で肥大化して保守不能になることもありうる。
rusqlite、clap、ratatui、tauriに相当するものを標準ライブラリにすべて備えた言語が、Java を除いて存在するのか疑問だ。また Tauri 自体が 14 個の crate で構成されていて、それぞれがビルドツリーに現れる点も見るべきだ。
https://github.com/tauri-apps/tauri/blob/dev/Cargo.toml
Ratatui も 6 個だ。
https://github.com/ratatui/ratatui/blob/main/Cargo.toml
パッケージ管理は、ほぼすべての言語と技術にとって頭痛の種だ。
これを「解決」できた者はおらず、今後も単一の解決策が出てくることは難しいと思う。
Go ではライブラリ開発者が セマンティックバージョニング を正確に守ると信じなければならず、バージョンを固定できない。これは個人的にもかなり気に障る点だ。
回避策はいくつかある。Git コミットハッシュのような SHA を使って擬似的なバージョンを作るか、既知の依存関係キャッシュである vendoring を使う方法だ。ただし vendoring にはキャッシュ管理の問題が伴う。
週末に Python の仮想環境を使う必要があったが、うまく終わらず、なぜ Python から離れたのかを改めて思い出した。
Perl の CPAN、Java の Maven/Gradle、Ruby の gems、Go の dep/glide/vgo/modules、Rust の Cargo、Node の npm/yarn など、どれも似たような問題を抱えている。
OS も Redhat の yum/rpm、Debian の apt、Ubuntu の snap といった具合。特に snap はいったいなぜああなっているのか分からない。
Go にはあまり詳しくないのだが、Go 標準ライブラリで Tauri に相当するもの が何なのか気になる。
ユースケースによっては、フロントエンドは引き続き Go のままにして、バックエンドだけ Rust にするのも理にかなっているのかもしれないと思った
この文章はマイグレーションガイドであると同時に Rust 擁護文書にもなろうとしていて、違和感がある
結局、Rust と Go のどちらを使うか悩むなら、核心はほぼ完全に「管理ランタイムを望むか」に尽きる
一世代の Rust プログラマーは、管理ランタイムは悪であり、存在しないことが重要な機能なのだと自分たちに言い聞かせてきた
しかし、これは明らかに間違っている。管理ランタイムを望むプログラミング領域のほうが、望まない領域より多い
だからといって、そのような場合すべてで Go を第一選択にすべきという意味ではない。Rust を好む主観的な理由も多い。Go を使っていると
matchは恋しくなるが、tokioと Async Rust は恋しくならないどちらも、問題空間を無理やりねじ曲げる必要がないほぼすべての場面で正当な選択だ。たとえば Go で Linux カーネルモジュールを書こうとするのは妙な選択だが
Rust 対 Go の争いは、この分野の奇妙で気恥ずかしい辺境のようなものだ。業界の大きな部分は Python や Node でシステム全体を問題なく作っており、どの静的型付きコンパイル言語を使うかで争う変わり者たちをあざ笑っている。本当の問いは Python 対 Rust/Go であって、Rust 対 Go ではない
Node を PureScript と一緒に使うのは悪くないかもしれない
しかし全体としては、Rust と Go 側は動的型付けの害悪に対抗して力を合わせるべきだ。型ヒントがいまやベストプラクティスと見なされているなら、それは実質的に欠陥だったと認めているようなものではないか
良い型ヒントがあっても、型推論には及ばない。型推論は、型を変更するときにも多くのコードをそのままにしておける一方で、意図しない型変更は防いでくれる
Node 側は静的コンパイル型を求めてTypeScriptを受け入れた
TS にはもう少しランタイムがあってほしい。Python でうらやましい唯一の点は、HTTP エンドポイントで JSON スキーマ検証をとても自然に行えることだ
Zod を通さなければならない手順は相変わらず苛立ちの種で、TS チームが教条的であることから生じた問題だと思う
LLM的な文章の痕跡はだんだん微妙になってきているが、それでもまだかなり目立つ。特に genuine という単語がそう。
「This is the area where Go genuinely shines, and it’s worth being precise about why」「the lack of GC pauses is a genuine selling point」「Humans are genuinely bad at reasoning about memory」「There are cases where the borrow checker is genuinely too strict」といった具合。
文章全体がAI生成だとは思わないが、AIの補助は受けていそう。だとしたら、筆者は genuinely うまくやったことになる。
他の人たちがこれに触れていないのを見ると、内容そのものを大きく損ねてはいないようだが、こういうことがますます一般的になって、しかも見分けにくくなっていくのは妙な感じがする
同意するが、なぜそう感じるのかはよく分からない。何がAI生成っぽく聞こえるのか正確には言えない。
「Go is clearly working for a lot of people,」あたりまで読んだところでAI補助ではないかと疑い始めた。もちろん違うかもしれないし、私は見分けるのが得意でもない。
具体的な手がかりというより、皮肉にも 感覚 に近い。ある文章がAI補助っぽく「聞こえる」と、文章自体が悪くなくてもすぐに興味を失ってしまう。
みんなが、自分の考えを頭に浮かぶ形のまま自分で書くことに、もっと気楽になってほしい
完全に話が逸れるが、
it's worth being precise about ...は genuine の使用 よりずっと強くAIっぽい表現だ文章全体がAI生成だと思う。筆者が下書きを入力として与えて、出力の一部を手直しした可能性はある。
たとえばこの段落を見るとそう思う: 「Go got generics in 1.18, and they’re useful, but the implementation has constraints (no methods with type parameters, GC shape stenciling, occasional surprising performance characteristics). Rust generics monomorphize, each instantiation produces specialized code with zero runtime cost. Combined with traits, this gives you real zero-cost abstractions.」
すべての文が何かを語っていて、すべての文が重要で、それぞれの役割を果たしている。こういう文章は、ブログ記事よりも非常に専門的な本や論文に期待するものだ。
だからこそ、かえって文章が読みにくく、退屈になっている
この1年ほど、LLMの文章は 表層 と、とりわけ 基盤層 について語りたがる傾向がやけに強いと感じている。
LLM生成テキストが陳腐な言い回しだらけでないことまでは期待していない。ただ、私たちみんながもう少し良い編集感覚を示して、同じ声を何度も読むことがないようになってほしい
新規プロジェクトなら、いくらでも Rust で書けばよい。
しかし既存コードがあり、動作していて収益を生んでいるシステムなら、書き直すべき部分だけを元の言語で直して継続するのが正しい。
自分たちが分かっている言語と信頼できるチームで、システムを小さく測定可能な形で改善せよ。それ以外は無駄な 宗教論争 だ。
ベンチマークを回す前から Rust は好きだったが、ほとんどの LLM における Rust と Go の記述効率の差は、思っていたよりはるかに大きかった。特に、初期環境の問題を修正できるエージェント型ハーネスではなおさらだった。
それを見て、かなり強い Rust 伝道者になった。既存コードベースから呼び出す バッチ処理ツール を Rust で書いて良い成果を得たが、本番環境全体のマイグレーションはまだ試していない。
記事で述べられている Go の問題、特に
nil処理に関する問題は、Codex で徹底的にコードレビューすれば徐々に解決されつつあると思う。そもそも問題がない方がよいが、設計や実装に注いだのと同じだけレビューと理解にも労力を払う開発者にとっては、こうしたセキュリティバグは次第に任意のものになりつつある。言語データは https://gertlabs.com/rankings?mode=agentic_coding にある。
Rust はユーザーを強く定められたレールの上に乗せる。Codex は常に何かしらコンパイルできるものを作り出す。
欠点は、慣用的なアプローチが不可能な場合には失敗すべきこともある一方で、コンパイルは通り要求も満たす愚かな実装を作れてしまう点だ。
LLM は人間より速くコードを書くため、相対的にコンパイル待ちの時間がより大きくなる。10 万行以上のある程度の規模のプロジェクトでは、Rust の約 10 倍遅いコンパイルがボトルネックとして現れ始める。
中核インフラを書くならそのコストを払う価値はあるが、インターネットに公開されない内部サービスを作るなら、開発速度の方がより大きな関心事かもしれない。
コンパイルの遅さは人間の開発速度にも影響すると考えているが、不思議なことに、開発者がこれを定量化しようとすることは非常に少ない。
冗長さが主な障害なら、Go 1.28 に入る予定のこれで大幅に減らせるかもしれない。
https://github.com/golang/go/issues/12854#issue-110104883
「組織が依存し、高い稼働率が必要で、事業に致命的なサービス」という表現が面白い。
その Rust サービス が Kubernetes 上で動いているときは特にそうだ
すでに Rust を使っていて Go の経験はないので、この記事は自分にとって完全に的を射た内容ではないかもしれない
ただ、ひとつ気になる点がある。Rust ではデータ競合が「コンパイル時に検出される」と言うのは、少なくとも少し誇張に感じられる
この表現だと、Rust が相互ロックの飢餓のようなものや、その他の並行性の問題まで扱えるかのような印象を与えかねない。実際にはそうではない
データ競合が範囲の狭い形式的な用語であることは分かっているが、それでももっと明確に書けると思う