A+B 複合 — 意味不明な命名 × ネスト × データクラス問題(クーポン適用ロジック Bad→Good Ch1-2)× coupon-service Cloud SQL コネクション枯渇対策(PgBouncer transaction pooling × リードレプリカ読み書き分離 × Workload Identity Federation × statement_timeout × 原子的なUPDATE...RETURNING × コネクション監視アラート)

2026-08-15 (Day 130) 土曜複合問題 ★★★★☆ Python 3.12 / dataclass / StrEnum / ガード節 / 書籍2周目1問目 GCP Cloud SQL / PgBouncer / Workload Identity Federation

概要

🏷️

省略形の命名は「読む人が定義元へ戻る回数」を増やすコスト

cd/tp/v/flg/tmpのような省略形は書く側には楽だが、読む側は毎回コメントに戻る必要がある。discount_type/has_applied_couponのような完全な名前はコメントなしで意味が通る(Ch1-2)。

📦

データクラス問題はデータと判断ロジックを引き離す

CouponDataは8フィールドを持つがメソッドを持たない「入れ物」だった。有効性判定・割引計算・残数消費をCoupon自身のメソッドに集約する(Ch1-2)。

🪜

ガード節で「条件の数」と「本体の深さ」を切り離す

4段ネストの奥にあった本体処理を、早期continueで常にトップレベル1段に保つ。条件が増えてもネストは深くならない(Ch1-2)。

🔌

コネクションプーラーは並行度とDB上限のギャップを吸収する

30レプリカが直接primaryへ接続するとmax_connectionsを超過する。PgBouncerのtransaction poolingで実コネクション数を固定上限に抑える。

問題 A: コーディング — 意味不明な命名 × ネスト × データクラス問題(クーポン適用ロジック Bad→Good)

以下の「悪いコード」は、ECサイト MOps チームが運用するクーポン適用ロジックです。省略形だらけの命名、深いネスト、そして「データを持つだけでロジックを持たない」クラス(データクラス問題)という3つの問題が同居しています。問題点を全て洗い出し、「良いコード・悪いコードで学ぶ設計入門」第1-2章(悪しき構造・設計の初歩: 意味不明な命名、ネスト、データクラス問題)を使って Bad→Good にリファクタリングしてください。今回は書籍ローテーションの2周目1問目(Ch1-2)です。

制約・前提条件

  • Python 3.12+
  • CouponData のような「フィールドを持つだけでロジックを持たないクラス」を廃止し、有効性判定・割引計算・残数消費をクーポン自身のメソッドとして持たせること(データクラス問題の解消)
  • cd/tp/v/mn/mx/st/exp/ns/d/flg/tmp/c/cs 等の省略形変数名は、目的が一目でわかる名前に改めること
  • for ループ内の多段 if ネストは、ガード節(早期 continue)でフラットにすること
  • apply 関数の未使用引数(mbr)は削除するか、使う場合は用途を明示すること
  • 割引種別(rate/fixed)は文字列比較ではなく Enum で表現すること
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章

悪いコード (Before) — カテゴリ A

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_coupon.py — 省略形命名×多段ネスト×データクラス問題(CouponDataがロジックを持たない)
class CouponData:
    def __init__(self, cd, tp, v, mn, mx, st, exp, ns):
        self.cd = cd    # クーポンコード
        self.tp = tp    # "rate" or "fixed"
        self.v = v      # 割引率 or 固定額
        self.mn = mn    # 最低購入金額
        self.mx = mx    # 割引上限額
        self.st = st    # 併用可能フラグ
        self.exp = exp  # 有効期限
        self.ns = ns    # 残り利用可能回数


def apply(cart, cs, mbr, now):
    d = 0
    flg = False
    for c in cs:
        if c.exp is not None:
            if now > c.exp:
                continue
        if c.mn is not None:
            if cart["total"] < c.mn:
                continue
        if c.ns is not None:
            if c.ns <= 0:
                continue
        if flg:
            if not c.st:
                continue
        tmp = 0
        if c.tp == "rate":
            tmp = cart["total"] * c.v / 100
            if c.mx is not None:
                if tmp > c.mx:
                    tmp = c.mx
        else:
            if c.tp == "fixed":
                tmp = c.v
        d = d + tmp
        flg = True
        c.ns = c.ns - 1 if c.ns is not None else None
    return d
