A+B 複合 — 状態機械 × 遷移テーブル(Ch7)× GKE Workload Identity + External Secrets Operator

2026-05-30 (Day 54) 土曜複合問題 ★★★★☆ Python 3.12 / StrEnum / frozen dataclass / State Machine Terraform 1.8+ / GKE Autopilot / ESO v0.9+ / Kustomize

概要

🔄

遷移テーブル(Ch5/Ch7)

if-elif チェーンで書かれた状態遷移を dict[OrderStatus, frozenset[OrderStatus]] に置き換え、新ステータス追加時に既存コード変更ゼロ(OCP)を実現する。

📦

コレクション操作の隠蔽(Ch7)

内部 dict を直接公開せず、意図を表すメソッド(transition, get_orders_by_status)経由のみで操作。外部からの不整合な状態変更を防ぐ。

🔑

Workload Identity Federation(GKE)

K8s ServiceAccount → Google Service Account のバインディングを Terraform で管理。ノード SA への過剰権限付与を解消し、Pod 単位で最小権限を実現する。

🔄

External Secrets Operator(ESO)

refreshInterval: 1h で Secret Manager → K8s Secret を自動同期。Pod 再起動なしで Secret を更新でき、Kustomize オーバーレイで本番/ステージ差分を吸収する。

問題 A: コーディング — 状態機械の設計(Ch7 コレクション操作)

以下の「悪いコード」は、ECサイトの注文ステータス遷移を管理するクラスです。問題点を全て洗い出し、Ch7(コレクション操作の隠蔽) および Ch2(型の活用) を適用してリファクタリングしてください。

制約・前提条件

  • Python 3.12+、型ヒント・dataclass(slots=True, frozen=True) を使うこと
  • 注文ステータスを StrEnum で定義すること
  • 遷移ルールをデータとして定義し、ロジックから分離すること(OCP)
  • 無効遷移は InvalidTransitionErrorraise すること(print 禁止)
  • get_orders_by_status はリスト内包表記または filter を使うこと
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章

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

このコードには 8つの設計上の問題 が隠れています。見つけてみてください。
bad_order_state.py — 問題だらけの注文ステータス管理
class OrderManager:
    def __init__(self):
        self.orders = {}  # order_id -> status (str)

    def update_status(self, order_id, new_status):
        current = self.orders.get(order_id)
        if current is None:
            print(f"order {order_id} not found")
            return False
        # 問題1: if-elif で遷移ルールをベタ書き(OCP 違反)
        if current == "pending":
            if new_status in ["confirmed", "cancelled"]:
                self.orders[order_id] = new_status
                return True
            else:
                print(f"invalid: {current} -> {new_status}")
                return False
        elif current == "confirmed":
            if new_status in ["shipped", "cancelled"]:
                self.orders[order_id] = new_status
                return True
            else:
                print(f"invalid: {current} -> {new_status}")
                return False
        elif current == "shipped":
            if new_status == "delivered":
                self.orders[order_id] = new_status
                return True
            else:
                print(f"invalid: {current} -> {new_status}")
                return False
        elif current == "delivered":
            print("already delivered")
            return False
        elif current == "cancelled":
            print("already cancelled")
            return False

    def get_orders_by_status(self, status):
        result = []
        for order_id, s in self.orders.items():
            if s == status:
                result.append(order_id)
        return result

    def add_order(self, order_id):
        self.orders[order_id] = "pending"
問題点サマリー(8点)
1if-elif で遷移ルールをハードコード(OCP 違反 Ch5) — 新ステータス追加のたびに関数を修正しなければならない
2ステータスが str — タイポ不検出"confimed" などのタイポを静的に検知できない
3型ヒントが皆無 — mypy / pyright で型検査不可
4print + return False — 呼び出し元が失敗を検知できない — 上位レイヤー(Argo Workflows など)が注文遷移失敗を気づけない
5self.orders が public — Ch7 コレクション操作の隠蔽違反 — 外部から mgr.orders["ORD-001"] = "delivered" で検証なしに変更可能
6get_orders_by_status がループでリスト構築 — リスト内包表記で意図を簡潔に表現できる
7"pending" などのマジックストリング — 初期ステータスが複数箇所にばらばらに書かれると変更漏れが起きる
8終端状態の処理が個別 elif — 遷移テーブルで統一表現できる(frozenset() = 遷移先なし)

