B システム設計/インフラ — coupon-issuance-service Pub/Sub非同期クーポン発行処理の信頼性設計(重複配信×順序保証×DLQ×冪等性)(MOps 販促システムチーム クーポン二重発行インシデント Bad→Good)

2026-09-08 (Day 121) 火曜 B: システム設計/インフラ ★★★★☆ Cloud Pub/Sub / Cloud Run push DLQ / Ordering Key / Firestore Transaction

概要

🔁

at-least-once配信を前提に受信側を冪等化

Pub/Subのpushサブスクリプションは重複配信を完全には排除できない。Pub/Sub側の機能に頼るのではなく、event_idベースのFirestoreトランザクションで「同じ処理を2回実行しても結果が変わらない」構造にする。

⏳

ack期限は実測値+安全マージンで決める

デフォルトの10秒のままではダウンストリーム(BigQuery)のセール中最大3秒という実測値を考慮できない。ack_deadline_secondsを60秒に引き上げ、不要な再送を防ぐ。

🪤

DLQで「壊れたメッセージ」を隔離

不正payloadを無限リトライさせ続けると正常なメッセージの処理能力を奪う。dead_letter_policyで5回失敗したメッセージをDLQトピックへ隔離し、正常系を守る。

🔀

順序が必要な処理にはordering keyを明示

同一注文の発行(issue)と取り消し(revoke)は発生順に処理される必要がある。enable_message_ordering+order_idをordering keyにすることで順序崩れによる矛盾を防ぐ。

問題

ECサイト MOps 販促システムチームが運用するクーポン発行サービス(coupon-issuance-service)は、キャンペーン管理基盤(campaign-orchestrator)がPub/Subトピックcampaign-eventsへpublishするcoupon.issue_requested(発行)・coupon.revoke_requested(注文キャンセル時の取り消し)イベントを、Cloud Runのpushサブスクリプションで受信し、Firestoreにクーポンを発行・失効させている。

先週起きたインシデント: 大型セール中、ダウンストリームのBigQueryログ書き込みが平常時200msからセール中最大3秒まで遅延した。Pub/Subのack期限はデフォルトの10秒のままだったため、処理が終わる前にack期限が切れてPub/Sub側が「未処理」と判断し同じメッセージを再送した。pushサブスクリプションはat-least-once配信であるにもかかわらず受信側に冪等性の仕組みが一切なく、同一の発行リクエストに対してクーポンが二重発行される事象が発生し、約1,200人の顧客に重複クーポンが付与された(想定損失 約360万円)。

問題点を全て洗い出し、Pub/Sub `enable_exactly_once_delivery` × アプリ側冪等性チェック(Firestoreトランザクション) × `ack_deadline_seconds`適正化 × Dead Letter Topic(DLQ) × メッセージ順序保証(ordering key) × サブスクリプションfilter × リトライポリシー(バックオフ) × バックログ監視アラートを考慮したBad→Goodリファクタリングを行ってください。

制約・前提条件

  • GCP Pub/Sub、Terraform 1.8+(googleプロバイダー5.x)、リージョンasia-northeast1、受信側はCloud Run v2(pushサブスクリプション経由)
  • campaign-eventsトピックには、クーポン発行・取り消し以外にも在庫連携・A/Bテスト割当などの無関係なイベントタイプが同じトピックに混在してpublishされている
  • ダウンストリームのBigQueryログ書き込みは平常時200ms、セール中ピークで最大3秒(この前提でack期限を設計すること)
  • coupon.issue_requestedとcoupon.revoke_requestedは同一order_idに対して発生し得る(例: クーポン発行直後に顧客が注文をキャンセルした場合)。この2つのイベントは同一注文内で発生順に処理される必要がある
  • 新しいキャンペーンタイプ追加時にバリデーションが漏れた不正payloadが将来的にも発生し得る前提で設計すること(今回は実際に発生し、無限リトライでCloud Run同時実行枠の約40%を2時間占有した)
  • 監視基盤: Cloud Monitoring + DataDog Agent v7 併用
期待する回答形式: 問題点の列挙(番号付き)+ 改善後構成(Terraform Pub/Sub設定・Pythonアプリ側冪等性ロジック・Cloud Monitoringアラート)+ 冪等性設計の考え方(Pub/Sub側とアプリ側それぞれの役割の説明)

悪い構成 (Before)

