A+B 複合 — Pydantic v2 BaseSettings(Ch2/Ch3/Ch10)× GitHub Actions OIDC + WIF + Cloud Run v2 キーレスデプロイ

2026-06-06 (Day 61) 土曜複合問題 ★★★★☆ Python 3.12 / Pydantic v2 / Annotated + Field / Literal Terraform 1.8+ / GitHub Actions OIDC / WIF / Cloud Run v2

概要

🛡️

Pydantic v2 BaseSettings — 起動時 Fail-Fast

os.getenv() は全て str | None を返し、バリデーションなし。BaseSettings は環境変数を自動で型変換・バリデーションし、不備があれば 起動時に即死(ValidationError)させる。「深夜バッチが半分で落ちる」問題を根絶する。

🔒

Annotated + Field で「型としての制約」

Annotated[int, Field(gt=0, le=10_000)] は「1以上10,000以下の整数」を型レベルで表現する。if ガードや docstring に頼らず、コードが制約の唯一の真実(SSOT) になる(Ch2 型の活用)。

🔑

GitHub Actions OIDC でキーレスデプロイ

SA キーを GitHub Secrets に保存する設計は、漏洩リスク・90日期限管理・ローテーション忘れがつきまとう。OIDC 一時トークンは GitHub Actions セッション中のみ有効。「キーを作らない設計」が最も安全な鍵管理。

🚀

Cloud Run v2 IAM 認証で公開 API を廃止

allUsers への roles/run.invoker 付与は「誰でも無制限に API 呼び出し可能」な状態。--no-allow-unauthenticated で IAM 認証を強制し、認可された SA・ユーザーのみに制限する。

問題 A: コーディング — Pydantic v2 Settings × Bad→Good リファクタリング

以下の「悪いコード」は、ECサイトのバッチ処理基盤(Argo Workflows)の設定読み込みを担うクラスです。問題点を全て洗い出し、Pydantic v2 BaseSettingsAnnotated + Fieldカスタムバリデーター を使って Bad→Good にリファクタリングしてください。

制約・前提条件

  • Python 3.12+、Pydantic v2(pydantic-settings 含む)を使うこと
  • GCP_PROJECT_ID は必須フィールドとし、未設定なら起動時にエラーを出すこと
  • BATCH_SIZE は 1〜10,000 の範囲制約を Annotated + Field(gt=0, le=10000) で付けること
  • ENV"staging" / "production" のみ許可(Literal で型制約)
  • SLACK_WEBHOOK_URLhttps://hooks.slack.com/ で始まること(カスタムバリデーター)
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章

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

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_config.py — 問題だらけの設定管理
import os

class Config:
    def __init__(self):
        # 問題1: 全フィールドが str | None
        #        型ヒントがなく mypy では検知不可
        self.project_id = os.getenv("GCP_PROJECT_ID")

        self.bq_dataset = os.getenv("BQ_DATASET", "mart")

        # 問題2: int() 変換は例外なく None → TypeError
        self.batch_size = int(os.getenv("BATCH_SIZE", "100"))

        # 問題3: RETRY_LIMIT=-1 でも通る(範囲バリデーションなし)
        self.retry_limit = int(os.getenv("RETRY_LIMIT", "3"))

        # 問題4: 不正 URL でも None でもエラーなし
        self.webhook_url = os.getenv("SLACK_WEBHOOK_URL")

        # 問題5: "prodution"(タイポ)でも通る
        self.env = os.getenv("ENV", "staging")

        # 問題6: シークレット値を print でログに出す
        print(cfg.project_id)


# 問題7: モジュールレベルで即時インスタンス化
# → テスト時に環境変数モックが困難
cfg = Config()
問題点サマリー(7点)
1型ヒントが皆無(Ch2)— os.getenv()str | None を返すが型注釈なし。mypy/pyright で型エラーを検知できない
2必須フィールドに None が混入(Ch10)— GCP_PROJECT_ID 未設定でも None のまま処理が進む。実行時エラーがランダムに発生する
3範囲バリデーションなし(Ch2)— BATCH_SIZE=-1 でも通る。BQ Insert で意図しない挙動になる
4ENV の値が任意文字列(Ch2)— ENV=prodution(タイポ)でもエラーなし。本番意図のコードがステージングで動く
5Webhook URL のフォーマット検証なし(Ch10)— 不正 URL が Slack API エラーとして本番で初めて判明する
6print でシークレット値をログ出力(セキュリティ)— Cloud Logging に GCP プロジェクト ID や Webhook URL が残る
7モジュールレベルで即時インスタンス化(Ch11 テスト容易性)— cfg = Config() は import 時に実行される。pytest で環境変数モックが困難