ヒント A(段階的開示)

ヒント1 — 方向性
if current == "pending": if new_status in ["confirmed", "cancelled"]: ... という遷移ルールは「遷移 = データ」として辞書に切り出せる。dict[OrderStatus, frozenset[OrderStatus]] が遷移テーブルになり、transition() の本体は「テーブルを引いて in で確認」のみになる。さらに内部 dict_orders(アンダースコアプレフィックス)にして、外部からはメソッド経由のみでアクセスさせると Ch7 の「コレクション操作の隠蔽」を満たせる。
ヒント2 — アプローチ
  • OrderStatus(StrEnum)PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED を定義
  • 遷移テーブル VALID_TRANSITIONS: Final[dict[OrderStatus, frozenset[OrderStatus]]] をモジュールレベルの定数として定義
  • 終端状態は frozenset()(空集合)で表現 — 特別な elif は不要
  • transition() は「テーブルを引いて new_status not in allowed なら InvalidTransitionError を raise」のみ
  • get_orders_by_status[oid for oid, s in self._orders.items() if s == status]
ヒント3 — コードの骨格
from enum import StrEnum
from typing import Final
from dataclasses import dataclass, field

class OrderStatus(StrEnum):
    PENDING   = "pending"
    CONFIRMED = "confirmed"
    SHIPPED   = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

# 遷移ルール = データ(if-elif を置き換える)
VALID_TRANSITIONS: Final[dict[OrderStatus, frozenset[OrderStatus]]] = {
    OrderStatus.PENDING:   frozenset({OrderStatus.CONFIRMED, OrderStatus.CANCELLED}),
    OrderStatus.CONFIRMED: frozenset({OrderStatus.SHIPPED,   OrderStatus.CANCELLED}),
    OrderStatus.SHIPPED:   frozenset({OrderStatus.DELIVERED}),
    OrderStatus.DELIVERED: frozenset(),  # 終端状態
    OrderStatus.CANCELLED: frozenset(),  # 終端状態
}

class InvalidTransitionError(ValueError): ...

@dataclass
class OrderStateManager:
    _orders: dict[str, OrderStatus] = field(default_factory=dict)

    def transition(self, order_id: str, new_status: OrderStatus) -> None:
        current = self._get_or_raise(order_id)
        allowed = VALID_TRANSITIONS[current]
        if new_status not in allowed:
            raise InvalidTransitionError(...)
        self._orders[order_id] = new_status

    def get_orders_by_status(self, status: OrderStatus) -> list[str]:
        return [oid for oid, s in self._orders.items() if s == status]

問題点分析 — カテゴリ A

#問題点分類改善方法
1if-elif で遷移ルールをハードコードOCP 違反 Ch5VALID_TRANSITIONS dict に切り出す
2ステータスが str — タイポ不検出型安全性 Ch2OrderStatus(StrEnum) で型安全化
3型ヒントが皆無型安全性 Ch2全引数・戻り値に型ヒント付与
4print + return False — 呼び出し元が失敗を検知できないエラー処理InvalidTransitionError / OrderNotFoundError を raise
5self.orders が public(Ch7 違反)コレクション隠蔽 Ch7_orders にしてメソッド経由のみ公開
6get_orders_by_status がループでリスト構築コレクション操作 Ch7リスト内包表記に置き換え
7"pending" などのマジックストリング定数管理INITIAL_STATUS = OrderStatus.PENDING で定数化
8終端状態が個別 elifOCP 違反 Ch5frozenset() で統一表現

模範解答 A

"""order_state.py — 注文ステータス遷移管理(状態機械パターン)。

良いコード・悪いコードで学ぶ設計入門(改訂新版)
  - Ch2: 型の活用(StrEnum, 型ヒント, frozen dataclass)
  - Ch7: コレクション操作の隠蔽(内部コレクションを直接公開しない)
  - Ch5: 条件分岐の削減(遷移テーブルで if-elif を廃止)
"""
from __future__ import annotations

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


# ── 定数 ─────────────────────────────────────────────────────────────────
INITIAL_STATUS: Final[str] = "pending"  # マジックストリング排除


# ── 列挙型(Ch2: 型の活用)────────────────────────────────────────────────
class OrderStatus(StrEnum):
    """注文ステータス。文字列互換でありつつ型安全。"""
    PENDING   = "pending"
    CONFIRMED = "confirmed"
    SHIPPED   = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"


