A+B 複合 — discount_calculator 条件分岐地獄 Bad→Good(第8章: 早期return × テーブル駆動 × ポリシーパターン)× price-promotion-calculationバッチのGKE Autopilot移行(Argo Workflows並列シャーディング × リソースサイジング × 冪等性)

2026-09-12 (Day 159) 土曜複合問題 ★★★★☆ Python 3.12 / テーブル駆動 / 第8章 GKE Autopilot / Argo Workflows / リソースサイジング

概要

🌳

ネストしたif/elifは「表」をコードで表現している

会員ランク×金額帯×キャンペーン×クーポンの組み合わせは、本質的には対応表(テーブル)。それを制御フローで表現すると、修正箇所を目視で探す作業になり修正ミスを誘発する(第8章)。

🧩

「基本値の決定」と「ボーナス加算」は別の関心事

同じ関数に混在させると、片方の変更がもう片方を巻き添えにする。独立した小さな関数(ポリシー)に分けると単独でテストできる。

🐢

CPU使用率28%は「処理が軽い」ではなく「I/O待ち」

外部API呼び出しの待ち時間が支配的な処理は、VMスペックを上げる垂直スケーリングではほとんど改善しない。並行化・水平分割が本質的な対策。

🔁

リトライ単位を細かくするほど障害は波及しない

単一プロセスの逐次処理は「全部やり直し」しかない。Argo WorkflowsのDAGでシャード単位に分割すれば、1つの失敗が他をブロックしない。

問題 A: コーディング — 条件分岐地獄(キャンペーン割引率計算 Bad→Good)

ECサイト MOps 販促システムチームが運用するキャンペーン割引率計算モジュール(discount_calculator.py)は、会員ランク・購入金額・キャンペーン会員かどうか・クーポンの有無と種別・累計注文数・初回購入かどうかから割引率を計算する。現在の実装は「会員ランク→購入金額→キャンペーン会員→クーポン→クーポン種別」という最大5階層のネストしたif/elif連鎖になっており、先月「gold会員向けキャンペーンのボーナス率を0.05から0.07に変更する」という一見単純な仕様変更で、修正すべき箇所を見誤り、silver会員のボーナス率まで誤って変更してしまうインシデントが発生した。問題点を全て洗い出した上で、「良いコード・悪いコードで学ぶ設計入門」第8章(条件分岐: 早期return、switch重複排除、ポリシーパターン)を使って Bad→Good にリファクタリングせよ。

制約・前提条件

  • Python 3.12+
  • customer_tierは"gold"/"silver"/"bronze"/"none"の4値、coupon_typeは"percentage"/"fixed"/Noneの3値
  • 割引率は単一のテーブル(定数)として1箇所に集約し、マーケティング担当者への説明時に「このテーブルを見れば全パターンの割引率が分かる」状態にすること
  • 「基本割引率の決定」と「ボーナス(キャンペーン会員・クーポン・初回購入・継続購入)の加算」は別々の関数に分離すること
  • 新しい会員ランク(例: "platinum")を追加する際、if/elif連鎖への追記が不要な設計にすること
  • 割引率の上限0.30は名前付き定数化し、なぜ0.30なのか(利益率死守ライン)をコメントで明記すること
  • @dataclass(frozen=True, slots=True)・StrEnum・Google スタイル docstring・型ヒントを用いること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章

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

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
discount_calculator.py — 5階層ネストの条件分岐
def calculate_discount_rate(
    customer_tier, purchase_amount, is_campaign_member,
    has_coupon, coupon_type, order_count, is_first_purchase,
):
    if customer_tier == "gold":
        if purchase_amount >= 50000:
            if is_campaign_member:
                if has_coupon:
                    if coupon_type == "percentage":
                        discount = 0.25
                    elif coupon_type == "fixed":
                        discount = 0.20
                    else:
                        discount = 0.22
                else:
                    discount = 0.20
            else:
                if has_coupon:
                    discount = 0.17
                else:
                    discount = 0.15
        elif purchase_amount >= 10000:
            if is_campaign_member:
                discount = 0.12  # HIGH帯は+0.05相当だがMID帯は+0.02相当
            else:
                discount = 0.10
        else:
            discount = 0.05
    elif customer_tier == "silver":
        if purchase_amount >= 50000:
            if is_campaign_member:
                discount = 0.15
            else:
                discount = 0.12
        elif purchase_amount >= 10000:
            if is_campaign_member:
                discount = 0.08
            else:
                discount = 0.06
        else:
            discount = 0.03
    elif customer_tier == "bronze":
        if purchase_amount >= 50000:
            discount = 0.08
        elif purchase_amount >= 10000:
            discount = 0.04
        else:
            discount = 0.0
    else:
        discount = 0.0

    if is_first_purchase:
        discount = discount + 0.05
    if order_count > 20:
        discount = discount + 0.03
    if discount > 0.30:  # 0.30の意味が説明されていない
        discount = 0.30
    return discount