ヒント A(段階的開示)

ヒント1 — 方向性
os.getenv() は全て str | None 型で、型安全性がない。Pydantic v2 BaseSettings.env ファイルや環境変数を自動で読み込み、型変換・バリデーションを実行してくれる。必須フィールド(デフォルトなし)は未設定なら即座に ValidationError を送出するため、「Fail-Fast」なエラー処理が自動的に実現する。Annotated[int, Field(gt=0, le=10_000)] は「1以上10,000以下の整数」を型レベルで表現できる。
ヒント2 — アプローチ
  • class AppConfig(BaseSettings) で継承し、model_config = SettingsConfigDict(env_file=".env") を設定
  • 必須フィールドはデフォルト値なしで定義: gcp_project_id: str
  • 範囲制約は Annotated[int, Field(gt=0, le=10_000)] で表現
  • Literal["staging", "production"] で env フィールドを型制約
  • @field_validator("slack_webhook_url") でカスタムバリデーション
  • @model_validator(mode="after") でクロスフィールド検証(本番環境では webhook 必須)
  • get_config() ファクトリ関数を用意してテスト可能に
ヒント3 — コードの骨格
from typing import Annotated, Literal
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

SLACK_WEBHOOK_PREFIX: str = "https://hooks.slack.com/"

class AppConfig(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    gcp_project_id: str  # 必須(デフォルトなし → 未設定で ValidationError)
    bq_dataset: str = "mart"
    batch_size: Annotated[int, Field(gt=0, le=10_000)] = 100
    retry_limit: Annotated[int, Field(ge=1, le=10)]    = 3
    env: Literal["staging", "production"] = "staging"
    slack_webhook_url: str | None = None

    @field_validator("slack_webhook_url")
    @classmethod
    def validate_webhook(cls, v: str | None) -> str | None:
        if v is not None and not v.startswith(SLACK_WEBHOOK_PREFIX):
            raise ValueError(f"SLACK_WEBHOOK_URL は {SLACK_WEBHOOK_PREFIX!r} で始まる必要があります")
        return v

    @model_validator(mode="after")
    def validate_prod_has_webhook(self) -> "AppConfig":
        if self.env == "production" and self.slack_webhook_url is None:
            raise ValueError("production 環境では SLACK_WEBHOOK_URL が必須です")
        return self

def get_config() -> AppConfig:
    return AppConfig()

問題点分析 — カテゴリ A

#問題点分類改善方法
1型ヒントが皆無型安全性 Ch2全フィールドに型ヒント付与
2必須フィールドが None になりうるエラー処理 Ch10gcp_project_id: str(デフォルトなし)で Fail-Fast
3範囲バリデーションなし型安全性 Ch2Annotated[int, Field(gt=0, le=10_000)]
4ENV が任意文字列型安全性 Ch2Literal["staging", "production"]
5Webhook URL フォーマット検証なしエラー処理 Ch10@field_validator でプレフィックスチェック
6print でシークレット漏洩セキュリティprint 除去(設定ロガーの level=DEBUG で管理)
7モジュールレベルで即時インスタンス化テスト容易性 Ch11get_config() ファクトリ関数に切り出す

模範解答 A

Before — 型なし・バリデーションなし・テスト困難
import os

class Config:
    def __init__(self):
        self.project_id = os.getenv("GCP_PROJECT_ID")
        self.bq_dataset = os.getenv("BQ_DATASET", "mart")
        self.batch_size = int(os.getenv("BATCH_SIZE", "100"))
        self.retry_limit = int(os.getenv("RETRY_LIMIT", "3"))
        self.webhook_url = os.getenv("SLACK_WEBHOOK_URL")
        self.env = os.getenv("ENV", "staging")
        print(cfg.project_id)

cfg = Config()
After — 型安全・Fail-Fast・テスト可能
"""config.py — Pydantic v2 BaseSettings による設定管理。

Ch2: 型の活用(Annotated, Field, Literal)
Ch10: エラー処理(起動時 Fail-Fast)
Ch11: テスト容易性(ファクトリ関数)
"""
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

# 定数: マジックストリング排除
SLACK_WEBHOOK_PREFIX: str = "https://hooks.slack.com/"
DEFAULT_BQ_DATASET: str   = "mart"
DEFAULT_BATCH_SIZE: int   = 100
DEFAULT_RETRY_LIMIT: int  = 3

class AppConfig(BaseSettings):
    """Argo Workflows バッチ処理基盤の設定。

    環境変数 / .env ファイルから自動読み込み。
    バリデーションエラーは起動時に ValidationError で即死。

    Attributes:
        gcp_project_id: GCP プロジェクト ID(必須)。
        bq_dataset: BigQuery データセット名。
        batch_size: BQ Insert バッチサイズ(1〜10,000)。
        retry_limit: Argo ジョブのリトライ上限(1〜10)。
        env: 実行環境("staging" or "production" のみ)。
        slack_webhook_url: Slack 通知先 Webhook URL(任意)。
    """
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
    )

    # 必須: デフォルトなし → 未設定で ValidationError(Fail-Fast)
    gcp_project_id: str

    bq_dataset: str = DEFAULT_BQ_DATASET

    # Annotated + Field: 型レベルで制約を表現(Ch2)
    batch_size: Annotated[int, Field(gt=0, le=10_000)] = DEFAULT_BATCH_SIZE
    retry_limit: Annotated[int, Field(ge=1, le=10)]    = DEFAULT_RETRY_LIMIT

    # Literal: 許容値を型制約(タイポを起動時に検知)
    env: Literal["staging", "production"] = "staging"

    slack_webhook_url: str | None = None

    @field_validator("slack_webhook_url")
    @classmethod
    def validate_webhook_prefix(cls, v: str | None) -> str | None:
        """Slack Webhook URL のプレフィックスを検証する。

        Raises:
            ValueError: URL が所定プレフィックスで始まらない場合。
        """
        if v is None:
            return v  # 未設定は OK
        if not v.startswith(SLACK_WEBHOOK_PREFIX):
            raise ValueError(
                f"SLACK_WEBHOOK_URL は {SLACK_WEBHOOK_PREFIX!r} で"
                f"始まる必要があります。受け取った値: {v!r}"
            )
        return v

    @model_validator(mode="after")
    def validate_prod_has_webhook(self) -> "AppConfig":
        """本番環境では Slack 通知を必須にする。

        Raises:
            ValueError: env='production' で webhook が未設定の場合。
        """
        if self.env == "production" and self.slack_webhook_url is None:
            raise ValueError(
                "production 環境では SLACK_WEBHOOK_URL が必須です。"
            )
        return self


