B システム設計/インフラ — campaign-notification-dispatcher 大量会員一斉通知配信の信頼性設計(同期ループ×冪等性欠如×fan-out×SLO)(MOps 販促システムチーム 大型セール通知未達・重複送信インシデント Bad→Good)

2026-09-15 (Day 128) 火曜 B: システム設計/インフラ ★★★★☆ Cloud Run / Pub/Sub fan-out 冪等性台帳 / DLQ / SLI・SLO

概要

🧩

「抽出」と「送信」は責務も障害モードも別物

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に対する優位性を具体的な理由とともに説明すること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後アーキテクチャ(構成図・各コンポーネントの役割)+ 冪等性・リトライ・DLQ設計 + Terraformモジュール構成 + SLI/SLO定義とDataDog監視設計 + 概算コスト試算

悪い構成 (Before)

この構成には 7つの設計上の問題 が隠れています。見つけてみてください。
現行実装(抜粋)
# 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設定はコンソールから手動変更されている
問題点サマリー(7点)
1抽出と送信が同期実行 — 60分タイムアウトで約210万人が未送信のまま打ち切り
2リトライなしで例外を握り潰す — 失敗した送信がどこにも記録・再送されない
3抽出クエリが毎回コールドスタート込み — 起動に3分以上かかる
4冪等性の仕組みが皆無 — 再実行で既送信約590万人へ重複送信
5min/max-instances=1に意図固定 — 根本原因(冪等性欠如)を回避しただけ
6APIキーが平文環境変数 — Secret Manager未使用
7Terraform未整備 — コンソール手動変更で追跡不能

ヒント(段階的開示)

ヒント1 — 方向性
問題は3層に分類できる。(1) 「抽出」と「送信」が1つの同期処理に同居している — BigQueryクエリ実行とAPI呼び出しループを同一リクエスト内で行っているため、Cloud Runの60分タイムアウトという単一の制約に全体が縛られている。(2) 「並列化すると重複送信が怖い」という懸念に対して、並列化を諦める以外の解決策が検討されていない — 本来解くべき問題は「並列化するかどうか」ではなく「並列に実行しても安全な構造(冪等性)を作れるかどうか」である。(3) 障害・部分失敗への備えがなく、しかも見えない — リトライなし・DLQなし・ビジネスメトリクスの可視化なしのまま800万件を扱っている。この3層を、非同期メッセージング基盤による「抽出と送信の分離」「バッチ単位のfan-out」「冪等性キー」「DLQ」「SLI/SLO」で埋めることを考えてみてください。
ヒント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)
5min/max-instances=1固定で根本原因を未解決のまま放置冪等性層冪等性確保のうえでスケーリング解放
6APIキーが環境変数に平文設定可観測性・ガバナンス層Secret Manager移行
7Terraform未整備で設定変更が追跡不能可観測性・ガバナンス層Terraformモジュール化 + SLI/SLO監視

障害伝播フロー図 — Bad vs Good の変換

Bad(変更前)— 同期ループ・冪等性・DLQなし 大型セール開始、/dispatchが800万件を同期ループ処理 問題①: 抽出と送信が同一リクエスト内に同居 Cloud Runの60分タイムアウトに全体が縛られる 60分経過でCloud Runがジョブを強制終了 問題②: 失敗は例外を握り潰すだけで記録が残らない → 約210万人が未送信のまま処理打ち切り SNSの「通知が来ない」で発覚(監視なし=問題⑦) 担当者が慌てて/dispatchを手動再実行 問題④: 冪等性チェックが一切ない → 既送信約590万人にも無条件で再送 「同じ通知が3回来た」というクレーム発生 問題⑤: min/max-instances=1固定は根本原因を 回避しただけで解決していなかった 構造的なリスク 問題⑥: APIキーが平文環境変数でSecret Manager未使用 問題⑦: Terraform未整備で設定変更が追跡不能 「なぜ1,1固定なのか」誰も説明できない状態 × 「並列化しない」ことで危険を回避したつもりが 未送信と重複送信の両方を引き起こした × 障害の兆候が顧客の声でしか分からない Good(変更後)— 分離×冪等性台帳×fan-out×SLO監視 target-extractorが対象会員を抽出しバッチ化 修正①③: 抽出/送信を分離、500件単位でPub/Subへpublish タイムアウト制約が抽出と送信で独立する notification-workerがバッチを冪等に送信 修正④⑤: idempotency_keyで送信済みならスキップ min/max-instances固定を撤廃し安全に並列化(max25) → 再実行しても重複送信が起きない構造 プロバイダの一時エラーはretry_policyで再送 修正②: 5回失敗したバッチはDLQへ隔離 失敗が記録され人手でdlq-reviewerから再処理できる concurrency=20×Semaphoreでレート制限を遵守 プロバイダの秒間500req制限を超えず並列処理 30分以内に800万件を送信完了できる設計 SLI/SLO監視(修正⑥⑦) SLO: 30分以内に99.5%完了、DataDogで進捗率を可視化 25分時点でSLI<95%ならPagerDuty通知 APIキーはSecret Manager、全構成はTerraform管理 ✓ 「並列化しても安全」な構造を先に作ってから スケーラビリティを解放する ✓ 目標に対する進捗が常に見える状態になる

