メインコンテンツへスキップ
友田 陽大
Pythonバックエンド
Python
FastAPI
アーキテクチャ設計
可観測性
パフォーマンス
型安全

FastAPI 本番運用ガイド:async の正しい使いどころ・Pydantic v2 境界バリデーション・DIと可観測性で落ちないAPIを作る

FastAPI を本番品質で運用する実装ガイド。公式ドキュメントに忠実な async def / def の使い分け、Pydantic v2 の境界バリデーション、Depends による依存性注入、構造化ログと OpenTelemetry の可観測性、BackgroundTasks の限界とタスクキューへの逃がし方、テストとデプロイまでを実コードで解説します。

公開日
読了時間
24分
著者
友田 陽大
シェア
目次

「FastAPI で API を立てたい」——要件としては一行です。@app.get を書けば 5 分で動くものができます。けれど本番に載せようとした瞬間、判断すべきことが一気に増えます。async defdef のどちらで書くのか。外部入力をどこで検証するのか。DB セッションをどう注入するのか。重い AI ジョブをリクエストの中で回していいのか。落ちたとき、ログから原因を追えるのか。

この記事は、FastAPI を本番品質で運用するための実装ガイドです。公式チュートリアルが「動かす」ところまでを丁寧に教えてくれるのに対し、ここでは「落ちない・追える・変更しやすい」を作るための判断基準とコードに焦点を当てます。題材として、私が国内大手放送事業者向けに構築した社内AIプラットフォーム(テロップ誤字検出パイプライン。FastAPI を API 層に据え、長時間の AI ジョブを Cloud Run Jobs / Cloud Workflows へ切り離して運用)での設計判断も交えます。

この記事のルール:API 仕様・推奨パターンは FastAPI / Pydantic 公式ドキュメント(2026年6月時点) に基づきます。仕様は改定されるため、本番投入前に必ず公式ドキュメントで最新の挙動を確認してください。コードは実運用で使える形に整えていますが、シークレット(DB URL・APIキー・署名鍵)は環境変数前提です(ハードコード厳禁)。


0. FastAPI が「速い・安全」と言える根拠を一行で

設計判断の前に、FastAPI が何の上に立っているかを押さえます。公式は次の3点を土台に挙げています。

  • 標準の Python 型ヒント:パラメータの型を書くだけで、バリデーション・変換・OpenAPI ドキュメント生成が自動で付いてくる。
  • Pydantic:データのバリデーションと解析。コア検証ロジックは Rust で書かれており、公式は「Python で最速級のデータ検証ライブラリの一つ」と述べています。
  • Starlette:ASGI の Web 層。公式の表現で「NodeJS や Go と同等の高性能」を支える部分。

つまり FastAPI の「速い」「バグが減る」は、型ヒント一本で検証・変換・ドキュメントを同時に得る設計から来ています。本記事はこの設計思想を、本番運用の文脈で最大限に活かすことを目指します。


1. 最重要:async defdef の正しい使い分け

ここを間違えると、スループットが出ない API か、たまにフリーズする API ができあがります。FastAPI 最大の落とし穴であり、最大のレバレッジです。

1.1 公式が定める「急いでいる人向け」ルール

公式ドキュメント(/async/)の判断基準を、そのまま引用します。

  1. await で呼べと指示された外部ライブラリを使うなら → async def

    @app.get("/")
    async def read_results():
        results = await some_library()
        return results
    
  2. 外部と通信するが await 非対応のライブラリ(多くの DB ライブラリが現状これ)なら → 普通の def

    @app.get("/")
    def results():
        results = some_library()
        return results
    
  3. 何とも通信せず待つ必要がないなら → async def(中で await を使わなくてよい)

  4. 判断が付かないなら → 普通の def

1.2 なぜ def でも遅くならないのか

