green snake
Photo by Pixabay on Pexels.com
目次

迷わない設定と秘密管理:FastAPI×pydantic-settings実務ガイド――環境変数・.env・多環境切替・型安全・バリデーション・シークレット運用・Feature Flag


要約(最初に全体像)

  • 目的は、FastAPIで設定と秘密情報の取り扱いを統一し、開発・ステージング・本番の多環境切替を安全に自動化することです。
  • 推奨構成はpydantic-settings v2を中核に、環境変数を最上位、.envはローカル補助、Secret ManagerDocker/KubernetesのSecretsを本番で利用する形です。
  • 設定は型安全バリデーションで品質を保ち、依存先ごとの構造体に分割。Feature Flagで挙動を段階的に切替え、テスト・CI/CDにも同じ規律を適用します。
  • 仕上げとして、ログ設定・CORS・DB接続・外部API鍵など現場で頻出の設定をサンプルで提示し、ローテーションインシデント時の失効まで運用面に踏み込みます。

誰が読んで得をするか(具体像)

  • 学習者Aさん(学部4年・個人開発):.envの置き方や読み込み順、本番での秘密管理の基本を確実に知りたい。
  • 小規模チームBさん(受託3名):ステージングと本番の差分が原因の障害が多い。一元化したSettingsバリデーションで事故を減らしたい。
  • SaaS開発Cさん(スタートアップ):Feature Flagロールアウトを導入して、安全に実験したい。鍵ローテーションランタイム差し替えの戦略が必要。

アクセシビリティ評価

  • 構造:冒頭サマリー→基本設計→実装→多環境→秘密管理→運用とテスト→落とし穴→まとめの逆三角形。
  • 言葉:専門語は初出で短く定義。コードはコメントを控えめに、固定幅ブロックで読みやすく提示。
  • 配慮:段落を短く、箇条書きで視線移動を少なくし、読者像と効果を各節で再確認。
  • 総合レベル:AA相当。

1. 基本方針(12-Factorの視点)

12-Factor Appでは設定を環境変数で注入するのが原則。リポジトリに秘密情報を含めないことが最重要です。
pydantic-settingsはこの原則を型安全に実現します。

方針の柱

  1. 単一の設定クラスをアプリの唯一の真実の源に。
  2. 読み込み優先度は「環境変数 > .env > 既定値」。
  3. 本番の秘密外部Secretで注入(環境変数やファイルマウント)。
  4. 検証変換を設定クラスで実施(起動時に即失敗)。
  5. 多環境Env名オプション差分で切替、条件分岐は最小限

2. まずは最小の設定クラスを作る

2.1 依存関係

pydantic>=2.6
pydantic-settings>=2.3
python-dotenv  # (任意).envの補助

2.2 最小サンプル

# app/core/settings.py
from pydantic import Field, PostgresDsn, AnyUrl, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal, Optional

EnvName = Literal["dev", "stg", "prod"]

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",               # ローカル補助
        env_file_encoding="utf-8",
        env_prefix="",                 # 任意のプレフィックス
        extra="ignore"                 # 想定外は無視(厳密化も可能)
    )

    # 共通
    app_name: str = "My FastAPI"
    env: EnvName = "dev"
    host: str = "0.0.0.0"
    port: int = 8000
    log_level: Literal["debug", "info", "warning", "error"] = "info"

    # DB
    database_url: PostgresDsn | str = "postgresql+psycopg://user:pass@localhost:5432/appdb"

    # CORS
    cors_origins: list[str] = ["http://localhost:3000"]

    # 外部API
    external_api_base: AnyUrl | None = None
    external_api_key: Optional[str] = Field(default=None, repr=False)

    # セキュリティ
    secret_key: str = Field(..., repr=False, description="JWT等に使用")
    access_token_expires_minutes: int = 15

    # Feature Flag
    enable_new_search: bool = False

    @field_validator("cors_origins", mode="before")
    @classmethod
    def parse_cors_origins(cls, v):
        if isinstance(v, str):
            # 環境変数から "https://a,https://b" のように来るのを配列化
            return [s.strip() for s in v.split(",") if s.strip()]
        return v

def get_settings() -> Settings:
    return Settings()

2.3 使い方(アプリ側)

# app/main.py
from fastapi import FastAPI
from app.core.settings import get_settings