問題点サマリー(7点)
1最大5階層のネストif/elif連鎖(Ch8) — 修正箇所を目視で探す必要があり、隣接ブロックの誤編集を誘発(実際に発生したインシデント)
2金額閾値50,000/10,000がtierごとに6箇所コピペ — 閾値変更時に全箇所を漏れなく直せる保証がない
3割引率がコードの奥深くに埋没、単一の真実源がない — マーケへの説明のたびコードを読み解く必要がある
4キャンペーンボーナスがtier/金額帯で暗黙的に不揃い(HIGH+0.05相当、MID+0.02相当) — 意図か実装ミスかコードから判別不能
5新tier追加でelif連鎖への丸ごと追記が必要(OCP違反)
6基本割引率決定とボーナス加算が同一関数に混在 — 異なる関心事が分離されていない
7上限0.30がマジックナンバー、理由がコメントなし

ヒント A(段階的開示)

ヒント1 — 方向性
if customer_tier == "gold": if purchase_amount >= 50000: if is_campaign_member: ...という書き方は、「ある条件の組み合わせに対してどんな値を返すか」という表(テーブル)で表現できる情報を、わざわざネストしたコードの制御フローとして表現している。この設計の最大の弱点は、「gold会員の高額帯のボーナス率を変えたい」という1行の仕様変更が、ネストの奥深くの特定のifブロックを正確に探し当てる作業になってしまうことだ(今回のインシデントはまさにこれで、隣接するsilverブロックを誤って編集してしまった)。第8章の核心は、「分岐条件と結果の対応関係を、コードの制御フローではなくデータ(テーブル)として表現し、1箇所にまとめる」という設計判断にある。加えて「基本割引率を決める」ことと「ボーナスを加算する」ことは別の関心事であり、それぞれ独立した小さな関数(ポリシー)に分離できないか考えてみてほしい。
ヒント2 — アプローチ
  • 購入金額をAmountTier(HIGH/MID/LOW)という中間概念に変換する関数を先に作り、閾値判定(50,000円・10,000円)を1箇所に集約する
  • dict[tuple[CustomerTier, AmountTier], float]という基本割引率テーブルを定義し、O(1)で基本割引率を引けるようにする
  • キャンペーン会員ボーナス・クーポンボーナス・初回購入/継続購入ボーナスは、それぞれ独立した関数(早期returnでガード)として実装する
  • 本体は「①amount_tierを求める → ②テーブルから基本割引率を引く → ③ボーナス関数群を合算する → ④上限でクランプする」という4ステップのパイプラインにする
ヒント3 — コードの骨格
_BASE_RATE_TABLE: Final[dict[tuple[CustomerTier, AmountTier], float]] = {
    (CustomerTier.GOLD, AmountTier.HIGH): 0.15,
    # ... 残り11パターン
}

def calculate_discount_rate(context: DiscountContext) -> float:
    amount_tier = _resolve_amount_tier(context.purchase_amount)
    base_rate = _BASE_RATE_TABLE[(context.customer_tier, amount_tier)]
    total_rate = (
        base_rate
        + _campaign_bonus(context, amount_tier)
        + _coupon_bonus(context)
        + _loyalty_bonus(context)
    )
    return min(total_rate, _MAX_DISCOUNT_RATE)

問題点分析 — カテゴリ A

#問題点分類改善方法
1最大5階層のネストif/elif連鎖条件分岐 Ch8テーブル駆動で制御フローをデータへ置換
2金額閾値が6箇所コピペCh8/Ch10_resolve_amount_tierに閾値判定を集約
3割引率がコードに埋没、単一の真実源なし保守性_BASE_RATE_TABLEへ一元化
4キャンペーンボーナスがtier/金額帯で不揃い仕様の曖昧さ明示ルールへ統一しIssue化
5新tier追加でelif連鎖への追記が必要OCP違反テーブルへの行追加のみで対応
6基本値決定とボーナス加算が同一関数に混在関心の分離 Ch6-7ポリシー関数群への分離
7上限0.30が無コメントのマジックナンバーCh10_MAX_DISCOUNT_RATE定数化+理由明記

模範解答 A

Before — ネストしたif/elif連鎖
def calculate_discount_rate(customer_tier, purchase_amount, ...):
    if customer_tier == "gold":
        if purchase_amount >= 50000:
            if is_campaign_member:
                ...  # さらに2階層のネストが続く
    elif customer_tier == "silver":
        ...  # goldとほぼ同じ構造がコピペされている
    elif customer_tier == "bronze":
        ...
    if is_first_purchase:
        discount = discount + 0.05  # ボーナス加算が基本値決定と同居
    if discount > 0.30:  # マジックナンバー
        discount = 0.30
    return discount
After — テーブル駆動 × ポリシー関数分離
"""discount_calculator.py — 条件分岐の解消(第8章)"""
from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum
from typing import Final