# ── 遷移テーブル(Ch5: ルール = データ)─────────────────────────────────────
# 新ステータス追加時はここに1行追加するだけ(OCP)
VALID_TRANSITIONS: Final[dict[OrderStatus, frozenset[OrderStatus]]] = {
    OrderStatus.PENDING:   frozenset({OrderStatus.CONFIRMED, OrderStatus.CANCELLED}),
    OrderStatus.CONFIRMED: frozenset({OrderStatus.SHIPPED,   OrderStatus.CANCELLED}),
    OrderStatus.SHIPPED:   frozenset({OrderStatus.DELIVERED}),
    OrderStatus.DELIVERED: frozenset(),   # 終端状態: frozenset() = 遷移先なし
    OrderStatus.CANCELLED: frozenset(),   # 終端状態
}


# ── カスタム例外 ─────────────────────────────────────────────────────────
class OrderNotFoundError(KeyError):
    """指定された注文IDが存在しない場合の例外。"""


class InvalidTransitionError(ValueError):
    """許可されていないステータス遷移が要求された場合の例外。"""


# ── 値オブジェクト(Ch3 + Ch4)─────────────────────────────────────────────
@dataclass(slots=True, frozen=True)  # frozen=True で不変、slots=True でメモリ効率化
class Order:
    """注文を表す値オブジェクト。不変(frozen=True)で副作用なし。

    Attributes:
        order_id: 注文ID。
        status: 現在のステータス。
    """
    order_id: str
    status: OrderStatus


# ── 状態管理クラス(Ch7: コレクション操作の隠蔽)────────────────────────────
@dataclass
class OrderStateManager:
    """注文ステータスの状態機械。

    内部コレクション(_orders)を直接公開せず、
    意図が明確なメソッド経由でのみ操作する(Ch7)。
    """
    _orders: dict[str, OrderStatus] = field(default_factory=dict)

    def add_order(self, order_id: str) -> None:
        """新規注文を初期ステータス(PENDING)で登録する。

        Args:
            order_id: 注文ID。

        Raises:
            ValueError: 既に存在する注文IDが指定された場合。
        """
        if order_id in self._orders:
            raise ValueError(f"Order already exists: {order_id!r}")
        self._orders[order_id] = OrderStatus.PENDING  # 定数経由(マジックストリング排除)

    def transition(self, order_id: str, new_status: OrderStatus) -> None:
        """注文ステータスを遷移させる。

        Args:
            order_id: 注文ID。
            new_status: 遷移先ステータス。

        Raises:
            OrderNotFoundError: 注文IDが存在しない場合。
            InvalidTransitionError: 許可されていない遷移が要求された場合。

        Example:
            >>> mgr = OrderStateManager()
            >>> mgr.add_order("ORD-001")
            >>> mgr.transition("ORD-001", OrderStatus.CONFIRMED)
        """
        current = self._get_or_raise(order_id)
        allowed = VALID_TRANSITIONS[current]  # テーブルを引くだけ(if-elif なし)

        if new_status not in allowed:
            raise InvalidTransitionError(
                f"Cannot transition order {order_id!r}: "
                f"{current.value!r} -> {new_status.value!r}. "
                f"Allowed next: {[s.value for s in allowed] or 'none (terminal state)'}"
            )
        self._orders[order_id] = new_status

    def get_status(self, order_id: str) -> OrderStatus:
        """指定注文の現在ステータスを返す。

        Args:
            order_id: 注文ID。

        Returns:
            現在のステータス。

        Raises:
            OrderNotFoundError: 注文IDが存在しない場合。
        """
        return self._get_or_raise(order_id)

    def get_orders_by_status(self, status: OrderStatus) -> list[str]:
        """指定ステータスの注文ID一覧を返す(Ch7: コレクション操作の隠蔽)。

        Args:
            status: フィルタリングするステータス。

        Returns:
            該当する注文IDのリスト。
        """
        # リスト内包表記で外部にコレクション操作を漏らさない(Ch7)
        return [oid for oid, s in self._orders.items() if s == status]

    def snapshot(self) -> list[Order]:
        """全注文の不変スナップショットを返す。

        Returns:
            Order 値オブジェクトのリスト(コピー)。
        """
        return [Order(oid, s) for oid, s in self._orders.items()]

    def _get_or_raise(self, order_id: str) -> OrderStatus:
        """order_id が存在しない場合に OrderNotFoundError を送出するヘルパー。"""
        status = self._orders.get(order_id)
        if status is None:
            raise OrderNotFoundError(f"Order not found: {order_id!r}")
        return status