問題点サマリー(7点)
1CouponDataが8フィールドを持ちメソッドを一切持たない(データクラス問題, Ch1-2) — 有効性判定・割引計算・残数消費が外部のapply関数に漏れ出している
2cd/tp/v/mn/mx/st/exp/nsという省略形フィールド名(意味不明な命名, Ch1-2) — __init__のコメントを読まないと意味が分からない
3d/flg/tmp/c/csという省略形ローカル変数名(意味不明な命名, Ch1-2) — 特にflgは何のフラグか名前から読み取れない
4forループ内で4段以上ネストしたif(ネスト, Ch1-2) — 早期continueを使わずすべての条件をネストで表現
5文字列比較 + 冗長なelse-if連鎖(Ch1-2/Ch10) — タイプミスを実行時まで検知できない
6未使用のmbr引数 — 会員ランク別クーポン制限を意図したと推測されるデッドパラメータ
7呼び出し元がc.nsを直接書き換え(カプセル化の欠如, Ch1-2/Ch3) — 残数消費という振る舞いがクーポン自身のメソッドになっていない

ヒント A(段階的開示)

ヒント1 — 方向性
CouponData は8個のフィールドを持つが、メソッドを一切持たない「ただの入れ物」になっている。一方で「有効期限切れか」「最低購入金額を満たすか」「割引額はいくつか」というクーポン自身が答えられるべき問いへの答えが、すべて外部の apply 関数に散らばっている。これが「データクラス問題」で、データと振る舞いが引き離された結果、apply 関数だけを読んでもクーポンのルールが理解しにくくなっている。また cd/tp/v/mn/mx のような省略形は、定義元(__init__のコメント)を見ないと意味が分からず、コードの自己説明性を破壊している。
ヒント2 — アプローチ
  • DiscountTypeStrEnumRATE/FIXED)にする
  • Coupon@dataclass(slots=True))に is_applicable(cart_total, now)calculate_discount(cart_total)consume_one_use() の3メソッドを持たせ、apply_coupons 関数からロジックを吸収する
  • apply_coupons 内のネストは、if not coupon.is_applicable(...): continue のようなガード節で1段に平坦化する
  • 「併用済みフラグ」は has_applied_coupon: bool のような意味の通る名前にする
  • 未使用の mbr 引数は削除する
ヒント3 — コードの骨格
class DiscountType(StrEnum):
    RATE = "rate"
    FIXED = "fixed"

@dataclass(slots=True)
class Coupon:
    code: str
    discount_type: DiscountType
    value: Decimal
    minimum_purchase_amount: Decimal | None = None
    maximum_discount_amount: Decimal | None = None
    is_stackable: bool = False
    expires_at: datetime | None = None
    remaining_uses: int | None = None

    def is_applicable(self, cart_total: Decimal, now: datetime) -> bool: ...
    def calculate_discount(self, cart_total: Decimal) -> Decimal: ...
    def consume_one_use(self) -> None: ...

def apply_coupons(cart_total: Decimal, coupons: list[Coupon], now: datetime) -> Decimal:
    total_discount = Decimal("0")
    has_applied_coupon = False
    for coupon in coupons:
        if not coupon.is_applicable(cart_total, now):
            continue
        if has_applied_coupon and not coupon.is_stackable:
            continue
        total_discount += coupon.calculate_discount(cart_total)
        coupon.consume_one_use()
        has_applied_coupon = True
    return total_discount

問題点分析 — カテゴリ A

#問題点分類改善方法
1CouponDataがロジックを持たないデータクラス問題 Ch1-2is_applicable/calculate_discount/consume_one_useへ集約
2cd/tp/v/mn/mx/st/exp/nsの省略形フィールド名意味不明な命名 Ch1-2discount_type等の完全な名前へ
3d/flg/tmp/c/csの省略形変数名意味不明な命名 Ch1-2has_applied_coupon等へ
44段以上のネストしたifネスト Ch1-2ガード節(早期continue)でフラット化
5文字列比較+冗長なelse-ifマジックストリング Ch1-2/Ch10StrEnum比較
6未使用のmbr引数デッドパラメータ削除
7呼び出し元がc.nsを直接書き換えカプセル化の欠如 Ch1-2/Ch3consume_one_use()に集約

模範解答 A

Before — 省略形命名・多段ネスト・データクラス問題
class CouponData:
    def __init__(self, cd, tp, v, mn, mx, st, exp, ns):
        self.cd = cd
        self.tp = tp
        ...  # ロジックを一切持たない8フィールド

def apply(cart, cs, mbr, now):     # mbrは未使用
    d = 0
    flg = False
    for c in cs:
        if c.exp is not None:      # 多段ネスト開始
            if now > c.exp:
                continue
        if c.mn is not None:
            if cart["total"] < c.mn:
                continue
        ...
        if c.tp == "rate":
            tmp = cart["total"] * c.v / 100
            ...
        else:
            if c.tp == "fixed":    # 冗長なelse-if
                tmp = c.v
        d = d + tmp
        flg = True
        c.ns = c.ns - 1 if c.ns is not None else None  # 呼び出し元が直接書き換え
    return d
After — Couponにis_applicable/calculate_discount/consume_one_useを集約、ガード節でフラット化
"""coupon.py — 意味不明な命名の解消 × ネストのフラット化 × データクラス問題の解消(Ch1-2)"""
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import StrEnum