def get_config() -> AppConfig:
    """設定ファクトリ関数(テスト容易性 Ch11)。

    モジュールレベルで即時インスタンス化しないことで、
    pytest の monkeypatch.setenv で環境変数モックが容易になる。

    Returns:
        バリデーション済みの AppConfig インスタンス。

    Raises:
        ValidationError: 必須フィールド未設定 or 制約違反の場合。
    """
    return AppConfig()
# ケース1: 正常系(全フィールド設定済み)
import os
os.environ.update({
    "GCP_PROJECT_ID": "my-ec-production",
    "BATCH_SIZE": "500",
    "ENV": "production",
    "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/xxx/yyy/zzz",
})

cfg = get_config()
print(cfg.gcp_project_id)  # "my-ec-production"
print(cfg.batch_size)      # 500  ← int 型に自動変換
print(cfg.env)             # "production"

# ケース2: 必須フィールド GCP_PROJECT_ID 未設定
del os.environ["GCP_PROJECT_ID"]
get_config()
# → pydantic_core.ValidationError: 1 validation error for AppConfig
#   gcp_project_id
#     Field required [type=missing, input_url=...]

# ケース3: BATCH_SIZE が範囲外
os.environ["GCP_PROJECT_ID"] = "proj"
os.environ["BATCH_SIZE"] = "99999"
get_config()
# → ValidationError: batch_size
#     Input should be less than or equal to 10000 [type=less_than_equal]

