はじめに
「スペース全体のエクスポート (数百 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 | 例 | 現時点 |
|---|---|---|
| 1 | Monolith CRUD | 採用 |
| 2 | Module 分割 (NestJS Module) | 採用 |
| 3 | 別 process (worker) | 採用 |
| 4 | イベント通信 (Pub/Sub) | 採用 (上限) |
| 5 | CQRS (read/write 分離) | 見送り |
| 6 | Event Sourcing | 見送り |
| 7 | Saga / 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 で十分です。