3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NestJSで作った非同期WorkerをRustで書き直して「爆速・省エネ」を証明してみた

Last updated at Posted at 2026-01-06

1. はじめに

バックエンド開発において、Node.js(TypeScript/NestJS)は開発スピードが速く、非常に強力な選択肢です。しかし、大量のデータ処理やCPU負荷の高いタスクに直面したとき、インフラコストやパフォーマンスの限界を感じることもあります。

そこで今回、同じSQSキューを監視し、同じDB/ストレージを操作する「Node.js版」と「Rust版」のWorkerを共存させ、その性能差を実機で検証してみました。
image.png

2. アーキテクチャ構成

システムの全体像は以下の通りです。

[ Browser ]
   ↓
[ Next.js (frontend / BFF) ]
   ↓
[ NestJS API (backend/api) ]  ← Producer
   ↓
[ SQS (LocalStack) ]
   ↓
   ├─→ [ NestJS Worker ] ← Consumer A
   │   ↓
   └─→ [ Rust Worker ] ← Consumer B
       ↓
   [ PostgreSQL & Local Storage ]

Producer: NestJS API(SQSにジョブを投入)
Queue: AWS SQS (LocalStack)
Consumer A: NestJS Worker
Consumer B: Rust Worker (AWS SDK for Rust + tokio-postgres)
Storage: PostgreSQL & Local Storage

「同じメッセージを先に奪い取ったほうが処理する」という競争コンシューマパターンを採用し、同一条件下での挙動を観察しました。

3. 実装のポイント(Rust版)

Rustでの実装において、特に意識したのは以下の点です。

3.1 非同期ランタイム(Tokio)

Node.jsのイベントループに近い操作感を持ちつつ、スレッドを最大限に活用します。

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    // PostgreSQL接続
    let (pg_client, connection) = tokio_postgres::connect(&database_url, NoTls).await?;
    
    // 接続を別タスクで管理(Node.jsのイベントループと同様)
    tokio::spawn(async move {
        if let Err(e) = connection.await {
            eprintln!("PostgreSQL connection error: {}", e);
        }
    });

    // SQSクライアントの初期化
    let sqs_client = Client::new(&shared_config);

    // メインループ:ロングポーリングでメッセージを受信
    loop {
        match sqs_client.receive_message()
            .queue_url(&queue_url)
            .wait_time_seconds(20)  // ロングポーリング
            .max_number_of_messages(1)
            .send()
            .await
        {
            Ok(rcv_output) => {
                let messages = rcv_output.messages();
                for message in messages {
                    // メッセージ処理...
                }
            }
            Err(e) => {
                eprintln!("Error receiving message: {}", e);
                tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
            }
        }
    }
}

ポイント: tokio::spawnで接続管理を分離し、メインループをブロックしない設計にしています。Node.jsのsetImmediateprocess.nextTickに相当する非同期処理が可能です。

3.2 厳格な型安全: serde を用いたJSONデシリアライズ

不正なメッセージを入り口で遮断します。

use serde::Deserialize;

#[derive(Deserialize)]
struct ReportMessage {
    #[serde(rename = "reportId")]  // JSONのキー名をRustの命名規則に変換
    report_id: String,
    name: String,
}

async fn process_message(
    msg: &Message,
    pg_client: &PgClient,
    storage_path: &str,
) -> Result<(), Box<dyn Error>> {
    // JSONパース時に型チェックが入る
    let body = msg.body()
        .ok_or("Message body is missing")?;
    let report_msg: ReportMessage = serde_json::from_str(body)?;
    // ↑ ここで型が合わない場合は即座にエラーになる
    
    // 以降、report_msg.report_id と report_msg.name は型安全に使用可能
    let report_id = &report_msg.report_id;
    let name = &report_msg.name;
    
    // ...
}

ポイント: TypeScriptの型チェックは実行時には消えますが、Rustの型チェックはコンパイル時に確実に検証されます。不正なJSONが来た瞬間にエラーになり、クラッシュを防げます。

3.3 所有権の活用: メモリコピーを最小限に抑制

文字列の結合やファイル書き込みにおいて、メモリ効率を最適化します。