class CustomerTier(StrEnum):
    GOLD = "gold"
    SILVER = "silver"
    BRONZE = "bronze"
    NONE = "none"


class AmountTier(StrEnum):
    HIGH = "high"  # 50,000円以上
    MID = "mid"    # 10,000円以上50,000円未満
    LOW = "low"    # 10,000円未満


# 修正2: 金額帯の閾値を1箇所に集約(旧: 6箇所重複)
_AMOUNT_THRESHOLDS: Final[list[tuple[int, AmountTier]]] = [
    (10_000, AmountTier.LOW),
    (50_000, AmountTier.MID),
]


def _resolve_amount_tier(purchase_amount: int) -> AmountTier:
    """購入金額から金額帯を決定する(早期return)。"""
    for threshold, tier in _AMOUNT_THRESHOLDS:
        if purchase_amount < threshold:
            return tier
    return AmountTier.HIGH


# 修正3: 基本割引率テーブルを単一の真実源とする
_BASE_RATE_TABLE: Final[dict[tuple[CustomerTier, AmountTier], float]] = {
    (CustomerTier.GOLD, AmountTier.HIGH): 0.15,
    (CustomerTier.GOLD, AmountTier.MID): 0.10,
    (CustomerTier.GOLD, AmountTier.LOW): 0.05,
    (CustomerTier.SILVER, AmountTier.HIGH): 0.12,
    (CustomerTier.SILVER, AmountTier.MID): 0.06,
    (CustomerTier.SILVER, AmountTier.LOW): 0.03,
    (CustomerTier.BRONZE, AmountTier.HIGH): 0.08,
    (CustomerTier.BRONZE, AmountTier.MID): 0.04,
    (CustomerTier.BRONZE, AmountTier.LOW): 0.0,
    (CustomerTier.NONE, AmountTier.HIGH): 0.0,
    (CustomerTier.NONE, AmountTier.MID): 0.0,
    (CustomerTier.NONE, AmountTier.LOW): 0.0,
}

# 修正4: HIGH帯+0.05相当/MID帯+0.02相当という不揃いを、明示的な単一ルールへ統一
_CAMPAIGN_BONUS_RATE: Final[float] = 0.05
_COUPON_TYPE_BONUS: Final[dict[str, float]] = {"percentage": 0.05, "fixed": 0.0}
_COUPON_DEFAULT_BONUS: Final[float] = 0.02
_FIRST_PURCHASE_BONUS: Final[float] = 0.05
_LOYALTY_ORDER_COUNT_THRESHOLD: Final[int] = 20
_LOYALTY_BONUS_RATE: Final[float] = 0.03
_MAX_DISCOUNT_RATE: Final[float] = 0.30  # 修正7: 原価率70%ライン=利益率死守の上限


@dataclass(frozen=True, slots=True)
class DiscountContext:
    """割引率計算に必要な入力値(不変)。"""
    customer_tier: CustomerTier
    purchase_amount: int
    is_campaign_member: bool
    has_coupon: bool
    coupon_type: str | None
    order_count: int
    is_first_purchase: bool


def _campaign_bonus(context: DiscountContext, amount_tier: AmountTier) -> float:
    """キャンペーン会員ボーナス。HIGH帯のみ対象(修正6: 関心事の分離)。"""
    if not context.is_campaign_member:  # 早期return
        return 0.0
    if amount_tier != AmountTier.HIGH:
        return 0.0
    return _CAMPAIGN_BONUS_RATE


def _coupon_bonus(context: DiscountContext) -> float:
    """クーポン種別に応じたボーナス。"""
    if not context.has_coupon:  # 早期return
        return 0.0
    return _COUPON_TYPE_BONUS.get(context.coupon_type, _COUPON_DEFAULT_BONUS)


def _loyalty_bonus(context: DiscountContext) -> float:
    """初回購入・継続購入ボーナス。"""
    bonus = 0.0
    if context.is_first_purchase:
        bonus += _FIRST_PURCHASE_BONUS
    if context.order_count > _LOYALTY_ORDER_COUNT_THRESHOLD:
        bonus += _LOYALTY_BONUS_RATE
    return bonus


def calculate_discount_rate(context: DiscountContext) -> float:
    """会員ランク・購入金額・キャンペーン状況から割引率を計算する。

    Raises: なし(不正な組み合わせはCustomerTier/AmountTierの型で防止)。
    """
    amount_tier = _resolve_amount_tier(context.purchase_amount)
    base_rate = _BASE_RATE_TABLE[(context.customer_tier, amount_tier)]
    total_rate = (
        base_rate
        + _campaign_bonus(context, amount_tier)
        + _coupon_bonus(context)
        + _loyalty_bonus(context)
    )
    return min(total_rate, _MAX_DISCOUNT_RATE)