settings = get_settings()
app = FastAPI(title=settings.app_name)

@app.get("/meta")
def meta():
    return {
        "app": settings.app_name,
        "env": settings.env,
        "log_level": settings.log_level,
        "flags": {"new_search": settings.enable_new_search}
    }

ポイント

  • 型注釈で入力を制約し、LiteralDsnで早期に不正値を弾きます。
  • repr=Falseで秘密をログ出力しない配慮。
  • field_validator環境変数の文字列→配列の変換など、現実的な前処理を記述。

3. 読み込み順と優先度(起動の保証)

優先度は原則環境変数が最上位.envはローカル開発の補助です。

  1. プロセス環境変数(ENV=prod 等)
  2. .env(Git管理しないのが基本。コミットするならテンプレのみ)
  3. 既定値(コードの初期値)

実務のコツ

  • .env.example をリポジトリで配布し、必須項目をコメント付きで明示。
  • 本番では.envを使わず、Secretマネージドな環境変数で注入。
  • すべての必須項目に既定値は置かないsecret_keyのように必須化)。

4. 多環境切替(dev/stg/prod)

4.1 ルール

  • envの値で振る舞いを切替。if文が増えないよう、値で制御できる設計に。
  • CORSやログ、外部APIエンドポイントなどは設定値で差し替え、アプリコードは不変に。

4.2 例:環境別の差分注入

# dev(ローカル).env
ENV=dev
SECRET_KEY=dev-secret
DATABASE_URL=postgresql+psycopg://dev:dev@localhost:5432/app_dev
CORS_ORIGINS=http://localhost:3000

# stg(CI/CDで注入)
ENV=stg
SECRET_KEY=stg-secret
DATABASE_URL=postgresql+psycopg://stg:stg@stg-db:5432/app_stg
CORS_ORIGINS=https://stg.example.com

# prod(Secret ManagerやK8sで注入)
ENV=prod
SECRET_KEY=prod-secret
DATABASE_URL=postgresql+psycopg://prod:prod@prod-db:5432/app
CORS_ORIGINS=https://app.example.com
LOG_LEVEL=warning
ENABLE_NEW_SEARCH=true

4.3 値だけで切替える設計例

# app/core/bootstrap.py
from app.core.settings import get_settings
from app.core.logging import setup_logging

def bootstrap():
    s = get_settings()
    setup_logging(s.log_level)
    # CORS ミドルウェアなどにも s.cors_origins をそのまま渡す

アプリ起動コードを不変に保てるほど、環境差分が事故を起こしにくくなります。


5. サブ設定での分割(見通しと再利用)

設定が増えるほど関心ごとに分割したくなります。例えば次のようにネスト型で整理します。

# app/core/settings.py(抜粋・分割)
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict

class DbConfig(BaseModel):
    url: str
    pool_size: int = 5
    echo: bool = False

class SecurityConfig(BaseModel):
    secret_key: str
    access_token_expires_minutes: int = 15

class CorsConfig(BaseModel):
    origins: list[str] = []
    allow_credentials: bool = True

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    env: EnvName = "dev"
    log_level: Literal["debug", "info", "warning", "error"] = "info"

    db: DbConfig = DbConfig(url="sqlite:///./app.db")   # 既定値を小さく
    security: SecurityConfig = SecurityConfig(secret_key="PLEASE-SET")
    cors: CorsConfig = CorsConfig(origins=["http://localhost:3000"])

環境変数でネストを上書きする場合は、DB__URL のようなダブルアンダースコア(pydanticのネスト表現)も使えます。

# 例
DB__URL=postgresql+psycopg://user:pass@host:5432/appdb
CORS__ORIGINS=https://app.example.com,https://stg.example.com

6. バリデーションと依存関係の整合

設定は起動時に壊れてほしいもの。条件間の整合性をチェックします。

# app/core/settings.py(整合チェック例)
from pydantic import model_validator

class Settings(BaseSettings):
    # ...(略)
    env: EnvName = "dev"
    external_api_base: AnyUrl | None = None
    external_api_key: str | None = None

    @model_validator(mode="after")
    def check_external_api(self):
        if (self.external_api_base is None) ^ (self.external_api_key is None):
            raise ValueError("external_api_baseとexternal_api_keyは同時に設定してください")
        if self.env == "prod" and self.log_level == "debug":
            raise ValueError("prodでlog_level=debugは許可されません")
        return self

