概要
「抽出」と「送信」は責務も障害モードも別物
BigQueryクエリ実行とAPI呼び出しループを同一リクエストに同居させると、無関係な2つの制約(クエリ遅延とレート制限)が同じタイムアウト予算を奪い合う。target-extractorとnotification-workerに分離し、Pub/Subで疎結合にする。
「並列化が怖い」への答えは冪等性
min-instances=1, max-instances=1固定は根本原因(冪等性の欠如)を回避しているだけ。idempotency_key = hash(campaign_id + member_id)による送信済みチェックで、並列化しても重複送信が起きない構造を先に作る。
バッチ単位fan-outでスループットとレート制限を両立
800万件を500件単位のバッチに分割しPub/Subへpublish。Cloud Runのconcurrencyとバッチ内Semaphoreの二重制御で、プロバイダのレート制限(秒間500req)を超えずに全体を並列化する。
SLI/SLOを数値化して初めて「間に合わなさそう」が見える
「30分以内に99.5%完了」という具体的な目標を置き、DataDogで進捗率を可視化する。目標がなければ、ダッシュボードを見ても「順調かどうか」を判断できない。
問題
EC企業 MOps 販促システムチームが運用するキャンペーン通知配信サービス(campaign-notification-dispatcher)は、大型キャンペーン開始時に対象会員(最大800万人)へプッシュ通知・メール通知を一斉送信する。現行実装は、単一のCloud Runサービスが/dispatchリクエスト内でBigQueryから対象会員を抽出し、そのまま同期ループで通知プロバイダAPIを1件ずつ呼び出している。
先月起きたインシデント: 大型セール開始時、このジョブはCloud Runの60分タイムアウトに達して強制終了し、対象800万人のうち約210万人に通知が届かないまま処理が打ち切られた。担当者が気づいたのは、SNSでの「セール開始通知が来ない」という顧客の声がきっかけだった。慌てて/dispatchを再実行したところ、冪等性の仕組みが一切ないため、既に送信済みの約590万人にも通知が重複送信され、「同じ通知が3回来た」というクレームにつながった。
問題点を全て洗い出し、抽出/送信の分離 × Pub/Subバッチ単位fan-out × member単位idempotency_key(冪等性台帳) × Dead Letter Topic(DLQ) × concurrency/Semaphoreによるレート制限遵守 × Secret Manager × Terraformモジュール化 × SLI/SLO監視を考慮したBad→Goodリファクタリングを行ってください。
制約・前提条件
- GCP、Terraform 1.8+(
googleプロバイダー5.x)、リージョンasia-northeast1 - 通知プロバイダAPIは1件あたり約150ms、レート制限は秒間500リクエストまで(超過で429エラー+一定時間ブロック)
- 対象会員数は最大800万人、
channelはpush/emailのいずれか - 通知プロバイダのAPI鍵はCloud Runの環境変数に平文設定されており、Secret Manager未使用
- スケーリング設定は
min-instances=1, max-instances=1に意図的に固定されている(担当者いわく「並列化すると重複送信が怖いから」) - 監視はCloud Runの標準ログのみで、送信成功率・失敗理由別件数・処理速度(件/秒)は可視化されていない
- Terraformコードは存在せず、Cloud Runの設定はGCPコンソールから手動変更されている
- 目標SLO: キャンペーン開始から30分以内に対象会員の99.5%へ送信完了(エラーバジェット0.5%)
- 監視基盤: Cloud Monitoring + DataDog Agent v7 併用
- GKE(Autopilot含む)を使う場合は、Cloud Runに対する優位性を具体的な理由とともに説明すること
悪い構成 (Before)
# campaign-notification-dispatcher/app/main.py
from fastapi import FastAPI
app = FastAPI()
@app.post("/dispatch")
async def dispatch_campaign(campaign_id: str):
# 問題③: BigQuery抽出とAPI送信が同一リクエスト内。コールドスタート込みで起動に3分以上
members = bq_client.query(
f"SELECT member_id, channel FROM `mart.campaign_targets` WHERE campaign_id = '{campaign_id}'"
).result()
sent, failed = 0, 0
# 問題①: 800万件を1リクエスト内で同期ループ。Cloud Runの60分タイムアウトに全体が縛られる
for member in members:
try:
notification_provider.send(member.member_id, member.channel, campaign_id) # 1件あたり約150ms
sent += 1
except ProviderError:
failed += 1
# 問題②: リトライなし。例外を握り潰して次の会員へ進むだけで失敗が記録に残らない
continue
return {"sent": sent, "failed": failed}
# 問題④: 冪等性の仕組みが一切ない。再実行すると送信済み会員にも無条件で再送する
# 問題⑤: min-instances=1, max-instances=1に固定(「並列化すると重複送信が怖い」という理由)
# 問題⑥: 通知プロバイダAPI鍵がCloud Run環境変数に平文設定。Secret Manager未使用
# 問題⑦: Terraformコードが存在せず、Cloud Run設定はコンソールから手動変更されている
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 問題①③: 抽出と送信を分離 → BigQuery抽出は専用のCloud Run jobsとして切り出し、結果を500件単位のバッチに分割してPub/Subへpublishする。送信処理は別のCloud Run serviceに任せ、それぞれ独立したタイムアウト・スケーリング設定を持たせる
- 問題④⑤: 冪等性キー →
idempotency_key = hash(campaign_id + member_id)を送信前にチェックする仕組みを作れば、「並列化すると重複送信が怖い」という懸念は解消でき、min/max-instances固定を撤廃できる - 問題②: リトライとDLQ → 送信失敗はPub/Subの
retry_policyに委ね、一定回数失敗したメッセージはdead_letter_policyでDLQへ隔離する - スループット設計: Cloud Runの
concurrencyとバッチ内asyncio.Semaphoreを組み合わせ、プロバイダのレート制限(秒間500リクエスト)を超えないように全体のスループットを二重に制御する - 問題⑥⑦: Secret ManagerでAPI鍵を管理し、Cloud Run/Pub/Sub/IAM/監視をすべてTerraformモジュール化する
- Cloud Run vs GKE: このワークロードはキャンペーン開始時にのみ瞬間的にスパイクし、それ以外はほぼゼロという性質を持つ。常時起動のクラスタ運用コストを払う理由が薄く、Pub/Subのpush配信+Cloud Runのオートスケール(0→最大インスタンス数)の方が運用負荷・コストの両面で合理的である
ヒント3 — コードの骨格
# Pub/Sub: バッチ単位fan-out × DLQ × retry policyのスケルトン
resource "google_pubsub_subscription" "notification_worker_push" {
name = "notification-worker-push-sub"
topic = google_pubsub_topic.notification_tasks.id
ack_deadline_seconds = 60
dead_letter_policy {
dead_letter_topic = google_pubsub_topic.notification_tasks_dlq.id
max_delivery_attempts = 5
}
retry_policy {
minimum_backoff = "10s"
maximum_backoff = "300s"
}
}
# アプリ側冪等性チェックのスケルトン
async def send_idempotent(campaign_id: str, member_id: str, channel: str) -> bool:
idempotency_key = hashlib.sha256(f"{campaign_id}:{member_id}".encode()).hexdigest()
if await already_sent(idempotency_key): # 送信済みログを確認
return False # 再送・再実行によるスキップ
async with semaphore: # プロバイダのレート制限(秒間500req)を超えないよう並行数を制御
await notification_provider.send(member_id, channel, campaign_id)
await mark_sent(idempotency_key)
return True
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | 抽出と送信が同期実行され60分タイムアウトに縛られる | 責務分離層 | target-extractor / notification-workerに分離 |
| 2 | リトライなしで例外を握り潰し失敗が記録に残らない | 異常系隔離層 | Pub/Sub retry_policy + dead_letter_policy |
| 3 | 抽出クエリを都度実行しコールドスタート込みで起動3分以上 | 責務分離層 | 専用Cloud Run jobで事前抽出・バッチpublish |
| 4 | 冪等性が皆無で再実行のたびに重複送信 | 冪等性層 | member単位idempotency_key台帳(Firestore) |
| 5 | min/max-instances=1固定で根本原因を未解決のまま放置 | 冪等性層 | 冪等性確保のうえでスケーリング解放 |
| 6 | APIキーが環境変数に平文設定 | 可観測性・ガバナンス層 | Secret Manager移行 |
| 7 | Terraform未整備で設定変更が追跡不能 | 可観測性・ガバナンス層 | Terraformモジュール化 + SLI/SLO監視 |
障害伝播フロー図 — Bad vs Good の変換
模範解答
改善後アーキテクチャ
[Cloud Scheduler / 手動トリガー]
│ campaign_id を publish
▼
[Pub/Sub topic: campaign-dispatch-requests]
│ push
▼
[Cloud Run job: target-extractor] ← 抽出専用。送信とタイムアウト制約を分離(修正1,3)
- BigQueryから対象会員を抽出(ページング、10万件ずつ)
- 500件単位でバッチ化し batch_id = hash(campaign_id + offset) を付与
│ publish (16,000 batches / 800万件)
▼
[Pub/Sub topic: notification-tasks]
- ack_deadline_seconds = 60
- retry_policy: min10s / max300s(指数バックオフ)
- dead_letter_policy: max_delivery_attempts = 5
│ push
▼
[Cloud Run service: notification-worker] ← concurrency/max-instancesで全体スループットを制御(修正4,5)
- concurrency = 20, max-instances = 25
- バッチ内はasyncio.Semaphore(20)で並行送信
- 送信前にidempotency_keyの送信済みログをチェック(修正4)
- Secret Managerからプロバイダ鍵を取得(修正6)
│ 5回失敗
▼
[Pub/Sub topic: notification-tasks-dlq]
│
▼
[Cloud Run job: dlq-reviewer](手動トリガー) ← DLQ滞留メッセージを人手で確認し再publish
[Firestore: notification_sent_log] 冪等性台帳(idempotency_key = document ID)
[Secret Manager: notification-provider-api-key] 修正6: 平文環境変数の廃止
[Cloud Monitoring / DataDog] 修正7: 送信速度・成功率・DLQ滞留の可視化
冪等性・リトライ・DLQ設計
"""notification-worker/app/handler.py
Pub/Sub push配信はat-least-once配信であり、かつ手動再実行によっても
同一バッチが複数回処理され得る。「同じ処理を何度実行しても結果が変わらない」
冪等性を持たせることで、min/max-instances固定を解除した並列化(修正4,5)
を安全に行える。
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
from typing import Final
logger = logging.getLogger(__name__)
_PROVIDER_CONCURRENCY: Final[int] = 20 # プロバイダのレート制限(秒間500req)を守るためのバッチ内並行数上限
_MAX_RETRY_ATTEMPTS: Final[int] = 5 # Pub/Sub側のdead_letter_policyと合わせる
async def handle_notification_batch(batch: NotificationBatch) -> BatchResult:
"""通知バッチ(会員500件)を冪等に送信する。
Args:
batch: campaign_id・batch_id・会員リスト(member_id, channel)を含むバッチ。
Returns:
バッチ内の送信成功/スキップ/失敗件数をまとめた結果。
Note:
1件ずつFirestoreへ問い合わせるとレイテンシが積み上がるため、
バッチ内の全idempotency_keyを1回のバッチ読み取りでまとめて
確認してから送信ループに入る。
"""
semaphore = asyncio.Semaphore(_PROVIDER_CONCURRENCY)
keys = {m.member_id: _idempotency_key(batch.campaign_id, m.member_id) for m in batch.members}
already_sent = await _bulk_check_sent(keys.values()) # 修正4: 冪等性チェックをバッチでまとめて実行
async def _send_one(member: Member) -> SendOutcome:
key = keys[member.member_id]
if key in already_sent:
return SendOutcome.SKIPPED_DUPLICATE # 再送・再実行によるスキップ
async with semaphore: # 修正5: 並列化してもレート制限は守られる
try:
await notification_provider.send(member.member_id, member.channel, batch.campaign_id)
except ProviderTransientError:
return SendOutcome.FAILED_RETRYABLE # 修正2: 例外を握り潰さず結果として返す
await _mark_sent(key)
return SendOutcome.SENT
outcomes = await asyncio.gather(*(_send_one(m) for m in batch.members))
if any(o == SendOutcome.FAILED_RETRYABLE for o in outcomes):
raise BatchPartiallyFailedError(batch.batch_id) # 非200を返しPub/Subに再送させる(DLQへ最終隔離)
return BatchResult.from_outcomes(outcomes)
def _idempotency_key(campaign_id: str, member_id: str) -> str:
"""campaign_id×member_id単位で一意な冪等性キーを生成する。"""
return hashlib.sha256(f"{campaign_id}:{member_id}".encode()).hexdigest()
# terraform/cloud_run_service.tf(抜粋)
resource "google_cloud_run_v2_service" "notification_worker" {
name = "notification-worker"
location = "asia-northeast1"
template {
# 修正5: min/max-instances固定(1,1)を撤廃し、冪等性を前提に安全に並列化する
scaling {
min_instance_count = 0
max_instance_count = 25
}
containers {
image = var.notification_worker_image
resources {
limits = { cpu = "1", memory = "512Mi" }
}
env {
name = "NOTIFICATION_PROVIDER_API_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.notification_provider_api_key.secret_id # 修正6
version = "latest"
}
}
}
}
max_instance_request_concurrency = 20 # プロバイダのレート制限を守るための1インスタンスあたり並行数上限
timeout = "60s"
}
}
# terraform/pubsub.tf(抜粋)
resource "google_pubsub_subscription" "notification_worker_push" {
name = "notification-worker-push-sub"
topic = google_pubsub_topic.notification_tasks.id
ack_deadline_seconds = 60
# 修正2: 5回失敗したメッセージはDLQへ隔離。正常なメッセージの処理能力を守る
dead_letter_policy {
dead_letter_topic = google_pubsub_topic.notification_tasks_dlq.id
max_delivery_attempts = 5
}
retry_policy {
minimum_backoff = "10s"
maximum_backoff = "300s"
}
push_config {
push_endpoint = "https://notification-worker-xxxx.a.run.app/pubsub/push"
oidc_token {
service_account_email = google_service_account.notification_pubsub_invoker.email
}
}
}
| 項目 | 内容 |
|---|---|
| SLI | 送信成功件数(SENT) ÷ 対象会員数 を経過時間ごとに集計 |
| SLO | キャンペーン開始から30分以内に99.5%完了(エラーバジェット0.5% = 800万人なら4万人まで許容) |
| DataDogダッシュボード | 送信速度(件/秒、目標ライン4,444件/秒)・累積成功率ゲージ・DLQ滞留件数・プロバイダAPIレイテンシp50/p95(OTel計装) |
| アラート | DLQ滞留 > 1,000でSlack通知/開始25分時点でSLI < 95%ならPagerDuty通知 |
| スループット計算 | 1バッチ(500件)処理時間 ≒ 500件÷20並行×0.15秒 ≒ 3.75秒。max-instances=25×concurrency=20で全体最大500並行に収め、プロバイダのレート制限(秒間500req)と一致させる |
| 概算コスト | 16,000バッチ×約3.75秒×1vCPU ≒ 60,000 vCPU秒/回 ≒ 約1.4ドル(約210円)/キャンペーン。Pub/Sub・BigQuery抽出も無料枠圏内で実質無視できる水準 |
| ポイント | 適用した設計/機能 | 役割 |
|---|---|---|
| target-extractor / notification-worker分離 | 責務分離 | 抽出遅延と送信レート制限という別種の制約を独立して扱う |
| member単位idempotency_key(Firestore) | 冪等性台帳 | 再実行・再送があっても副作用は1回に限定される |
| dead_letter_policy + retry_policy | DLQによる異常系隔離 | 恒久エラーが正常系の処理能力を奪わない |
| concurrency=20 × Semaphore(20) | 二重のレート制御 | プロバイダの秒間500req制限を超過させない |
| SLI/SLO + DataDogダッシュボード | 可観測性 | 「間に合わなさそう」を期限前に検知できる |
| Secret Manager + Terraformモジュール化 | ガバナンス | 鍵の平文露出と設定の追跡不能状態を解消 |
| Before(Bad) | After(Good) |
|---|---|
| 抽出と送信が同一リクエストで60分タイムアウトに縛られる | target-extractor/notification-workerに分離、独立したタイムアウト |
| リトライなしで例外を握り潰し失敗が消える | Pub/Sub retry_policy + dead_letter_policyで再送・隔離 |
| 冪等性なしで再実行のたびに重複送信 | member単位idempotency_key台帳でスキップ判定 |
| min/max-instances=1固定で並列化の恩恵ゼロ | 冪等性確保のうえでmax-instances=25まで解放 |
| ビジネスメトリクスが不可視で顧客の声で発覚 | SLI/SLO+DataDogダッシュボードで進捗を常時可視化 |
| APIキーが平文環境変数 | Secret Manager経由で参照 |
| Terraform未整備でコンソール手動変更 | modules/campaign-notification/配下でIaC管理 |
ポイント解説
min/max-instances=1固定は根本原因(冪等性の欠如)を回避しているだけで解決していない。冪等性キーによる重複防止を実装した上で初めて、スケーラビリティという本来の要件を安全に満たせる。実務への応用
- MOps販促システムチームでは、キャンペーン通知の他にも「クーポン発行通知」「カート放棄リマインドメール」「在庫復活通知」など、対象会員数が数百万〜数千万規模になり得る一括配信処理が今後も発生しうる: 今回設計した「抽出/送信の分離 × バッチ単位fan-out × member単位冪等性台帳 × DLQ × SLI/SLO監視」というパターンはそのまま横展開できる
- 「並列化・自動化すると事故が怖いので手動運用・逐次処理のままにする」という判断は一見安全に見えるが、実際には障害時の被害(今回のタイムアウト+重複送信)をむしろ拡大させることがある: 安全策として選ぶべきは「並列化しない」ではなく「並列化しても安全な構造(冪等性・DLQ)を先に作る」ことだという判断軸は、他の一括処理の設計判断にも応用できる
notification_sent_log)は無制限に増え続けるため、実運用ではFirestoreのTTLポリシーやBigQueryへの定期エクスポート+パーティション削除など、一定期間経過後にレコードを削除する設計も合わせて検討する必要がある。
今日のまとめ
次のステップ
- 発展問題: キャンペーンが複数同時に開始される場合(例: 複数事業部が同日に別々のキャンペーンを実行)、
notification-workerのmax-instancesをキャンペーン間でどう配分するか、Pub/Subのトピック設計(単一トピック+filter vs キャンペーン別トピック)を含めて検討せよ - 発展問題: Firestoreの冪等性台帳(
notification_sent_log)は無制限に増え続ける。TTLポリシーやBigQueryへの定期エクスポートを含めたライフサイクル管理を設計せよ - 参考: Cloud Run v2 scaling / concurrency設定、Cloud Pub/Sub Dead Letter Topics、Cloud Run jobsとservicesの使い分け、Cloud Monitoring SLOモニタリング機能