# ケース4: ENV タイポ
os.environ["BATCH_SIZE"] = "100"
os.environ["ENV"] = "prodution"  # タイポ
get_config()
# → ValidationError: env
#     Input should be 'staging' or 'production' [type=literal_error]

# ケース5: 不正 Webhook URL
os.environ["ENV"] = "staging"
os.environ["SLACK_WEBHOOK_URL"] = "http://example.com/hook"
get_config()
# → ValidationError: slack_webhook_url
#     SLACK_WEBHOOK_URL は 'https://hooks.slack.com/' で始まる必要があります

# ケース6: production で Webhook 未設定(クロスフィールドバリデーション)
os.environ.pop("SLACK_WEBHOOK_URL", None)
os.environ["ENV"] = "production"
get_config()
# → ValidationError: AppConfig
#     production 環境では SLACK_WEBHOOK_URL が必須です
ポイント適用した設計原則/パターン書籍対応章
BaseSettings 継承で環境変数を型変換型の活用(Value Object)Ch2
Annotated[int, Field(gt=0, le=10_000)]型レベルで制約を表現Ch2
Literal["staging", "production"]型による許容値制限(タイポ検知)Ch2
@field_validator でフォーマット検証Fail-Fast エラー処理Ch10
@model_validator でクロスフィールド検証クラス設計(責務の集約)Ch3
get_config() ファクトリ関数テスト容易性(依存注入の入口)Ch11
名前付き定数 SLACK_WEBHOOK_PREFIXマジックストリング排除Ch2/Ch7
# tests/test_config.py
import pytest
from pydantic import ValidationError
from config import get_config, AppConfig

# pytest の monkeypatch で環境変数を安全にモック
class TestAppConfig:
    def test_valid_staging(self, monkeypatch):
        """正常系: staging 設定が正しく読み込まれる。"""
        monkeypatch.setenv("GCP_PROJECT_ID", "test-project")
        monkeypatch.setenv("BATCH_SIZE", "200")
        monkeypatch.setenv("ENV", "staging")

        cfg = get_config()

        assert cfg.gcp_project_id == "test-project"
        assert cfg.batch_size == 200  # str → int 変換済み
        assert cfg.env == "staging"

    def test_missing_project_id_raises(self, monkeypatch):
        """必須フィールド未設定で ValidationError が上がる。"""
        monkeypatch.delenv("GCP_PROJECT_ID", raising=False)
        with pytest.raises(ValidationError, match="gcp_project_id"):
            get_config()

    def test_batch_size_out_of_range(self, monkeypatch):
        """BATCH_SIZE=0 で ValidationError(gt=0 制約違反)。"""
        monkeypatch.setenv("GCP_PROJECT_ID", "proj")
        monkeypatch.setenv("BATCH_SIZE", "0")
        with pytest.raises(ValidationError, match="batch_size"):
            get_config()

    def test_invalid_env_literal(self, monkeypatch):
        """ENV タイポで ValidationError(Literal 制約違反)。"""
        monkeypatch.setenv("GCP_PROJECT_ID", "proj")
        monkeypatch.setenv("ENV", "prodution")
        with pytest.raises(ValidationError, match="env"):
            get_config()

    def test_production_without_webhook_raises(self, monkeypatch):
        """production + webhook 未設定でクロスフィールドバリデーション。"""
        monkeypatch.setenv("GCP_PROJECT_ID", "proj")
        monkeypatch.setenv("ENV", "production")
        monkeypatch.delenv("SLACK_WEBHOOK_URL", raising=False)
        with pytest.raises(ValidationError, match="production"):
            get_config()

問題 B: インフラ — GitHub Actions OIDC × Cloud Run v2 キーレスデプロイ

ECサイトの注文イベントAPIは Cloud Run v2 で稼働しており、現状の課題:

現状の課題:
  • GitHub Actions の CI/CD で SA キー JSON を GitHub Secrets に保存してデプロイしている
  • SA キーの 90日有効期限管理が属人的 で、切れると本番デプロイが止まる
  • Cloud Run サービスが allUsersroles/run.invoker を付与(誰でも呼び出し可能)
  • デプロイ用 SA に roles/editor が付与(最小権限違反)

要件

