ブログ一覧に戻る
GCPCloud RunEventarcアーキテクチャEDA

Cloud Run Job + Eventarc で非同期処理: CQRS を採用しない判断

はじめに

「スペース全体のエクスポート (数百 MB の zip 生成)」のような重い非同期処理を実装するとき、最初に頭をよぎるのは:

  • CQRS (Command Query Responsibility Segregation): write/read を分離
  • Event Sourcing: state ではなく event を永続化
  • DDD bounded context: 業務領域を境界で囲む

これらは強力ですが、複雑度コストが高いです。私たちは意図的に 採用せず、Cloud Run Job + Eventarc + Pub/Sub の純粋な EDA (Event-Driven Architecture) で済ませました。

本記事では、3 つの architecture 候補を比較した判断と、最終採用した Cloud Run Job ベースの実装パターンを共有します。

機能要件

  • ユーザーが「スペース全体をエクスポート」ボタンを押す
  • 全テーブルを CSV 化 + zip → GCS に保存 → ダウンロード URL を通知
  • 処理時間: スペース規模により数秒〜数十分
  • 実行頻度: 月数回〜数十回程度 (退会前のデータ持ち出しが主用途)

検討した 3 案

案 A: in-app worker (NestJS 内で background 処理)

@Post("ws-export")
async startExport(@Body() body) {
  const job = await this.create(body);
  // 非同期で開始 (await しない)
  this.runInBackground(job.id).catch(err => /* log */);
  return { jobId: job.id };
}

メリット:

  • 実装最小、Cloud 依存なし
  • 既存 NestJS app に統合

デメリット:

  • API process が長時間 CPU/メモリ占有 → 他リクエストに影響
  • Cloud Run の deploy 時に途中で kill される (進行中 job が無効化)
  • timeout 制限 (Cloud Run 60 分上限) を超える可能性

案 B: HTTP polling worker (別 Cloud Run service)

API → DB に job 登録 → worker が 30 秒ごとに DB poll → 処理 → DB 更新 → API が status 返却

メリット:

  • API process から重い処理を分離
  • worker は別 service で deploy 独立

デメリット:

  • 30 秒の polling 遅延 + DB 負荷増
  • worker が常時起動 → コスト
  • 「DB を queue として使う」には lock 設計が必要で、私たちの要件では避けました

案 C: Cloud Run Job + Eventarc trigger (採用)

API → DB に job 登録 → Pub/Sub publish → Eventarc trigger → Cloud Run Job 起動 → 処理 → DB 更新 → API に通知

メリット:

  • Job の lifecycle は完全独立 (API deploy 影響なし)
  • 起動時のみ課金 (常駐コストゼロ)
  • Pub/Sub の delivery guarantee + Eventarc の retry policy
  • timeout 24 時間まで対応可

デメリット:

  • GCP 依存 (vendor lock-in)
  • 開発環境の構築複雑 (後述: emulator + HTTP mode で simulate)

なぜ CQRS / Event Sourcing を採用しなかったか

案 C を「EDA」と呼んでいますが、これは イベントを使った非同期通信 という意味であり、CQRS / Event Sourcing の thick な意味ではありません。

私たちは現時点の architecture complexity level の上限を Level 4 (= state ベース + イベント通信 + 別 service) に設定し、Level 5+ (CQRS / Event Sourcing / DDD bounded context) は現状の私たちの規模では不要と判断しています:

Level現時点
1Monolith CRUD採用
2Module 分割 (NestJS Module)採用
3別 process (worker)採用
4イベント通信 (Pub/Sub)採用 (上限)
5CQRS (read/write 分離)見送り
6Event Sourcing見送り
7Saga / Process Manager見送り

理由:

  • 複雑度が一段上がるごとに 学習コスト・デバッグコスト・テストコスト が指数関数的に増える
  • 私たちの業務ドメイン (給与・会計・勤怠) では state ベースで十分に表現可能
  • Event Sourcing が真価を発揮するのは「過去 state を再現したい」要件があるとき (= 私たちにはなく、監査ログで代替済)
  • CQRS は「read が write を遥かに上回るスケール」が前提 (= 現状の私たちの規模では不要)

「複雑度を上げる前に、本当にそれが必要か」を常に問い、上げないで済ませることを優先しています。

実装パターン (Cloud Run Job + Eventarc)

構成要素

┌─────────────────┐   POST /ws-export     ┌──────────────┐
│  Web client     │ ─────────────────────▶│  REST API    │
│  (Web UI)       │                        │  (NestJS)    │
└─────────────────┘                        └──────┬───────┘
                                                  │ insert job + publish
                                                  ▼
                                          ┌──────────────┐
                                          │  Pub/Sub     │
                                          │  topic       │
                                          └──────┬───────┘
                                                 │ Eventarc trigger
                                                 ▼
                                          ┌──────────────────┐
                                          │ Cloud Run Job    │
                                          │ ws-export-worker │
                                          │ (NestJS)         │
                                          └──────┬───────────┘
                                                 │ CSV + zip + upload
                                                 ▼
                                          ┌──────────────┐
                                          │  GCS bucket  │
                                          └──────┬───────┘
                                                 │ 完了通知
                                                 ▼
                                          ┌──────────────┐
                                          │  REST API    │
                                          │  (notify)    │
                                          └──────────────┘