ctx1 = DiscountContext(
    customer_tier=CustomerTier.GOLD, purchase_amount=60_000,
    is_campaign_member=True, has_coupon=True, coupon_type="percentage",
    order_count=25, is_first_purchase=False,
)
print(calculate_discount_rate(ctx1))
# → 0.28  (base0.15 + campaign0.05 + coupon0.05 + loyalty0.03)

ctx2 = DiscountContext(
    customer_tier=CustomerTier.SILVER, purchase_amount=8_000,
    is_campaign_member=False, has_coupon=False, coupon_type=None,
    order_count=0, is_first_purchase=True,
)
print(calculate_discount_rate(ctx2))
# → 0.08  (base0.03 + first_purchase0.05)

ctx3 = DiscountContext(
    customer_tier=CustomerTier.GOLD, purchase_amount=100_000,
    is_campaign_member=True, has_coupon=True, coupon_type="percentage",
    order_count=50, is_first_purchase=True,
)
print(calculate_discount_rate(ctx3))
# → 0.30  (0.15+0.05+0.05+0.03+0.05=0.33 → 上限0.30でクランプ)
ポイント適用した設計原則/パターン書籍対応章
_resolve_amount_tierで閾値判定を早期returnに統一早期returnCh8
_BASE_RATE_TABLEでif/elif連鎖をデータへ置換テーブル駆動(switch重複排除)Ch8
_campaign_bonus/_coupon_bonus/_loyalty_bonusを独立関数化ポリシーパターンCh8/Ch6-7
DiscountContextをfrozen=True, slots=True値オブジェクトとしての不変性Ch3
_MAX_DISCOUNT_RATE等の定数化と理由明記マジックナンバー排除Ch10
# tests/test_discount_calculator.py
import pytest
from discount_calculator import (
    AmountTier, CustomerTier, DiscountContext, calculate_discount_rate,
)


class TestCalculateDiscountRate:
    def test_gold_high_campaign_percentage_coupon(self):
        ctx = DiscountContext(
            CustomerTier.GOLD, 60_000, True, True, "percentage", 25, False
        )
        assert calculate_discount_rate(ctx) == pytest.approx(0.28)

    def test_clamped_at_max_discount_rate(self):
        # 複数ボーナスが重なっても上限0.30を超えない回帰テスト
        ctx = DiscountContext(
            CustomerTier.GOLD, 100_000, True, True, "percentage", 50, True
        )
        assert calculate_discount_rate(ctx) == pytest.approx(0.30)

    def test_adding_new_tier_requires_no_if_elif_edit(self):
        # 新tier追加はテーブルへの行追加のみで完結することを示す回帰テスト
        from discount_calculator import _BASE_RATE_TABLE
        assert (CustomerTier.BRONZE, AmountTier.LOW) in _BASE_RATE_TABLE

問題 B: インフラ — price-promotion-calculationバッチのGKE Autopilot移行

問題Aのcalculate_discount_rateを実際に全注文へ適用する日次バッチprice-promotion-calculationは、現在Compute Engine VM(e2-standard-4: 4vCPU/16GB)上のcronで、深夜0:00 JSTに起動し、当日確定した380,000件の注文を単一プロセスで逐次処理している。

Cloud Profilerでの実測結果:
  • 処理時間: 平均47分
  • ピークメモリ使用量: 1.4GiB(全注文をいったんリストへロードしてから処理する実装のため)
  • 平均CPU使用率: 28%(各注文ごとにorder-serviceへの同期HTTP呼び出し(往復約60ms)を挟んでおり、大半の時間がI/O待ち)
現状(Bad)の課題:
  • 単一VM・単一プロセスの逐次処理のため、注文数の増加に対して処理時間がリニアに伸びるだけで水平スケールできない
  • 途中(例: 35,000件目)でorder-serviceが5xxを返し続けて失敗すると、cronジョブ全体が異常終了し、リトライ単位が「全体」しかないため既に成功した34,999件も含めて最初からやり直す設計になっている
  • VMスペック(4vCPU/16GB)が「余裕を見て」決め打ちされており、実測(CPU使用率28%・メモリ使用率約9%)との乖離が大きく、コストの大部分が使われないリソースに費やされている
  • CPU使用率が低い原因は「order-service呼び出しのネットワークI/O待ち」であるにもかかわらず、対策としてVMスペックを上げる垂直スケーリングが検討されている
  • cronの成否がVM上のログファイルにしか残らず、失敗時にSlack通知が飛ばない。DataDog Agentは導入済みだがこのバッチのメトリクスは一切送信されていない
  • 社内標準のArgo Workflowsを導入済みであるにもかかわらず、このバッチだけがVM cronのまま特別扱いされ、DAGによる部分リトライ・並列実行を活用できていない
  • order-serviceへの書き込みがPOSTによる新規作成になっており、リトライで同一注文に対して割引レコードが重複作成されるリスクがある

要件