普通の def でパスオペレーション関数を書くと、FastAPI はそれを外部スレッドプールで実行して await します(直接呼ぶとサーバーがブロックするため)。これはパスオペレーション関数だけでなく依存(Depends)にも適用されます。

ここが他の async フレームワークと決定的に違う点です。

  • def のブロッキング呼び出し:FastAPI がスレッドプールに逃がすので、イベントループは止まらない。
  • async def の中のブロッキング呼び出し:そのままイベントループ上で走るので、全リクエストが固まる

1.3 一番多い事故:async def の中で同期ブロッキングを呼ぶ

これが現場で最も頻発する、そして最も気づきにくいバグです。

import time
import requests  # 同期ライブラリ(await できない)

@app.get("/bad")
async def bad_endpoint():
    # ❌ async def の中で同期ブロッキング I/O を呼んでいる
    # この requests.get が返るまで、サーバー全体の処理が止まる
    res = requests.get("https://slow-upstream.example.com/data")
    time.sleep(1)  # ❌ これも同様にイベントループを丸ごと止める
    return res.json()

async def と宣言しているのに中身は同期です。1 リクエストの requests.get / time.sleep の間、同じワーカーが捌くはずの他の全リクエストがブロックされます。負荷試験で「同時接続を上げた途端にレイテンシが崩壊する」典型がこれです。

修正の方針は2つ。(A) def にしてスレッドプールに任せるか、(B) async 対応ライブラリに替えて await するか。

import anyio
import httpx  # async 対応の HTTP クライアント

# (A) 同期ライブラリを使い続けるなら def にする(FastAPIがスレッドプールへ逃がす)
@app.get("/ok-sync")
def ok_sync_endpoint():
    res = requests.get("https://slow-upstream.example.com/data")
    return res.json()

# (B) async ネイティブのライブラリで正しく await する
@app.get("/ok-async")
async def ok_async_endpoint():
    async with httpx.AsyncClient() as client:
        res = await client.get("https://slow-upstream.example.com/data")
    return res.json()

# どうしても async def の中で重い同期処理を呼ぶしかないときは、明示的にスレッドへ逃がす
@app.get("/ok-offload")
async def ok_offload_endpoint():
    # anyio.to_thread.run_sync でブロッキング関数をスレッドプールへ
    data = await anyio.to_thread.run_sync(blocking_cpu_or_io_work)
    return data

1.4 決定表:async defdef

あなたの I/O関数の宣言理由
await で呼ぶ async ライブラリ(httpx, asyncpg, async SDK)async defネイティブに非ブロッキング。await できる
同期 DB ドライバ・requeststime.sleep・同期 SDKdefFastAPI がスレッドプールへ逃がし、ループを止めない
CPU バウンドな重い計算(画像処理・暗号・ML 推論)def(または別プロセス/キュー)スレッドでも GIL の影響あり。重ければ第7章のキューへ
外部と通信しない純粋なロジックasync def待ちがないので何でも可。公式は async を推奨
判断が付かないdef公式の「迷ったら def」。事故率が最も低い

設計の指針(KISS):「全部 async def にすれば速い」は誤りです。async def は『中で本当に await するとき』にだけ使う。中身が同期なら素直に def にして FastAPI に任せるのが、最も事故が少なく速い選択です。


2. Pydantic v2:外部入力を境界で殺す

API の本質は「外の世界から来る信用できないデータを、内側の信用できる型に変換する関所」です。Pydantic v2 はその関所を型ヒント一本で作れます。外部入力は一切信用しない——これがセキュリティの第一原則です。

2.1 リクエスト/レスポンスモデルを分ける

入力用と出力用のモデルは別物にします。入力に idcreated_at を含めさせない、出力にパスワードハッシュを漏らさない——これは責務分離(SRP)であり、セキュリティでもあります。

from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, field_validator