class DiscountType(StrEnum):
    """クーポンの割引種別。"""
    RATE = "rate"
    FIXED = "fixed"


@dataclass(slots=True)
class Coupon:
    """クーポン(単なるデータの入れ物ではなく、自身の妥当性判定・割引計算・
    残数消費を担う, Ch1-2)。

    Args:
        code: クーポンコード。
        discount_type: 割引種別(定率/定額)。
        value: 割引率(%)または割引額(円)。
        minimum_purchase_amount: 適用に必要な最低購入金額(円)。Noneなら制限なし。
        maximum_discount_amount: 割引額の上限(円)。Noneなら上限なし。
        is_stackable: 他のクーポンと併用可能か。
        expires_at: 有効期限。Noneなら無期限。
        remaining_uses: 残り利用可能回数。Noneなら無制限。
    """
    code: str
    discount_type: DiscountType
    value: Decimal
    minimum_purchase_amount: Decimal | None = None
    maximum_discount_amount: Decimal | None = None
    is_stackable: bool = False
    expires_at: datetime | None = None
    remaining_uses: int | None = None

    def is_applicable(self, cart_total: Decimal, now: datetime) -> bool:
        """クーポンが今このカート合計に適用可能かを判定する。"""
        if self.expires_at is not None and now > self.expires_at:
            return False
        if self.minimum_purchase_amount is not None and cart_total < self.minimum_purchase_amount:
            return False
        if self.remaining_uses is not None and self.remaining_uses <= 0:
            return False
        return True

    def calculate_discount(self, cart_total: Decimal) -> Decimal:
        """割引額を計算する(定率の場合はmaximum_discount_amountでクリップ)。"""
        if self.discount_type is DiscountType.RATE:
            raw_discount = cart_total * self.value / Decimal("100")
        else:
            raw_discount = self.value
        if self.maximum_discount_amount is not None:
            return min(raw_discount, self.maximum_discount_amount)
        return raw_discount

    def consume_one_use(self) -> None:
        """残り利用可能回数を1減らす(呼び出し元による直接書き換えを廃止, Ch1-2/Ch3)。"""
        if self.remaining_uses is not None:
            self.remaining_uses -= 1


def apply_coupons(cart_total: Decimal, coupons: list[Coupon], now: datetime) -> Decimal:
    """カート合計にクーポンを適用し、合計割引額を返す。

    Args:
        cart_total: 割引適用前のカート合計金額(円)。
        coupons: 適用candidateのクーポン一覧(適用順に評価される)。
        now: 有効期限判定に使う現在時刻。

    Returns:
        合計割引額(円)。併用不可クーポンは最初に適用された1件のみが有効。
    """
    total_discount = Decimal("0")
    has_applied_coupon = False  # 意味の通る名前(旧flg): 既に併用不可クーポンを適用済みか

    for coupon in coupons:
        if not coupon.is_applicable(cart_total, now):
            continue
        if has_applied_coupon and not coupon.is_stackable:
            continue  # ガード節でネストをフラット化(旧: 多段if)

        total_discount += coupon.calculate_discount(cart_total)
        coupon.consume_one_use()
        has_applied_coupon = True

    return total_discount
now = datetime(2026, 8, 15, 10, 0)
coupons = [
    Coupon(
        code="SUMMER10", discount_type=DiscountType.RATE, value=Decimal("10"),
        minimum_purchase_amount=Decimal("3000"), maximum_discount_amount=Decimal("1000"),
        is_stackable=False, expires_at=datetime(2026, 8, 31), remaining_uses=100,
    ),
    Coupon(
        code="FREESHIP500", discount_type=DiscountType.FIXED, value=Decimal("500"),
        is_stackable=True, remaining_uses=50,
    ),
]

discount = apply_coupons(Decimal("8000"), coupons, now)
print(discount)
# SUMMER10: 8000*10/100=800円(上限1000円未満なのでそのまま) + FREESHIP500: 500円(併用可なので加算)
# → Decimal("1300")

print(coupons[0].remaining_uses)  # → 99 (consume_one_useで内部的に減算)
print(coupons[1].remaining_uses)  # → 49
ポイント適用した設計原則/パターン書籍対応章
Couponにis_applicable/calculate_discount/consume_one_useを実装データクラス問題の解消(データと振る舞いの一体化)Ch1-2
cd/tp/v/flg/tmp等を目的が読み取れる名前に変更意味不明な命名の解消Ch1-2
ガード節(早期continue)で多段ifを解消ネストのフラット化Ch1-2
discount_type=="rate"をStrEnum比較に変更マジックストリングの排除Ch1-2/Ch10
consume_one_use()で残数減算をクーポン自身に閉じ込めカプセル化Ch1-2/Ch3
# tests/test_coupon.py
import pytest
from datetime import datetime
from decimal import Decimal
from coupon import Coupon, DiscountType, apply_coupons