正常系(複数注文のステータス遷移)

mgr = OrderStateManager()
mgr.add_order("ORD-001")
mgr.add_order("ORD-002")
mgr.add_order("ORD-003")

mgr.transition("ORD-001", OrderStatus.CONFIRMED)
mgr.transition("ORD-002", OrderStatus.CONFIRMED)
mgr.transition("ORD-002", OrderStatus.SHIPPED)
mgr.transition("ORD-003", OrderStatus.CANCELLED)

出力(各ステータスのフィルタリング)

print(mgr.get_orders_by_status(OrderStatus.CONFIRMED))  # ['ORD-001']
print(mgr.get_orders_by_status(OrderStatus.SHIPPED))    # ['ORD-002']
print(mgr.get_orders_by_status(OrderStatus.CANCELLED))  # ['ORD-003']

異常系(無効遷移)

try:
    mgr.transition("ORD-001", OrderStatus.DELIVERED)  # CONFIRMED → DELIVERED は不可
except InvalidTransitionError as e:
    print(e)
# → Cannot transition order 'ORD-001': 'confirmed' -> 'delivered'.
#   Allowed next: ['shipped', 'cancelled']

try:
    mgr.transition("ORD-003", OrderStatus.CONFIRMED)  # CANCELLED は終端状態
except InvalidTransitionError as e:
    print(e)
# → Cannot transition order 'ORD-003': 'cancelled' -> 'confirmed'.
#   Allowed next: none (terminal state)

新ステータス追加(transition() は変更不要)

# OrderStatus に RETURNING を追加
class OrderStatus(StrEnum):
    ...
    RETURNING = "returning"  # ← 1行追加

# 遷移テーブルに追加するだけ
VALID_TRANSITIONS = {
    ...
    OrderStatus.DELIVERED: frozenset({OrderStatus.RETURNING}),  # ← 変更
    OrderStatus.RETURNING: frozenset({OrderStatus.CANCELLED}),  # ← 追加
}
# OrderStateManager.transition() は変更不要(OCP)
ポイント適用した設計原則/パターン書籍対応章
VALID_TRANSITIONS dict で if-elif 廃止State Machine + Registry PatternCh5 条件分岐の削減
OrderStatus(StrEnum) で型安全化Value Object(型の活用)Ch2 型の活用
_orders をメソッド経由のみ公開コレクション操作の隠蔽Ch7
Order(frozen=True, slots=True)不変の活用Ch4
InvalidTransitionError を raiseFail-Fast エラー処理Ch10
終端状態を frozenset() で統一表現Open-Closed PrincipleCh5/Ch7

問題 B: インフラ — Terraform × GKE Autopilot Workload Identity + ESO v0.9+

ECサイトの注文 API が GKE Autopilot 上で稼働しており、現状は以下の問題がある:

現状の課題:
  • Cloud SQL の接続情報(DSN)が Deployment の env にベタ書きされている
  • GKE ノードの default service account に roles/secretmanager.secretAccessor が付与されている(最小権限違反)
  • Secret の更新時に Pod の再起動が必要
  • ステージング/本番でマニフェストを手動コピーしている

要件

#要件
1Workload Identity Federation for GKE(v2 API)を Terraform で設定すること
2K8s ServiceAccount → Google Service Account の IAM バインディングを Terraform で管理すること
3Cloud SQL DSN を Secret Manager に格納し、external-secrets-operator(ESO v0.9+)で K8s Secret に同期すること
4ESO の ExternalSecretrefreshInterval: 1h を設定し、Pod 再起動なしで Secret を更新できるようにすること
5Kustomize のオーバーレイ(base / overlays/staging / overlays/production)で環境差分を吸収すること

ヒント B(段階的開示)