# --- 入力(クライアントが送ってよいものだけ) ---
class UserCreate(BaseModel):
    email: EmailStr                                  # 形式不正は422で弾かれる
    display_name: str = Field(min_length=1, max_length=50)
    age: int = Field(ge=0, le=150)                   # 範囲制約(負の年齢を入れさせない)

    @field_validator("display_name")
    @classmethod
    def no_control_chars(cls, v: str) -> str:
        # ドメイン固有のルールはvalidatorで明示的に検証する
        if any(ord(c) < 0x20 for c in v):
            raise ValueError("制御文字は使用できません")
        return v.strip()

# --- 出力(サーバーが返してよいものだけ。秘密は含めない) ---
class UserPublic(BaseModel):
    id: int
    email: EmailStr
    display_name: str
    created_at: datetime
    # password_hash は意図的に含めない → 漏洩を型で防ぐ

Field の制約(min_length / max_length / ge / le / gt など)と field_validator を使うと、ビジネスルールがモデル定義に集約されます。検証ロジックがハンドラ本体に散らばらないので、読みやすく変更しやすい(ETC)。

2.2 ハンドラで使う:response_model で出力も型で縛る

from fastapi import FastAPI

app = FastAPI()

@app.post("/users", response_model=UserPublic, status_code=201)
async def create_user(payload: UserCreate) -> UserPublic:
    # payload はここに来た時点で「検証済み」。中で再検証は不要(DRY)
    user = await repository.insert_user(payload)
    # response_model=UserPublic により、余分なフィールドは自動で削ぎ落とされる
    return user

payload: UserCreate と書くだけで、不正なリクエストはハンドラに到達する前に 422 で弾かれます。検証コードをハンドラに一行も書かなくていい——これが「型ヒント一本で検証が付く」の実体です。response_model を付ければ、出力側も契約どおりの形に整形され、秘密フィールドの漏洩を構造的に防げます。

2.3 「型でない場所」から来るデータ:model_validate / model_validate_json

外部 API のレスポンス、メッセージキューのペイロード、設定ファイル——FastAPI のハンドラ引数を通らないデータも、内側に入れる前に必ず検証します。

import httpx

async def fetch_external_user(user_id: str) -> UserPublic:
    async with httpx.AsyncClient() as client:
        res = await client.get(f"https://upstream.example.com/users/{user_id}")
        res.raise_for_status()
    # 外部APIのJSONも「信用できない外部入力」。dictで持ち回らず即座にモデル化する
    return UserPublic.model_validate(res.json())     # dict → 検証済みモデル
    # JSON文字列を直接渡すなら model_validate_json(res.text) が一手少ない

Pydantic v2 の注意strict モード(型変換しない)と lax モード("123"123 のように coerce する)があります。信用できない外部入力には strict を検討してください。"true"True に化けるような暗黙変換が、思わぬバグの温床になります。検証に失敗すると ValidationErrorどのフィールドがなぜ落ちたかを構造化して返すので、エラーログがそのまま原因解析になります。


3. 依存性注入(Depends):DBセッション・認証・設定を注入する

Depends は FastAPI で最も価値の高い機能です。「ハンドラが必要とするもの」を宣言すると、フレームワークが用意して注入してくれる。DB セッション・認証ユーザー・設定を、ハンドラ本体から切り離せます(SRP)。

3.1 Annotated で型エイリアス化する(公式推奨)

公式は FastAPI 0.95.0 以降、Annotated を使う書き方を推奨しています。依存を型エイリアスに畳んで再利用するのが定石です。

from typing import Annotated
from fastapi import Depends

# 設定(環境変数から読む。シークレットはここに集約し、ハードコードしない)
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    jwt_secret: str
    class Config:
        env_file = ".env"

from functools import lru_cache

@lru_cache  # 設定は毎回読み直さず、プロセス内で1つ(コスト効率)
def get_settings() -> Settings:
    return Settings()  # 環境変数 DATABASE_URL / JWT_SECRET を読む

SettingsDep = Annotated[Settings, Depends(get_settings)]

