概要
ネストした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・型ヒントを用いること
悪いコード (Before) — カテゴリ A
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
ヒント 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
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
"""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に統一 | 早期return | Ch8 |
| _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件の注文を単一プロセスで逐次処理している。
- 処理時間: 平均47分
- ピークメモリ使用量: 1.4GiB(全注文をいったんリストへロードしてから処理する実装のため)
- 平均CPU使用率: 28%(各注文ごとに
order-serviceへの同期HTTP呼び出し(往復約60ms)を挟んでおり、大半の時間がI/O待ち)
- 単一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による新規作成になっており、リトライで同一注文に対して割引レコードが重複作成されるリスクがある
要件
| # | 要件 |
|---|---|
| 1 | GKE Autopilot上のArgo CronWorkflowとして再設計し、DAGで注文をN個のシャードに分割してfan-out並列処理すること |
| 2 | シャード数は、目標シャード処理時間(1シャード5分以内)と実測の全体処理時間(47分)から根拠を示して決定すること |
| 3 | 各シャードPodのCPU/メモリのrequests・limitsを、Cloud Profilerの実測値(1.4GiB・CPU使用率28%)を根拠に計算式付きで算出すること |
| 4 | CPU使用率が低い原因(I/O待ち)を踏まえ、各シャード内ではasyncio + 同時接続数を絞ったHTTPクライアントで並行I/Oを行うこと |
| 5 | リトライはシャード単位とし、retryStrategy(バックオフ+最大試行回数)を設定すること |
| 6 | order-serviceへの書き込みをPATCH(upsert)+Idempotency-Keyに変更し、再実行しても割引レコードが重複しないようにすること |
| 7 | OpenTelemetryで処理件数・失敗件数を計装し、DataDogでワークフロー全体のdurationにSLO(45分以内)アラートを設定すること |
| 8 | concurrencyPolicyで、前日分が未完了のまま次のスケジュールが来た場合に二重実行が起きないようにすること |
ヒント B(段階的開示)
ヒント1 — 方向性
ヒント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:
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}}"
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 による並列シャーディング
模範解答 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
テーブル(dict)として1箇所に集約すれば、修正箇所を探す作業が「該当行を1行編集する」だけになり、隣接ブロックの誤編集を構造的に防げる。
同じ関数に混在させると、片方を変更するときにもう片方まで壊すリスクが生まれる。独立した小さな関数に分離すると、それぞれを単独でテストできる。
今回、キャンペーンボーナスがHIGH帯とMID帯で異なる値になっていたことがリファクタリング中に発覚した。この種の「意図不明な不揃い」はネストしたif文の中に隠れていると気づきにくい。
カテゴリ B
I/O待ちが支配的な処理では、コア数を増やす垂直スケーリングはほぼ効果がなく、同時に処理する件数を増やす(並行化・水平分割)方が効く。
「なぜこの値か」を後から説明できる設計にしておくと、コスト最適化のレビュー時にも根拠を示せる。
Argo WorkflowsのDAGは「独立して失敗・独立してリトライできる単位」への分割を表現するための仕組みである。
POST(新規作成)をPATCH(upsert)+Idempotency-Keyに変えるだけで、リトライに対して安全な設計になる。計算ロジック自体を純粋関数に保つ(カテゴリAの設計)ことも冪等性を支える前提になっている。
実務への応用
- テーブル駆動 × ポリシー関数分離は、割引率計算以外にも「複数の条件の組み合わせで挙動が変わるビジネスルール」全般(送料計算、ポイント付与率、与信スコアリング等)に横展開できる: マーケティングやビジネスサイドが「ルールの一覧」を確認したいとき、テーブル1つを見せれば完結する設計は、コミュニケーションコストの削減にも直結する
- 「CPU使用率が低い=リソースが余っている」と早合点せず、I/Oバウンドかどうかをプロファイリングで確認してから対策を選ぶ習慣は、他のバッチ処理(レポート集計・メール配信・在庫連携)のコスト最適化全般に応用できる
- Argo WorkflowsのDAGによるシャーディングパターン(list→fan-out→aggregate)は、大量データを扱う日次バッチ処理の標準形として、他のバッチにも横展開できる: 「VM cronで動いているレガシーバッチ」を棚卸しし、同様の移行対象を洗い出すことが次のアクションになる
今日のまとめ
カテゴリ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 のリソースリクエスト」