fn generate_csv(name: &str) -> Result<String, Box<dyn Error>> {
    // String::with_capacity で事前にメモリを確保(再アロケーションを防ぐ)
    let mut csv = String::from("ID,Name,Value,Date\n");
    
    for i in 1..=1000 {
        let value = rand::random::<u32>() % 1000;
        let date = chrono::Utc::now().to_rfc3339();
        let item_name = format!("{} Item {}", name, i);
        
        // push_str は既存のStringに追記(新しいStringを作らない)
        csv.push_str(&format!("{},{},{},{}\n", i, item_name, value, date));
    }
    
    Ok(csv)
}

// ファイル書き込みも所有権を活用
let file_path = format!("{}/{}.csv", storage_path, report_id);
match generate_csv(name) {
    Ok(csv_content) => {
        // fs::write は所有権を取るため、メモリコピーが発生しない
        if let Err(e) = fs::write(&file_path, csv_content) {
            // エラーハンドリング...
        }
    }
    Err(e) => {
        // エラーハンドリング...
    }
}

ポイント: Node.jsでは文字列結合のたびに新しいオブジェクトが作られますが、Rustでは所有権システムにより、不要なコピーを避けられます。特に1000行のCSV生成では、この差が顕著に現れます。

3.4 エラーハンドリング: Result型による明示的なエラー処理

// エラーが発生する可能性のある処理はすべてResult型で表現
async fn process_message(
    msg: &Message,
    pg_client: &PgClient,
    storage_path: &str,
) -> Result<(), Box<dyn Error>> {
    // 各ステップでエラーチェック
    if let Err(e) = pg_client.execute(
        "UPDATE reports SET status = $1 WHERE id = $2",
        &[&"PROCESSING", &report_id],
    ).await {
        eprintln!("Error updating status to PROCESSING: {}", e);
        return Err(e.into());  // 早期リターンでエラーを伝播
    }
    
    // CSV生成とファイル保存
    match generate_csv(name) {
        Ok(csv_content) => {
            // 成功時の処理
        }
        Err(e) => {
            // エラー時はDBのステータスをERRORに更新
            let _ = pg_client.execute(
                "UPDATE reports SET status = $1 WHERE id = $2",
                &[&"ERROR", &report_id],
            ).await;
            return Err(e);
        }
    }
    
    Ok(())
}

ポイント: RustのResult型により、エラーが発生する可能性のある処理を明示的に扱えます。Node.jsのtry-catchと異なり、コンパイル時にエラーハンドリングの漏れを検出できます。

4. 実装のポイント(NestJS版との比較)

4.1 メッセージ処理の比較

NestJS版:

private async processMessage(message: any) {
  try {
    const body = JSON.parse(message.Body);
    const { reportId, name } = body;
    
    // ステータス更新
    await this.databaseService.query(
      'UPDATE reports SET status = $1 WHERE id = $2',
      ['PROCESSING', reportId]
    );
    
    // CSV生成
    const csv = this.generateCsv(name);
    const filePath = `${this.storagePath}/${reportId}.csv`;
    await fs.promises.writeFile(filePath, csv);
    
    // 完了
    await this.databaseService.query(
      'UPDATE reports SET status = $1, completed_at = $2, file_path = $3 WHERE id = $4',
      ['DONE', new Date(), filePath, reportId]
    );
  } catch (error) {
    console.error('Error processing message:', error);
    // エラー処理...
  }
}

Rust版:

async fn process_message(
    msg: &Message,
    pg_client: &PgClient,
    storage_path: &str,
) -> Result<(), Box<dyn Error>> {
    // 型安全なJSONパース
    let body = msg.body().ok_or("Message body is missing")?;
    let report_msg: ReportMessage = serde_json::from_str(body)?;
    
    // 所有権を活用した効率的な処理
    let report_id = &report_msg.report_id;
    let name = &report_msg.name;
    
    // エラーハンドリングが明示的
    pg_client.execute(
        "UPDATE reports SET status = $1 WHERE id = $2",
        &[&"PROCESSING", &report_id],
    ).await?;  // ?演算子でエラーを自動的に伝播
    
    // CSV生成(メモリ効率的)
    let csv_content = generate_csv(name)?;
    let file_path = format!("{}/{}.csv", storage_path, report_id);
    fs::write(&file_path, csv_content)?;
    
    // 完了
    let now = Utc::now().naive_utc();
    pg_client.execute(
        "UPDATE reports SET status = $1, completed_at = $2, file_path = $3 WHERE id = $4",
        &[&"DONE", &now, &file_path, &report_id],
    ).await?;
    
    Ok(())
}