#要件
1GitHub Actions OIDC + Workload Identity Federation を Terraform で設定し、SA キーを撤廃すること
2デプロイ用 SA の権限を最小化すること(roles/run.developer + roles/artifactregistry.writer のみ)
3Cloud Run v2 の認証を IAM に変更し(--no-allow-unauthenticated)、呼び出し元に roles/run.invoker を付与すること
4google-github-actions/auth@v2 + google-github-actions/deploy-cloudrun@v2 を使った GitHub Actions ワークフローを作成すること

ヒント B(段階的開示)

ヒント1 — 方向性
GitHub Actions OIDC の本質は「GitHub のトークン(JWT)を Google の Workload Identity Pool で信頼し、一時的な GCP 認証情報を払い出す」仕組み。SA キーは不要になる。OIDC トークンは GitHub Actions セッション(通常 1h 以内)のみ有効で、漏洩しても即座に無効化される。attribute_condition で特定リポジトリ・ブランチからのリクエストのみ許可することが重要。
ヒント2 — Terraform のリソース構成
  • google_iam_workload_identity_pool → WIF Pool の作成
  • google_iam_workload_identity_pool_provider(OIDC)→ GitHub の issuer_uri を登録
  • google_service_account → デプロイ用 SA(最小権限)
  • google_project_iam_member × 2 → roles/run.developer + roles/artifactregistry.writer
  • google_service_account_iam_memberroles/iam.workloadIdentityUser(principalSet で特定リポジトリ)
  • GitHub Actions: permissions: id-token: write が必須
ヒント3 — Terraform の骨格と GitHub Actions YAML の要点
WIF Pool + Provider(Terraform)
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github-actions-pool"
}

resource "google_iam_workload_identity_pool_provider" "github_oidc" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-oidc-provider"
  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
  }
  attribute_condition = "attribute.repository == 'my-org/order-service'"
}
GitHub Actions 認証ステップ
permissions:
  id-token: write  # OIDC トークン発行に必須
  contents: read

steps:
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: >-
        projects/PROJECT_NUMBER/locations/global/
        workloadIdentityPools/github-actions-pool/
        providers/github-oidc-provider
      service_account: >-
        cloudrun-deploy-sa@PROJECT_ID.iam.gserviceaccount.com

アーキテクチャ図 — GitHub Actions OIDC → WIF → Cloud Run v2

GitHub GitHub Actions Runner on: push - branches: [main] permissions: id-token: write OIDC JWT トークンを GitHub から取得 assertion.repository = "my-org/order-service" assertion.ref = "refs/heads/main" Google Cloud Platform (my-ec-production) Workload Identity Federation Pool: github-actions-pool Provider: github-oidc-provider issuer: token.actions.githubusercontent.com condition: repository == 'my-org/order-service' 一時的な GCP 認証情報を払い出す(SA キー不要) Deploy Service Account(最小権限) cloudrun-deploy-sa@my-ec-production.iam... roles/run.developer roles/ar.writer WIF バインディング経由でのみ権限借用可能 Artifact Registry asia-northeast1-docker.pkg.dev mops-images/order-event-api :${{ github.sha }} digest ピン留め(latest タグ禁止) クリーンアップポリシー: 最新5世代 Cloud Run v2 — order-event-api asia-northeast1 / --no-allow-unauthenticated(IAM 認証必須) IAM 認証(旧: allUsers 公開) roles/run.invoker: SA + 特定ユーザーのみ ✅ 認証あり(DDoS/意図しない呼び出し防止) ❌ Before: allUsers = 誰でも無制限呼び出し可 デプロイ設定 --min-instances=1 --max-instances=10 --memory=512Mi --cpu=1 image: ar.../order-event-api:$SHA Terraform: google_cloud_run_v2_service OIDC JWT 一時トークン払い出し docker push roles/ar.writer gcloud run deploy roles/run.developer イメージ参照 Before: allUsers 公開 → 廃止

模範解答 B

# terraform/modules/github-actions-wif/main.tf
# GitHub Actions OIDC → Cloud Run v2 デプロイのキーレス認証基盤

# ── Workload Identity Pool ────────────────────────────────────────────────
resource "google_iam_workload_identity_pool" "github_actions" {
  project                   = var.project_id
  workload_identity_pool_id = "github-actions-pool"
  display_name              = "GitHub Actions WIF Pool"
  description               = "GitHub Actions OIDC 用 Workload Identity Pool"
}