#要件
1GKE Autopilot上のArgo CronWorkflowとして再設計し、DAGで注文をN個のシャードに分割してfan-out並列処理すること
2シャード数は、目標シャード処理時間(1シャード5分以内)と実測の全体処理時間(47分)から根拠を示して決定すること
3各シャードPodのCPU/メモリのrequests・limitsを、Cloud Profilerの実測値(1.4GiB・CPU使用率28%)を根拠に計算式付きで算出すること
4CPU使用率が低い原因(I/O待ち)を踏まえ、各シャード内ではasyncio + 同時接続数を絞ったHTTPクライアントで並行I/Oを行うこと
5リトライはシャード単位とし、retryStrategy(バックオフ+最大試行回数)を設定すること
6order-serviceへの書き込みをPATCH(upsert)+Idempotency-Keyに変更し、再実行しても割引レコードが重複しないようにすること
7OpenTelemetryで処理件数・失敗件数を計装し、DataDogでワークフロー全体のdurationにSLO(45分以内)アラートを設定すること
8concurrencyPolicyで、前日分が未完了のまま次のスケジュールが来た場合に二重実行が起きないようにすること
期待する回答形式: Argo CronWorkflow(YAML、DAG定義一式) + Python(シャード処理: asyncio並行I/O + 冪等書き込み + OTel計装) + リソースサイジングの計算根拠 + Bad vs Good 比較表 + 確認コマンド

ヒント B(段階的開示)

ヒント1 — 方向性
このバッチの本質的な特徴は、「CPUバウンドではなくI/Oバウンド」であるという点にある。CPU使用率28%という数字は「処理が軽い」ことを意味しない。「1件ごとに外部APIへの通信を待っている時間が大半」であることを意味する。この場合、VMのスペックを上げても処理時間はほとんど改善しない。有効な対策は2つの組み合わせになる。(1) 同時に処理する注文の数を増やす(並行I/O、または水平分割)ことで待ち時間そのものを重ね合わせて短縮する。(2) 処理単位を細かく分割することで一部が失敗しても全体をやり直さずに済むようにする。Argo WorkflowsのDAG(fan-out/fan-in)は、まさにこの「独立して実行・独立してリトライできる単位への分割」を表現するための仕組みである。
ヒント2 — アプローチ
  • シャード数 = ceil(現状の全体処理時間 ÷ 目標シャード処理時間)という単純な式で見積もる
  • 1シャードあたりのメモリrequestsは、全体実測メモリ ÷ シャード数を出発点に、Pythonランタイム自体のベースラインメモリ分の安全マージンを上乗せする
  • CPUは「コア数」ではなく「同時に開けるHTTP接続数」で律速するため、asyncio.Semaphoreで同時接続数の上限を明示し、CPU requestsはAutopilotが許容する小さめの値(例: 250m)から始める
  • ArgoのwithParamにシャードIDのJSON配列を渡し、process-shardテンプレートをfan-outさせる。各shardタスクにretryStrategyを個別設定する
  • 冪等性は「POST新規作成」を「PATCH upsert + Idempotency-Keyヘッダー」に変えるだけで実現できる
ヒント3 — リソースの骨格
DAGの骨格(Argo YAML)
dag:
  tasks:
    - name: list-target-orders
      template: list-target-orders
    - name: process-shard
      template: process-shard
      dependencies: [list-target-orders]
      arguments:
        parameters: [{name: shard-id, value: "{{item}}"}]
      withParam: "{{tasks.list-target-orders.outputs.parameters.shard-ids}}"
シャードPodのリソース骨格
resources:
  requests: {cpu: "250m", memory: "256Mi"}
  limits:   {cpu: "500m", memory: "512Mi"}
retryStrategy:
  limit: 3
  backoff: {duration: "30s", factor: "2"}

リソースサイジング根拠

シャード数 = ceil(現状の全体処理時間 ÷ 目標シャード処理時間)
           = ceil(47分 ÷ 5分) = ceil(9.4) = 10シャード

1シャードあたりの注文数 = 380,000件 ÷ 10 = 38,000件

メモリ requests = (全体実測ピークメモリ ÷ シャード数) + ランタイムベースライン
                = (1.4GiB ÷ 10) + 約116MiB(Pythonインタプリタ+HTTPコネクションプール等の実測ベースライン)
                ≈ 140MiB + 116MiB ≈ 256MiB → requests.memory = "256Mi"
メモリ limits   = requests × 2(突発的なGC遅延・一時的なバッファ増加への安全マージン) → "512Mi"

CPU requests    = I/O待ち律速のためコア数は効かない。Autopilotの最小割当に近い値から開始 → "250m"
CPU limits      = requests × 2 → "500m"
(同時実行の並行度はCPUコア数ではなくasyncio.Semaphoreの同時接続数(20)で制御する)

アーキテクチャ図 — Argo CronWorkflow による並列シャーディング