主な違い:

  • 型安全性: Rustはコンパイル時に型チェック、TypeScriptは実行時に型チェック
  • エラーハンドリング: RustのResult型は明示的、TypeScriptのtry-catchは暗黙的
  • メモリ管理: Rustの所有権システムにより、不要なコピーを回避

5. 衝撃の検証結果:docker stats で見る性能差

システムを稼働させ、レポート生成を(ボタン連打!!!)連続実行した際のメトリクスがこちらです。

項目 NestJS Worker Rust Worker 性能差
CPU使用率 3.16% 〜 7.61% 0.20% 〜 1.13% 約7〜15倍の効率
メモリ使用量 286.6 MiB 274.1 MiB Rustが安定
BLOCK I/O 49 MB / 1.33 MB 342 MB / 28 MB 処理完遂数の差

5.1 考察

CPUの「余裕」: Rustはアイドリング時やパース時のCPU負荷が極めて低く、同じCPUリソースであればRustの方が圧倒的に多くのタスクを並列処理できることが分かります。

スループットの差: BLOCK I/O(書き込み量)に大きな差が出たのは、Rust版の処理が速すぎて、同じ時間内にSQSからより多くのメッセージを奪い取って処理を完了させたためです。

メモリ効率: 初回起動時はRustの方がメモリを多く使いますが(コンパイル済みバイナリの読み込み)、実行中のメモリ使用量は安定しています。

5.2 実際の動作確認

# 両方のWorkerを起動して、メッセージを送信
docker-compose -f docker-compose.local.yml up -d

# メトリクスを監視
docker stats

# レポートを連続生成
# (フロントエンドから複数のレポートを作成)

# 結果:Rust Workerがより多くのメッセージを処理

平常時のdockerのステータスです👇(2つのworkerの差が歴然)
image.png

6. まとめ:適材適所のバックエンド開発

今回の検証を通じて、「開発スピードのNode.js」と「圧倒的パフォーマンスのRust」を共存させるメリットを実感しました。

  • Node.js: ビジネスロジックの変更が激しいAPIやBFFに
  • Rust: レポート生成、画像処理、大量集計など、リソースを食うWorker処理に

このように特定のボトルネックだけをRustに切り出す**「ストラングラー・パターン」**的なアプローチは、既存のTypeScript資産を活かしつつ、システムを強化できる非常に現実的な戦略だと言えます。

6.1 実務での活用シーン

  • 既存システムの最適化: 巨大なNestJSシステムがあるが、特定の「レポート生成」だけが重すぎる
  • カナリアリリース: 新しく作ったRust版Workerを1台だけ混ぜて、全体の数%だけ処理させてみる
  • コスト削減: 同じ処理量を少ないリソースで処理できるため、インフラコストを削減

7. 補足:今後の展望

今後は、Node.jsのイベントループの仕組みをさらに深掘りしつつ、Rust側では「ストリーム処理」を導入して、数百万行のCSV生成におけるメモリ消費量をさらに最適化していく予定です。

7.1 ストリーム処理の実装例(予定)

use tokio::io::{AsyncWriteExt, BufWriter};
use tokio::fs::File;

async fn generate_csv_streaming(
    name: &str,
    file_path: &str,
) -> Result<(), Box<dyn Error>> {
    let file = File::create(file_path).await?;
    let mut writer = BufWriter::new(file);
    
    // ヘッダーを書き込み
    writer.write_all(b"ID,Name,Value,Date\n").await?;
    
    // ストリームで1行ずつ書き込み(メモリ効率が良い)
    for i in 1..=1_000_000 {
        let value = rand::random::<u32>() % 1000;
        let date = chrono::Utc::now().to_rfc3339();
        let line = format!("{},{},Item {},{}\n", i, name, i, value, date);
        writer.write_all(line.as_bytes()).await?;
        
        // 一定行数ごとにフラッシュ
        if i % 10000 == 0 {
            writer.flush().await?;
        }
    }
    
    writer.flush().await?;
    Ok(())
}

この実装により、数百万行のCSV生成でもメモリ使用量を一定に保てます。

8. 参考資料


3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?