これで不正な組合せがデプロイ時ではなく即時に失敗します。


7. 機密情報の扱い(安全な運用)

7.1 原則

  • 秘密はコードにハードコードしない.envにもできるだけ置かない
  • 本番はSecret ManagerKubernetes/DockerのSecretsを用い、環境変数として注入。
  • ログに秘密を出さないrepr=False、マスク処理)。
  • ローテーション(定期的な鍵更新)と失効(流出時の即切り)を計画。

7.2 ファイルマウント型の秘密

クラウドやKubernetesのSecretをファイルとしてマウントする場合もあります。pydantic-settingsはファイル読みのヘルパーを自作すれば簡単です。

# app/core/secret_loader.py
from pathlib import Path

def from_file(path: str) -> str:
    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(path)
    return p.read_text(encoding="utf-8").strip()
# app/core/settings.py(利用例)
secret_key: str = Field(default_factory=lambda: from_file("/run/secrets/secret_key"))

本番はランタイム注入を前提にしつつ、ローカルは.envで補助する構成が安定します。


8. Feature Flag(挙動を安全に切替)

新機能を一気に本番へ出すのではなく、Flagで段階的に有効化します。

8.1 設計

  • 設定クラスにenable_xxx: boolを持たせる。
  • ルートやサービス層で条件分岐するが、分岐点を局所化する。
  • 実験用に割合や対象を絞る場合は、環境変数+ユーザー属性で判定。

8.2 例

# app/services/search.py
from app.core.settings import get_settings

def search(query: str):
    s = get_settings()
    if s.enable_new_search:
        return new_engine(query)
    return legacy_engine(query)

Flagは必ず削除する前提で、有効期間を決めて運用すると設定が腐らずに済みます。


9. ログ・CORS・DBなど頻出設定のサンプル

9.1 ログ設定

# app/core/logging.py
import json, logging, sys
from typing import Any

class JsonFormatter(logging.Formatter):
    def format(self, record):
        payload: dict[str, Any] = {
            "t": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
            "lvl": record.levelname,
            "name": record.name,
            "msg": record.getMessage(),
        }
        return json.dumps(payload, ensure_ascii=False)

def setup_logging(level: str = "info"):
    h = logging.StreamHandler(sys.stdout)
    h.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers[:] = [h]
    root.setLevel(level.upper())

9.2 CORS

