迷わない設定と秘密管理:FastAPI×pydantic-settings実務ガイド――環境変数・.env・多環境切替・型安全・バリデーション・シークレット運用・Feature Flag
要約(最初に全体像)
- 目的は、FastAPIで設定と秘密情報の取り扱いを統一し、開発・ステージング・本番の多環境切替を安全に自動化することです。
- 推奨構成はpydantic-settings v2を中核に、環境変数を最上位、
.envはローカル補助、Secret ManagerやDocker/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はこの原則を型安全に実現します。
方針の柱
- 単一の設定クラスをアプリの唯一の真実の源に。
- 読み込み優先度は「環境変数 >
.env> 既定値」。 - 本番の秘密は外部Secretで注入(環境変数やファイルマウント)。
- 検証と変換を設定クラスで実施(起動時に即失敗)。
- 多環境は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}
}
ポイント
- 型注釈で入力を制約し、
LiteralやDsnで早期に不正値を弾きます。 repr=Falseで秘密をログ出力しない配慮。field_validatorで環境変数の文字列→配列の変換など、現実的な前処理を記述。
3. 読み込み順と優先度(起動の保証)
優先度は原則環境変数が最上位。.envはローカル開発の補助です。
- プロセス環境変数(
ENV=prod等) .env(Git管理しないのが基本。コミットするならテンプレのみ)- 既定値(コードの初期値)
実務のコツ
.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 ManagerやKubernetes/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とテスト専用のDBやsecret_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=...やcomposeのenvironmentで注入。- 秘密は**
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_validator・model_validatorで起動時失敗 |
16. 導入ロードマップ
- 単一のSettingsを導入し、
.env.exampleを整備。必須項目は既定値を持たせない。 - 多環境の値を環境変数で注入し、アプリは値の受け取りだけに専念。
- バリデーションと整合チェックを追加。CORSやDB、外部API鍵を例に失敗で停止させる。
- 秘密管理をSecret ManagerやK8s Secretへ移行。ローテーション手順を確立。
- Feature FlagとA/B配布を導入し、安全な段階ロールアウトを実現。
- CI/CDに設定注入とスキーマ検証を組み込み、差分の事故を防止。
参考リンク
- Pydantic
- pydantic-settings
- 12-Factor
- FastAPI
- セキュリティ/Secrets
まとめ
- 設定は型安全・環境変数最優先・.envはローカル補助が基本。
- pydantic-settingsで構造化し、バリデーションと整合チェックで起動時に失敗させると運用が安定します。
- 秘密情報は外部Secretで注入、ローテーションと失効を踏まえたライフサイクルで管理します。
- 多環境は値で切替、コードは不変を目指す。Feature Flagで段階的ロールアウトが可能になり、リスクの高い変更も安全に試せます。
- 今日から、Settingsの一本化と必須項目の見直し、
.env.exampleの整備から始めてください。設定の土台が整うと、FastAPIの開発と運用は驚くほど滑らかになります。わたしも応援しています。