この構成には 7つの設計上の問題 が隠れています。見つけてみてください。
現行実装(抜粋)
# coupon-issuance-service/app/handler.py
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/pubsub/push")
async def handle_push(request: Request):
    envelope = await request.json()
    message = envelope["message"]
    data = json.loads(base64.b64decode(message["data"]))

    # 問題①: event_idによる冪等性チェックが一切ない
    # 問題⑤: coupon以外のイベントも全部届き、if文でフィルタしている
    event_type = data.get("event_type")
    if event_type == "coupon.issue_requested":
        await issue_coupon(data["order_id"], data["customer_id"], data["discount_rate"])
    elif event_type == "coupon.revoke_requested":
        await revoke_coupon(data["order_id"])
    else:
        pass  # 在庫連携・A/Bテストイベント等は無視するだけ

    return {"status": "ok"}


async def issue_coupon(order_id, customer_id, discount_rate):
    # 問題①: 同じorder_idで再度呼ばれても無条件で新規発行してしまう
    coupon = Coupon(order_id=order_id, customer_id=customer_id, discount_rate=discount_rate)
    await firestore_client.collection("coupons").add(coupon.to_dict())
    await bq_client.insert_rows_json("coupon_issuance_log", [coupon.to_dict()])  # 最大3秒


# terraform/pubsub.tf(抜粋)
resource "google_pubsub_topic" "campaign_events" {
  name = "campaign-events"
  # 問題④: message_orderingが未設定、順序が保証されない
}

resource "google_pubsub_subscription" "coupon_issuance_push" {
  name  = "coupon-issuance-push-sub"
  topic = google_pubsub_topic.campaign_events.id
  push_config {
    push_endpoint = "https://coupon-issuance-service-xxxx.a.run.app/pubsub/push"
  }
  # 問題②: ack_deadline_secondsが未設定(デフォルト10秒のまま)
  # 問題③: dead_letter_policyが未設定。不正payloadが無限に再送され続ける
  # 問題⑤: filterが未設定。coupon関連以外の全イベントを受信してしまう
  # 問題⑥: retry_policyが未設定(デフォルトの即時・最小バックオフ)
}
# 問題⑦: バックログ・DLQ滞留を監視するアラートが存在しない
問題点サマリー(7点)
1冪等性チェック不在 — 再送のたびにクーポンが重複発行される
2ack期限がデフォルト10秒 — ダウンストリーム実測(最大3秒)を考慮していない
3DLQ未設定 — 不正payloadが無限に再送され同時実行枠を占有
4順序保証がない — issue/revokeの処理順序が崩れ得る
5filter未設定 — 無関係なイベントも全受信し無駄なCloud Run起動
6retry_policy未設定 — 障害時に一斉再送でサンダリングハード誘発
7バックログ監視なし — 滞留に人間が気づく手段がない

ヒント(段階的開示)

ヒント1 — 方向性
問題は4層に分類できる。(1) 「同じメッセージが2回届く」というat-least-once配信の大前提に対する備えがない — Pub/Subのpushサブスクリプションは仕組み上メッセージの重複配信を完全には排除できない。受信側が冪等でなければ、再送のたびに副作用(クーポン発行)が繰り返される。(2) 「壊れたメッセージ」への備えがない — 不正payloadを処理できずにエラーを返し続けるメッセージは、DLQがなければ永遠に再送され続け、正常なメッセージの処理能力を奪う。(3) 「順序」が必要な場面で順序が保証されていない — 同一注文のissue/revokeのように、発生順に処理しないと業務的に矛盾した結果になるケースがある。(4) 「今どれくらい滞留しているか」が見えない — バックログやDLQの滞留を監視していなければ、障害に人間が気づく手段が顧客からの問い合わせしかなくなる。これら4層を、Pub/Subの機能(exactly-once配信オプション、ack期限、DLQ、ordering key、filter、retry policy)とアプリ側の冪等性チェックを組み合わせて埋めることを考えてみてください。
ヒント2 — アプローチ
  • 問題①: 冪等性チェックがない → event_idをキーにFirestoreのcoupon_issuance_logコレクションへトランザクション付き条件書き込み(既に存在すればスキップ)を行う。これがPub/Subの配信保証レベルに関わらず重複発行を防ぐ本質的な対策になる
  • 問題②: ack期限がデフォルト10秒 → ダウンストリーム処理の実測値(セール中最大3秒)に安全マージンを載せ、ack_deadline_secondsを60秒に引き上げる
  • 問題③: DLQが未設定 → dead_letter_policyでmax_delivery_attempts(例: 5回)を設定し、失敗し続けるメッセージをcoupon-events-dlqトピックへ隔離する
  • 問題④: 順序保証がない → Publisher側はorder_idをordering_keyとして送信し、サブスクリプション側でenable_message_orderingを有効化する
  • 問題⑤: filterが未設定で無関係なイベントも全部受信 → サブスクリプションのfilter属性でattributes.event_typeがcoupon関連のものだけを配信するようにする
  • 問題⑥: retry_policyがデフォルト(即時・最小バックオフ) → minimum_backoffとmaximum_backoffを明示し、サンダリングハードを防ぐ
  • 問題⑦: バックログ・DLQ監視アラートがない → subscription/oldest_unacked_message_ageとsubscription/dead_letter_message_countにCloud Monitoringアラートを設定する
