Rustを6ヶ月本番投入して分かった、誰も教えてくれなかった現実

「Goでよくない?」から始まったRust本番導入。所有権エラーで心が折れかけた話から、6ヶ月運用して気づいた本当のメリット・デメリットまで正直に書きます。

去年の秋、うちのチームで新規の高スループットAPIをRustで書くことになった。正直最初は「Goでよくない?」って思ってたんですよね。でも6ヶ月運用してみると、Rustへの印象が根本から変わった。良い意味でも、悪い意味でも。

これは「Rustって何?」という入門的な話じゃなくて、「実際に本番で使うとどうなるか」を正直に書いたものだ。以前にGoについて書いた記事を読んでくれた方から「Rustも試してみたいけど怖い」という反応が多かったので、その疑問に答えるつもりで書く。

Rustを触り始めて最初の2週間で心が折れかけた話

最初にぶつかる壁は誰でも同じで、所有権(Ownership)と借用(Borrowing)だ。コンパイラが「この値はすでに移動された」「借用がまだ残ってる」と怒ってくる。GoやPythonに慣れきった脳みそには本当にきつい。

具体的にどういうエラーと戦ったか、最初期のコードを晒す。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // s1の所有権がs2に移動
    
    println!("{}", s1); // コンパイルエラー: s1はもう使えない
}
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:20
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`
3 |     let s2 = s1;
  |              -- value moved here
5 |     println!("{}", s1);
  |                    ^^ value borrowed here after move

このエラーを最初に見たとき「何言ってるの?」ってなる。でもこれ、ガベージコレクターなしでメモリ安全を保証するための根本的な仕組みなんですよね。理解できると「なるほど天才的だな」ってなる。ただし、なるまでに1週間くらいかかる(個人差あり)。

実務で一番使うのは clone() による明示的なコピーと、参照の借用だ。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // 明示的にヒープのデータをコピー
    
    println!("s1={}, s2={}", s1, s2); // 両方使える
}

// 参照で借用する場合(所有権を移動させない)
fn calculate_length(s: &String) -> usize {
    s.len()
}

fn main_2() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // 参照を渡す
    
    println!("'{}'の長さは{}", s1, len); // s1はまだ使える
}

clone() を多用するとパフォーマンスが落ちるから避けろ」みたいな記事をよく見るけど、入門段階で気にしすぎる必要はないと思う。まず動くコードを書いて、プロファイリングして問題になったら最適化する、という順番が現実的だ。

非同期処理(Tokio)でハマった3つのパターン

本番のAPIはほぼ全部非同期で書くことになる。RustではTokioというランタイムが事実上の標準で、2026年時点でも選択肢はTokio一強だ。基本はこんな感じ。

# Cargo.toml
[dependencies]
tokio = { version = "1.40", features = ["full"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use axum::{
    routing::get,
    Json, Router,
};
use serde::Serialize;

#[derive(Serialize)]
struct HealthResponse {
    status: String,
    version: String,
}

async fn health_check() -> Json<HealthResponse> {
    Json(HealthResponse {
        status: "ok".to_string(),
        version: "1.0.0".to_string(),
    })
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/health", get(health_check));
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    
    println!("Server running on port 3000");
    axum::serve(listener, app).await.unwrap();
}

Axumは2026年時点でv0.8系が安定していて、Actix-webと並ぶ定番フレームワークだ。チームはAxumを選んだけど、正直どちらを選んでも大差ない印象で、ここは好みが分かれるかもしれない。

で、実際にハマったパターンを3つ紹介する。

ハマり1:async クロージャのライフタイム問題

// これはコンパイルエラーになる
let data = vec![1, 2, 3];
let task = tokio::spawn(async {
    println!("{:?}", data); // dataの所有権を非同期タスクに移動できない
});
// move キーワードで所有権を移動する
let data = vec![1, 2, 3];
let task = tokio::spawn(async move {
    println!("{:?}", data); // OK: dataの所有権がタスクに移動
});

非同期タスクをspawnするときは async move がほぼ必須だ。最初にこれを知らなくて30分溶かした。

ハマり2:Arcでの共有状態管理

APIハンドラー間でDB接続プールを共有する場合、こういう構成になる。

use std::sync::Arc;
use axum::extract::State;

#[derive(Clone)]
struct AppState {
    db_pool: Arc<sqlx::PgPool>, // Arcで参照カウント管理
}

async fn get_user(
    State(state): State<AppState>,
    axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<User>, StatusCode> {
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(id)
        .fetch_one(&*state.db_pool)
        .await
        .map_err(|_| StatusCode::NOT_FOUND)?;
    
    Ok(Json(user))
}

Goだと何も考えずに構造体のポインタを渡せばいいんだけど、Rustでは Arc(Atomic Reference Counted)を使って複数スレッドからの参照を安全に管理する必要がある。最初は冗長に感じるけど、慣れると「なぜここで共有してるか」が明示されて逆に読みやすくなるんですよね。

ハマり3:エラー処理の ? 演算子

use anyhow::Result;

async fn fetch_user_data(id: i64) -> Result<UserData> {
    let db_result = query_db(id).await?;   // エラーなら即return
    let cache = get_from_cache(id).await?; // ここも同様
    
    Ok(UserData {
        db: db_result,
        cache,
    })
}

? 演算子は Result<T, E> を返す関数の中でエラーを簡潔に伝播できる。anyhow クレートと組み合わせると、複数のエラー型を統一的に扱えてかなり楽になる。これは地味に便利で、最初に覚えると一気に書きやすくなった。

GoとRust、どっちをいつ選ぶか問題

6ヶ月本番で使った結果、「Rustは万能じゃない」というのが正直な感想だ。使い分けを整理するとこうなる。

観点GoRust
学習コスト低い(1〜2週間で基本習得)高い(1〜2ヶ月で基本習得)
コンパイル速度速い遅い(大規模では顕著)
実行時パフォーマンス高い最高レベル(C相当)
メモリ使用量GCあり・多少多いGCなし・最小
並行処理goroutine・シンプルasync/await・型安全
エコシステム成熟度成熟成長中(2026年でかなり成熟)
チームへの導入しやすさ高い低〜中(事前トレーニング必要)
メモリ安全性GCによる保証コンパイル時保証

うちのチームが出した結論は「マイクロサービスのデータ変換レイヤーやパフォーマンスが極限まで求められる部分にRust、通常のAPI開発やCLIツールはGo」という使い分けだ。Clean Architectureの実装でGoとRustを比較した記事でも触れているが、アーキテクチャ設計の観点でもどちらを選ぶかでアプローチが変わってくる。

xychart-beta
    title "Go vs Rust 本番運用スコア比較(高いほど良い)"
    x-axis ["学習コスト", "初期開発速度", "実行速度", "メモリ効率", "長期保守性"]
    y-axis "スコア" 0 --> 10
    bar [8, 9, 7, 7, 8]
    bar [4, 5, 10, 10, 9]

(棒グラフ左がGo、右がRust)

2026年時点のRustエコシステム、実際に使ってるクレート

2026年のRustエコシステムは2年前と比べてかなり成熟してきた印象だ。正直まだGoほどではないけど、実務で困ることはほとんどなくなってきた。うちのチームが実際に本番で使っているクレートをまとめるとこうなる。

[dependencies]
# Webフレームワーク
axum = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }

# 非同期ランタイム
tokio = { version = "1.40", features = ["full"] }

# データベース
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "macros"] }

# シリアライズ
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# エラーハンドリング
anyhow = "1"
thiserror = "2"

# ロギング
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json"] }

# HTTP クライアント
reqwest = { version = "0.12", features = ["json"] }

# 設定管理
config = "0.14"

特に sqlx は非同期対応のPostgreSQL/MySQLクライアントで、コンパイル時にSQLクエリの型チェックをしてくれる。「SQLのタイポで本番が落ちる」問題がなくなってマジで助かった。

// sqlxのコンパイル時クエリチェック
#[derive(sqlx::FromRow, serde::Serialize)]
struct User {
    id: i64,
    name: String,
    email: String,
    created_at: chrono::DateTime<chrono::Utc>,
}

async fn get_user_by_id(
    pool: &sqlx::PgPool,
    id: i64,
) -> Result<User, sqlx::Error> {
    // query_as! マクロがコンパイル時にSQLを検証
    sqlx::query_as!(
        User,
        "SELECT id, name, email, created_at FROM users WHERE id = $1",
        id
    )
    .fetch_one(pool)
    .await
}

ログは tracing + tracing-subscriber の組み合わせで構造化ログを出力している。ECS(Elastic Container Service)にデプロイしてCloudWatchでのモニタリングと組み合わせると、エラー追跡が格段に楽になった。

テストと本番デプロイの現実

Rustのテストはかなりやりやすい。標準の #[cfg(test)] でユニットテストをそのまま書けて、#[tokio::test] で非同期テストも対応できる。

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(2 + 2, 4);
    }

    #[tokio::test]
    async fn test_health_endpoint() {
        let app = create_app();
        let client = axum_test::TestClient::new(app);

        let response = client.get("/health").await;
        assert_eq!(response.status(), 200);
        
        let body: serde_json::Value = response.json().await;
        assert_eq!(body["status"], "ok");
    }

    // インテグレーションテスト(実DBを使う)
    #[sqlx::test]
    async fn test_create_user(pool: sqlx::PgPool) {
        let result = create_user(&pool, "test@example.com", "Test User").await;
        assert!(result.is_ok());
    }
}

sqlx::test アトリビュートはテスト用DBをトランザクション内で自動的にロールバックしてくれる。これも地味に便利で、テストが相互に干渉しなくなった。個人的にはGoのテストより書きやすいと感じてるくらいだ。

CIのフローはこんなイメージになる。

flowchart LR
    A[Push to branch] --> B[cargo fmt --check]
    B --> C[cargo clippy]
    C --> D[cargo test]
    D --> E[cargo build --release]
    E --> F[Docker build]
    F --> G[ECR push]
    G --> H[ECS deploy]

    style A fill:#f9f9f9
    style H fill:#4CAF50,color:#fff

cargo clippy は静的解析ツールで、コードレビューで毎回指摘されるようなパターンを自動検出してくれる。CIに組み込むと「Lintを通ってからレビューしてください」と言わなくてよくなるので、コードレビューの質が上がった。地味に人間関係にも優しい。

ビルドについて正直に言うと、Rustのコンパイルは遅い。CI環境でのフルビルドが3〜5分かかるので、キャッシュ設計が重要になってくる。

# GitHub Actions でのキャッシュ設定例
- name: Cache Rust dependencies
  uses: actions/cache@v4
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target/
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-cargo-

これをやるだけで2回目以降のビルドが3分→40秒程度になる。やらない手はないです。

パフォーマンスについては実測数値を出したいところだけど、アプリケーションに依存しすぎるので一概には言えない。ただ、同じロジックをGoで書いたAPIと比べてメモリ使用量が約40〜50%少なかったのは体感できた。

xychart-beta
    title "API メモリ使用量比較(同等の負荷・MB)"
    x-axis ["アイドル時", "低負荷(100 req/s)", "中負荷(1000 req/s)", "高負荷(5000 req/s)"]
    y-axis "メモリ使用量 MB" 0 --> 300
    bar [45, 55, 70, 95]
    bar [22, 28, 35, 48]

(棒グラフ左がGo、右がRust)

Rustの6ヶ月本番運用でも触れていたが、GCがないことによるメモリ効率の差は本番で明確に現れる。特にコンテナをたくさん立ち上げるマイクロサービス環境では、この差がコストに直結するんですよね。

まとめ

6ヶ月Rustを本番で使って気づいたことを整理する。

  • 所有権・借用はつらいけど報われる: 最初の2〜3週間はコンパイルエラーと格闘することになる。でも「メモリ安全をコンパイル時に保証する」というアイデアは天才的で、慣れると本当に安心して書けるようになる
  • ToolchainとEcosystemは2026年時点でかなり成熟: AxumとSQLxとTokioの組み合わせで、実務のWebAPIは普通に書ける。3年前に比べてドキュメントも格段に充実してきた
  • パフォーマンスは本物: GoやPythonと比べてメモリ使用量が圧倒的に少ない。大量のコンテナを立てるシステムほどコスト差が出る
  • チーム全体の習得は時間がかかる: 個人が習得するより、チームで使えるようになるほうが難しい。最低1〜2名がRust経験者じゃないと最初の半年はきつい
  • 「最初からRustで全部書く」は危険: 既存のGoやPythonのコードを全部Rustに書き換える必要はない。パフォーマンスが重要な部分や、メモリ安全性が求められるシステムプログラミング的な部分から始めるのがおすすめ

次のアクション: まず rustup をインストールして cargo new hello-world から始めよう。The Book(https://doc.rust-lang.org/book/)は無料で読めて品質が高い。AxumのサンプルREADMEを動かしながら覚えるのが一番早いと思う。

みなさんはRustを実務で使ってますか?「学習コストが高くて踏み出せない」という人が多い印象だけど、実際に触り始めると「なぜこう設計されているか」の論理が一貫していて、ストレスが少ないんですよね。もしRustへの疑問があればコメントで教えてください。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事