3.2 yield 依存でクリーンアップ(DBセッションの定番)

yield を使う依存は、yield の前で確保し、レスポンス後に後始末します。DB セッションのクローズはこのパターンの王道です。

from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

# lifespan で作ったエンジンから sessionmaker を用意(第4章参照)
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
    session_factory: async_sessionmaker = request.app.state.session_factory
    async with session_factory() as session:   # yield の前 = 確保
        yield session                           # ハンドラへ注入
        # yield の後 = 後始末。async with が確実にクローズする(リーク防止)

DbDep = Annotated[AsyncSession, Depends(get_db)]

3.3 認証も依存にする

認証は「全ハンドラで繰り返される横断的関心事」。依存に切り出せば、各ハンドラは current_user を受け取るだけで済みます(DRY)。

from fastapi import HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer = HTTPBearer()

async def get_current_user(
    creds: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
    settings: SettingsDep,
    db: DbDep,
) -> UserPublic:
    # トークン検証は外部入力の検証そのもの。失敗は401で即座に返す
    user = await verify_jwt(creds.credentials, settings.jwt_secret, db)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="認証に失敗しました",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user

CurrentUser = Annotated[UserPublic, Depends(get_current_user)]


@app.get("/me", response_model=UserPublic)
async def read_me(current_user: CurrentUser) -> UserPublic:
    return current_user  # 認証ロジックはハンドラに一切ない

依存はハンドラと独立に async def でも def でも書けます(公式が明言)。テスト時は丸ごと差し替えられる(第8章 dependency_overrides)——これが DI の最大の見返りです。


4. lifespan:起動時に一度だけ重い資源を確保する

DB プール・ML モデル・HTTP クライアントを、リクエストごとに作ってはいけません。接続確立やモデルロードは秒単位のコストです。公式推奨の lifespan で、起動時に一度だけ確保し、ワーカー内で共有します。

from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

@asynccontextmanager
async def lifespan(app: FastAPI):
    # --- 起動時(yield の前。一度だけ実行される) ---
    settings = get_settings()
    engine = create_async_engine(settings.database_url, pool_size=10, max_overflow=20)
    app.state.session_factory = async_sessionmaker(engine, expire_on_commit=False)
    app.state.http = httpx.AsyncClient(timeout=10.0)  # 使い回す共有クライアント
    # ML モデルなど重い資源もここでロードして app.state に置く

    yield  # ← ここでアプリが起動し、リクエストを受け始める

    # --- 終了時(yield の後。グレースフルシャットダウン時に一度だけ) ---
    await app.state.http.aclose()
    await engine.dispose()  # コネクションを綺麗に返す(リーク・接続枯渇の防止)

app = FastAPI(lifespan=lifespan)

重要:旧来の @app.on_event("startup") / @app.on_event("shutdown")非推奨です。lifespan なら起動と終了のロジックが一箇所にまとまり、yield を挟んで状態を共有できます。新規コードでは必ず lifespan を使ってください。

放送局向けプラットフォームでも、AI モデルや GCP クライアント・DB プールはすべて lifespan で確保し、app.state に載せて共有しました。リクエスト毎の確保をやめるだけで、p99 レイテンシとコールドな初動が劇的に安定します(コスト効率・パフォーマンス)。


5. エラーハンドリング:4xx は即時、5xx は構造化ログ

エラー処理の原則はシンプルです。外部入力起因のエラー(クライアントが直せるもの)は 4xx で即座に返す。サーバー側の想定外(クライアントが直せないもの)は 5xx を返し、詳細は構造化ログにだけ出す。クライアントにスタックトレースや内部例外メッセージを返してはいけません(情報漏洩)。

5.1 業務エラーは HTTPException

from fastapi import HTTPException, status

