A+B 複合 — プリミティブ型執着 × staticメソッド誤用 × ファクトリメソッド(配送料金計算 Bad→Good Ch5)× shipping-fee-service DataDog Agent v7 OTLPネイティブ取り込み(DaemonSet化 × Resource属性 × traceparent伝播 × ヘッド/テールサンプリング × カーディナリティ制御 × Log-Trace Correlation × TLS化)

2026-08-01 (Day 122) 土曜複合問題 ★★★★☆ Python 3.12 / frozen dataclass / 値オブジェクト / ファクトリメソッド GKE Autopilot / DataDog Agent v7 / OTel Collector / OTLP

概要

🔢

プリミティブ型執着は「バリデーションの散逸」を招く

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 ではなく rangeShippingZone のタプルテーブルを for ループで走査する形にし、該当なしは UnsupportedPostalCodeError
  • FuelSurchargePolicyShippingFeeCalculator から独立したクラスとして切り出し、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

#問題点分類改善方法
1weight_kgに0以下チェックなしプリミティブ型執着 Ch5Weight値オブジェクトでバリデーション
2郵便番号のフォーマット検証なしプリミティブ型執着 Ch5PostalCode.parse()ファクトリメソッド
3ゾーン判定がif/elif羅列条件分岐のデータ化 Ch5/Ch8範囲テーブル(_ZONE_PREFIX_RANGES)走査
4料金体系がクラス変数にハードコード設計の悪魔 Ch10名前付き定数+マッピングに整理
5apply_fuel_surchargeの無関係な同居staticメソッド誤用 Ch5FuelSurchargePolicyへ分離
6未知郵便番号のサイレントkantoフォールバックフェイルファスト Ch10UnsupportedPostalCodeError送出
7端数処理ルールが不明瞭設計の悪魔 Ch10quantize(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-apishipping-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://設定で、郵便番号等を含みうるトレースデータが暗号化されずに流れている

要件

#要件
1DataDog AgentをDaemonSet(ノードに1つずつ)としてデプロイし、Podサイドカーの重複起動を解消すること
2OTEL_RESOURCE_ATTRIBUTESを設定し、DataDog Service Mapに正しくサービスが表示されるようにすること
3traceparent(W3C Trace Context)を自動伝播させ、1つの注文確定フローが1本のトレースとして繋がるようにすること
4共有OTel Collectorでヘッド型サンプリング(10%) + テール型サンプリング(エラーは100%保持)を設定すること
5高カーディナリティ属性はスパンには残しつつ、メトリクス生成タグには低カーディナリティな派生属性を使うこと
6構造化ログにdd.trace_id/dd.span_idを出力し、Log-Trace Correlationを有効化すること
7OTLP 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_ATTRIBUTESOTEL_PROPAGATORS=tracecontext,baggageをDeploymentの環境変数に設定し、opentelemetry-instrumentで自動計装する
  • tail_samplingプロセッサでstatus_code: ERRORは100%保持、それ以外はprobabilistic_sampling_percentage: 10
  • ログのlogging.Filtertrace.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 × サンプリング × カーディナリティ制御

checkout-api 注文確定フロー opentelemetry-instrument traceparent発行(修正3) shipping-fee-service 問題Aの成果物(GKE Autopilot) 修正2: OTEL_RESOURCE_ATTRIBUTES 修正3: traceparent受信・連結 Datadog SaaS APM Service Map Custom Metrics Log-Trace Correlation otel-collector-gateway (Deployment, replicas=2) 修正7: OTLP receiver(TLS) grpc: 0.0.0.0:4317 + tls証明書 Before: http://(平文)を廃止 memory_limiter: 800MiB 修正4: tail_sampling status_code=ERROR → 100%保持 正常系 → probabilistic 10% decision_wait: 10s 修正5: attributes/derive_low_cardinality_tags Before: postal_code/user_idを生でメトリクスタグ化 → タイムシリーズ爆発 After: shipping.zone(低カーディナリティ)を派生タグに使用。生の値はスパン属性のみ保持 exporters: [datadog] (api.key=DD_API_KEY, site=datadoghq.com) DataDog Agent v7 — DaemonSet(ノードに1つずつ) Node A datadog-agent Pod ×1 修正1: サイドカー廃止 containerCollectAll: true cpu=200m/memory=256Mi (ログ/インフラ収集専任) Before: shipping-fee-service Pod数(最大20)分Agentが重複起動 Node B ... Node N 同様にノード単位で1つ ノード数と同数のAgent Pod Podが増減してもAgent数は不変 → コストがノード数に比例 修正6: Log-Trace Correlation shipping-fee-serviceの構造化ログに dd.trace_id / dd.span_id を埋め込み (DatadogTraceCorrelationFilter: trace.get_current_span()から変換) ✓ DaemonSet化でAgentの重複起動を解消(コストはノード数に比例) ✓ Resource属性でDataDog Service Mapのunknown_service解消 ✓ traceparent自動伝播でcheckout-api↔shipping-fee-serviceが1本のトレースに ✓ ヘッド10%+テールでエラー100%保持のハイブリッドサンプリング ✓ 低カーディナリティ派生タグでCustom Metricsのタイムシリーズ爆発を防止 ✓ dd.trace_id/dd.span_idでログ→トレースのドリルダウンが可能に ✓ OTLP gRPCをTLS化しクラスタ内通信でも暗号化 HTTP+traceparent OTLP/gRPC(TLS) ログはAgent経由 datadog exporter(トレース) ログ/インフラメトリクス

模範解答 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_serviceOTEL_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が何百に増えてもトレース処理層はレプリカ数を絞ったまま運用できる。
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では、重量・郵便番号という「独自ルールを持つ概念」を生のプリミティブ型のまま扱っていた配送料金計算を、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-gatewaytail_sampling に「特定顧客(VIP会員)のトレースは常に100%保持する」というビジネスルールに基づくサンプリングポリシーを追加する
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」Ch5(実践カプセル化)/ Ch6-7(関心の分離)/ Ch8(条件分岐)/ Ch10(設計の悪魔)、OpenTelemetry Collector tail_sampling プロセッサ、DataDog Agent v7 OTLP Ingestion、DataDog APM Service Map

自己評価(あとで記入)

自分の回答

気づき・メモ