# ── OIDC プロバイダー(GitHub の issuer_uri を信頼)─────────────────────────
resource "google_iam_workload_identity_pool_provider" "github_oidc" {
  project                            = var.project_id
  workload_identity_pool_id          = google_iam_workload_identity_pool.github_actions.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-oidc-provider"
  display_name                       = "GitHub OIDC Provider"

  oidc {
    # GitHub の OIDC issuer(固定値)
    issuer_uri = "https://token.actions.githubusercontent.com"
  }

  # GitHub JWT クレーム → GCP attribute のマッピング
  attribute_mapping = {
    "google.subject"       = "assertion.sub"         # repo:org/repo:ref:refs/heads/main
    "attribute.repository" = "assertion.repository"  # "my-org/order-service"
    "attribute.ref"        = "assertion.ref"          # "refs/heads/main"
    "attribute.actor"      = "assertion.actor"        # GitHub ユーザー名(監査ログ用)
  }

  # セキュリティ境界: 特定リポジトリからのリクエストのみ許可
  # フォークリポジトリからのデプロイを防ぐ
  attribute_condition = "attribute.repository == 'my-org/order-service'"
}

# ── デプロイ用 Google Service Account(最小権限)────────────────────────────
resource "google_service_account" "deploy_sa" {
  project      = var.project_id
  account_id   = "cloudrun-deploy-sa"
  display_name = "Cloud Run デプロイ SA(GitHub Actions 専用)"
  description  = "GitHub Actions OIDC 経由でのみ権限借用可能。SA キーなし。"
}

# Cloud Run v2 デプロイ権限(最小: deploy のみ、delete や IAM 管理は不可)
resource "google_project_iam_member" "deploy_run_developer" {
  project = var.project_id
  role    = "roles/run.developer"
  member  = "serviceAccount:${google_service_account.deploy_sa.email}"
}

# Artifact Registry へのイメージ Push 権限(roles/editor より遥かに狭い)
resource "google_project_iam_member" "deploy_ar_writer" {
  project = var.project_id
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.deploy_sa.email}"
}

# ── WIF バインディング: 特定リポジトリのみが SA を借用可能 ─────────────────
# principalSet で「my-org/order-service リポジトリからのリクエスト全て」を対象
resource "google_service_account_iam_member" "github_wif_binding" {
  service_account_id = google_service_account.deploy_sa.name
  role               = "roles/iam.workloadIdentityUser"

  # principalSet: Pool 内の特定 attribute を持つ identity の集合
  member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github_actions.name}/attribute.repository/my-org/order-service"
}

# ── Cloud Run v2 IAM(認証あり: allUsers 廃止)──────────────────────────────
# 呼び出し元(ここではデプロイ SA + 他の承認 SA)のみに invoker を付与
resource "google_cloud_run_v2_service_iam_member" "invoker" {
  project  = var.project_id
  location = var.region
  name     = "order-event-api"
  role     = "roles/run.invoker"

  # デプロイ SA を invoker に追加(ヘルスチェック等のために必要)
  member = "serviceAccount:${google_service_account.deploy_sa.email}"
}
# terraform/modules/github-actions-wif/outputs.tf
output "workload_identity_provider" {
  description = "GitHub Actions ワークフローの workload_identity_provider 入力値"
  value       = google_iam_workload_identity_pool_provider.github_oidc.name
  # 例: projects/123456789/locations/global/workloadIdentityPools/github-actions-pool/providers/github-oidc-provider
}

output "deploy_service_account_email" {
  description = "GitHub Actions が借用するデプロイ用 SA のメールアドレス"
  value       = google_service_account.deploy_sa.email
}
# .github/workflows/deploy-cloudrun.yaml
# SA キーレスデプロイ: GitHub OIDC → WIF → Cloud Run v2
# google-github-actions/auth@v2 + deploy-cloudrun@v2

name: Deploy to Cloud Run v2

on:
  push:
    branches: [main]

# OIDC トークン発行に必須(これがないと WIF 認証が失敗する)
permissions:
  id-token: write   # ← 必須: GitHub OIDC トークン取得
  contents: read