ヒント1 — 方向性
Workload Identity の本質は「K8s Pod が GSA(Google Service Account)の権限を借用する」仕組み。ノード SA に権限を付与する旧パターンは「全 Pod が同じ権限を持つ」問題があった。v2(iam.workloadIdentityUser)では google_service_account_iam_memberserviceAccount:PROJECT.svc.id.goog[NAMESPACE/KSA] を principal に指定することで、特定 Namespace の特定 KSA のみに借用権限を付与できる。ESO はこの権限を使って Secret Manager から値を取得し、K8s Secret として同期する。
ヒント2 — アプローチ
  • Terraform: google_service_accountgoogle_project_iam_memberroles/secretmanager.secretAccessor)→ google_service_account_iam_member(Workload Identity バインディング)
  • K8s ServiceAccount に iam.gke.io/gcp-service-account annotation を付与
  • ESO: ClusterSecretStoreauth.workloadIdentity を設定し、ExternalSecretsecretStoreRef を参照
  • Kustomize: base/ に共通リソース、overlays/ に JSON patch で project_id を差し替え
ヒント3 — Terraform の骨格
Workload Identity バインディング
resource "google_service_account_iam_member" "wi" {
  service_account_id = google_service_account.order_api.name
  role               = "roles/iam.workloadIdentityUser"
  # KSA → GSA のバインディング
  member = "serviceAccount:${var.project_id}.svc.id.goog[${var.k8s_namespace}/${var.ksa_name}]"
}
ESO ExternalSecret
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: order-api-secrets
spec:
  refreshInterval: 1h  # Pod 再起動なしで同期
  secretStoreRef:
    name: gcp-secretmanager-store
    kind: ClusterSecretStore
  target:
    name: order-api-secrets
    creationPolicy: Owner
  data:
    - secretKey: cloud_sql_dsn
      remoteRef:
        key: order-api-cloud-sql-dsn
        version: latest

アーキテクチャ図 — Workload Identity + ESO + Kustomize

Google Cloud Platform GKE Autopilot App Container order-event-api env: DATABASE_URL ← K8s Secret からマウント K8s Secret order-api-secrets DATABASE_URL = *** ESO が自動同期 External Secrets Operator ExternalSecret refreshInterval: 1h Pod 再起動なしで Secret 更新 ClusterSecretStore 経由で SM にアクセス K8s ServiceAccount (KSA) order-api-ksa annotation: iam.gke.io/gcp-sa → GSA へのバインディング(Terraform 管理) Kustomize Overlays base/ 共通マニフェスト SA + ESO + Deploy overlays/staging/ patch-project-id.yaml staging project_id overlays/production/ patch-project-id.yaml production project_id Google Service Account order-api-sa@PROJECT.iam.gserviceaccount.com roles/secretmanager.secretAccessor (Pod 単位、最小権限) Secret Manager order-api-cloud-sql-dsn version: latest 自動レプリケーション(auto) IAM Binding (Terraform) workloadIdentityUser svc.id.goog[ns/ksa] → order-api-sa Terraform google_service_account google_project_iam_member google_service_account _iam_member(WI binding) google_secret_manager_secret _version environments/ staging.tfvars production.tfvars env マウント 同期 WI 借用 取得(1h) K8s リソース Secret 同期 WI 借用 Terraform 管理

模範解答 B

# modules/gke-workload-identity/main.tf

# ── Google Service Account(GSA)──────────────────────────────────────────
resource "google_service_account" "order_api" {
  account_id   = "order-api-sa"
  display_name = "Order API — GKE Workload Identity SA"
  description  = "ECサイト注文APIがSecretManagerにアクセスするためのGSA"
  project      = var.project_id
}

# ── Secret Manager へのアクセス権限(最小権限)──────────────────────────────
# ノード SA ではなく、この GSA にのみ secretAccessor を付与
resource "google_project_iam_member" "order_api_secret_accessor" {
  project = var.project_id
  role    = "roles/secretmanager.secretAccessor"
  member  = "serviceAccount:${google_service_account.order_api.email}"
}

# ── Workload Identity バインディング(KSA → GSA)────────────────────────────
# K8s Pod(KSA)が GSA の権限を借用できるようにする
# principal 形式: serviceAccount:.svc.id.goog[/]
resource "google_service_account_iam_member" "workload_identity_binding" {
  service_account_id = google_service_account.order_api.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "serviceAccount:${var.project_id}.svc.id.goog[${var.k8s_namespace}/${var.k8s_service_account_name}]"
}

# ── Secret Manager シークレット定義 ────────────────────────────────────────
resource "google_secret_manager_secret" "cloud_sql_dsn" {
  secret_id = "order-api-cloud-sql-dsn"
  project   = var.project_id

  replication {
    auto {}  # 自動レプリケーション(単一リージョン指定不要)
  }

  labels = {
    app         = "order-api"
    environment = var.environment
  }
}