@app.get("/users/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbDep) -> UserPublic:
    user = await repository.find_user(db, user_id)
    if user is None:
        # 「存在しない」はクライアントが知るべき情報 → 404で即時
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ユーザーが見つかりません")
    return user

5.2 ドメイン例外を集約ハンドラで HTTP に翻訳する

ハンドラの随所で HTTPException を投げ散らかすより、ドメイン層は素のドメイン例外を投げ、境界で HTTP に翻訳する方が綺麗です(関心の分離)。

import logging
from fastapi import Request
from fastapi.responses import JSONResponse

logger = logging.getLogger("app")

class DomainError(Exception):
    """業務ルール違反。messageはユーザーに見せてよい前提で書く。"""
    def __init__(self, message: str, status_code: int = 400):
        self.message = message
        self.status_code = status_code

@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
    # 4xx: クライアント起因。messageはそのまま返してよい
    return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})

@app.exception_handler(Exception)
async def unhandled_error_handler(request: Request, exc: Exception):
    # 5xx: 想定外。詳細はログにだけ残し、クライアントには汎用メッセージ
    logger.exception("unhandled error", extra={"request_id": getattr(request.state, "request_id", None)})
    return JSONResponse(status_code=500, content={"detail": "内部エラーが発生しました"})

logger.exception はスタックトレースをログにだけ残し、レスポンスは「内部エラーが発生しました」で固定する。何が落ちたかはサーバーが知り、クライアントには漏らさない——これが本番のエラー設計です。


6. 可観測性:リクエストID・構造化ログ・OpenTelemetry

「落ちないAPI」の半分は「落ちたときに即座に原因を追えるAPI」です。本番でログを役立たせる最小装備は3つ——リクエストID・構造化ログ・分散トレーシング。

6.1 リクエストID付与ミドルウェア

1リクエストを貫く ID があると、複数行のログを横断して1件の処理を再構成できます。受信ヘッダがあれば引き継ぎ(上流からのトレース継続)、なければ採番します。

import uuid
from starlette.middleware.base import BaseHTTPMiddleware

class RequestIDMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # 上流が付けた ID を引き継ぐ。なければ採番(分散トレースの連結)
        request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
        request.state.request_id = request_id
        response = await call_next(request)
        response.headers["x-request-id"] = request_id  # 呼び出し側にも返す
        return response

app.add_middleware(RequestIDMiddleware)

6.2 構造化ログ(JSON)

本番のログは機械可読であるべきです。request_id / メソッド / パス / ステータス / 所要時間を JSON で吐けば、ログ基盤(Cloud Logging 等)で絞り込み・集計できます。

import json, logging, time

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "level": record.levelname,
            "message": record.getMessage(),
            "request_id": getattr(record, "request_id", None),
        }
        return json.dumps(payload, ensure_ascii=False)

# アクセスログをミドルウェアで(所要時間つき)
class AccessLogMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        start = time.perf_counter()
        response = await call_next(request)
        elapsed_ms = (time.perf_counter() - start) * 1000
        logger.info(
            "request",
            extra={"request_id": getattr(request.state, "request_id", None)},
        )
        # ⚠️ ここで body・メールアドレス・トークンなどPIIをログに入れない
        response.headers["x-response-time-ms"] = f"{elapsed_ms:.1f}"
        return response

PIIをログに残さない:リクエストボディ・メールアドレス・認証トークン・氏名を構造化ログに流すのは事故です。記録するのは**メタデータ(ID・ステータス・所要時間・エラー種別)**だけ。内部統制案件では絶対条件であり、放送局向けプラットフォームでも徹底しました。

6.3 OpenTelemetry 連携の要点

複数サービス(FastAPI → DB → 外部API → ジョブ実行基盤)にまたがる処理は、ログだけでは追い切れません。OpenTelemetry の自動計装で、リクエストを貫くトレースを取ります。要点はこれだけです。

from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

# 起動時に一度だけ。HTTPサーバ・クライアント・DBのスパンが自動で繋がる
FastAPIInstrumentor.instrument_app(app)