ヒント3 — コードの骨格
# Pub/Sub: 順序保証 × DLQ × filter × retry policyのスケルトン
resource "google_pubsub_subscription" "coupon_issuance_push" {
  name                     = "coupon-issuance-push-sub"
  topic                    = google_pubsub_topic.campaign_events.id
  ack_deadline_seconds     = 60
  enable_message_ordering  = true
  filter                   = "attributes.event_type = \"coupon.issue_requested\" OR attributes.event_type = \"coupon.revoke_requested\""

  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.coupon_events_dlq.id
    max_delivery_attempts = 5
  }
  retry_policy {
    minimum_backoff = "10s"
    maximum_backoff = "600s"
  }
}

# アプリ側冪等性チェックのスケルトン
async def issue_coupon_idempotent(event_id, order_id, customer_id, discount_rate):
    doc_ref = firestore_client.collection("coupon_issuance_log").document(event_id)

    @firestore.transactional
    def _tx(transaction):
        snapshot = doc_ref.get(transaction=transaction)
        if snapshot.exists:
            return False  # 既に処理済み = 再送とみなしスキップ
        transaction.set(doc_ref, {"order_id": order_id, "processed_at": ...})
        return True

    if _tx(firestore_client.transaction()):
        ...  # 初回のみクーポンを実際に発行する

問題点分析(7点)

#問題点分類改善方法
1event_idによる冪等性チェックが無くクーポン二重発行配信保証層Firestoreトランザクションで条件付き書き込み
2ack_deadline_secondsがデフォルト10秒配信保証層実測(最大3秒)+マージンで60秒に設定
3DLQ未設定で不正payloadが無限リトライ異常系隔離層dead_letter_policy(max_delivery_attempts=5)
4ordering key未設定でissue/revokeの順序が崩れ得る異常系隔離層enable_message_ordering+ordering_key=order_id
5filter未設定で無関係イベントも全受信効率化層サブスクリプションfilter属性
6retry_policyがデフォルトでサンダリングハード誘発効率化層minimum_backoff/maximum_backoff
7バックログ・DLQ滞留の監視アラートが無い可観測性層Cloud Monitoringアラート2種

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

Bad(変更前)— 冪等性・DLQ・順序保証なし セール中、BigQuery書き込みが200ms→最大3秒に劣化 問題②: ack_deadline_secondsがデフォルト10秒のまま 処理中にack期限切れが発生 Pub/Subが「未処理」と判断し同一メッセージを再送 問題①: event_idの冪等性チェックが無い issue_couponが無条件で再実行される → 約1,200人にクーポン二重発行(想定損失360万円) 新キャンペーンタイプで不正payloadが混入 問題③: DLQ未設定で無限リトライ Cloud Run同時実行枠の40%を2時間占有 同一注文のrevokeがissueより先に処理される 問題④: ordering key未設定で順序保証なし キャンセル済み注文のクーポンが発行されたまま残存 構造的なリスク 問題⑤⑥: filter/retry_policy未設定で無駄なコストと ダウンストリーム障害時のサンダリングハードのリスク 問題⑦: 滞留に気づく手段が顧客問い合わせしかない × at-least-once配信を前提とした冪等性が無い × 壊れたメッセージが正常系の処理能力を奪う × 順序が必要な処理で順序が保証されていない Good(変更後)— 冪等性台帳 × DLQ × 順序保証 × 監視 ack_deadline_seconds=60s、実測(最大3秒)+マージン 修正②: 不要な再送そのものを減らす 修正⑤: filterでcoupon関連イベントのみ配信 再送が発生してもFirestoreトランザクションで判定 修正①: event_idが処理済みならスキップ(冪等) クーポンは1注文につき最大1回だけ発行される → 二重発行による損失を構造的に防止 不正payloadは5回失敗後DLQへ隔離 修正③: dead_letter_policy(max_delivery_attempts=5) 正常なメッセージの処理能力は守られる order_id単位でissue/revokeが発生順に処理される 修正④: enable_message_ordering + ordering_key=order_id キャンセル済み注文にクーポンが残らない Cloud Monitoring監視(修正⑥⑦) retry_policy(min10s/max600s)でサンダリングハード回避 oldest_unacked_message_age > 300sでSlack通知 dead_letter_message_count > 0でPagerDuty通知 ✓ at-least-once配信でも副作用が1回に限定される ✓ 壊れたメッセージが正常系をブロックしない ✓ 順序・滞留の両方が可視化・保証される