class TestCouponIsApplicable:
    def test_expired_coupon_is_not_applicable(self):
        coupon = Coupon(
            code="EXPIRED", discount_type=DiscountType.FIXED, value=Decimal("100"),
            expires_at=datetime(2026, 1, 1),
        )
        assert coupon.is_applicable(Decimal("5000"), datetime(2026, 8, 15)) is False

    def test_below_minimum_purchase_is_not_applicable(self):
        coupon = Coupon(
            code="MIN3000", discount_type=DiscountType.FIXED, value=Decimal("100"),
            minimum_purchase_amount=Decimal("3000"),
        )
        assert coupon.is_applicable(Decimal("2000"), datetime(2026, 8, 15)) is False


class TestCouponCalculateDiscount:
    def test_rate_discount_is_clipped_by_maximum(self):
        coupon = Coupon(
            code="RATE10", discount_type=DiscountType.RATE, value=Decimal("10"),
            maximum_discount_amount=Decimal("500"),
        )
        assert coupon.calculate_discount(Decimal("10000")) == Decimal("500")


class TestApplyCoupons:
    def test_non_stackable_coupon_applied_once_only(self):
        now = datetime(2026, 8, 15)
        coupons = [
            Coupon(code="A", discount_type=DiscountType.FIXED, value=Decimal("100"), is_stackable=False),
            Coupon(code="B", discount_type=DiscountType.FIXED, value=Decimal("200"), is_stackable=False),
        ]
        assert apply_coupons(Decimal("5000"), coupons, now) == Decimal("100")

    def test_remaining_uses_is_decremented(self):
        now = datetime(2026, 8, 15)
        coupon = Coupon(code="C", discount_type=DiscountType.FIXED, value=Decimal("100"), remaining_uses=1)
        apply_coupons(Decimal("5000"), [coupon], now)
        assert coupon.remaining_uses == 0

問題 B: インフラ — coupon-service の Cloud SQL コネクション枯渇対策(PgBouncer × リードレプリカ × Workload Identity)

問題Aのクーポン適用ロジックは coupon-service として GKE Autopilot 上で稼働し、Cloud SQL for PostgreSQL に接続しています。セール当日は HPA で最大30レプリカまでスケールしますが、以下の課題があります。

現状の課題:
  • coupon-service の各Podが Cloud SQL primary へ直接コネクションを張っており、コネクションプーラーが存在しない。30レプリカまでスケールするとmax_connectionsを容易に超過する
  • クーポン検索(読み取り、全トラフィックの約9割)と残数消費(書き込み)がすべてprimaryに集中しており、リードレプリカが活用されていない
  • DB接続情報がKubernetes Secretに平文で格納されており、Secret Managerによる一元管理・自動ローテーションの対象外
  • statement_timeoutが未設定で、遅いクエリがコネクションを握り続け枯渇を悪化させる
  • 残数消費処理に排他制御がなく、在庫僅少のクーポンへの同時リクエストでLost Updateにより残数がマイナスまで消費されうる
  • Cloud SQLのアクティブコネクション数とmax_connectionsの比率を監視するアラートが未設定

要件

#要件
1PgBouncer(transaction poolingモード)をCloud SQL Auth Proxyと組み合わせ、レプリカ数が増えてもmax_connectionsを超えないプールサイズに固定すること
2クーポン検索はリードレプリカへ、残数消費はprimaryへ、という読み書き分離のコネクション経路を用意すること
3DB認証情報をSecret Managerで管理し、GKEWorkload Identity Federation経由でアクセスさせること
4コネクション/プールレベルでstatement_timeoutを設定すること
5残数減算はUPDATE...WHERE remaining_uses>0 RETURNING...の原子的な1クエリで行い、Lost Updateを防ぐこと
6Cloud SQLのpostgresql/num_backendsCloud Monitoringで監視しアラートすること
期待する回答形式: Terraform(Cloud SQLリードレプリカ + Secret Manager + Workload Identityバインディング)+ Kubernetesマニフェスト(PgBouncer + Cloud SQL Auth Proxy構成)+ Python(読み書き分離リポジトリ + 原子的な残数減算)+ Bad vs Good比較表 + 確認コマンド

ヒント B(段階的開示)