# app/main.py(CORS)
from fastapi.middleware.cors import CORSMiddleware
from app.core.settings import get_settings
s = get_settings()
app.add_middleware(
    CORSMiddleware,
    allow_origins=s.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

9.3 DB接続(SQLAlchemy)

# app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.core.settings import get_settings
s = get_settings()

engine = create_engine(s.database_url, pool_pre_ping=True, echo=False)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

10. テストとCIでの設定注入

10.1 テスト用の.env.testを使い分け

  • pytest実行時に ENV=devテスト専用のDBsecret_keyを注入。
  • BaseSettings(env_file=".env") の代わりに、テスト起動時だけ別ファイルを指す。
# tests/conftest.py
import os
os.environ["ENV"] = "dev"
os.environ["SECRET_KEY"] = "test-secret"
os.environ["DATABASE_URL"] = "sqlite:///./test.db"

テストは環境変数で上書きするのがシンプルで、プロセス内の一貫性も保てます。

10.2 CI/CDでの注入

  • GitHub ActionsやGitLab CIのSecret機能に鍵を保存し、ジョブの環境変数として注入。
  • マージ前にpydanticの検証が走るため、設定漏れはその場で検知。

11. Docker/Kubernetesでの運用

11.1 Docker

  • ENV命令で不定の値は置かず、docker run -e KEY=...composeenvironmentで注入。
  • 秘密は**docker secretや外部Secret**から(環境変数化してから渡すか、ファイルで読み取り)。

docker-compose.yml例:

services:
  app:
    image: my-fastapi:latest
    environment:
      ENV: "stg"
      LOG_LEVEL: "info"
      DATABASE_URL: "${DATABASE_URL}"
      SECRET_KEY: "${SECRET_KEY}"
    ports: ["8000:8000"]

11.2 Kubernetes

  • ConfigMapで非機密の設定、Secretで機密を分離。
  • Podへは環境変数またはボリュームで注入。
  • ローテーション時はRolling Updateと連携し、readinessで安定化。

12. 失敗時のふるまいと安全装置

  • 起動時に検証エラーで即時停止。曖昧に動かすより安全。
  • 重要な設定にはフェイルクローズ(値が無ければ機能停止)。
  • 外部API鍵が無い時は、ダミー実装に切替えるのではなくエラーにする方が事故を防げます。

13. インシデント対応(失効とローテーション)

  • 鍵が漏洩したら直ちに失効。発行元のコンソールやAPIで再生成し、環境変数を更新
  • アプリは設定の再読み込みを想定すると便利。SIGHUPで再読込や、定期リロードを仕掛ける方法もあります(ただし秘密の再読込は運用設計が必要)。

簡易の再読込フック例:

# app/core/runtime.py
from app.core.settings import Settings
_settings_cache: Settings | None = None

def current_settings() -> Settings:
    global _settings_cache
    if _settings_cache is None:
        _settings_cache = Settings()
    return _settings_cache

def reload_settings():
    global _settings_cache
    _settings_cache = Settings()

14. 現場で役立つパターン集

14.1 A/Bテスト(割合フラグ)

# app/core/flags.py
import hashlib
from app.core.settings import get_settings

def rollout(user_id: str, percentage: int) -> bool:
    # ハッシュで安定割当
    v = int(hashlib.sha256(user_id.encode()).hexdigest()[:8], 16) % 100
    return v < percentage

def new_ui_enabled(user_id: str) -> bool:
    s = get_settings()
    if not s.enable_new_search:
        return False
    return rollout(user_id, 20)  # 20%配布

14.2 接続先切替(リージョン/テナント)

# app/core/endpoint_resolver.py
from app.core.settings import get_settings

def api_base_for_tenant(tenant: str) -> str:
    s = get_settings()
    if s.env == "prod":
        return f"https://{tenant}.api.example.com"
    return f"https://stg-{tenant}.api.example.com"

14.3 期限ベースの自動無効化

# app/core/flag_until.py
from datetime import datetime, timezone

def enabled_until(dt_iso: str) -> bool:
    # "2025-12-31T23:59:59Z"
    limit = datetime.fromisoformat(dt_iso.replace("Z","+00:00"))
    return datetime.now(timezone.utc) < limit

15. よくある落とし穴と対策

症状 原因 対策
本番だけ動かない .env依存・環境変数差し替え漏れ 環境変数最優先の方針、必須値は既定値なし
秘密がログに出た printや例外に含まれた repr=Falseマスク・構造化ログでフィールド制御
CORSが通らない 文字列のまま配列に変換していない 文字列→配列のvalidator、ログで最終値確認
Flagが増えすぎて混乱 期限なしに積み上げ 有効期限除去タスク、命名規約
検証が甘く後から崩れる バリデーション不足 field_validatormodel_validator起動時失敗

16. 導入ロードマップ

  1. 単一のSettingsを導入し、.env.exampleを整備。必須項目は既定値を持たせない。
  2. 多環境の値を環境変数で注入し、アプリは値の受け取りだけに専念。
  3. バリデーション整合チェックを追加。CORSやDB、外部API鍵を例に失敗で停止させる。
  4. 秘密管理をSecret ManagerやK8s Secretへ移行。ローテーション手順を確立。
  5. Feature FlagA/B配布を導入し、安全な段階ロールアウトを実現。
  6. CI/CDに設定注入とスキーマ検証を組み込み、差分の事故を防止。

参考リンク


まとめ

  • 設定は型安全・環境変数最優先.envはローカル補助が基本。
  • pydantic-settingsで構造化し、バリデーション整合チェックで起動時に失敗させると運用が安定します。
  • 秘密情報は外部Secretで注入ローテーション失効を踏まえたライフサイクルで管理します。
  • 多環境は値で切替、コードは不変を目指す。Feature Flagで段階的ロールアウトが可能になり、リスクの高い変更も安全に試せます。
  • 今日から、Settingsの一本化と必須項目の見直し、.env.exampleの整備から始めてください。設定の土台が整うと、FastAPIの開発と運用は驚くほど滑らかになります。わたしも応援しています。

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)