模範解答

# terraform/pubsub.tf
resource "google_pubsub_topic" "campaign_events" {
  name = "campaign-events"
}

# 修正③: DLQ専用トピック。不正payloadをここへ隔離し正常メッセージの処理能力を守る
resource "google_pubsub_topic" "coupon_events_dlq" {
  name = "coupon-events-dlq"
}

resource "google_pubsub_subscription" "coupon_issuance_push" {
  name  = "coupon-issuance-push-sub"
  topic = google_pubsub_topic.campaign_events.id

  # 修正②: ダウンストリーム実測(セール中最大3秒)に安全マージンを載せて60秒に設定
  ack_deadline_seconds = 60

  # 修正④: order_id単位での順序保証を有効化(Publisher側もordering_key=order_idで送信)
  enable_message_ordering = true

  # 修正⑤: coupon関連イベントのみを配信し、無関係なイベントによる無駄な起動を防ぐ
  filter = "attributes.event_type = \"coupon.issue_requested\" OR attributes.event_type = \"coupon.revoke_requested\""

  # 修正③: 5回失敗したメッセージはDLQへ隔離。無限リトライによる同時実行枠の占有を防ぐ
  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.coupon_events_dlq.id
    max_delivery_attempts = 5
  }

  # 修正⑥: ダウンストリーム障害時の再送を指数バックオフで分散し、サンダリングハードを防ぐ
  retry_policy {
    minimum_backoff = "10s"
    maximum_backoff = "600s"
  }

  push_config {
    push_endpoint = "https://coupon-issuance-service-xxxx.a.run.app/pubsub/push"
    oidc_token {
      service_account_email = google_service_account.coupon_pubsub_invoker.email
    }
  }
}