ヒント1 — 方向性
コネクション枯渇は「入口を絞らないまま出口(レプリカ数)を増やし続けた」結果として起きる典型的な障害パターン。対処は「コネクションプーラーで実際のDBコネクション数に上限を設ける」「読み取りと書き込みを別の経路に分ける」「遅いクエリでコネクションを塞がせない」という3点の組み合わせ。Lost Updateはこれとは別の問題で、「読んでから書く」という2ステップ操作を並行実行すると起きる典型的な競合状態であり、1つの原子的なSQL文に凝縮することで解決する。
ヒント2 — アプローチ
  • google_sql_database_instancereplica_configuration でリードレプリカを作成
  • PgBouncerを primary用/replica用の2インスタンスに分け、pool_mode: transactiondefault_pool_sizeはレプリカ数上限×プールサイズがmax_connectionsを超えない値に設定
  • Cloud SQL Auth Proxyはサイドカーとして配置し、Workload Identity FederationでIAM認証(サービスアカウントキー不要)
  • psycopgの接続オプションに-c statement_timeout=3000を付与
  • 残数減算はUPDATE coupons SET remaining_uses = remaining_uses - 1 WHERE code = %s AND remaining_uses > 0 RETURNING remaining_usesの1文で行い、RETURNINGの有無で成功/失敗を判定
ヒント3 — リソースの骨格
PgBouncerの骨格(K8sマニフェスト)
- name: pgbouncer-primary
  image: edoburu/pgbouncer:1.22.1
  env:
    - name: DATABASE_URL
      value: "postgresql://coupon_app@127.0.0.1:5432/coupon"
    - name: POOL_MODE
      value: "transaction"
    - name: DEFAULT_POOL_SIZE
      value: "5"   # 30レプリカ×5=150 < max_connections 200
  ports: [{ containerPort: 6432 }]
原子的な残数減算(SQL)
UPDATE coupons
SET remaining_uses = remaining_uses - 1
WHERE code = %s AND remaining_uses > 0
RETURNING remaining_uses;
-- RETURNINGに行が返らなければ「既に残数0」= 減算失敗

アーキテクチャ図 — PgBouncer プールサイズ固定 × 読み書き分離 × Workload Identity

GKE Autopilot — coupon-service (HPA 2〜30 replicas) app コンテナ DB_WRITE_DSN → 127.0.0.1:6432 (primary用PgBouncer) DB_READ_DSN → 127.0.0.1:6433 (replica用PgBouncer) 修正3: serviceAccountName=coupon-service (Workload Identity) 修正1: pgbouncer-primary pool_mode=transaction default_pool_size=5 修正2: pgbouncer-replica pool_mode=transaction default_pool_size=5 cloud-sql-proxy-primary 修正3: IAM認証(SAキー不要) cloud-sql-proxy-replica 修正3: IAM認証(SAキー不要) coupon-db-primary max_connections=200 書き込み(残数消費) 修正5: UPDATE...RETURNING coupon-db-replica 修正2: 読み取り専用 クーポン検索(9割トラフィック) pg_is_in_recovery()=true Secret Manager 修正3: coupon-db-password K8s Secret平文格納を廃止 secretAccessor権限 Workload Identity coupon-service SA roles/iam.workloadIdentityUser roles/cloudsql.client 修正6: Cloud Monitoring アラート num_backends / max_connections > 0.8 (160/200) 枯渇の予兆を検知しPagerDutyへ通知 Before → After Before: 各Podがprimaryへ直接接続 30レプリカ × アプリ側プール設定 → max_connections超過 読み取りもすべてprimaryに集中(replica未活用) K8s Secretに平文パスワード statement_timeoutなし → 遅いクエリがコネクション占有 SELECT→判定→UPDATEの3段階 → Lost Updateの危険 After: PgBouncer transaction pooling固定上限 30レプリカ×5コネクション=150 < max_connections 200 検索はreplica経由、残数消費のみprimary経由 Secret Manager + Workload Identity Federation statement_timeout=3秒で強制打ち切り UPDATE...WHERE...RETURNINGで原子化 ✓ コネクション枯渇の根本原因(入口/出口/滞留時間)をすべて解消 ✓ 認証情報の平文格納・Lost Update・監視欠如も同時に解決

模範解答 B

resource "google_sql_database_instance" "coupon_db_primary" {
  name             = "coupon-db-primary"
  database_version = "POSTGRES_16"
  region           = "asia-northeast1"

  settings {
    tier = "db-custom-4-16384"
    ip_configuration {
      ipv4_enabled    = false
      private_network = google_compute_network.mops_vpc.id
    }
    database_flags {
      name  = "max_connections"
      value = "200"
    }
  }
}

# 修正2: 読み取り専用トラフィック(検索の約9割)をprimaryから分離するリードレプリカ
resource "google_sql_database_instance" "coupon_db_replica" {
  name                 = "coupon-db-replica"
  master_instance_name = google_sql_database_instance.coupon_db_primary.name
  database_version     = "POSTGRES_16"
  region                = "asia-northeast1"

  replica_configuration {
    failover_target = false
  }
  settings {
    tier = "db-custom-4-16384"
    ip_configuration {
      ipv4_enabled    = false
      private_network = google_compute_network.mops_vpc.id
    }
  }
}