CronWorkflow schedule: 0 0 * * * (Asia/Tokyo) 修正8: concurrencyPolicy=Forbid 前日分未完了なら新規実行スキップ (二重実行防止) list-target-orders 対象380,000件を取得 order_idのhash%10で分割 shard-ids: [0..9] を出力 修正1・5: process-shard × 10 (fan-out、withParam) shard 0 38,000件 asyncio Semaphore(20) req:256Mi shard 1 38,000件 asyncio Semaphore(20) req:256Mi shard 2 38,000件 asyncio Semaphore(20) req:256Mi … shard 9 38,000件 asyncio Semaphore(20) req:256Mi 修正5: シャード単位retryStrategy limit=3, backoff(30s, factor=2) 1shard失敗が他の9shardをブロックしない (旧: VM cronは全体リトライしかなかった) CPU requests=250m / limits=500m order-service GET /orders/{id} 修正6: PATCH /orders/{id}/discount + Idempotency-Key POST新規作成 → PATCH upsertへ 変更し重複レコードを防止 応答60ms/req、往復I/O待ちが律速 aggregate-results (fan-in) 各shardの処理件数・失敗件数を集計し、ワークフロー全体の成否を判定 修正7: OTel計装 → DataDog orders_processed/failed カウンタ、duration SLOアラート Before → After Before: 単一VM逐次処理・全体リトライ・POST重複リスク e2-standard-4(4vCPU/16GB)上のcronで380,000件を逐次処理、平均47分 CPU使用率28%(I/O待ち)なのに垂直スケーリング(VMスペック向上)を検討 失敗時はジョブ全体が異常終了、リトライ単位が「全体」しかない POST新規作成のため、リトライで割引レコードが重複作成されるリスク 失敗時Slack通知なし、DataDogにメトリクス未送信 Argo Workflows導入済みだが、このバッチだけVM cronのまま特別扱い After: GKE Autopilot × Argo CronWorkflowによる並列シャーディング 10シャードへfan-out、各shard 38,000件をasyncio(同時接続20)で並行I/O処理 リソースrequests/limitsを実測(1.4GiB/28%CPU)から計算式で算出(req:256Mi/250m) retryStrategyでシャード単位リトライ、1shard失敗が他をブロックしない PATCH upsert + Idempotency-Keyで再実行しても割引レコードは重複しない OTelカウンタ計装 → DataDogでduration SLO(45分)アラート concurrencyPolicy=Forbidで前日分未完了時の二重実行を防止

模範解答 B

# workflows/price-promotion-calculation.yaml
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
  name: price-promotion-calculation
  namespace: mops-batch
spec:
  schedule: "0 0 * * *"
  timezone: "Asia/Tokyo"
  # 修正8: 前日分が未完了のまま次のスケジュールが来ても新規実行を作らない
  concurrencyPolicy: "Forbid"
  startingDeadlineSeconds: 600
  workflowSpec:
    entrypoint: main
    serviceAccountName: price-promotion-sa  # Workload Identity Federation経由でorder-service呼び出し権限を付与
    templates:
      - name: main
        dag:
          tasks:
            - name: list-target-orders
              template: list-target-orders
            # 修正1・5: 10シャードへfan-out。各shardは独立してリトライされ、他shardをブロックしない
            - name: process-shard
              template: process-shard
              dependencies: [list-target-orders]
              arguments:
                parameters:
                  - name: shard-id
                    value: "{{item}}"
              withParam: "{{tasks.list-target-orders.outputs.parameters.shard-ids}}"
            - name: aggregate-results
              template: aggregate-results
              dependencies: [process-shard]

      - name: list-target-orders
        container:
          image: asia-northeast1-docker.pkg.dev/mops/price-promotion:latest
          command: [python, -m, batch.list_target_orders]
          # 出力: [0,1,...,9] というshard-idのJSON配列(order_idのhash % 10で担当を分割)
        outputs:
          parameters:
            - name: shard-ids
              valueFrom:
                path: /tmp/shard-ids.json

      - name: process-shard
        inputs:
          parameters:
            - name: shard-id
        container:
          image: asia-northeast1-docker.pkg.dev/mops/price-promotion:latest
          command:
            - python
            - -m
            - batch.process_shard
            - "--shard-id={{inputs.parameters.shard-id}}"
            - "--shard-count=10"
          resources:
            requests: {cpu: "250m", memory: "256Mi"}  # 実測(1.4GiB/28%CPU)÷10shard+ベースライン
            limits:   {cpu: "500m", memory: "512Mi"}
        # 修正5: シャード単位のリトライ。1shardの失敗が他の9shardをブロックしない
        retryStrategy:
          limit: 3
          retryPolicy: "OnFailure"
          backoff: {duration: "30s", factor: "2", maxDuration: "5m"}

      - name: aggregate-results
        container:
          image: asia-northeast1-docker.pkg.dev/mops/price-promotion:latest
          command: [python, -m, batch.aggregate_results]