API サーバー側 (publish)

@Post("ws-export")
async startExport(@Body() body, @CurrentUser() user) {
  // 1. DB に job 登録 (status=PENDING)
  const job = await this.db.insert(wsExportJobs).values({
    workspaceId: body.workspaceId,
    requestedBy: user.id,
    status: "PENDING",
  }).returning();

  // 2. Pub/Sub publish
  await this.pubsub.topic("ws-export").publishMessage({
    json: { jobId: job[0].id, workspaceId: body.workspaceId },
  });

  return { jobId: job[0].id };
}

Eventarc trigger (gcloud で作成)

gcloud eventarc triggers create ws-export-trigger \
  --location=asia-northeast1 \
  --destination-run-job=ws-export-worker \
  --destination-run-region=asia-northeast1 \
  --event-filters="type=google.cloud.pubsub.topic.v1.messagePublished" \
  --transport-topic=ws-export \
  --service-account=ws-export-trigger@$PROJECT.iam.gserviceaccount.com

ポイント:

  • Eventarc trigger は Terraform provider が一部未対応 のため gcloud で手動作成 (運用手順書に記録)
  • Pub/Sub message が来るたびに Job 起動

Cloud Run Job 側 (worker)

// export worker: main.ts
async function bootstrap() {
  const runMode = process.env.EXPORT_RUN_MODE ?? "job";

  if (runMode === "http") {
    // Dev mode: HTTP server (API サーバーから直接 POST で呼ぶ)
    const app = await NestFactory.create(AppModule);
    await app.listen(8083);
    return;
  }

  // Prod mode: Cloud Run Job (1 回起動 → 処理 → exit)
  const app = await NestFactory.createApplicationContext(AppModule);
  const jobId = process.env.JOB_ID; // Eventarc が message data を JOB_ID として注入
  try {
    await app.get(AppService).processJob(jobId);
    process.exit(0);
  } catch (err) {
    console.error(err);
    process.exit(1); // Cloud Run Job の taskRetryPolicy がリトライ
  }
}

ポイント:

  • 同じ code base で Dev (HTTP server) / Prod (Job) を切替
  • Job mode は NestFactory.createApplicationContext (HTTP server なし、DI のみ)
  • process.exit(1) で Cloud Run Job retry を発火 (Pub/Sub redelivery と独立)

Dev 環境の構築

Cloud Run Job + Eventarc は ローカル環境で再現困難 な GCP-managed service です。開発時は以下で simulate:

# docker-compose.yml
ws-export-worker:
  environment:
    - EXPORT_RUN_MODE=http # HTTP server として起動 (Job mode 不可)
    - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 # Pub/Sub emulator 使用

API サーバーは Pub/Sub emulator 経由で publish しつつ、worker は HTTP endpoint を listen します。本物の Eventarc trigger は無いため、API サーバーが直接 HTTP で worker を呼ぶフォールバックも持っています (Mode A):

if (process.env.PUBSUB_EMULATOR_HOST) {
  // Dev: Pub/Sub emulator に publish (worker は subscriber を持たないので、別途 HTTP fallback)
  await this.pubsub.publishMessage(...);
  await this.http.post(`http://ws-export-worker:8083/run`, { jobId });
} else {
  // Prod: Pub/Sub publish のみ (Eventarc trigger が拾う)
  await this.pubsub.publishMessage(...);
}

これにより:

  • Local: docker compose up で全部 simulate
  • STG / PRD: 本物の GCP managed services

監視と運用

  • Pub/Sub ack 失敗: Cloud Monitoring metric subscription/num_undelivered_messages で alert
  • Job 実行失敗: Cloud Run Job の logs を Sentry にも転送 (Sentry.captureException + flush(5000) 必須、Job 終了時に flush しないと event ロスト)
  • 長時間実行: Cloud Run Job の execution timeout (24 時間) 内で完了するよう、大規模なスペースは分割処理します

まとめ

観点評価
実装コスト中 (CQRS より遥かに低い)
運用コスト低 (起動時のみ課金、常駐ゼロ)
デバッグdev で再現困難 (HTTP mode で simulate)
スケールPub/Sub で自然にスケール
GCP lock-in強 (AWS 等への移植には書き換え必要)

CQRS / Event Sourcing を採用しなくても、Pub/Sub + Eventarc + Cloud Run Job だけで「重い非同期処理」「リトライ」「スケール」の要件は満たせます。

「複雑度を上げる前に、本当にそれが必要か」を常に問い、上げないで済ませることが、長期的なコード保守性に直結します。CQRS が必要になるのは「read >> write」「過去 state 再現」「Multi-bounded-context」のいずれかが本当に求められた時で、それまでは Level 4 で十分です。