# 修正3: DB認証情報をSecret Managerで一元管理(K8s Secretへの平文格納を廃止)
resource "google_secret_manager_secret" "coupon_db_password" {
  secret_id = "coupon-db-password"
  replication { auto {} }
}

resource "google_service_account" "coupon_service_sa" {
  account_id = "coupon-service"
}

resource "google_secret_manager_secret_iam_member" "coupon_service_secret_access" {
  secret_id = google_secret_manager_secret.coupon_db_password.id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.coupon_service_sa.email}"
}

resource "google_project_iam_member" "coupon_service_cloudsql_client" {
  project = var.project_id
  role    = "roles/cloudsql.client"
  member  = "serviceAccount:${google_service_account.coupon_service_sa.email}"
}

# 修正3(続き): GKE Workload Identity Federationバインディング(サービスアカウントキー不要)
resource "google_service_account_iam_member" "workload_identity_binding" {
  service_account_id = google_service_account.coupon_service_sa.name
  role                = "roles/iam.workloadIdentityUser"
  member              = "serviceAccount:${var.project_id}.svc.id.goog[mops/coupon-service]"
}

# 修正6: アクティブコネクション数がmax_connectionsに対して80%を超えたらアラート
resource "google_monitoring_alert_policy" "coupon_db_connection_saturation" {
  display_name = "coupon-db-primary connection saturation"
  combiner      = "OR"
  conditions {
    display_name = "num_backends / max_connections > 0.8"
    condition_threshold {
      filter          = "resource.type=\"cloudsql_database\" AND resource.labels.database_id=\"${var.project_id}:coupon-db-primary\" AND metric.type=\"cloudsql.googleapis.com/database/postgresql/num_backends\""
      comparison      = "COMPARISON_GT"
      threshold_value = 160  # max_connections=200の80%
      duration        = "60s"
    }
  }
  notification_channels = [google_monitoring_notification_channel.mops_pagerduty.id]
}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coupon-service
  namespace: mops
spec:
  replicas: 2  # HPAで最大30まで
  selector:
    matchLabels: { app: coupon-service }
  template:
    metadata:
      labels: { app: coupon-service }
    spec:
      serviceAccountName: coupon-service  # 修正3: Workload Identity Federation
      containers:
        - name: app
          image: asia-northeast1-docker.pkg.dev/PROJECT_ID/mops/coupon-service@sha256:abc123
          env:
            - name: DB_WRITE_DSN
              value: "postgresql://coupon_app@127.0.0.1:6432/coupon"  # 修正1/2: PgBouncer経由(primary=書き込み)
            - name: DB_READ_DSN
              value: "postgresql://coupon_app@127.0.0.1:6433/coupon"  # 修正2: PgBouncer経由(replica=読み取り)
          resources:
            requests: { cpu: "250m", memory: "512Mi" }
            limits:   { cpu: "250m", memory: "512Mi" }

        # 修正1: primary用PgBouncer(transaction pooling)
        - name: pgbouncer-primary
          image: edoburu/pgbouncer:1.22.1
          env:
            - name: DATABASE_URL
              value: "postgresql://coupon_app@127.0.0.1:5432/coupon"  # cloud-sql-proxy(primary)経由
            - name: POOL_MODE
              value: "transaction"  # トランザクション単位でコネクションを使い回す
            - name: MAX_CLIENT_CONN
              value: "1000"
            - name: DEFAULT_POOL_SIZE
              value: "5"  # 30レプリカ×5=150 < max_connections 200(replicaと合わせても余裕を残す)
          ports: [{ containerPort: 6432 }]

        # 修正2: replica用PgBouncer(読み取り専用トラフィック)
        - name: pgbouncer-replica
          image: edoburu/pgbouncer:1.22.1
          env:
            - name: DATABASE_URL
              value: "postgresql://coupon_app@127.0.0.1:5433/coupon"
            - name: POOL_MODE
              value: "transaction"
            - name: DEFAULT_POOL_SIZE
              value: "5"
          ports: [{ containerPort: 6433 }]

        # 修正3: Cloud SQL Auth Proxy(primary) — Workload IdentityによるIAM認証、SAキー不要
        - name: cloud-sql-proxy-primary
          image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.0
          args:
            - "--structured-logs"
            - "--port=5432"
            - "PROJECT_ID:asia-northeast1:coupon-db-primary"
          resources:
            requests: { cpu: "100m", memory: "128Mi" }

        # 修正3: Cloud SQL Auth Proxy(replica)
        - name: cloud-sql-proxy-replica
          image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.0
          args:
            - "--structured-logs"
            - "--port=5433"
            - "PROJECT_ID:asia-northeast1:coupon-db-replica"
          resources:
            requests: { cpu: "100m", memory: "128Mi" }