resource "google_secret_manager_secret_version" "cloud_sql_dsn" {
  secret      = google_secret_manager_secret.cloud_sql_dsn.id
  secret_data = var.cloud_sql_dsn

  lifecycle {
    ignore_changes = [secret_data]  # CI/CD パイプライン側で更新させる
  }
}
# modules/gke-workload-identity/variables.tf

variable "project_id" {
  type        = string
  description = "GCP プロジェクト ID"
}

variable "environment" {
  type        = string
  description = "環境識別子(staging / production)"
  validation {
    condition     = contains(["staging", "production"], var.environment)
    error_message = "environment は staging または production のみ許可"
  }
}

variable "k8s_namespace" {
  type        = string
  description = "K8s Namespace(例: production)"
}

variable "k8s_service_account_name" {
  type        = string
  description = "K8s ServiceAccount 名(例: order-api-ksa)"
}

variable "cloud_sql_dsn" {
  type        = string
  sensitive   = true
  description = "Cloud SQL DSN(Secret Manager に格納する値)"
}
# k8s/base/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: order-api-ksa
  namespace: production
  annotations:
    # Workload Identity Federation for GKE — GSA を紐付ける
    iam.gke.io/gcp-service-account: order-api-sa@PROJECT_ID.iam.gserviceaccount.com
# k8s/base/clustersecretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: gcp-secretmanager-store
spec:
  provider:
    gcpsm:
      projectID: PROJECT_ID   # overlays で kustomize patch で上書き
      auth:
        workloadIdentity:
          clusterLocation: asia-northeast1
          clusterName: order-api-cluster
          serviceAccountRef:
            name: order-api-ksa
            namespace: production
# k8s/base/externalsecret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: order-api-secrets
  namespace: production
spec:
  # refreshInterval: Pod 再起動なしで1時間ごとに Secret Manager から同期
  refreshInterval: 1h

  secretStoreRef:
    name: gcp-secretmanager-store
    kind: ClusterSecretStore

  target:
    name: order-api-secrets        # 生成される K8s Secret 名
    creationPolicy: Owner          # ExternalSecret 削除時に K8s Secret も削除
    template:
      type: Opaque
      data:
        # K8s Secret のキー名 → Secret Manager からの値をテンプレートで注入
        DATABASE_URL: "{{ .cloud_sql_dsn }}"

  data:
    - secretKey: cloud_sql_dsn
      remoteRef:
        key: order-api-cloud-sql-dsn   # Secret Manager のシークレット名
        version: latest

ディレクトリ構成

k8s/
├── base/
│   ├── kustomization.yaml          # 共通リソース一覧
│   ├── serviceaccount.yaml         # order-api-ksa
│   ├── clustersecretstore.yaml     # gcp-secretmanager-store
│   ├── externalsecret.yaml         # refreshInterval: 1h
│   └── deployment.yaml             # order-event-api Deployment
├── overlays/
│   ├── staging/
│   │   ├── kustomization.yaml      # base を参照 + patches 適用
│   │   └── patch-project-id.yaml  # PROJECT_ID を staging 用に上書き
│   └── production/
│       ├── kustomization.yaml
│       └── patch-project-id.yaml  # PROJECT_ID を production 用に上書き

overlays/production/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patches:
  - path: patch-project-id.yaml
    target:
      kind: ClusterSecretStore
      name: gcp-secretmanager-store

overlays/production/patch-project-id.yaml

- op: replace
  path: /spec/provider/gcpsm/projectID
  value: "my-ec-production-project"  # 本番 project_id

デプロイコマンド

# 本番デプロイ(マニフェスト手動コピー不要)
kubectl apply -k k8s/overlays/production/

# ステージングデプロイ
kubectl apply -k k8s/overlays/staging/

Workload Identity: 旧パターン vs v2 の比較

観点旧パターン(ノード SA に権限付与)Workload Identity v2
権限スコープ ノード上の全 Pod が同じ権限を持つ
→ 不要な Pod も Secret にアクセス可能
特定 Namespace の特定 KSA にのみ権限を付与
→ Pod 単位の最小権限
設定箇所 ノード SA の IAM + GKE 設定 Terraform で google_service_account_iam_member + KSA annotation のみ
監査ログ ノード SA での操作として記録(どの Pod かわからない) KSA 名がログに含まれる(どの Pod の操作かトレース可能)
Secret ローテーション Pod 再起動が必要 ESO refreshInterval で自動同期(再起動不要)