opentelemetry-instrumentation-fastapi を入れて1行計装すれば、どのスパンで時間を食ったかがトレースで可視化されます。request_id をトレース ID と紐づければ、ログとトレースを相互参照できます。


7. 重い処理:BackgroundTasks の限界とタスクキューへの逃がし方

ここが本番設計の分水嶺です。「メール送信」「サムネ生成」「AI による長時間ジョブ」——これらをどこで処理するか。

7.1 BackgroundTasks でできること・できないこと

FastAPI の BackgroundTasks は、レスポンスを返した後に同じプロセスで実行される仕組みです。「軽く・短く・失敗しても致命的でない」副作用にだけ向いています。

from fastapi import BackgroundTasks

@app.post("/users", status_code=201)
async def create_user(payload: UserCreate, bg: BackgroundTasks, db: DbDep):
    user = await repository.insert_user(db, payload)
    # 軽い後処理ならOK:ウェルカムメール送信など
    bg.add_task(send_welcome_email, user.email)
    return {"id": user.id}

BackgroundTasks の限界は明確です。

  • 同一プロセス内で動く:ワーカーの CPU・メモリを食う。重い処理を載せると API のレイテンシが悪化する。
  • 永続化されない:プロセスが落ちたらタスクは消える。リトライも進捗追跡もない。
  • スケールしない:API のスケール(レプリカ数)と処理能力が結合してしまう。

7.2 決定表:BackgroundTasks か 本物のタスクキューか

観点BackgroundTasks で十分本物のタスクキュー(Celery / Cloud Run Jobs / Workflows)へ
所要時間数百ミリ秒〜数秒数十秒〜数十分の長時間ジョブ
失敗時消えても許容できるリトライ・再開・進捗追跡が必須
リソース軽い I/O(メール・通知)CPU/GPU 集約(動画・OCR・ASR・LLM)
スケールAPI と一緒でよいAPI と独立にスケールさせたい
可視性不要ジョブ単位の状態管理が要る

判断基準は一行です。「失敗したらやり直したい」「数十秒以上かかる」「CPU/GPUを食う」のどれかに当てはまったら、プロセス内 BackgroundTasks を卒業し、永続キュー / ジョブ実行基盤へ逃がす(YAGNI の裏返し:必要になるまでキューを入れないが、必要になったら迷わず入れる)。

7.3 実例:FastAPI から長時間 AI ジョブを切り離す

放送局向けプラットフォームでは、テロップ誤字検出のために動画から OCR(画面の文字)と ASR(発話の文字起こし)を抽出して突き合わせる——どう見ても数分〜数十分かかる重いジョブでした。これを FastAPI のリクエスト内や BackgroundTasks で回すのは論外です。API が詰まり、デプロイのたびにジョブが消えます。

そこで構成をこう分離しました。

  • FastAPI(async):受付・認証・バリデーション・ジョブの起動とステータス返却に専念。リクエストは即座に返す。
  • Cloud Run Jobs:実際の重い処理(OCR / ASR / 突き合わせ)を、API とは独立にスケールする実行基盤で走らせる。
  • Cloud Workflows:複数ステップのオーケストレーション。独立な工程を並列化し、逐次 18 分かかっていた処理を約 30%短縮(並列 13 分)しました。
@app.post("/jobs", status_code=202)  # 202 Accepted:受け付けたが完了はしていない
async def enqueue_job(req: JobRequest, current_user: CurrentUser, db: DbDep):
    # APIの仕事は「検証して、ジョブを起動して、追跡用IDを返す」まで
    job = await repository.create_job(db, owner=current_user.id, spec=req)
    await workflows_client.start_execution(job_id=job.id, spec=req.model_dump())
    # 重い処理はここで待たない。クライアントは job.id でステータスをポーリング
    return {"job_id": job.id, "status": "queued"}