env:
  PROJECT_ID:   my-ec-production
  REGION:       asia-northeast1
  SERVICE_NAME: order-event-api
  AR_HOST:      asia-northeast1-docker.pkg.dev
  AR_REPO:      mops-images
  IMAGE_NAME:   order-event-api

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # GitHub Environments で手動承認を設定可能

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # ── Google 認証(OIDC キーレス)─────────────────────────────────────
      - name: Authenticate to Google Cloud
        id: auth
        uses: google-github-actions/auth@v2
        with:
          # terraform output で取得した値を GitHub Variables に登録して参照
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.DEPLOY_SA_EMAIL }}
          # SA キーは一切不要。OIDC トークンで一時的な認証情報を取得

      # ── Docker イメージビルド & Push ────────────────────────────────────
      - name: Configure Docker for Artifact Registry
        run: gcloud auth configure-docker ${{ env.AR_HOST }} --quiet

      - name: Build and Push Docker image
        id: build
        run: |
          # digest ピン留め: latest タグではなく git SHA を使う
          IMAGE_TAG="${{ env.AR_HOST }}/${{ env.PROJECT_ID }}/${{ env.AR_REPO }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
          docker build --tag "${IMAGE_TAG}" .
          docker push "${IMAGE_TAG}"
          # IMAGE_TAG を後続ステップで参照できるよう出力
          echo "image=${IMAGE_TAG}" >> "${GITHUB_OUTPUT}"

      # ── Cloud Run v2 デプロイ ────────────────────────────────────────────
      - name: Deploy to Cloud Run v2
        id: deploy
        uses: google-github-actions/deploy-cloudrun@v2
        with:
          service: ${{ env.SERVICE_NAME }}
          region:  ${{ env.REGION }}
          image:   ${{ steps.build.outputs.image }}
          flags: |
            --no-allow-unauthenticated
            --min-instances=1
            --max-instances=10
            --memory=512Mi
            --cpu=1
            --ingress=all

      - name: Deployment summary
        run: |
          echo "✅ Deployed: ${{ steps.deploy.outputs.url }}"
          echo "   Image: ${{ steps.build.outputs.image }}"

Bad vs Good 設計比較