"""batch/process_shard.py — シャード単位の並列処理 × 冪等な書き込み(修正6) × OTel計装(修正7)"""
from __future__ import annotations

import argparse
import asyncio
from typing import Final

import httpx
from opentelemetry import metrics, trace

from discount_calculator import CustomerTier, DiscountContext, calculate_discount_rate

tracer = trace.get_tracer(__name__)
meter = metrics.get_meter(__name__)
_processed_counter = meter.create_counter("price_promotion.orders_processed")
_failed_counter = meter.create_counter("price_promotion.orders_failed")

# 修正4: order-serviceへの同時接続数を絞りI/O待ちを重ね合わせる(CPUコア数ではなくここが律速)
_MAX_CONCURRENT_REQUESTS: Final[int] = 20


async def process_order(client: httpx.AsyncClient, semaphore: asyncio.Semaphore, order_id: str) -> None:
    """1注文を取得・割引計算・冪等書き込みする。"""
    async with semaphore:
        with tracer.start_as_current_span("process_order") as span:  # 修正7
            span.set_attribute("order_id", order_id)
            try:
                order = (await client.get(f"/orders/{order_id}")).json()
                context = DiscountContext(
                    customer_tier=CustomerTier(order["customer_tier"]),
                    purchase_amount=order["purchase_amount"],
                    is_campaign_member=order["is_campaign_member"],
                    has_coupon=order["has_coupon"],
                    coupon_type=order.get("coupon_type"),
                    order_count=order["order_count"],
                    is_first_purchase=order["is_first_purchase"],
                )
                discount_rate = calculate_discount_rate(context)

                # 修正6: POST新規作成からPATCH upsertへ変更。リトライしても重複レコードを作らない
                await client.patch(
                    f"/orders/{order_id}/discount",
                    json={"discount_rate": discount_rate},
                    headers={"Idempotency-Key": f"price-promo-{order_id}-{order['target_date']}"},
                )
                _processed_counter.add(1)
            except httpx.HTTPError as exc:
                span.record_exception(exc)
                _failed_counter.add(1)
                raise  # ArgoのretryStrategyに処理を委ねる(shard単位リトライ)


async def process_shard(shard_id: int, shard_count: int) -> None:
    """担当シャード分の注文をasyncioで並行処理する。"""
    order_ids = await fetch_shard_order_ids(shard_id, shard_count)  # order_idのhash % shard_countで担当分を取得
    semaphore = asyncio.Semaphore(_MAX_CONCURRENT_REQUESTS)
    timeout = httpx.Timeout(connect=2.0, read=5.0)
    async with httpx.AsyncClient(base_url="https://order-service.internal", timeout=timeout) as client:
        await asyncio.gather(*(process_order(client, semaphore, oid) for oid in order_ids))


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--shard-id", type=int, required=True)
    parser.add_argument("--shard-count", type=int, required=True)
    args = parser.parse_args()
    asyncio.run(process_shard(args.shard_id, args.shard_count))

Bad vs Good 設計比較

観点Bad(現状)Good(改善後)
実行基盤単一VM(e2-standard-4)のcron逐次処理GKE Autopilot上のArgo CronWorkflow、10シャード並列fan-out
スケーラビリティ注文数増加に対しリニアに処理時間が伸びるシャード数を増やすだけで水平スケール
リトライ単位ジョブ全体(失敗時は最初からやり直し)シャード単位(他の9シャードをブロックしない)
リソースサイジング「余裕を見て」4vCPU/16GB決め打ち(実測乖離)プロファイリング実測(1.4GiB/28%CPU)を根拠に算出
並列化方針垂直スケーリング(VMスペック向上)を検討I/O待ち律速と判明しているため水平分割+asyncio並行接続
冪等性POST新規作成、リトライで重複レコード発生リスクPATCH upsert + Idempotency-Keyで再実行しても安全
可観測性ログファイルのみ、失敗時Slack通知なしOTelカウンタ計装+DataDog、durationのSLOアラート
二重実行防止cron単体では考慮なしconcurrencyPolicy: Forbidで前日分未完了時は新規実行をスキップ

確認コマンド

# 1. CronWorkflowが登録されていることを確認
argo cron list -n mops-batch
# Expected: price-promotion-calculation  Schedule=0 0 * * *  Suspended=false

# 2. 直近の実行が10シャード並列で走ったことを確認
argo get price-promotion-calculation-xxxxx -n mops-batch
# Expected: process-shard(0)〜process-shard(9)がすべてSucceeded

# 3. 各Podのリソース使用量がrequests/limitsの範囲内か確認
kubectl top pods -n mops-batch -l workflows.argoproj.io/workflow=price-promotion-calculation-xxxxx
# Expected: 各Podのメモリが256Mi〜512Miの範囲内

