概要
プリミティブ型執着は「バリデーションの散逸」を招く
weight_kg/postal_code を生の float/str のまま扱うと、正の値チェックやフォーマット検証が呼び出し側ごとに重複・漏れする。Weight/PostalCode 値オブジェクトが自分自身の妥当性を保証する設計に置き換える(Ch5)。
ファクトリメソッドは「外部入力の検証」と「内部構築」を分離する
PostalCode.parse(raw) が外部入力(ハイフンあり/なし)を検証・正規化し、コンストラクタは常に正規化済みの値だけを受け付ける(完全コンストラクタ)。
staticメソッドは無関係な関心事の置き場になりやすい
燃料費特別付加運賃(業者契約条件)が料金計算クラスに static として同居していた。FuelSurchargePolicy に分離し、単一責任を回復する(Ch5/Ch6-7)。
DaemonSet + 共有Collectorでトレース基盤のコストと柔軟性を両立
DataDog Agentはノード単位のインフラ/ログ収集に専念させ、サンプリングやカーディナリティ制御という柔軟な処理は共有の OTel Collector(Deployment)に担わせる。
問題 A: コーディング — プリミティブ型執着 × staticメソッド誤用 × ファクトリメソッド(配送料金計算 Bad→Good)
以下の「悪いコード」は、ECサイト MOps チームが運用する配送料金計算モジュールです。重量(kg)や郵便番号を生のプリミティブ型(float/str)のまま扱い、配送業者の燃料費特別付加運賃という無関係な関心事までstaticメソッドとして同居させています。問題点を全て洗い出し、「良いコード・悪いコードで学ぶ設計入門」第5章(実践カプセル化: プリミティブ型執着・staticメソッド誤用・ファクトリメソッド)を使って Bad→Good にリファクタリングしてください。
制約・前提条件
- Python 3.12+
- 重量(kg)・配送料金(円)はプリミティブ型のまま扱わず、バリデーション付きの値オブジェクトとして表現すること
- 郵便番号は外部入力(ハイフンあり/なし混在)を受け取るファクトリメソッドでフォーマット検証を行い、コンストラクタは正規化済みの値のみを受け付けること(完全コンストラクタ)
- 配送ゾーンの判定は
if/elifの郵便番号prefix比較ではなく、データ(範囲テーブル)で表現し、未知の郵便番号はサイレントにデフォルトゾーンへフォールバックせず例外を送出すること - 燃料費特別付加運賃の計算は配送料金計算(重量×単価+ゾーン割増)と別の関心事として分離すること(staticメソッド誤用の是正)
- 端数処理ルール(四捨五入)を明示すること
- Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章
悪いコード (Before) — カテゴリ A
このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_shipping_fee.py — プリミティブ型執着・if/elif羅列・staticメソッド誤用・サイレントフォールバック
class ShippingFeeCalculator:
BASE_RATE_PER_KG = 150 # 問題3: 料金体系がマジックナンバーとしてクラス変数に散在
ZONE_SURCHARGE = {"kanto": 0, "kansai": 200, "hokkaido": 800, "okinawa": 1200}
@staticmethod
def calc(weight_kg, postal_code):
if postal_code.startswith(("10", "11", "12", "13", "14")): # 問題2/3: prefix比較のベタ書き
zone = "kanto"
elif postal_code.startswith(("53", "54", "55", "56", "57")):
zone = "kansai"
elif postal_code.startswith(("00", "01", "02", "03", "04")):
zone = "hokkaido"
elif postal_code.startswith("90"):
zone = "okinawa"
else:
zone = "kanto" # 問題6: 未知の郵便番号もサイレントに関東扱い(フォールバック)
fee = weight_kg * ShippingFeeCalculator.BASE_RATE_PER_KG # 問題1: weight_kgに負値・0チェックなし
fee += ShippingFeeCalculator.ZONE_SURCHARGE[zone]
return int(fee) # 問題7: 四捨五入か切り捨てか不明瞭なint()変換
@staticmethod
def apply_fuel_surcharge(fee, carrier): # 問題5: 料金計算と無関係な業者契約条件がstaticメソッドとして同居
if carrier == "yamato":
return fee * 1.05
elif carrier == "sagawa":
return fee * 1.03
return fee
def checkout(cart):
fee = ShippingFeeCalculator.calc(cart["weight"], cart["postal_code"]) # 問題2(続き): dictアクセスで型不明
fee = ShippingFeeCalculator.apply_fuel_surcharge(fee, cart.get("carrier", "yamato"))
return fee
問題点サマリー(7点)
1weight_kgをプリミティブ型のまま扱い0以下チェックなし(Ch5) — マイナス・0kgでも料金計算がそのまま実行される
2郵便番号もフォーマット検証なしの生文字列(Ch5) — dictアクセスで型不明、どんな入力でも素通り
3ゾーン判定がif/elifのprefix比較の羅列(Ch5/Ch8) — ゾーン追加・変更のたびに関数全体を修正
4料金体系がクラス変数にハードコード(Ch10) — 料金改定のたびにクラス定義を直接書き換え
5apply_fuel_surchargeが無関係な関心事として同居(staticメソッド誤用, Ch5) — 業者契約条件が料金計算クラスに混在
6未知の郵便番号prefixをelseで無条件kanto扱い(Ch10) — サイレントフォールバックで対応不能な配送先も料金計算が通る
7int(fee)の端数処理ルールが不明瞭(Ch10) — 四捨五入か切り捨てか暗黙的
ヒント A(段階的開示)
ヒント1 — 方向性
「重量」「郵便番号」はただの数値・文字列ではなく、それぞれ固有のルール(重量は正の値のみ、郵便番号は7桁固定フォーマット)を持つ概念。プリミティブ型のまま扱うと、バリデーションが呼び出し側に漏れ出し、あちこちで同じチェックが重複する。ゾーン判定の
if/elif 羅列も「新しい配送先が増えるたびに関数を書き換える」という拡張性の低さを生む。また、燃料費特別付加運賃(業者との契約条件)は「重量×単価+ゾーン割増」という基本料金計算とは別の関心事であり、staticメソッドとして同じクラスに同居させるべきではない。
ヒント2 — アプローチ
Weight/PostalCode/ShippingFeeをそれぞれfrozen dataclass(slots=True)の値オブジェクトにするPostalCodeはコンストラクタでは正規化済みの値のみ受け付け、外部入力の検証はPostalCode.parse(raw)ファクトリメソッドに担わせる(完全コンストラクタ + ファクトリメソッドの役割分担)- ゾーン判定は
if/elifではなくrangeとShippingZoneのタプルテーブルをforループで走査する形にし、該当なしはUnsupportedPostalCodeError FuelSurchargePolicyをShippingFeeCalculatorから独立したクラスとして切り出し、carrierごとの料率を保持する- 端数処理は
Decimal.quantize(Decimal("1"), rounding=ROUND_HALF_UP)で明示する
ヒント3 — コードの骨格
@dataclass(frozen=True, slots=True)
class Weight:
kg: Decimal
def __post_init__(self) -> None: ... # kg <= 0 は例外
@dataclass(frozen=True, slots=True)
class PostalCode:
digits: str
def __post_init__(self) -> None: ... # 7桁数字のみ許可
@classmethod
def parse(cls, raw: str) -> "PostalCode": ... # ハイフン除去+フォーマット検証
def resolve_zone(postal_code: PostalCode) -> ShippingZone:
... # 範囲テーブル走査、該当なしは例外
class ShippingFeeCalculator:
def calculate(self, weight: Weight, postal_code: PostalCode) -> ShippingFee: ...
@dataclass(frozen=True, slots=True)
class FuelSurchargePolicy:
carrier: Carrier
def apply(self, fee: ShippingFee) -> ShippingFee: ...
問題点分析 — カテゴリ A
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | weight_kgに0以下チェックなし | プリミティブ型執着 Ch5 | Weight値オブジェクトでバリデーション |
| 2 | 郵便番号のフォーマット検証なし | プリミティブ型執着 Ch5 | PostalCode.parse()ファクトリメソッド |
| 3 | ゾーン判定がif/elif羅列 | 条件分岐のデータ化 Ch5/Ch8 | 範囲テーブル(_ZONE_PREFIX_RANGES)走査 |
| 4 | 料金体系がクラス変数にハードコード | 設計の悪魔 Ch10 | 名前付き定数+マッピングに整理 |
| 5 | apply_fuel_surchargeの無関係な同居 | staticメソッド誤用 Ch5 | FuelSurchargePolicyへ分離 |
| 6 | 未知郵便番号のサイレントkantoフォールバック | フェイルファスト Ch10 | UnsupportedPostalCodeError送出 |
| 7 | 端数処理ルールが不明瞭 | 設計の悪魔 Ch10 | quantize(ROUND_HALF_UP)で明示 |
模範解答 A
Before — プリミティブ型執着・if/elif羅列・staticメソッド誤用
class ShippingFeeCalculator:
BASE_RATE_PER_KG = 150 # マジックナンバー
ZONE_SURCHARGE = {"kanto": 0, ...}
@staticmethod
def calc(weight_kg, postal_code): # プリミティブ型のまま
if postal_code.startswith(("10","11",...)): # if/elif羅列
zone = "kanto"
...
else:
zone = "kanto" # サイレントフォールバック
fee = weight_kg * BASE_RATE_PER_KG # 0以下チェックなし
fee += ZONE_SURCHARGE[zone]
return int(fee) # 端数処理ルール不明瞭
@staticmethod
def apply_fuel_surcharge(fee, carrier): # 無関係な関心事が同居
if carrier == "yamato":
return fee * 1.05
...
After — Weight/PostalCode値オブジェクト × ファクトリメソッド × FuelSurchargePolicy分離
"""shipping_fee.py — プリミティブ型執着の解消 × staticメソッド誤用の是正 × ファクトリメソッド(Ch5/Ch6-7/Ch8/Ch10)"""
from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from enum import StrEnum
from typing import Final
BASE_RATE_PER_KG: Final[Decimal] = Decimal("150")
_POSTAL_CODE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^\d{3}-?\d{4}$")
class InvalidPostalCodeError(ValueError): ...
class UnsupportedPostalCodeError(ValueError): ...
class InvalidWeightError(ValueError): ...
@dataclass(frozen=True, slots=True)
class Weight:
"""配送重量を表す値オブジェクト(プリミティブ型執着の解消, Ch5)。"""
kg: Decimal
def __post_init__(self) -> None:
if self.kg <= Decimal("0"):
raise InvalidWeightError(f"重量は0より大きい必要があります: {self.kg}")
@classmethod
def from_kg(cls, value: float | str) -> Weight:
"""キログラム単位の数値から生成するファクトリメソッド。"""
return cls(kg=Decimal(str(value)))
@classmethod
def from_grams(cls, grams: int) -> Weight:
"""グラム単位の整数から生成するファクトリメソッド。"""
return cls(kg=Decimal(grams) / Decimal("1000"))
@dataclass(frozen=True, slots=True)
class PostalCode:
"""郵便番号を表す値オブジェクト。コンストラクタは正規化済みの値のみ受付(完全コンストラクタ)。"""
digits: str
def __post_init__(self) -> None:
if not (len(self.digits) == 7 and self.digits.isdigit()):
raise InvalidPostalCodeError(f"郵便番号は7桁の数字である必要があります: {self.digits}")
@classmethod
def parse(cls, raw: str) -> PostalCode:
"""外部入力(ハイフンあり/なし)を検証・正規化するファクトリメソッド(Ch5)。"""
if not _POSTAL_CODE_PATTERN.match(raw):
raise InvalidPostalCodeError(f"郵便番号のフォーマットが不正です: {raw}")
return cls(digits=raw.replace("-", ""))
@property
def prefix3(self) -> str:
return self.digits[:3]
class ShippingZone(StrEnum):
KANTO = "kanto"; KANSAI = "kansai"; HOKKAIDO = "hokkaido"; OKINAWA = "okinawa"
# if/elif連鎖ではなくデータ(範囲テーブル)で表現(Ch5/Ch8)
_ZONE_PREFIX_RANGES: Final[tuple[tuple[range, ShippingZone], ...]] = (
(range(0, 50), ShippingZone.HOKKAIDO),
(range(100, 150), ShippingZone.KANTO),
(range(530, 580), ShippingZone.KANSAI),
(range(900, 908), ShippingZone.OKINAWA),
)
_ZONE_SURCHARGE: Final[dict[ShippingZone, Decimal]] = {
ShippingZone.KANTO: Decimal("0"), ShippingZone.KANSAI: Decimal("200"),
ShippingZone.HOKKAIDO: Decimal("800"), ShippingZone.OKINAWA: Decimal("1200"),
}
def resolve_zone(postal_code: PostalCode) -> ShippingZone:
"""郵便番号から配送ゾーンを解決(責務分離, Ch5/Ch6-7)。該当なしは例外(サイレントフォールバック禁止)。"""
prefix = int(postal_code.prefix3)
for prefix_range, zone in _ZONE_PREFIX_RANGES:
if prefix in prefix_range:
return zone
raise UnsupportedPostalCodeError(f"配送ゾーンを解決できない郵便番号です: {postal_code.digits}")
@dataclass(frozen=True, slots=True)
class ShippingFee:
yen: int
def __post_init__(self) -> None:
if self.yen < 0:
raise ValueError(f"配送料金は0円以上である必要があります: {self.yen}")
class ShippingFeeCalculator:
"""重量とゾーンから基本配送料金を計算する(燃料サーチャージ等の無関係な関心事は持たない, Ch5)。"""
def calculate(self, weight: Weight, postal_code: PostalCode) -> ShippingFee:
zone = resolve_zone(postal_code)
raw_fee = weight.kg * BASE_RATE_PER_KG + _ZONE_SURCHARGE[zone]
rounded = raw_fee.quantize(Decimal("1"), rounding=ROUND_HALF_UP) # 端数処理ルールを明示
return ShippingFee(yen=int(rounded))
class Carrier(StrEnum):
YAMATO = "yamato"; SAGAWA = "sagawa"
_FUEL_SURCHARGE_RATES: Final[dict[Carrier, Decimal]] = {
Carrier.YAMATO: Decimal("1.05"), Carrier.SAGAWA: Decimal("1.03"),
}
@dataclass(frozen=True, slots=True)
class FuelSurchargePolicy:
"""配送業者ごとの燃料費特別付加運賃ポリシー(ShippingFeeCalculatorから分離, Ch5/Ch6-7)。"""
carrier: Carrier
def apply(self, fee: ShippingFee) -> ShippingFee:
rate = _FUEL_SURCHARGE_RATES.get(self.carrier, Decimal("1"))
surcharged = (Decimal(fee.yen) * rate).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return ShippingFee(yen=int(surcharged))
def checkout(weight: Weight, postal_code: PostalCode, carrier: Carrier) -> ShippingFee:
base_fee = ShippingFeeCalculator().calculate(weight, postal_code)
return FuelSurchargePolicy(carrier).apply(base_fee)
weight = Weight.from_kg(3.2)
postal_code = PostalCode.parse("530-0001") # 大阪 → kansai
fee = checkout(weight, postal_code, Carrier.YAMATO)
print(fee)
# 基本料金: 3.2*150 + 200(kansai割増) = 680円
# 燃料サーチャージ: 680 * 1.05 = 714円
# → ShippingFee(yen=714)
# 不正な郵便番号フォーマットは生成時点でフェイルファスト
PostalCode.parse("12-345")
# → InvalidPostalCodeError: 郵便番号のフォーマットが不正です: 12-345
# ゾーン未対応の郵便番号は例外(旧実装のような無言の関東扱いはしない)
checkout(Weight.from_kg(1.0), PostalCode.parse("650-0001"), Carrier.SAGAWA)
# → UnsupportedPostalCodeError: 配送ゾーンを解決できない郵便番号です: 6500001
# 不正な重量も生成時点で例外
Weight.from_kg(-2.0)
# → InvalidWeightError: 重量は0より大きい必要があります: -2.0
| ポイント | 適用した設計原則/パターン | 書籍対応章 |
|---|---|---|
| Weight/PostalCode/ShippingFeeを値オブジェクト化 | プリミティブ型執着の解消 | Ch5 |
| PostalCode.parse()/Weight.from_kg()/from_grams() | ファクトリメソッド | Ch5 |
| apply_fuel_surchargeをFuelSurchargePolicyに分離 | staticメソッド誤用の是正・関心の分離 | Ch5/Ch6-7 |
| ゾーン判定を範囲テーブルで表現 | 条件分岐のデータ化 | Ch5/Ch8 |
| 未知の郵便番号でUnsupportedPostalCodeErrorを送出 | フェイルファスト・サイレントフォールバック禁止 | Ch10 |
| quantize(ROUND_HALF_UP)で端数処理ルールを明示 | 設計の悪魔対策 | Ch10 |
# tests/test_shipping_fee.py
import pytest
from decimal import Decimal
from shipping_fee import (
Weight, PostalCode, Carrier, checkout,
InvalidWeightError, InvalidPostalCodeError, UnsupportedPostalCodeError,
)
class TestCheckout:
def test_kansai_with_yamato_surcharge(self):
fee = checkout(Weight.from_kg(3.2), PostalCode.parse("530-0001"), Carrier.YAMATO)
assert fee.yen == 714
def test_unknown_zone_raises_instead_of_silent_fallback(self):
with pytest.raises(UnsupportedPostalCodeError):
checkout(Weight.from_kg(1.0), PostalCode.parse("650-0001"), Carrier.SAGAWA)
class TestWeight:
def test_zero_or_negative_raises(self):
with pytest.raises(InvalidWeightError):
Weight.from_kg(0)
with pytest.raises(InvalidWeightError):
Weight.from_kg(-1.5)
class TestPostalCode:
def test_parse_accepts_both_hyphen_forms(self):
assert PostalCode.parse("100-0001").digits == "1000001"
assert PostalCode.parse("1000001").digits == "1000001"
def test_parse_rejects_malformed_input(self):
with pytest.raises(InvalidPostalCodeError):
PostalCode.parse("12-345")
問題 B: インフラ — GKE Autopilot 配送料金計算サービスの DataDog Agent v7 OTLPネイティブ取り込み × トレーシング設計
問題Aの配送料金計算は shipping-fee-service として GKE Autopilot 上で稼働し、checkout-api から HTTP で呼び出されています。現状の可観測性構成には以下の課題があります。
現状の課題:
- DataDog Agent を各Podにサイドカーコンテナとして同居させており、Autopilotは Pod単位課金のため
shipping-fee-service(HPAで最大20レプリカ)分だけAgentのCPU/メモリも重複起動し、無駄なコストが発生している - 手動でOTel spanを生成しているがResource属性(
service.name/service.version/deployment.environment)を設定しておらず、DataDog APM Service Mapでunknown_serviceとして表示される checkout-api→shipping-fee-serviceの呼び出しでtraceparentヘッダーが伝播されておらず、1回の注文確定フローが2つの断絶したトレースとして記録されている- トレースのサンプリング設定が一切なく100%送信されており、フラッシュセール時のトラフィック急増でDataDog APMのIndexed Spans課金コストが跳ね上がる
postal_code/user_idをスパン属性としてそのまま付与しており、DataDog側の生成メトリクスタグとして扱われ高カーディナリティによるCustom Metricsのタイムシリーズ数爆発を招いている- アプリケーションログに
trace_id/span_idが含まれておらず、Log-Trace Correlationができない - OTLP exporterの送信先エンドポイントが平文(
http://)設定で、郵便番号等を含みうるトレースデータが暗号化されずに流れている
要件
| # | 要件 |
|---|---|
| 1 | DataDog AgentをDaemonSet(ノードに1つずつ)としてデプロイし、Podサイドカーの重複起動を解消すること |
| 2 | OTEL_RESOURCE_ATTRIBUTESを設定し、DataDog Service Mapに正しくサービスが表示されるようにすること |
| 3 | traceparent(W3C Trace Context)を自動伝播させ、1つの注文確定フローが1本のトレースとして繋がるようにすること |
| 4 | 共有OTel Collectorでヘッド型サンプリング(10%) + テール型サンプリング(エラーは100%保持)を設定すること |
| 5 | 高カーディナリティ属性はスパンには残しつつ、メトリクス生成タグには低カーディナリティな派生属性を使うこと |
| 6 | 構造化ログにdd.trace_id/dd.span_idを出力し、Log-Trace Correlationを有効化すること |
| 7 | OTLP exporterのエンドポイントをTLS化すること |
期待する回答形式: Python(OTel自動計装セットアップ + ログ相関)+ Kubernetes マニフェスト(DataDog Agent DaemonSet + OTel Collector パイプライン設定)+ Bad vs Good 比較表 + 確認コマンド
ヒント B(段階的開示)
ヒント1 — 方向性
DataDog Agentは「ノード単位のインフラ・ログ収集」を担うためDaemonSetが適切だが、トレースのサンプリングや属性加工のような柔軟な処理はAgent単体では担いきれないため、標準的な構成では前段に共有の OTel Collector(Deployment) を置き、そこでtail_sampling・attributes処理を行ってからDataDogへエクスポートする。Resource属性の欠落・伝播の断絶・高カーディナリティは、いずれも「計装は入っているが設定が不十分」という典型的な落とし穴。
ヒント2 — アプローチ
- DataDog AgentはHelm chartで
agents.useHostNetwork等DaemonSet前提の設定のまま使用し、Agent自体はログ/インフラ収集に専念させる - トレース処理は
otel-collector-gateway(Deployment, replicas: 2)を別途配置し、receivers: [otlp]→processors: [memory_limiter, attributes, tail_sampling]→exporters: [datadog]のパイプラインを組む OTEL_RESOURCE_ATTRIBUTESとOTEL_PROPAGATORS=tracecontext,baggageをDeploymentの環境変数に設定し、opentelemetry-instrumentで自動計装するtail_samplingプロセッサでstatus_code: ERRORは100%保持、それ以外はprobabilistic_sampling_percentage: 10- ログの
logging.Filterでtrace.get_current_span()からdd.trace_id/dd.span_idを構造化ログに埋め込む
ヒント3 — リソースの骨格
Resource属性 + サンプリング(アプリ側env)
env:
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=shipping-fee-service,..."
- name: OTEL_PROPAGATORS
value: "tracecontext,baggage"
- name: OTEL_TRACES_SAMPLER
value: "parentbased_traceidratio"
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.1"
Collectorパイプライン(tail_sampling)
processors:
tail_sampling:
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: sample-normal-traffic
type: probabilistic
probabilistic: { sampling_percentage: 10 }
exporters:
datadog:
api: { key: ${env:DD_API_KEY} }
アーキテクチャ図 — DaemonSet化 × 共有Collector × サンプリング × カーディナリティ制御
模範解答 B
"""observability.py — shipping-fee-service の DataDog Log-Trace Correlation"""
from __future__ import annotations
import logging
from opentelemetry import trace
class DatadogTraceCorrelationFilter(logging.Filter):
"""構造化ログにDataDog形式のtrace_id/span_idを埋め込むフィルタ(修正6)。
Note:
OTelのtrace_id(128bit)はDataDogでは下位64bitの10進数表記(dd.trace_id)を
要求するため変換する。span_id(64bit)はそのまま10進数に変換する。
"""
def filter(self, record: logging.LogRecord) -> bool:
span_context = trace.get_current_span().get_span_context()
if span_context.is_valid:
record.dd_trace_id = str(span_context.trace_id & 0xFFFFFFFFFFFFFFFF)
record.dd_span_id = str(span_context.span_id)
else:
record.dd_trace_id = "0"
record.dd_span_id = "0"
return True
def configure_logging() -> None:
"""JSON構造化ログにDataDog trace correlationフィルタを組み込む。"""
handler = logging.StreamHandler()
handler.addFilter(DatadogTraceCorrelationFilter())
handler.setFormatter(logging.Formatter(
'{"timestamp":"%(asctime)s","level":"%(levelname)s",'
'"message":"%(message)s","dd.trace_id":"%(dd_trace_id)s",'
'"dd.span_id":"%(dd_span_id)s"}'
))
logging.basicConfig(level=logging.INFO, handlers=[handler])
# Before: 手動計装のみでResource属性未設定(service.name欠落 → unknown_service)
python shipping_fee_service.py
# After: opentelemetry-instrument による自動計装(修正2/3)。環境変数はDeploymentマニフェスト側で注入
opentelemetry-instrument python shipping_fee_service.py
# deployment.yaml — shipping-fee-service: Resource属性 + サンプリング + TLS化OTLP送信先
apiVersion: apps/v1
kind: Deployment
metadata:
name: shipping-fee-service
namespace: mops
spec:
replicas: 2
template:
spec:
containers:
- name: app
image: asia-northeast1-docker.pkg.dev/PROJECT_ID/mops/shipping-fee-service@sha256:abc123
command: ["opentelemetry-instrument", "python", "shipping_fee_service.py"]
env:
# 修正2: Resource属性を明示(DataDog Service Mapのunknown_service解消)
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=shipping-fee-service,service.version=1.4.0,deployment.environment=production"
# 修正3: W3C Trace Contextの自動伝播
- name: OTEL_PROPAGATORS
value: "tracecontext,baggage"
# 修正4: ヘッド型サンプリングは10%(エラーはCollector側のtail_samplingで別途100%保持)
- name: OTEL_TRACES_SAMPLER
value: "parentbased_traceidratio"
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.1"
# 修正7: 送信先は共有Collectorの4317(gRPC/TLS)。平文直叩きを廃止
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "https://otel-collector-gateway.mops.svc.cluster.local:4317"
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: "grpc"
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "250m", memory: "512Mi" }
---
# otel-collector-gateway.yaml — トレース処理(サンプリング + カーディナリティ制御) → Datadog Exporter
apiVersion: apps/v1
kind: Deployment
metadata:
name: otel-collector-gateway
namespace: mops
spec:
replicas: 2 # 修正1: PodサイドカーではなくNamespace共有の集約Collector
template:
spec:
containers:
- name: otel-collector
image: otel/opentelemetry-collector-contrib:0.105.0
args: ["--config=/etc/otel/config.yaml"]
env:
- name: DD_API_KEY
valueFrom:
secretKeyRef: { name: datadog-api-key, key: api-key }
volumeMounts:
- name: otel-config
mountPath: /etc/otel
- name: otlp-tls-certs
mountPath: /etc/otlp-tls
readOnly: true
resources:
requests: { cpu: "500m", memory: "1Gi" }
limits: { cpu: "1", memory: "1Gi" }
volumes:
- name: otel-config
configMap: { name: otel-collector-gateway-config }
- name: otlp-tls-certs
secret: { secretName: otlp-internal-tls } # 修正7
---
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-collector-gateway-config
namespace: mops
data:
config.yaml: |
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
tls:
cert_file: /etc/otlp-tls/tls.crt # 修正7: 平文(http://)を廃止しTLS化
key_file: /etc/otlp-tls/tls.key
processors:
memory_limiter:
check_interval: 1s
limit_mib: 800
# 修正5: postal_code/user_idはスパン属性としては残すが、低カーディナリティな
# shipping.zoneをメトリクス生成タグとして使う(生の値をメトリクスタグに昇格させない)
attributes/derive_low_cardinality_tags:
actions:
- key: shipping.zone
action: upsert
from_attribute: shipping_zone
# 修正4: エラースパンは100%保持、正常系は10%のみ保持するテール型サンプリング
tail_sampling:
decision_wait: 10s
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: sample-normal-traffic
type: probabilistic
probabilistic: { sampling_percentage: 10 }
exporters:
datadog:
api:
key: ${env:DD_API_KEY}
site: datadoghq.com
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, attributes/derive_low_cardinality_tags, tail_sampling]
exporters: [datadog]
---
# datadog-agent-daemonset-values.yaml — Helm(datadog/datadog): ノード/ログ収集専任のDaemonSet
datadog:
apiKeyExistingSecret: datadog-api-key
logs:
enabled: true
containerCollectAll: true # 修正1: トレース処理はCollectorに任せ、Agentはインフラ/ログ収集に専念
agents:
containers:
agent:
resources:
requests: { cpu: "200m", memory: "256Mi" }
limits: { cpu: "200m", memory: "256Mi" }
Bad vs Good 設計比較
| 観点 | Bad(現状) | Good(改善後) |
|---|---|---|
| Agentデプロイ形態 | 各Podにサイドカーとして同居、Autopilotでレプリカ数分課金 | DataDog AgentはDaemonSet(ノード単位)、トレース処理は共有Collector Deployment |
| Resource属性 | 未設定、APM Service Mapにunknown_service | OTEL_RESOURCE_ATTRIBUTESでservice.name/version/environmentを明示 |
| トレース伝播 | traceparent未伝播、フローが2本に断絶 | 自動計装+OTEL_PROPAGATORSでW3C Trace Context伝播、1本のトレースに連結 |
| サンプリング | 100%送信、フラッシュセールでコスト急増 | ヘッド10%(parentbased_traceidratio)+ テールでエラーは100%保持 |
| カーディナリティ | postal_code/user_idを生でメトリクスタグ化、タイムシリーズ数が爆発 | 低カーディナリティなshipping.zoneをメトリクス生成タグに使用、生の値はスパン属性のみに保持 |
| ログ相関 | trace_id/span_id未出力、ログ→トレースのドリルダウン不可 | dd.trace_id/dd.span_idを構造化ログに埋め込みLog-Trace Correlation有効化 |
| 通信経路 | OTLP平文(http://) | OTLP gRPC + TLS(証明書をSecretでマウント) |
確認コマンド
# 1. DataDog AgentがDaemonSetとして各ノードに1つずつ起動していることを確認
kubectl get pods -n datadog -l app=datadog-agent -o wide
# Expected: ノード数と同じPod数、各ノードに1つずつ配置
# 2. otel-collector-gatewayのReady状態確認
kubectl get pods -n mops -l app=otel-collector-gateway
# Expected: 2/2 Running
# 3. shipping-fee-serviceのResource属性設定を確認
kubectl exec -n mops deploy/shipping-fee-service -- env | grep OTEL_RESOURCE_ATTRIBUTES
# Expected: service.name=shipping-fee-service を含む
# 4. トレース連結の確認(checkout-api → shipping-fee-serviceが1本のtrace_idに繋がっているか)
kubectl logs -n mops -l app=otel-collector-gateway --since=5m | grep -o '"trace_id":"[a-f0-9]*"' | sort | uniq -c | sort -rn | head -3
# Expected: checkout-apiとshipping-fee-service双方のスパンが同一trace_idに複数出現
# 5. テールサンプリングの動作確認(エラーレスポンスを発生させて100%保持されるか)
curl -X POST https://checkout-api.internal/orders -d '{"postal_code":"999-9999"}' # 意図的に不正な郵便番号
kubectl logs -n mops -l app=otel-collector-gateway --since=1m | grep "keep-errors"
# Expected: 発生させたエラートレースがkeep-errorsポリシーで保持されていること
# 6. TLS化の確認(平文gRPCでの接続が拒否されること)
kubectl exec -n mops deploy/checkout-api -- curl -sv --http2-prior-knowledge http://otel-collector-gateway:4317 2>&1 | grep -i "tls\|refused"
# Expected: 平文接続が拒否される、またはTLSハンドシェイクが要求されること
ポイント解説
カテゴリ A
1
プリミティブ型執着の解消は「バリデーションの一元化」を実現する(Ch5)
weight_kg/postal_code を生の float/str のまま扱うと、呼び出し側ごとにバリデーションが重複したり漏れたりする。Weight/PostalCode が自分自身の妥当性を __post_init__ で保証することで、「不正な値オブジェクトは存在しない」という契約が成立する。
2
ファクトリメソッドは「外部入力の検証」と「内部構築」を分離する(Ch5)
PostalCode.parse(raw) が外部入力(ハイフンあり/なし)を検証・正規化し、コンストラクタは常に正規化済みの値だけを受け付ける。この分離により「どこで検証されたか」が型シグネチャから読み取れるようになる。
3
staticメソッドは「クラスの責務と無関係な処理の置き場」になりやすい(Ch5)
apply_fuel_surcharge は配送業者との契約条件という別の関心事だが、ShippingFeeCalculator にstaticとして同居していた。FuelSurchargePolicy に分離することで、それぞれのクラスが単一の理由でしか変更されなくなる(単一責任原則)。
カテゴリ B
4
DaemonSetと共有Collectorの役割分担でコストと柔軟性を両立する
DataDog Agentはノード単位のインフラ・ログ収集に専念させ(サイドカーの重複起動を排除)、サンプリングや属性加工のような柔軟な処理は共有のOTel Collector(Deployment)に担わせることで、Podが何百に増えてもトレース処理層はレプリカ数を絞ったまま運用できる。
DataDog Agentはノード単位のインフラ・ログ収集に専念させ(サイドカーの重複起動を排除)、サンプリングや属性加工のような柔軟な処理は共有のOTel Collector(Deployment)に担わせることで、Podが何百に増えてもトレース処理層はレプリカ数を絞ったまま運用できる。
5
Resource属性とPropagatorはAPMの「地図」を描く基礎情報
service.name が無ければService Mapは unknown_service の塊になり、traceparent が伝播しなければサービス間の呼び出し関係そのものが記録されない。どちらも「後から直す」のではなく最初の計装設定に組み込むべき項目。
6
カーディナリティ管理はコストとデバッグ性のトレードオフを設計で解決する
postal_code/user_id を丸ごとメトリクスタグにすると時系列数が爆発するが、丸ごと消すとデバッグ時に個別リクエストを追えなくなる。スパン属性としては保持しつつ、メトリクス生成には低カーディナリティな派生値(shipping.zone)を使うことで両立できる。
実務への応用
Weight/PostalCodeのような値オブジェクト + ファクトリメソッドのパターンは、MOpsの他のドメイン(メールアドレス・電話番号・SKUコード・会員ランク等)にも同じ形で横展開できる: 「プリミティブ型で受け取っている引数のうち、独自のバリデーションルールを持つものはないか」をコードレビューの観点に加えると発見しやすい- staticメソッドを見たら「このクラスの責務か、別の関心事が紛れ込んでいないか」を自問する習慣は、Manager/Utilクラスの肥大化を未然に防ぐ:
FuelSurchargePolicyのように独立クラスへ切り出すことで、テストも料金計算とサーチャージ計算を別々にモックできるようになる - OTel Collectorを「App→Collector→Datadog」の共有ゲートウェイとして配置するパターンは、shipping-fee-serviceだけでなくMOpsの全マイクロサービスに横展開できる標準的な可観測性基盤: サービスが増えるたびにサイドカーAgentを増やすのではなく、共有Collectorのレプリカ数をトラフィック量に応じて調整するだけで済む
- カーディナリティ設計(生の値はスパンに残し、メトリクスタグには低カーディナリティな派生値を使う)は、DataDogに限らずPrometheus/Cloud Monitoringなど他の可観測性基盤でも共通する原則: 「このタグの取りうる値は何種類あるか」を計装の設計段階で見積もる習慣が課金事故を防ぐ
今日のまとめ
カテゴリAでは、重量・郵便番号という「独自ルールを持つ概念」を生のプリミティブ型のまま扱っていた配送料金計算を、
カテゴリBでは、Podサイドカーとして重複起動していたDataDog AgentをDaemonSetに置き換えて役割をインフラ/ログ収集に絞り、トレースのサンプリング・カーディナリティ制御・TLS化は共有のOTel Collectorに集約した。どちらも共通するのは「本来別々であるべき関心事が1つの場所に押し込められていたものを、専用の型・専用のコンポーネントに分離する」という設計思想である。
Weight/PostalCode/ShippingFee の値オブジェクトとファクトリメソッド(parse/from_kg)に置き換え、燃料費特別付加運賃という無関係な関心事を FuelSurchargePolicy として分離した(Ch5、Ch6-7/Ch8/Ch10)。カテゴリBでは、Podサイドカーとして重複起動していたDataDog AgentをDaemonSetに置き換えて役割をインフラ/ログ収集に絞り、トレースのサンプリング・カーディナリティ制御・TLS化は共有のOTel Collectorに集約した。どちらも共通するのは「本来別々であるべき関心事が1つの場所に押し込められていたものを、専用の型・専用のコンポーネントに分離する」という設計思想である。
次のステップ
- 発展問題:
FuelSurchargePolicyに「配送業者ごとの契約更新(料率変更)」をBigQueryのマスタテーブルから動的にロードする仕組みを追加し、デプロイなしで料率改定できるようにする(Ch5の発展としてリポジトリパターンを導入) - 発展問題:
otel-collector-gatewayのtail_samplingに「特定顧客(VIP会員)のトレースは常に100%保持する」というビジネスルールに基づくサンプリングポリシーを追加する - 参考: 「良いコード・悪いコードで学ぶ設計入門」Ch5(実践カプセル化)/ Ch6-7(関心の分離)/ Ch8(条件分岐)/ Ch10(設計の悪魔)、OpenTelemetry Collector
tail_samplingプロセッサ、DataDog Agent v7 OTLP Ingestion、DataDog APM Service Map