1. はじめに
バックエンド開発において、Node.js(TypeScript/NestJS)は開発スピードが速く、非常に強力な選択肢です。しかし、大量のデータ処理やCPU負荷の高いタスクに直面したとき、インフラコストやパフォーマンスの限界を感じることもあります。
そこで今回、同じSQSキューを監視し、同じDB/ストレージを操作する「Node.js版」と「Rust版」のWorkerを共存させ、その性能差を実機で検証してみました。

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のsetImmediateやprocess.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の差が歴然)

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生成でもメモリ使用量を一定に保てます。