模範解答

改善後アーキテクチャ

[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滞留の可視化
Cloud Run vs GKE Autopilotの選定理由: このワークロードはキャンペーン開始時(月数回)にのみ瞬間的にスパイクし、それ以外はほぼゼロトラフィックという性質を持つ。GKE Autopilotを使ってもPod単位課金でスケールは可能だが、①クラスタのバージョン管理・ノードプール監視という運用コストが常時発生する、②Pub/Subのpush配信+Cloud Runのオートスケール(0→最大インスタンス数)の方が単発の非同期ワーカーには構成がシンプルになる、という2点でCloud Runを選定する。K8sが優位になるのは常時稼働する他サービスとリソースプールを共有する場合や、Gateway APIでのカナリアリリースなど高度なL7制御が必要な場合であり、本ワークロードには当てはまらない。

冪等性・リトライ・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_policyDLQによる異常系隔離恒久エラーが正常系の処理能力を奪わない
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管理

ポイント解説

1「並列化すると危ない」への正しい答えは「並列化しない」ではなく「並列化しても安全な構造にする」こと — min/max-instances=1固定は根本原因(冪等性の欠如)を回避しているだけで解決していない。冪等性キーによる重複防止を実装した上で初めて、スケーラビリティという本来の要件を安全に満たせる。
2「抽出」と「送信」は責務も障害モードも異なるため、別コンポーネントに分離する — BigQueryクエリの遅延とAPI呼び出しのレート制限は無関係な制約であり、同じタイムアウト予算を共有させる理由がない。分離することで、それぞれに適したリトライ・スケーリング設定を独立して持てる。
3冪等性は「Pub/Subの配信保証」と「アプリ側のチェック」の両方で初めて成立する — retry_policyやexactly-once配信オプションだけでは、手動再実行やPublisher側の重複という配信レイヤー外の要因までは防げない。member単位の冪等性台帳を持つことが本質的な対策になる。
4SLI/SLOを数値で定義して初めて「間に合わなさそう」を早期検知できる — 「30分以内に99.5%」という具体的な目標がなければ、DataDogダッシュボードを見ても「順調かどうか」を判断できない。目標に対する進捗率という形でメトリクスを設計することが監視設計の出発点になる。
5Secret ManagerとTerraform化はセキュリティだけでなく変更の追跡可能性にも効く — 平文環境変数・コンソール手動変更は、単なるセキュリティリスクだけでなく「なぜmin/max-instancesが1に固定されているのか誰も説明できない」という運用上のブラックボックス化も引き起こしていた。

実務への応用

  • MOps販促システムチームでは、キャンペーン通知の他にも「クーポン発行通知」「カート放棄リマインドメール」「在庫復活通知」など、対象会員数が数百万〜数千万規模になり得る一括配信処理が今後も発生しうる: 今回設計した「抽出/送信の分離 × バッチ単位fan-out × member単位冪等性台帳 × DLQ × SLI/SLO監視」というパターンはそのまま横展開できる
  • 「並列化・自動化すると事故が怖いので手動運用・逐次処理のままにする」という判断は一見安全に見えるが、実際には障害時の被害(今回のタイムアウト+重複送信)をむしろ拡大させることがある: 安全策として選ぶべきは「並列化しない」ではなく「並列化しても安全な構造(冪等性・DLQ)を先に作る」ことだという判断軸は、他の一括処理の設計判断にも応用できる
冪等性台帳(notification_sent_log)は無制限に増え続けるため、実運用ではFirestoreのTTLポリシーやBigQueryへの定期エクスポート+パーティション削除など、一定期間経過後にレコードを削除する設計も合わせて検討する必要がある。

今日のまとめ

今回のインシデントの本質は、「並列化すると重複送信が怖い」という懸念を、並列化そのものを諦めることで回避しようとした点にある。抽出と送信の責務を分離し、Pub/Subによるバッチ単位のfan-outとmember単位の冪等性台帳を組み合わせることで、「並列化しても安全」という構造を先に作れば、本来満たすべきスケーラビリティとSLOの両方を達成できる。

次のステップ

  • 発展問題: キャンペーンが複数同時に開始される場合(例: 複数事業部が同日に別々のキャンペーンを実行)、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モニタリング機能

自己評価(あとで記入)