# DLQへのpublish権限をPub/Subサービスエージェントに付与(dead_letter_policy利用の前提条件)
resource "google_pubsub_topic_iam_member" "dlq_publisher" {
  topic  = google_pubsub_topic.coupon_events_dlq.id
  role   = "roles/pubsub.publisher"
  member = "serviceAccount:service-${data.google_project.current.number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}
"""coupon-issuance-service/app/handler.py

Pub/Sub は at-least-once 配信のため、同一メッセージが複数回届くことを
前提に、受信側で冪等性を担保する(修正①)。
"""
from __future__ import annotations

import base64
import json
import logging
from typing import Final

from fastapi import FastAPI, Request
from google.cloud import firestore

logger = logging.getLogger(__name__)
app = FastAPI()

_ISSUANCE_LOG_COLLECTION: Final[str] = "coupon_issuance_log"  # event_idをdocument IDにする冪等性台帳


@app.post("/pubsub/push")
async def handle_push(request: Request) -> dict:
    """Pub/Sub push サブスクリプションからのリクエストを処理する。

    Args:
        request: Pub/Sub push エンベロープを含むHTTPリクエスト。

    Returns:
        処理結果を示すdict。200を返すとPub/Subはメッセージをackしたとみなす。
    """
    envelope = await request.json()
    message = envelope["message"]
    event_id: str = message["messageId"]  # 修正①: Pub/Subが払い出す一意なmessageIdを冪等性キーに使う
    data = json.loads(base64.b64decode(message["data"]))

    event_type = data["event_type"]  # filter設定済みなのでcoupon関連イベントのみ届く(修正⑤)
    if event_type == "coupon.issue_requested":
        await issue_coupon_idempotent(
            event_id=event_id,
            order_id=data["order_id"],
            customer_id=data["customer_id"],
            discount_rate=data["discount_rate"],
        )
    elif event_type == "coupon.revoke_requested":
        await revoke_coupon_idempotent(event_id=event_id, order_id=data["order_id"])

    return {"status": "ok"}


async def issue_coupon_idempotent(
    event_id: str, order_id: str, customer_id: str, discount_rate: float
) -> None:
    """クーポンを冪等に発行する。同一event_idの再送では何もしない。

    Args:
        event_id: Pub/Subメッセージの一意ID(冪等性キー)。
        order_id: 対象注文ID。
        customer_id: 対象顧客ID。
        discount_rate: 割引率。

    Note:
        Firestoreトランザクションで「未処理なら記録して発行」を1操作にまとめ、
        再送によるレースコンディションでも二重発行が起きないようにする(修正①)。
    """
    doc_ref = firestore_client.collection(_ISSUANCE_LOG_COLLECTION).document(event_id)

    @firestore.transactional
    def _issue_if_new(transaction: firestore.Transaction) -> bool:
        snapshot = doc_ref.get(transaction=transaction)
        if snapshot.exists:
            return False  # 既に処理済み = Pub/Subによる再送とみなしスキップ
        transaction.set(doc_ref, {"order_id": order_id, "event_type": "issue", "processed_at": firestore.SERVER_TIMESTAMP})
        return True

    is_new = _issue_if_new(firestore_client.transaction())
    if not is_new:
        logger.info("coupon_issuance_skipped_duplicate", extra={"event_id": event_id, "order_id": order_id})
        return

    coupon = Coupon(order_id=order_id, customer_id=customer_id, discount_rate=discount_rate)
    await firestore_client.collection("coupons").add(coupon.to_dict())
    await bq_client.insert_rows_json("coupon_issuance_log", [coupon.to_dict()])


async def revoke_coupon_idempotent(event_id: str, order_id: str) -> None:
    """クーポンを冪等に失効させる。issue/revokeの処理順序はordering keyで保証される(修正④)。"""
    doc_ref = firestore_client.collection(_ISSUANCE_LOG_COLLECTION).document(event_id)

    @firestore.transactional
    def _revoke_if_new(transaction: firestore.Transaction) -> bool:
        snapshot = doc_ref.get(transaction=transaction)
        if snapshot.exists:
            return False
        transaction.set(doc_ref, {"order_id": order_id, "event_type": "revoke", "processed_at": firestore.SERVER_TIMESTAMP})
        return True

    if _revoke_if_new(firestore_client.transaction()):
        await mark_coupons_revoked(order_id)
resource "google_monitoring_alert_policy" "coupon_subscription_backlog" {
  display_name = "coupon-issuance-push-sub バックログ滞留"
  conditions {
    display_name = "oldest_unacked_message_age > 300s"
    condition_threshold {
      filter = <<-EOT
        resource.type="pubsub_subscription"
        AND resource.label.subscription_id="coupon-issuance-push-sub"
        AND metric.type="pubsub.googleapis.com/subscription/oldest_unacked_message_age"
      EOT
      comparison      = "COMPARISON_GT"
      threshold_value = 300
      duration        = "60s"
    }
  }
  combiner               = "OR"
  notification_channels  = [google_monitoring_notification_channel.slack.id]
}

resource "google_monitoring_alert_policy" "coupon_dlq_messages" {
  display_name = "coupon-events-dlq メッセージ滞留"
  conditions {
    display_name = "dead_letter_message_count > 0"
    condition_threshold {
      filter = <<-EOT
        resource.type="pubsub_subscription"
        AND resource.label.subscription_id="coupon-issuance-push-sub"
        AND metric.type="pubsub.googleapis.com/subscription/dead_letter_message_count"
      EOT
      comparison      = "COMPARISON_GT"
      threshold_value = 0
      duration        = "0s"
    }
  }
  combiner               = "OR"
  notification_channels  = [google_monitoring_notification_channel.pagerduty.id]
}
ポイント適用した設計/機能役割
event_idベースFirestoreトランザクションアプリ側冪等性配信保証レベルに関わらず二重発行を防止
ack_deadline_seconds=60ack期限の適正化不要な再送そのものを減らす
dead_letter_policyDLQによる異常系隔離不正payloadが正常系をブロックしない
enable_message_ordering + ordering_key順序保証issue/revokeの発生順処理を保証
subscription filter配信の絞り込み無関係イベントによる無駄な起動を削減
retry_policy(min/max backoff)再送の分散ダウンストリーム障害時のサンダリングハード防止
oldest_unacked_message_age / dead_letter_message_count監視可観測性滞留を人間が能動的に検知できる状態にする
Before(Bad)After(Good)
冪等性チェックが無く再送のたびに二重発行event_idベースのFirestoreトランザクションで1回のみ処理
ack_deadline_secondsがデフォルト10秒実測(最大3秒)+マージンで60秒
不正payloadが無限リトライされ同時実行枠を占有5回失敗でDLQへ隔離、正常系を保護
issue/revokeの処理順序が保証されないordering_key=order_idで発生順に処理
無関係イベントも全受信しif文で捨てるsubscription filterでcoupon関連のみ配信
retry_policyがデフォルトで一斉再送のリスクmin/maxバックオフで再送を分散
滞留に気づく手段が顧客問い合わせのみバックログ/DLQへのCloud Monitoringアラート

ポイント解説

1Pub/Subの配信保証機能とアプリ側の冪等性は「両方」必要 — Pub/Subにはenable_exactly_once_deliveryというオプションも存在するが、push配信の再送やPublisher側の重複publishまでは防げない。本質的な対策はアプリ側で「同じ処理を2回実行しても結果が変わらない」冪等性を持たせることであり、Firestoreトランザクションによる条件付き書き込みでそれを実現した。
2ack期限は「ダウンストリームの実測値+安全マージン」で決める — デフォルト値のままでは、実際の処理時間がそれを超えた瞬間に「正常に処理中のメッセージ」まで再送対象になってしまう。ack期限はSLAではなく、実際に観測された処理時間の分布(p99等)を根拠に設定する。
3DLQは「守りたいものを守る」ための機構 — 不正payloadのような回復不能なメッセージを無限リトライさせ続けると、それが正常なメッセージの処理能力を奪う。DLQへ隔離することで「一部のメッセージが壊れていても、他の正常なメッセージの処理は止めない」という分離が実現できる。
4順序が必要な処理には明示的にordering keyを使う — Pub/Subはデフォルトでは順序を保証しない。同一注文のissue/revokeのように「順序が業務ロジック上意味を持つ」場合は、ordering keyを明示し、Publisher側・Subscription側の両方で順序保証を有効にする必要がある。
5filterはコストと信頼性の両方に効く — 無関係なイベントを受信させないことは、単なるコスト削減ではなく「本来処理すべきイベントの処理遅延を防ぐ」という信頼性上の意味も持つ。

実務への応用

  • MOps販促システムチームでは、Argo Workflowsのバッチ完了通知やA/Bテスト割当イベントなど、Pub/Subを介した非同期メッセージングが今後さらに増える見込みである: 今回設計した「event_idベースの冪等性台帳(Firestoreトランザクション) × DLQ × ordering key × filter」というパターンは、決済確定イベントや在庫引当イベントのような「重複実行が金銭的損失に直結する」処理全般に横展開できる
  • 金銭が絡む非同期処理では、Pub/Sub側の配信保証機能を過信せず、必ずアプリ側の冪等性チェックとセットで設計する習慣を持つことが重要である
冪等性台帳(coupon_issuance_log)は無制限に増え続けるため、実運用ではFirestoreのTTLポリシーやBigQueryへの定期エクスポート+パーティション削除など、一定期間経過後にレコードを削除する設計も合わせて検討する必要がある。

今日のまとめ

今回のインシデントの本質は「Pub/Subが重複配信した」ことではなく、at-least-once配信という大前提に対して受信側が冪等でなかったことにある。ack期限の適正化・DLQ・ordering key・filterはいずれも「発生し得る異常系(重複・不正payload・順序崩れ・無関係イベント)を構造的に無害化する」ための仕組みであり、これらを組み合わせることで、次に同様のダウンストリーム遅延が起きてもクーポンの二重発行のような金銭的損失を防げる設計になる。

次のステップ

  • 発展問題: DLQへ滞留したメッセージを人手で確認した後、修正済みのpayloadとして元のトピックへ再publishする「再処理(replay)」の運用フローと、そのためのCloud Run管理エンドポイント(認可・監査ログ含む)を設計せよ
  • 参考: Cloud Pub/Sub Dead Letter Topics / Ordering Keys / Exactly-once Delivery(公式ドキュメント)、Firestore Transactions、Cloud Monitoring pubsub.googleapis.com/subscription/* メトリクス

自己評価(あとで記入)