# 4. 冪等性: 同一注文への再実行で重複レコードが作られないことを確認
curl -s https://order-service.internal/orders/{order_id}/discount/history | jq 'length'
# Expected: 1 (再実行後も1件のまま)

# 5. ワークフロー全体のdurationがSLO(45分)以内か確認(DataDog)
# ダッシュボード: price_promotion.workflow_duration_seconds

ポイント解説

カテゴリ A

1 ネストしたif/elifは「分岐条件→結果」の対応表をコードの制御フローとして表現している(Ch8)
テーブル(dict)として1箇所に集約すれば、修正箇所を探す作業が「該当行を1行編集する」だけになり、隣接ブロックの誤編集を構造的に防げる。
2 「基本値の決定」と「ボーナスの加算」は別の関心事(Ch8/Ch6-7)
同じ関数に混在させると、片方を変更するときにもう片方まで壊すリスクが生まれる。独立した小さな関数に分離すると、それぞれを単独でテストできる。
3 テーブル駆動化は「暗黙の不揃い」を可視化する副産物がある
今回、キャンペーンボーナスがHIGH帯とMID帯で異なる値になっていたことがリファクタリング中に発覚した。この種の「意図不明な不揃い」はネストしたif文の中に隠れていると気づきにくい。

カテゴリ B

4 CPU使用率が低い=処理が軽いとは限らない(修正4)
I/O待ちが支配的な処理では、コア数を増やす垂直スケーリングはほぼ効果がなく、同時に処理する件数を増やす(並行化・水平分割)方が効く。
5 リソースのrequests/limitsは「余裕を見て」ではなく実測値から計算式で導く(サイジング根拠)
「なぜこの値か」を後から説明できる設計にしておくと、コスト最適化のレビュー時にも根拠を示せる。
6 リトライの単位を細かくするほど、部分的な障害が全体に波及しにくくなる(修正1・5)
Argo WorkflowsのDAGは「独立して失敗・独立してリトライできる単位」への分割を表現するための仕組みである。
7 冪等性は「書き込み方法」を変えるだけで実現できることが多い(修正6)
POST(新規作成)をPATCH(upsert)+Idempotency-Keyに変えるだけで、リトライに対して安全な設計になる。計算ロジック自体を純粋関数に保つ(カテゴリAの設計)ことも冪等性を支える前提になっている。

実務への応用

  • テーブル駆動 × ポリシー関数分離は、割引率計算以外にも「複数の条件の組み合わせで挙動が変わるビジネスルール」全般(送料計算、ポイント付与率、与信スコアリング等)に横展開できる: マーケティングやビジネスサイドが「ルールの一覧」を確認したいとき、テーブル1つを見せれば完結する設計は、コミュニケーションコストの削減にも直結する
  • 「CPU使用率が低い=リソースが余っている」と早合点せず、I/Oバウンドかどうかをプロファイリングで確認してから対策を選ぶ習慣は、他のバッチ処理(レポート集計・メール配信・在庫連携)のコスト最適化全般に応用できる
  • Argo WorkflowsのDAGによるシャーディングパターン(list→fan-out→aggregate)は、大量データを扱う日次バッチ処理の標準形として、他のバッチにも横展開できる: 「VM cronで動いているレガシーバッチ」を棚卸しし、同様の移行対象を洗い出すことが次のアクションになる

今日のまとめ

カテゴリAでは、5階層のネストしたif/elif連鎖によって「割引率テーブル」という本質的にはデータでしかない情報をコードの制御フローとして表現していた設計を、テーブル駆動×早期return×ポリシー関数分離(第8章)で書き直し、修正箇所を誤るリスクを構造的に減らした。

カテゴリBでは、その計算ロジックを使う日次バッチを、実測プロファイリング値を根拠にリソースサイジングした上でArgo Workflowsの並列シャーディングへ移行し、リトライ単位を「全体」から「シャード単位」へ縮小し、書き込みをPOSTからPATCH upsertへ変えて冪等性を確保した。両カテゴリに共通するのは、「暗黙のうちに1つの塊として扱われていたものを、独立して扱える単位に分割する」という視点である。

次のステップ

  • 発展問題: シャード数を「固定10」ではなく、その日の対象注文数に応じて動的に決定する設計(例: 40,000件を超えたら11シャード以上にする)を、Argo WorkflowsのDAGでどう表現するか検討せよ
  • 発展問題: _BASE_RATE_TABLEをコードにハードコードする代わりに、マーケティング担当者がテーブルを直接編集できるように、Firestore等の外部設定ストアから読み込む設計に変更する場合のキャッシュ戦略・反映タイミングを検討せよ
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第8章(条件分岐)、Argo Workflows公式ドキュメント「DAG Templates」「Retry」「CronWorkflow」、Google Cloud「GKE Autopilot のリソースリクエスト」

自己評価(あとで記入)

自分の回答

気づき・メモ