観点Bad(現状)Good(改善後)
認証方式 SA キー JSON を GitHub Secrets に保存 OIDC 一時トークン(キーレス)
キー管理 90日期限の属人管理、切れると本番停止、漏洩リスク キー不要。GitHub Actions セッション(〜1h)のみ有効
デプロイ SA 権限 roles/editor(過剰:全リソース変更可能) roles/run.developer + roles/artifactregistry.writer(最小権限)
Cloud Run 認証 allUsersroles/run.invoker(誰でも呼び出し可能) IAM 認証必須(--no-allow-unauthenticated
監査ログ SA キー操作として記録(誰が実行したか不明) リポジトリ名・ブランチ・actor が Cloud Audit Logs に含まれる
イメージタグ latest タグ(どのビルドか追跡不可) :${{ github.sha }}(git コミットと1対1で対応)
フォークリポジトリ対策 なし(フォークから SA キーを使ったデプロイが可能) attribute_condition で特定リポジトリのみに制限

OIDC 認証フロー(時系列)

1. GitHub Actions ランナーが起動し、OIDC JWT トークンを GitHub から取得
   JWT クレーム: { "repository": "my-org/order-service", "ref": "refs/heads/main", ... }

2. google-github-actions/auth@v2 が JWT を WIF に送信
   → WIF が attribute_condition を確認:
     "attribute.repository == 'my-org/order-service'" → ✅ 一致

3. WIF が一時的な GCP 認証情報(access_token)を払い出す
   有効期限: デフォルト 1時間(セッション中のみ)

4. 認証情報で Artifact Registry に docker push
   roles/artifactregistry.writer → ✅

5. 認証情報で Cloud Run v2 に gcloud run deploy
   roles/run.developer → ✅

6. SA キーは一切使用していない。漏洩するものが存在しない。

ポイント解説

カテゴリ A

1 Pydantic v2 BaseSettings の起動時 Fail-Fast(Ch10)
環境変数の不備をアプリ起動時に ValidationError として検知できる。None のまま処理が進んで本番でランダムに落ちる問題を根絶できる。Argo Workflows のバッチは深夜に動くため、「バッチが半分終わったところで None エラー」より「起動直後に設定エラーで即死」の方が圧倒的に対処しやすい。
2 Annotated + Field で「型としての制約」(Ch2)
batch_size: Annotated[int, Field(gt=0, le=10_000)] = 100 は「1以上10,000以下の整数」を型レベルで表現する。if ガードや docstring に頼らず、コードが制約の唯一の真実(SSOT) になる。IDE・mypy・Pydantic が全て同じ制約を認識する。
3 @model_validator でクロスフィールドバリデーション(Ch3)
「production 環境では slack_webhook_url が必須」というビジネスルールは、@field_validator では表現できない(単一フィールドの検証のみ)。@model_validator(mode="after") は全フィールドの値が確定した後に実行され、複数フィールドにまたがる条件を型安全に実装できる。

カテゴリ B

4 GitHub Actions OIDC でキーを「持たない設計」
SA キーは「漏洩リスク・期限管理・ローテーション」が常につきまとう。OIDC 一時トークンは GitHub Actions セッション中のみ有効で、漏洩しても即座に無効化される。「キーを作らない設計」が最も安全な鍵管理attribute_condition でリポジトリを限定することで、フォークからの意図しないデプロイも防げる。
5 roles/editor → 最小権限への絞り込み
roles/editor は「プロジェクト内のほぼ全リソースを変更できる」権限で、IAM 管理を除けば何でもできる。デプロイ SA に必要なのは roles/run.developer(Cloud Run デプロイのみ)と roles/artifactregistry.writer(イメージ Push のみ)の2つだけ。侵害されても影響範囲をこの2操作に限定できる。
6 Cloud Run --no-allow-unauthenticated で公開 API を廃止
allUsers への roles/run.invoker 付与は「インターネット上の誰でも無制限に API を呼び出せる」状態。DDoS・スクレイピング・意図しない API 濫用のリスクがある。IAM 認証を強制することで、認可された SA・ユーザーのみが呼び出せる。Cloud Endpoints や API Gateway と組み合わせることでさらに細かい認可制御が可能。

実務への応用

  • Pydantic v2 Settings は Argo Workflows バッチ設定管理に直結: Argo WorkflowTemplate の env: セクションから ConfigMap / ExternalSecret 経由で注入される環境変数を、バッチ起動直後に AppConfig でバリデーションすることで、設定ミスを「バッチ実行の最初の1秒」で検知できる。深夜バッチが途中で落ちるより圧倒的に早い
  • GitHub Actions OIDC は MOps チームの CI/CD セキュリティ基盤として必須: SA キーを GitHub Secrets に入れている運用は、退職者のキー失効・90日更新・ローテーション忘れで本番デプロイが止まるリスクがある。OIDC に移行することでこれらの運用コストをゼロにできる
  • get_config() ファクトリ関数でテスト可能な設計: pytest のフィクスチャで monkeypatch.setenv("GCP_PROJECT_ID", "test-project") としてから get_config() を呼ぶことで、テスト専用の設定を簡単に作れる。モジュールレベルの cfg = Config() ではこれができない
  • WIF + OIDC は GKE Workload Identity と設計思想が同じ: 前日(05-30)の GKE Workload Identity は「K8s Pod が GSA を借用」、今日の GitHub Actions OIDC は「CI/CD Runner が GSA を借用」。どちらも「キーレスで一時的な権限借用」という同じ設計原則。GCP の認証設計を統一的に理解できる
  • 証券マン視点: セキュリティ投資の ROI を数値化: SA キー漏洩インシデント対応コスト(調査・対応・ブランド毀損)は数百万円〜数千万円になりうる。OIDC 移行の工数(3〜5人日)と比べると、ペイバックが「侵害1件を防ぐだけで元が取れる」水準。セキュリティ投資はリスク低減の期待値で ROI を計算する

今日のまとめ

Pydantic v2 BaseSettings + Annotated + Field + Literal を組み合わせることで、環境変数バリデーションを「型レベル × 起動時 Fail-Fast」で実現でき、実行時の予期せぬ None エラーを根絶できる(Ch2/Ch10)。

GitHub Actions OIDC + Workload Identity Federation でサービスアカウントキーを撤廃し、Cloud Run v2 の --no-allow-unauthenticated で認証を強制することで、「キーを持たない・公開しない」セキュリティ設計が完成する。どちらも「問題を実行時ではなく設計段階で排除する」という同じ設計哲学に基づく。

自己評価

自分の回答

気づき・メモ