@app.get("/jobs/{job_id}")
async def get_job_status(job_id: str, current_user: CurrentUser, db: DbDep):
    job = await repository.find_job(db, job_id, owner=current_user.id)
    if job is None:
        raise HTTPException(404, "ジョブが見つかりません")
    return {"job_id": job.id, "status": job.status, "progress": job.progress}

API は「速く返す薄い層」、重さは外の実行基盤」——この境界を引くだけで、API は落ちにくくなり、ジョブは独立にスケール・リトライできるようになります。202 Accepted + ステータスポーリング(または Webhook)が、非同期ジョブ API の素直な型です。


8. テスト:TestClient と httpx AsyncClient、依存のオーバーライド

検証パスのない本番投入はありません。FastAPI はテストが書きやすく設計されています。

8.1 同期テスト:TestClient

公式の基本形は fastapi.testclient.TestClient です。**テスト関数は普通の def**で、await 不要で呼べます(内部で ASGI を駆動)。

from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_create_user_validates_input():
    # 不正な入力(age が負)は 422 で弾かれるはず
    res = client.post("/users", json={"email": "x@example.com", "display_name": "Yu", "age": -1})
    assert res.status_code == 422

def test_create_user_ok():
    res = client.post("/users", json={"email": "yu@example.com", "display_name": "Yu", "age": 30})
    assert res.status_code == 201
    assert "id" in res.json()

8.2 非同期テスト:httpx AsyncClient + ASGITransport

DB を await で叩くような非同期コードを、HTTP 越しでなく直接テストしたいときは、公式が案内するとおり httpx の AsyncClientASGITransportアプリに繋ぎます。

import pytest
import httpx
from httpx import ASGITransport
from app.main import app

@pytest.mark.anyio
async def test_me_async():
    transport = ASGITransport(app=app)  # アプリを直接ドライブ(実ネットワーク不要)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
        res = await ac.get("/me", headers={"Authorization": "Bearer faketoken"})
    assert res.status_code in (200, 401)

8.3 依存のオーバーライド:dependency_overrides

DI の最大の見返りがここです。本物の DB や認証を、テスト用のフェイクに丸ごと差し替える。外部依存を切り離せるので、テストが速く・決定的になります。

async def override_get_db():
    # 本物の get_db の代わりに、テスト用のインメモリ/トランザクションロールバックなセッションを返す
    async with test_session_factory() as session:
        yield session

def fake_current_user() -> UserPublic:
    return UserPublic(id=1, email="test@example.com", display_name="Test", created_at=datetime.now())

# キーは「元の依存関数」、値は「差し替え関数」
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = fake_current_user

def test_read_me_with_fake_auth():
    res = client.get("/me")
    assert res.status_code == 200
    assert res.json()["email"] == "test@example.com"

# テスト後は必ずクリアして、他テストへの汚染を防ぐ
app.dependency_overrides.clear()

認証・DB・外部 API を依存に切り出しておくと、dependency_overridesピンポイントに偽装できます。第3章で認証を Depends にした投資が、ここで回収されます。


9. デプロイ:uvicorn / gunicorn とグレースフルシャットダウン

9.1 ワーカー構成

ASGI サーバの uvicorn を、本番ではプロセスマネージャの下で複数ワーカーで動かします。古典的には gunicorn + uvicorn.workers.UvicornWorker、もしくは uvicorn --workers N を使います。

# uvicorn 単体で複数ワーカー(シンプルな構成)
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4

# gunicorn をプロセスマネージャに、各ワーカーを uvicorn で(堅牢な定番)
gunicorn app.main:app \
  --worker-class uvicorn.workers.UvicornWorker \
  --workers 4 \
  --bind 0.0.0.0:8000 \
  --timeout 30 \
  --graceful-timeout 30

ワーカー数の目安は CPU コア数を基準に、ワークロード(I/O バウンドか CPU バウンドか)で調整します。やみくもに増やすとメモリを食うだけです——lifespan で確保した DB プールや HTTP クライアントはワーカーごとに複製されるため、ワーカー数 × プールサイズが DB の最大接続数を超えないよう設計してください(接続枯渇の典型事故)。