ESO refreshInterval の挙動

設定挙動ユースケース
refreshInterval: 1h1時間ごとに SM → K8s Secret を同期DBパスワードのローテーション
refreshInterval: 0同期しない(初回のみ)変更なし・高頻度アクセス制限
Secret をファイルマウント同期後すぐに反映(Pod 再起動不要)アプリが動的に設定ファイルを読む場合
Secret を env マウントPod 再起動後に反映環境変数は Pod 起動時に固定されるため

ポイント解説

カテゴリ A

1 遷移テーブル(VALID_TRANSITIONS)で if-elif を根絶(Ch5)
ステータス遷移ルールを dict[OrderStatus, frozenset[OrderStatus]] として定義すると、遷移の追加・変更はテーブルの1行変更で済む。終端状態は frozenset()(空集合)で統一表現でき、特別な elif current == "delivered": print("already delivered") が不要になる。
2 コレクション操作の隠蔽(Ch7)
self._orders(アンダースコアプレフィックス)にして transition(), get_orders_by_status(), snapshot() 経由のみでアクセスさせる。外部から mgr._orders["ORD-001"] = "delivered" という検証なしの変更を防ぐことで、状態機械の整合性を保証する。
3 Order(frozen=True, slots=True) 値オブジェクト(Ch4)
注文エンティティを不変オブジェクトとして表現することで、意図しない状態変更によるバグを防ぐ。snapshot()Order のリストを返すので、呼び出し側が取得したスナップショットを変更しても内部状態に影響しない。

カテゴリ B

4 Workload Identity v2 で Pod 単位の最小権限を実現
google_service_account_iam_membermemberserviceAccount:PROJECT.svc.id.goog[NAMESPACE/KSA] を指定することで、特定 Namespace の特定 KSA のみが GSA 権限を借用できる。ノード SA への権限付与と異なり、同一ノード上の他 Pod には権限が及ばない。
5 ESO refreshInterval: 1h で Pod 再起動なし Secret 更新
External Secrets Operator が定期的に Secret Manager から値を取得し K8s Secret を更新する。Secret をファイルとしてマウントしている場合は Pod 再起動なしで新しい値が反映される。DBパスワードのローテーションや証明書の更新で再起動が不要になり、ダウンタイムを削減できる。
6 Kustomize オーバーレイで環境差分を1箇所に集約
base/ に共通マニフェストを置き、overlays/staging/overlays/production/ の JSON patch(patch-project-id.yaml)でプロジェクト ID だけ差し替える。kubectl apply -k overlays/production/ で本番デプロイが完結し、手動コピーによる設定ミスを防げる。

実務への応用

  • カテゴリ A: MOpsチームの施策ステータス(DRAFT → SCHEDULED → RUNNING → COMPLETED / FAILED)は同様の状態機械パターンが当てはまる。遷移テーブルを dict で管理することで、「COMPLETED に戻ってしまった」「RUNNING のまま DRAFT に戻せた」というバグをコンパイル時に防げる。Argo Workflows のワークフローステップ管理にも応用可能。
  • カテゴリ B: GKE Autopilot で稼働するデータパイプライン(BigQuery API, Secret Manager)へのアクセスを Workload Identity + ESO に統一することで、default SA への過剰な権限付与を解消できる。本番/ステージングの Terraform state を environments/production/terraform.tfvarsenvironments/staging/terraform.tfvars で分けて管理する運用と組み合わせると、インフラコードのセキュリティ審査も効率化できる。

今日のまとめ

状態遷移ルールを dict[OrderStatus, frozenset[OrderStatus]] で表現すると if-elif チェーンを根絶でき(Ch5)、内部コレクションをメソッド経由のみで公開することで整合性が保証される(Ch7)。

インフラ側では Workload Identity(KSA → GSA のバインディング)で Pod 単位の最小権限を実現し、ESO の refreshInterval で Pod 再起動なしの Secret 更新を可能にする。Kustomize オーバーレイにより本番/ステージングの差分を1箇所の patch に集約でき、手動コピーによる設定ミスをゼロにできる。

自己評価

自分の回答

気づき・メモ