"""coupon_repository.py — 読み書き分離(修正2) × statement_timeout(修正4) × 原子的な残数減算(修正5)"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Final

import psycopg

# 名前付き定数: 遅いクエリでコネクションを握り続けさせない上限(修正4)
_STATEMENT_TIMEOUT_MS: Final[int] = 3000


@dataclass(frozen=True, slots=True)
class CouponRecord:
    code: str
    remaining_uses: int | None


class CouponRepository:
    """クーポンの検索(読み取りレプリカ)と残数消費(primary)を分離するリポジトリ。

    Args:
        write_dsn: 書き込み用(primary経由PgBouncer)のDSN。
        read_dsn: 読み取り用(replica経由PgBouncer)のDSN。
    """

    def __init__(self, write_dsn: str, read_dsn: str) -> None:
        self._write_dsn = write_dsn
        self._read_dsn = read_dsn

    def find_by_code(self, code: str) -> CouponRecord | None:
        """クーポンコードで検索する(修正2: 読み取りレプリカへルーティング)。"""
        with psycopg.connect(
            self._read_dsn, options=f"-c statement_timeout={_STATEMENT_TIMEOUT_MS}"  # 修正4
        ) as conn:
            row = conn.execute(
                "SELECT code, remaining_uses FROM coupons WHERE code = %s",
                (code,),
            ).fetchone()
            return CouponRecord(code=row[0], remaining_uses=row[1]) if row else None

    def consume_one_use(self, code: str) -> bool:
        """残数を原子的に1減算する(修正5: SELECT→アプリ判定→UPDATEの2段階操作をやめ、
        1つのUPDATE文でLost Updateを防ぐ)。

        Args:
            code: 消費対象のクーポンコード。

        Returns:
            減算に成功した場合True。残数が既に0で減算できなかった場合False。
        """
        with psycopg.connect(
            self._write_dsn, options=f"-c statement_timeout={_STATEMENT_TIMEOUT_MS}"  # 修正4
        ) as conn:
            row = conn.execute(
                """
                UPDATE coupons
                SET remaining_uses = remaining_uses - 1
                WHERE code = %s AND remaining_uses > 0
                RETURNING remaining_uses
                """,
                (code,),
            ).fetchone()
            conn.commit()
            return row is not None

Bad vs Good 設計比較

観点Bad(現状)Good(改善後)
コネクション経路各Podがprimaryへ直接接続、30レプリカでmax_connections超過PgBouncer(transaction pooling)経由でプールサイズを固定上限化
読み書き分離読み取り9割もprimaryに集中検索はreplica経由PgBouncer、残数消費のみprimary経由
DB認証情報K8s Secretに平文パスワードSecret Manager + Workload Identity Federation(IAM認証)
遅いクエリ対策statement_timeoutなし、コネクション握り続けstatement_timeout=3秒で強制打ち切り
残数消費の競合安全性SELECT→アプリ判定→UPDATE(Lost Updateの危険)UPDATE...WHERE remaining_uses>0 RETURNINGの1文で原子化
監視コネクション枯渇の予兆を検知できないnum_backends/max_connections比率にCloud Monitoringアラート

確認コマンド

# 1. PgBouncerがtransaction poolingモードで起動していることを確認
kubectl exec -n mops deploy/coupon-service -c pgbouncer-primary -- cat /etc/pgbouncer/pgbouncer.ini | grep pool_mode
# Expected: pool_mode = transaction

# 2. Cloud SQL replicaへの接続確認(recovery mode=trueならレプリカ)
kubectl exec -n mops deploy/coupon-service -c app -- psql "$DB_READ_DSN" -c "SELECT pg_is_in_recovery();"
# Expected: t

# 3. アクティブコネクション数の監視値を確認
gcloud monitoring time-series list \
  --filter='metric.type="cloudsql.googleapis.com/database/postgresql/num_backends"' \
  --format="table(points[0].value.int64Value)"
# Expected: max_connections(200)を大きく下回る値で安定

# 4. 残数消費の競合安全性を確認(remaining_uses=1のクーポンに同時2リクエスト)
for i in 1 2; do
  kubectl exec -n mops deploy/coupon-service -c app -- \
    python -c "from coupon_repository import CouponRepository; \
    repo = CouponRepository('$DB_WRITE_DSN', '$DB_READ_DSN'); \
    print(repo.consume_one_use('LOWSTOCK1'))" &
done
wait
# Expected: 片方はTrue、もう片方はFalse(remaining_usesがマイナスにならない)

# 5. Secret ManagerからDB認証情報が取得できていることを確認(K8s Secretに平文パスワードがないこと)
kubectl get secret -n mops coupon-db-credentials 2>&1
# Expected: Error from server (NotFound) — Secret Manager + Workload Identityへ完全移行済み

ポイント解説

カテゴリ A

1 データクラス問題の本質は「データと、そのデータに関する判断ロジックが別々の場所にある」こと(Ch1-2)
CouponDataが単なる入れ物のままだと、「有効かどうか」を判定するコードを書くたびにapplyのような外部関数が全フィールドを覗き込む必要がある。Coupon.is_applicable()のようにクラス自身にロジックを持たせると、判定ルールの変更が1箇所(クラス定義)に閉じる。
2 省略形の命名は「読む人が定義元に戻る回数」を増やすコスト
tp/v/mn/mxは書く側には短くて楽だが、読む側は毎回__init__のコメントに戻る必要がある。discount_type/value/minimum_purchase_amountのような完全な名前は、コメントなしでも意味が通るようになる。
3 ガード節(早期continue/return)はネストの深さを「条件の数」ではなく「条件を除いた後の本体」だけに保つ
元のコードは4段ネストの奥に本体処理があったが、Good版では本体(割引加算・残数消費)が常にトップレベルの1段で読める。

カテゴリ B

4 コネクションプーラーは「アプリの並行度」と「DBが許容できる同時接続数」の間のギャップを吸収する層
HPAでレプリカ数を増やすほど直接接続では線形にコネクション数が増えるが、PgBouncerのtransaction poolingは実際のトランザクション実行中だけDBコネクションを占有するため、少数のDBコネクションで多数のクライアント接続を捌ける。
5 読み書き分離は「読み取りが大半のワークロード」に対してコスト効率よくスケールする定石
クーポン検索のような読み取りをreplicaに逃すことで、primaryのコネクション・CPUリソースを書き込み(残数消費)に集中させられる。
6 Lost Updateは「読んでから書く」を2つの独立した文に分けたときに発生する
SELECTでremaining_usesを読み、アプリ側で>0を判定し、別のUPDATE文で書き込む、という3段階の間に別リクエストが割り込むと、両方が同じ古い値を元に減算してしまう。UPDATE...WHERE...RETURNINGのように「条件チェックと更新を1つの原子的な文にする」ことで、DBのロック機構に競合解決を委ねられる。

実務への応用

  • Couponのようにデータクラス問題を解消するリファクタリングは、MOpsの他のドメイン(キャンペーン設定・ポイント付与ルール・A/Bテストのバリアント設定等)にも同じ形で横展開できる: 「このクラスは自分の状態について何か判断できるか、それとも外部の関数が判断しているか」をコードレビューの観点に加えると発見しやすい
  • ガード節によるネストのフラット化は、条件分岐が3段以上になったコードすべてに機械的に適用できる即効性の高いリファクタリングで、レビューコストが低い割に可読性の改善幅が大きい
  • PgBouncer + リードレプリカの読み書き分離パターンは、coupon-serviceに限らずセール当日にトラフィックが跳ねる全MOpsサービス(クーポン・在庫・レコメンド)に横展開できる標準構成。読み取り比率が高いサービスほど効果が大きい
  • UPDATE...WHERE...RETURNINGによる原子的な減算パターンは、在庫数・クーポン残数・キャンペーン予算消費額など「複数リクエストから同時に減らされうる数値」全般に適用できる: SELECT FOR UPDATEより単純で、明示的なトランザクション制御なしに競合を解決できる

今日のまとめ

カテゴリAでは、8フィールドを持ちながらロジックを一切持たない CouponData(データクラス問題)と、省略形の命名・深いネストが同居していたクーポン適用ロジックを、Couponis_applicable/calculate_discount/consume_one_use を持たせてデータと振る舞いを一体化し、ガード節でネストをフラット化した(Ch1-2)。

カテゴリBでは、その Coupon.consume_one_use() を実運用で支える coupon-service のCloud SQL接続を、PgBouncerによるプールサイズ固定読み書き分離Workload Identity Federation原子的なUPDATE...RETURNINGで堅牢化した。どちらも共通するのは「本来1箇所にまとまるべき責務(データの意味、DBコネクションの管理、更新の原子性)が、複数の場所に薄く広く漏れ出していたものを、専用の場所に集約する」という設計思想である。

次のステップ

  • 発展問題: apply_coupons に「クーポンの適用順序(割引額が大きい順)」というビジネスルールを追加し、Couponのデータクラス問題を再発させずに(ソートロジックをどこに置くべきか)実装する
  • 発展問題: PgBouncerのプールサイズをセール当日のトラフィック予測に応じて動的に変更する運用(HPAのレプリカ数と連動したtfvars切り替え)を設計する
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」Ch1-2(悪しき構造・設計の初歩)、PgBouncer pool_mode ドキュメント、Cloud SQL 読み取りレプリカ、PostgreSQL UPDATE...RETURNING

自己評価(あとで記入)

自分の回答

気づき・メモ