9.2 グレースフルシャットダウン

デプロイのたびにリクエストを落とさないために、シャットダウンは穏やかにgunicorn--graceful-timeout は「処理中のリクエストを待つ猶予」、コンテナ基盤(Cloud Run / Kubernetes)なら SIGTERM を受けてから猶予内に既存リクエストを捌き切ってから終了します。このとき第4章の lifespan 終了処理(engine.dispose() / http.aclose())が走り、コネクションを綺麗に返してから落ちます。これが「デプロイ中も 5xx を出さない」ための仕上げです。

コスト効率の一言:I/O バウンドな API はワーカーあたりの同時実行が高く取れるため、async を正しく使えば少ないインスタンスで高いスループットが出せます。逆に CPU バウンドな処理を API プロセスに抱えると、コア数ぶんしか並列が効かず、無駄にインスタンスを増やす羽目になります。第7章の「重い処理は外へ」は、信頼性だけでなく請求額にも直結します。


10. まとめ:本番 FastAPI チートシート

迷ったときの早見表です。

  • async defdefawait する async ライブラリ → async def。同期ライブラリ・requeststime.sleepdef(FastAPI がスレッドプールへ逃がす)。迷ったら defasync def の中で同期ブロッキングを呼ばない。
  • バリデーション:入力/出力モデルを分け、Field 制約と field_validator境界で殺す。外部 API の JSON も model_validate で即モデル化。信用できない入力は strict を検討。
  • DI:DB セッション・認証・設定は Depends + Annotated。後始末は yield 依存で。テストで dependency_overrides できる設計にしておく。
  • 起動コスト:DB プール・モデル・HTTP クライアントは lifespan で一度だけ確保し app.state で共有。@app.on_event は使わない。
  • エラー:4xx は即時返却、5xx は汎用メッセージ + 構造化ログ。スタックトレースをクライアントに返さない。
  • 可観測性:リクエストID付与 → 構造化(JSON)ログ → OpenTelemetry 計装。PII はログに出さない
  • 重い処理:数十秒以上 / リトライ必須 / CPU・GPU 集約なら、BackgroundTasks を卒業して Celery / Cloud Run Jobs / Workflows へ。API は 202 Accepted で薄く返す。
  • テスト:同期は TestClient、非同期は httpx.AsyncClient + ASGITransport。依存は dependency_overrides で差し替える。
  • デプロイgunicorn + UvicornWorker で複数ワーカー、--graceful-timeoutlifespan 終了処理でグレースフルに。ワーカー数 × プールサイズ ≤ DB 最大接続数。

FastAPI は「5 分で動く」フレームワークですが、本番品質は境界の設計で決まります。外部入力を型で殺し、横断的関心事を依存に逃がし、重さを外の実行基盤へ追い出し、すべてを ID で追えるようにする——どれも派手ではありませんが、これらの積み重ねが「落ちない・追える・変更しやすい API」を作ります。

私は放送事業者向けの社内 AI プラットフォームで、FastAPI を長時間 AI ジョブの薄い受付層として設計し、重い処理を Cloud Run Jobs / Cloud Workflows へ分離、可観測性と冪等性を担保した本番運用に乗せました。生成 AI(Claude Code)を相棒に、一人で速く・安く作りつつ、検証ゲートで品質を担保するのが私の進め方です。

「FastAPI でこの API を本番に載せたいが、async の使い分けやジョブ分離・可観測性をどう設計すればいいか」——その判断から実装・運用まで、一気通貫で伴走します。 要件整理の段階からでも、お気軽にご相談ください。


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

国内大手放送事業者の番組制作を支援する社内AIプラットフォーム(FastAPI + Cloud Workflows で長時間AIジョブを運用)

ケーススタディを見る