3. 開発環境と本番環境は何が違うのか

python manage.py runserveruvicorn main:app --reload で手元のブラウザに画面が表示されると、「動いた」という達成感があります。 しかし、この「動いた」はあくまで開発者のマシン上での話です。 本番環境でアプリケーションを安定して動かし続けることは、開発環境で動かすこととは根本的に異なる営みです。

本章では、ここまで学んできた HTTP・WSGI・ASGI・並行処理・サーバ選定の知識を、本番環境にどう持ち込むかを考えていきます。 その出発点として、「開発環境と本番環境は具体的に何が違うのか」を 4 つの観点から見ていきます。

diagram

注釈

Vol.1「WSGI が生まれた背景」で WSGI の責務を学んだとき、「どの層が何を担っているか」を意識することの重要性を確認しました。 本番環境の設計でも同じ思考法が必要です。違いを漠然と感じているだけではなく、何がどう違うのかを言語化できることが、適切な設計判断の第一歩になります。

3.1. 1プロセス前提ではない

開発環境と本番環境のもっとも根本的な違いは、アプリケーションが動くプロセスの数です。

  • python manage.py runserver は 1 つのプロセスでリクエストを逐次的に処理します。

  • uvicorn main:app --reload も同様に、デフォルトでは 1 つのワーカープロセスで動作します。

開発中はこれで十分です。リクエストを送るのは開発者自身だけですし、同時アクセスは事実上ありません。

本番環境では、2 章なぜ Web 開発で並行処理が重要なのか)で詳しく見たように、複数のワーカープロセスが同時にリクエストを処理します。 Gunicorn であれば pre-fork モデルで複数のワーカーが起動し、Uvicorn であれば --workers オプションやプロセスマネージャを通じて複数プロセスが並行稼働します。 これは単にスループットを上げるためだけではなく、1 つのワーカーが異常終了しても他のワーカーがリクエストを処理し続けられるようにするためでもあります。

この「複数プロセス」という前提は、アプリケーションの設計に直接影響します。 開発環境で動いていたコードが、本番環境で予想外の挙動を見せることがあるのです。 たとえば、モジュールレベルの変数にデータをキャッシュしているコードを考えてみましょう。

# views.py
_cache = {}

def get_config(key):
    if key not in _cache:
        _cache[key] = load_from_database(key)
    return _cache[key]

警告

開発環境では、プロセスは 1 つなので _cache は常に最新の状態を保ちます。 しかし本番環境では、ワーカーごとに独立したメモリ空間を持つため、ワーカー A の _cache とワーカー B の _cache は別物です。あるワーカーでキャッシュに書き込んだ値が、別のワーカーからは見えません。 管理画面で設定を変更したのに反映されないワーカーがある、という症状はこれが原因です。

12-2 で「プロセスはメモリ空間が独立している」と学びましたが、その知識がここで実務に直結します。

同様に、ファイルへの書き込みも注意が必要です。 複数のワーカーが同じファイルに同時に書き込むと、内容が混在したり、一方の書き込みがもう一方に上書きされたりします。 開発環境では起きなかった競合が、本番環境のマルチプロセス構成で初めて顕在化するのです。

3.2. 外部からのアクセス

開発環境では、アプリケーションにアクセスするのは localhost からの開発者自身です。 本番環境では、インターネット経由で不特定多数のクライアントがアクセスしてきます。 この違いは、アプリケーションの手前に何を置くかという構成の違いとして現れます。

runserveruvicorn --reload は、クライアントからの TCP 接続を直接受け付けます。 開発環境ではこれで問題ありませんが、本番環境ではアプリケーションサーバの手前にリバースプロキシ(nginx や Caddy など)を配置するのが一般的です。

リバースプロキシが担う役割は多岐にわたります。主なものを以下に整理します。

  • TLS 終端: HTTPS の暗号化・復号をリバースプロキシで処理することで、アプリケーションサーバは暗号化を意識せずに済みます

  • 静的ファイル配信: CSS、JavaScript、画像などの配信をリバースプロキシに任せることで、アプリケーションサーバは動的なリクエスト処理に専念できます

  • バッファリング: 遅いクライアントからのリクエストをバッファリングすることで、アプリケーションサーバのワーカーが長時間占有されることを防ぎます

1 章「Webサーバ」という言葉の混乱を解く)で Gunicorn の sync ワーカーが keep-alive をサポートしないと述べたとき、「本番環境ではバッファリングプロキシの背後に置く」と補足したのは、この構成を前提にしていたからです。

Tip

この構成は、Vol.1「HTTP は何をやりとりしているのか」で学んだ HTTP の層構造の延長上にあります。 クライアントとアプリケーションの間に新たな層が挿入されるわけですが、各層の責務を理解していれば、どの問題がどの層で起きているかを切り分けられます。

  • 502 Bad Gateway が返っているなら、リバースプロキシはクライアントと通信できているが、アプリケーションサーバとの通信に失敗しています

  • 504 Gateway Timeout なら、アプリケーションサーバは生きているが応答が遅すぎて、リバースプロキシのタイムアウトに引っかかっています

開発環境ではクライアントとアプリケーションの間に何もないため、こうした層構造に起因する問題は経験しません。 本番環境で初めて 502 や 504 に遭遇したとき、リバースプロキシの存在を忘れていると原因の切り分けに手間取ることになります。

3.3. 可観測性

開発環境では、問題が起きたらターミナルに表示されるトレースバックを見て原因を特定できます。 print() を仕込んで再実行することも、デバッガをアタッチしてステップ実行することもできます。 開発者の目の前でアプリケーションが動いているから、こうしたことが可能なのです。

本番環境では、アプリケーションは開発者の目の届かない場所で動いています。 クラウド上の仮想マシン、コンテナオーケストレーションの中の Pod、あるいはサーバレスの実行環境かもしれません。 問題が起きたとき、ターミナルにトレースバックが表示されるわけではありませんし、print() で仕込んだデバッグ出力は、どこにも表示されずに消えてしまうかもしれません。

本番環境で「何が起きているか」を知るためには、可観測性(observability) の仕組みを意図的に組み込む必要があります。 具体的には、次の 3 つです。

種類

役割

ログ

アプリケーションが何をしたかの記録

リクエストの受信、エラーの詳細

メトリクス

アプリケーションの状態を数値で表したもの

レスポンスタイム、エラー率、メモリ使用量

トレース

1 つのリクエストの処理経路と各処理の所要時間

どの DB クエリが遅いか

ログは Python の logging モジュールを使い、ログレベル(DEBUG、INFO、WARNING、ERROR)による重要度の区別、タイムスタンプ、リクエスト ID などのコンテキスト情報を含めます。

import logging

logger = logging.getLogger(__name__)

def process_order(order_id):
    logger.info("Processing order", extra={"order_id": order_id})
    try:
        result = charge_payment(order_id)
        logger.info("Payment succeeded", extra={"order_id": order_id})
    except PaymentError as e:
        logger.error(
            "Payment failed",
            extra={"order_id": order_id, "error": str(e)},
            exc_info=True,
        )
        raise

重要

可観測性は「あると便利」なものではなく、本番環境を運用するための必須のインフラです。 開発環境ではこれらの仕組みがなくても困りませんが、本番環境ではこれらがなければ問題の原因を特定できません。

3.4. 再起動やデプロイ

開発環境では、コードを変更したらサーバを止めて再起動するか、--reload オプションでファイル変更を検知して自動的に再起動させます。 この間、リクエストは処理されませんが、開発者しかアクセスしていないので問題ありません。

本番環境では、ユーザーが常にアクセスしている状態でコードを更新しなければなりません。 デプロイのたびにサービスが数秒でも停止すれば、その間のリクエストはエラーになります。 頻繁にデプロイする運用であれば、ユーザー体験への影響は無視できません。

この課題に対するアプローチのひとつが graceful restart(優雅な再起動) です。 Gunicorn は HUP シグナルを受け取ると、新しい設定でワーカーを再起動しますが、処理中のリクエストが完了するまで古いワーカーを生かしておきます。 つまり、リクエストを処理している最中のワーカーが突然終了させられることなく、自然に処理を終えてから新しいワーカーに置き換わるのです。

# Gunicorn の graceful restart
# 処理中のリクエストが完了してからワーカーが入れ替わる
kill -HUP $(cat /var/run/gunicorn.pid)

コンテナ環境では、ローリングアップデート という手法が使われます。 新しいバージョンのコンテナを段階的に起動しながら、古いバージョンのコンテナを段階的に停止していくことで、全体としてサービスを停止させずにデプロイを完了させます。 Kubernetes はこのローリングアップデートを標準機能として提供しています。

注意

データベースのスキーマ変更を伴うデプロイでは、新しいコードと古いスキーマ、あるいは古いコードと新しいスキーマが同時に存在する瞬間が発生します。 Django の migrate コマンドをデプロイのどのタイミングで実行するか、マイグレーションが後方互換性を持つかどうかは、開発環境では気にならないが本番環境では慎重に考えるべき問題です。


以上の 4 つの観点――マルチプロセス、外部からのアクセス、可観測性、再起動とデプロイ――は、独立した話題ではなく互いに関連しています。プロセス間のリソース競合は可観測性なしに気づけませんし、外部からのアクセスを受け続けるからこそ graceful restart による無停止デプロイが求められます。

これらの違いを念頭に置いた上で、次節からは本番デプロイの具体的な構成を見ていきます。 リバースプロキシとアプリケーションサーバをどう組み合わせ、各層にどの責務を割り当てるのか――Vol.1「HTTP は何をやりとりしているのか」から積み上げてきた「層と責務」の思考法を、本番環境の設計に適用していきましょう。

3.5. リバースプロキシの役割

前節で、本番環境ではアプリケーションサーバの手前にリバースプロキシを配置するのが一般的であると述べました。 しかし「一般的」と言われても、なぜ必要なのか、具体的に何をしているのかが分からなければ、設定ファイルをコピー&ペーストするだけの作業になってしまいます。

注釈

LLM に「nginx の設定を書いて」と頼めば、それらしい設定ファイルは出てきます。 しかし、その設定の各行が何を意味し、なぜそう書く必要があるのかを理解していなければ、問題が起きたときに自力で対処できません。 設定の「意味」を理解することが、本節の目的です。

以降では、リバースプロキシの代表的な役割を次の 4 つに分けて見ていきます。

  1. TLS 終端

  2. 静的ファイル配信

  3. パス振り分け

  4. ヘッダ転送

diagram

例として nginx の設定を中心に示しますが、Caddy のような新しいリバースプロキシにも触れます。 いずれも、Vol.1「HTTP は何をやりとりしているのか」で学んだ HTTP の構造と、Vol.1「WSGI が生まれた背景」〜Vol.2「なぜ ASGI が必要になったのか」で見た WSGI・ASGI の責務の区分けが前提知識になります。

3.5.1. TLS 終端

ブラウザのアドレスバーに https:// と表示されているとき、ブラウザとサーバの間の通信は TLS(Transport Layer Security)で暗号化されています。 この暗号化・復号の処理を誰が担うかという問いに対する、本番環境での一般的な答えが「TLS 終端をリバースプロキシで行う」という構成です。

TLS 終端とは、クライアントからの暗号化された接続をリバースプロキシが受け取り、復号して平文の HTTP リクエストに変換し、それをアプリケーションサーバに転送する仕組みです。 アプリケーションサーバから見ると、受け取るのは普通の HTTP リクエストであり、暗号化のことを一切意識する必要がありません。

クライアント ──── HTTPS (暗号化) ────→ nginx ──── HTTP (平文) ────→ Gunicorn/Uvicorn
                                       ↑
                                  TLS 終端はここ

nginx で TLS 終端を行う設定の核心部分を見てみましょう。

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
    }
}
  • listen 443 ssl: ポート 443(HTTPS の標準ポート)を TLS 付きで待ち受けます

  • ssl_certificate / ssl_certificate_key: 証明書と秘密鍵のパスを指定します

  • proxy_pass http://127.0.0.1:8000: 復号後のリクエストをローカルのポート 8000 に平文の HTTP で転送します

nginx とアプリケーションサーバが同一マシン上にある場合、この内部通信は 127.0.0.1 を経由するため、平文であってもネットワーク上に流れることはありません。

TLS 終端をリバースプロキシに任せる利点は次の通りです。

  • 証明書の管理がアプリケーションから完全に分離され、証明書の更新や差し替えはリバースプロキシの設定変更だけで完了します

  • TLS のハンドシェイク処理は計算コストが高いため、C で書かれた高性能なリバースプロキシに委ねることで Python アプリケーションサーバの負荷を下げられます

  • TLS の設定(暗号スイートの選択、プロトコルバージョンの制限など)をリバースプロキシに集約することで、セキュリティポリシーの管理が一箇所にまとまります

コラム: Caddy による TLS の自動化

Caddy というリバースプロキシを使うと、TLS 終端の設定はさらに簡潔になります。 Caddy は Let’s Encrypt との連携による証明書の自動取得・自動更新を標準機能として備えており、ドメイン名を指定するだけで HTTPS が有効になります。

``` example.com { reverse_proxy 127.0.0.1:8000 } ```

この 3 行だけで、証明書の取得、HTTPS の有効化、HTTP から HTTPS へのリダイレクト、そしてリバースプロキシとしてのリクエスト転送がすべて設定されます。 nginx + certbot の組み合わせで実現していたことが、Caddy では宣言的な設定だけで完結するのです。

3.5.2. 静的ファイル配信

CSS、JavaScript、画像、フォントといった静的ファイルの配信は、リバースプロキシのもう一つの重要な役割です。

Django の開発サーバ(runserver)は、DEBUG = True のとき静的ファイルを自動的に配信してくれます。 しかし、本番環境で DEBUG = True にすることはセキュリティ上許されません。 そして DEBUG = False にすると、Django は静的ファイルを一切配信しなくなります。

重要

これは Django の設計上の意図的な判断です。 静的ファイルの配信はアプリケーションフレームワークの責務ではなく、Web サーバの責務だという考え方です。 Vol.2「Django を WSGI 視点で見る」で整理した「Django の責務の外側」にある処理が、まさに静的ファイル配信です。

なぜ静的ファイルをアプリケーションサーバで配信すべきでないのかは、理由が明確です。

  • 効率: nginx は sendfile システムコール、ファイルディスクリプタのキャッシュ、gzip 圧縮などの最適化を備えており、Python のアプリケーションサーバとは桁違いのスループットで配信できます

  • ワーカーの節約: 静的ファイルのリクエストがアプリケーションサーバに到達しないということは、ワーカーが動的なリクエスト処理に専念できるということです

Django の場合、python manage.py collectstatic コマンドで静的ファイルを一箇所に集め、そのディレクトリを nginx に配信させます。

server {
    # ... TLS 設定は省略 ...

    # 静的ファイルは nginx が直接配信する
    location /static/ {
        alias /var/www/myproject/staticfiles/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # それ以外のリクエストはアプリケーションサーバに転送する
    location / {
        proxy_pass http://127.0.0.1:8000;
    }
}

expires 30dCache-Control ヘッダは、ブラウザに「このファイルは 30 日間キャッシュしてよい」と伝えるものです。 静的ファイルは内容が変わらない(変わる場合はファイル名にハッシュが付与される)ため、積極的にキャッシュさせることでネットワーク帯域とサーバ負荷の両方を削減できます。

FastAPI の場合は、StaticFiles をマウントして静的ファイルを配信する機能がありますが、これも本番環境ではリバースプロキシに任せるのが定石です。

3.5.3. パス振り分け

リバースプロキシは、リクエストの URL パスに基づいて転送先を振り分けることができます。

diagram

これは、1 つのドメインで複数のアプリケーションを動かしたり、API と管理画面で異なるサーバ構成を使ったりする場面で活用されます。

たとえば、Django で構築した管理画面と FastAPI で構築した API を、同じドメインで公開する構成を考えてみましょう。

server {
    listen 443 ssl;
    server_name example.com;
    # ... TLS 設定は省略 ...

    # /api/ 以下は FastAPI(Uvicorn)に転送
    location /api/ {
        proxy_pass http://127.0.0.1:8001;
    }

    # /admin/ 以下は Django(Gunicorn)に転送
    location /admin/ {
        proxy_pass http://127.0.0.1:8000;
    }

    # 静的ファイル
    location /static/ {
        alias /var/www/staticfiles/;
    }

    # その他は Django に転送
    location / {
        proxy_pass http://127.0.0.1:8000;
    }
}

この設定では、nginx がリクエストの URL パスを見て転送先を決定します。クライアントから見れば、すべてが example.com という 1 つのドメインから提供されているように見えます。

注釈

このパス振り分けがアプリケーション層ではなくリバースプロキシ層の責務であるという点が重要です。 Django のルーティングや FastAPI のルーティングは、リクエストがアプリケーションサーバに到達した後の処理です。 リバースプロキシのパス振り分けは、それよりも手前の段階で「どのアプリケーションサーバに転送するか」を決定します。

パス振り分けのもう一つの実用的な活用例は、バッファリングと遅いクライアントの処理です。 nginx は proxy_buffering をデフォルトで有効にしており、アプリケーションサーバからのレスポンスを一旦バッファに溜めてからクライアントに送信します。 これにより、通信速度が遅いモバイルクライアントへの送信は nginx が引き受け、Gunicorn の sync ワーカーが遅いクライアントに長時間占有されるという問題を防げます。

3.5.4. ヘッダ転送

リバースプロキシが介在することで、アプリケーションサーバが受け取るリクエストには 1 つ重要な変化が生じます。 リクエストの送信元が、本来のクライアントではなくリバースプロキシになるのです。

リバースプロキシを経由すると、REMOTE_ADDR にはリバースプロキシ自身の IP アドレス(多くの場合 127.0.0.1)が入ります。 アプリケーションから見ると、すべてのリクエストが同じ IP アドレスから来ているように見えてしまうのです。

この問題を解決するのが、X-Forwarded-ForX-Forwarded-ProtoX-Forwarded-Host といった転送ヘッダです。

location / {
    proxy_pass http://127.0.0.1:8000;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

各行の意味を整理します。

ヘッダ設定

役割

Host $host

クライアントがリクエストした元のホスト名をアプリケーションに伝えます。これがないと URL 生成やリダイレクト先がおかしくなります

X-Forwarded-For $proxy_add_x_forwarded_for

クライアントの IP アドレスを転送します。多段プロキシではカンマ区切りで連なります

X-Forwarded-Proto $scheme

元のリクエストが HTTP か HTTPS かをアプリケーションに伝えます

Django はこれらのヘッダを読み取るための設定を持っています。

# Django settings.py

# X-Forwarded-Proto を信頼して HTTPS 判定に使う
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# リバースプロキシの IP アドレスを信頼するクライアントとして設定
USE_X_FORWARDED_HOST = True

SECURE_PROXY_SSL_HEADER は、指定したヘッダの値が "https" であればリクエストを HTTPS として扱うよう Django に指示します。 この設定がなければ、Django は常にリクエストを HTTP と判定し、request.is_secure()False を返します。 CSRF 保護やセッションの Secure フラグの挙動に影響するため、本番環境では必ず設定が必要です。

FastAPI(Starlette)でも同様の考慮が必要です。 Uvicorn は --proxy-headers オプション(デフォルトで有効)で X-Forwarded-ForX-Forwarded-Proto を信頼する設定になっています。 --forwarded-allow-ips オプションで、どの IP アドレスからの転送ヘッダを信頼するかを制限できます。

危険

X-Forwarded-For は通常の HTTP ヘッダであり、クライアントが自由に付与できます。 リバースプロキシを経由せずに直接アプリケーションサーバにアクセスされた場合、クライアントが偽の X-Forwarded-For ヘッダを送信することで IP アドレスを詐称できてしまいます。 転送ヘッダを信頼する相手(リバースプロキシの IP アドレス)を明示的に設定し、信頼できないソースからのヘッダは無視する必要があります。

4 章例外はどこで拾われるのか)で扱うセキュリティの観点とも密接に関連するテーマです。


TLS 終端、静的ファイル配信、パス振り分け、ヘッダ転送のいずれも、「アプリケーションサーバの責務ではないことをリバースプロキシに委ねる」という設計原則に基づいています。リバースプロキシの役割を理解することは、Vol.1「WSGI が生まれた背景」以来積み上げてきた「責務の境界」という思考法の延長線上にあります。

次節では、ここまでに登場した構成要素――リバースプロキシ、アプリケーションサーバ、ワーカー設計――を組み合わせて、本番環境の全体構成をどのように組み立てるかを見ていきます。

3.6. static files と media files

前節で、nginx に静的ファイル配信を任せる設定を紹介しました。 location /static/ と書いてディレクトリを指定する――一見するとそれだけの話に見えます。 しかし、本番環境で扱う「静的なファイル」には実は 2 つの異なる種類があり、それぞれ性質も管理方法も配信戦略も異なります。

警告

この区別を曖昧にしたまま本番環境を構築すると、次のようなトラブルに見舞われます。

  • デプロイのたびにファイルが消える

  • ユーザーがアップロードした画像が表示されない

  • キャッシュが効かなくて遅い

以降では、static files(アプリケーションに同梱される静的アセット)と media files(ユーザーがアップロードするファイル)の違いを明確にし、それぞれの配信をどの層に任せるべきかを考えます。

diagram

3.6.1. フレームワークに任せない理由

まず、前節でも触れた前提を改めて整理します。 Django の開発サーバは DEBUG = True のとき、CSS や JavaScript といった静的ファイルを自動で配信してくれます。 FastAPI にも StaticFiles をマウントして静的ファイルを返す機能があります。 開発環境ではこれで何の問題もありません。

では、本番環境でもフレームワークにファイル配信を任せればよいのではないかと思うかもしれません。 答えは明確で、任せるべきではありません。理由は効率・ワーカーの占有・責務の分離の 3 点に集約されます。

  • 効率: nginx はカーネルの sendfile システムコールを使い、ファイルの内容をユーザー空間にコピーすることなくカーネル空間内でソケットに直接転送できます。静的ファイルの配信に WSGI や ASGI のプロトコル変換と Python インタープリタを通す必要はありません

  • ワーカーの節約: CSS ファイル 1 つを返すためにワーカーが 1 つ占有されるのは限られたリソースの無駄遣いです。ページ 1 つの表示に数十ファイルが必要なことを考えると、その影響は大きいです

  • 責務の分離: フレームワークの本分は HTTP リクエストを受け取りビジネスロジックを実行して動的なレスポンスを生成することです。ファイルをそのまま返す処理は Web サーバの責務です

例外: WhiteNoise

Django には WhiteNoise というサードパーティライブラリがあり、これを使うとアプリケーションサーバ自身が静的ファイルを効率的に配信できます。 WhiteNoise は WSGI ミドルウェアとして動作し、静的ファイルをメモリにキャッシュし、圧縮済みのレスポンスを返します。

Heroku のような PaaS 環境で nginx を自由に設定できない場合や、構成をできるだけシンプルに保ちたい小規模なアプリケーションでは、現実的な選択肢です。 ただし、nginx が使える環境であれば、nginx に任せるほうが効率は上です。

3.6.2. 配信責務の分離

ここからが本節の核心です。 「静的なファイル」と一口に言っても、static files と media files ではまったく性質が異なります。

比較項目

static files

media files

内容

CSS、JS、アイコン、Web フォントなど

プロフィール画像、添付ファイルなど

管理者

開発者がソースコードと一緒に管理

ユーザーがアプリを通じてアップロード

更新タイミング

デプロイ時のみ(collectstatic で収集)

運用中に随時追加・変更・削除

キャッシュ戦略

長期キャッシュが安全

短めのキャッシュまたは再検証が必要

static files は内容が変わらないことが保証されているため、積極的なキャッシュが可能です。 Django の ManifestStaticFilesStorage を使うと、ファイル名にコンテンツのハッシュ値が付与されます(例: style.abc123def.css)。 内容が変われば名前も変わるため、ブラウザに「このファイルは永遠にキャッシュしてよい」と伝えても安全です。

# static files: 長期キャッシュが安全
location /static/ {
    alias /var/www/myproject/staticfiles/;
    expires 365d;
    add_header Cache-Control "public, immutable";
}

immutable という指示は「このリソースは絶対に変わらない」という宣言です。 ブラウザは有効期限内であればサーバへの問い合わせすら行わず、ローカルキャッシュから即座にファイルを返します。

media files はそうはいきません。 ユーザーがプロフィール画像を差し替えた場合、同じ URL で内容が変わる可能性があります。

# media files: 短めのキャッシュまたは再検証を要求
location /media/ {
    alias /var/www/myproject/media/;
    expires 1h;
    add_header Cache-Control "public, must-revalidate";
}

must-revalidate は「キャッシュを使う前にサーバに問い合わせて、変わっていないか確認してください」という指示です。

もう 1 つ重要な違いは、デプロイ時の扱いです。コンテナ環境を例に考えましょう。

Docker イメージをビルドするとき、static files はイメージの中に含めることができます。

# Dockerfile の例(Django)
FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# ビルド時に static files を収集する
RUN python manage.py collectstatic --noinput

CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]

危険

media files をイメージに含めることはできません。 ユーザーがアップロードするファイルはデプロイとは無関係に増え続けるため、コンテナのファイルシステムとは独立した永続ストレージ(ボリュームマウントや外部オブジェクトストレージ)に置く必要があります。 コンテナが再作成されるたびに media files が消えてしまう、というのは本番環境で実際に起きる事故です。

大規模なアプリケーションや複数サーバ構成では、media files を Amazon S3 や Google Cloud Storage のようなオブジェクトストレージに保存し、CDN(Content Delivery Network)経由で配信するパターンが一般的です。 Django であれば django-storages ライブラリを使って、FileFieldImageField のアップロード先を S3 に切り替えることができます。

開発環境:
  クライアント → runserver → static files (自動配信)
                           → media files  (MEDIA_ROOT から配信)

本番環境 (シンプル):
  クライアント → nginx → /static/ → ファイルシステム (collectstatic で収集済み)
                       → /media/  → ファイルシステム (永続ボリューム)
                       → /        → Gunicorn/Uvicorn → Django/FastAPI

本番環境 (大規模):
  クライアント → CDN → /static/ → オブジェクトストレージ
                     → /media/  → オブジェクトストレージ
               → nginx → /     → Gunicorn/Uvicorn → Django/FastAPI

この図を見ると、開発環境では 1 つのプロセスがすべてを担っていたものが、本番環境では層ごとに責務が分離されていることが分かります。 13-1 で「開発環境と本番環境は何が違うのか」を 4 つの観点から見ましたが、static files と media files の扱いの違いはそのすべてに関わっています。マルチプロセス環境でのファイルシステム共有の問題、CDN やオブジェクトストレージの活用、キャッシュヒット率の監視、そしてデプロイ時の static files 収集と media files の永続化という形で、それぞれの観点が実務上の判断として現れます。

次節では、これらの構成要素を組み合わせた本番環境の全体像を、より具体的な構成例として見ていきます。

3.7. timeout と keep-alive

前節までで、リバースプロキシの役割と静的ファイルの配信戦略を確認しました。 nginx がリクエストを受け取り、Gunicorn や Uvicorn に転送し、レスポンスを返す――この流れ自体はシンプルです。 しかし、本番環境ではこの流れの途中で「待ち時間」が発生します。データベースクエリが遅いとき、外部 API が応答しないとき、クライアントの通信が不安定なとき。 こうした「待ち」をどこまで許容するかを決めるのが、タイムアウトの設定です。

警告

タイムアウトの設定を誤ると、本番環境でもっとも厄介な種類の障害が起きます。

  • 「ときどき 502 が出る」

  • 「特定のページだけタイムアウトする」

  • 「原因不明でワーカーが再起動される」

これらの症状の多くは、リバースプロキシとアプリケーションサーバのタイムアウト設定が噛み合っていないことに起因します。 LLM に設定ファイルを生成させると、それぞれの値はもっともらしく見えるのに、全体として整合が取れていないことがあります。各層のタイムアウトがどう連鎖するかを理解していなければ、この不整合に気づくことができません。

以降では、リクエストの一生を時間軸で追いながら、接続タイムアウト、read timeout、worker timeout、idle connection の 4 つのタイムアウトを順に見ていきます。

diagram

3.7.1. 接続タイムアウト

リクエスト処理の最初のステップは、nginx がアプリケーションサーバとの TCP 接続を確立することです。 nginx の proxy_passhttp://127.0.0.1:8000 を指定している場合、nginx はローカルのポート 8000 に対して TCP の接続を試みます。 この接続確立を待つ最大時間が接続タイムアウトです。

location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_connect_timeout 5s;   # 接続確立の最大待ち時間
}

nginx のデフォルトの proxy_connect_timeout は 60 秒ですが、nginx とアプリケーションサーバが同一マシン上にある構成では、接続確立は通常ミリ秒単位で完了します。 接続が確立できないということは、アプリケーションサーバが起動していないか、ソケットのパスが間違っているか、プロセスがハングしているかのいずれかであり、60 秒待っても状況は変わりません。 5 秒程度に短縮するのが実務的な設定です。

接続タイムアウトが発生したとき、nginx はクライアントに 502 Bad Gateway を返します。 nginx のエラーログには次のようなメッセージが記録されます。

connect() to 127.0.0.1:8000 failed (111: Connection refused)

このエラーを見たら、まず確認すべきは「Gunicorn(または Uvicorn)のプロセスは生きているか」です。 アプリケーションのコードやデータベースを調べる前に、ps aux | grep gunicorn でプロセスの生存確認をするのが最初の一手です。

3.7.2. read timeout

TCP 接続が確立したあと、nginx はリクエストをアプリケーションサーバに送信し、レスポンスを待ちます。 この「レスポンスを待つ最大時間」が read timeout です。

location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_connect_timeout 5s;
    proxy_read_timeout 60s;     # レスポンスの最大待ち時間
}

nginx の proxy_read_timeout のデフォルトは 60 秒です。 アプリケーションサーバがリクエストを受け取ってから 60 秒以内にレスポンスヘッダを返し始めなければ、nginx は接続を切断し、クライアントに 504 Gateway Timeout を返します。

Tip

502 と 504 の違いを正確に理解しておくと、障害対応で層の切り分けができます。

ステータスコード

意味

502 Bad Gateway

アプリケーションサーバに接続できなかった、または異常な応答を受け取った

504 Gateway Timeout

アプリケーションサーバには接続できたが、応答が時間内に返ってこなかった

proxy_read_timeout の値は、アプリケーションの性質によります。 レポート生成や大量データのエクスポートなど、処理に時間がかかるエンドポイントがある場合は、そのエンドポイントだけタイムアウトを長くする設定が考えられます。

# 通常のリクエスト
location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_read_timeout 60s;
}

# 時間のかかるレポート生成エンドポイント
location /api/reports/ {
    proxy_pass http://127.0.0.1:8000;
    proxy_read_timeout 300s;
}

注意

タイムアウトを長くすることは対症療法にすぎません。 レポート生成に 5 分かかるのであれば、バックグラウンドジョブとして非同期に処理し、完了したら通知する設計のほうが根本的な解決策です。 タイムアウトを延ばし続けるのは、ワーカーを長時間占有することを意味します。

3.7.3. worker timeout

ここまでは nginx 側のタイムアウトでしたが、アプリケーションサーバ側にも独自のタイムアウトがあります。 Gunicorn の worker timeout は、ワーカーがリクエスト処理中に応答なく沈黙し続ける最大時間を定めるものです。

Gunicorn のアーキテクチャでは、arbiter プロセス(親プロセス)が各ワーカーの生存を監視しています。 ワーカーは定期的に arbiter にハートビートを送り、「自分は生きている」と報告します。 このハートビートが --timeout で指定した秒数以上途絶えると、arbiter はそのワーカーを「スタックした(固まった)」と判断し、強制的に kill して新しいワーカーを起動します。

# Gunicorn の worker timeout(デフォルト: 30 秒)
gunicorn myproject.wsgi:application --timeout 30

Gunicorn のログには、次のような CRITICAL レベルのメッセージが記録されます。

[CRITICAL] WORKER TIMEOUT (pid:3438)

重要

この worker timeout と nginx の proxy_read_timeout の関係が、本節でもっとも重要なポイントです。

Gunicorn の --timeout が nginx の proxy_read_timeout よりも短い場合に何が起きるか、具体例で考えてみましょう。

  • アプリケーションの処理に 45 秒かかるリクエストがある

  • Gunicorn の --timeout が 30 秒、nginx の proxy_read_timeout が 60 秒

30 秒が経過した時点で、Gunicorn の arbiter がワーカーを kill します。 nginx から見ると、レスポンスを待っていた接続先が突然閉じられたように見えます。 nginx のエラーログには「upstream prematurely closed connection」が記録され、クライアントには 502 Bad Gateway が返されます。 504 ではなく 502 です。nginx 自身はまだタイムアウトしていない(60 秒のうち 30 秒しか経っていない)のに、上流が勝手に接続を切ったからです。

正しい設定の原則は次の通りです。

推奨される設定の関係:

nginx proxy_read_timeout  <  Gunicorn --timeout
        (: 60s)                (: 75s)

こうすれば、nginx が先にクライアントへ 504 を返し、その直後に Gunicorn がワーカーを再起動する、という順序になります。 クライアントは明確なタイムアウトエラーを受け取り、ワーカーは速やかに解放されます。

3.7.4. idle connection

最後に、リクエスト処理とは別の時間軸のタイムアウトである idle connection(アイドル接続)について考えます。 これは keep-alive 接続に関わる設定です。

HTTP/1.1 では、1 つの TCP 接続で複数のリクエストを連続して処理する keep-alive がデフォルトで有効です。 リクエストが完了しても接続を閉じず、次のリクエストが来るのを待ちます。 この「次のリクエストを待つ最大時間」が keep-alive タイムアウトです。

# クライアント側: ブラウザとの接続を 65 秒間保持
keepalive_timeout 65s;

# アプリケーションサーバ側: upstream への接続を再利用する設定
upstream app_server {
    server 127.0.0.1:8000;
    keepalive 32;              # 保持するアイドル接続の最大数
}

location / {
    proxy_pass http://app_server;
    proxy_http_version 1.1;
    proxy_set_header Connection "";   # keep-alive を有効にする
}

デフォルト値を整理すると次のようになります。

コンポーネント

設定

デフォルト値

Uvicorn

--timeout-keep-alive

5 秒

Gunicorn

--keep-alive

2 秒

nginx(クライアント向け)

keepalive_timeout

75 秒

警告

ここに落とし穴があります。 nginx がアプリケーションサーバとの keep-alive 接続を使い回している場合、アプリケーションサーバ側が先に接続を閉じると、nginx が次のリクエストを送ろうとした瞬間に接続が切れていることがあります。 この状態を「レースコンディション」と呼び、nginx はクライアントに 502 を返します。

この問題を防ぐ原則は、「アプリケーションサーバの keep-alive タイムアウトを、nginx の upstream keep-alive タイムアウトよりも長くする」ことです。

# Uvicorn: nginx よりも長い keep-alive タイムアウトを設定
uvicorn myapp:app --timeout-keep-alive 75

# Gunicorn: 同様に長めに設定
gunicorn myproject.wsgi:application --keep-alive 75

ただし、1 章「Webサーバ」という言葉の混乱を解く)で確認したように Gunicorn の sync ワーカーは keep-alive をサポートしていません。 gthread ワーカーまたは async ワーカーを使っている場合にのみ、この設定が意味を持ちます。


4 つのタイムアウトの関係を 1 つの図で示します。

クライアント ────── nginx ────── Gunicorn/Uvicorn ────── アプリケーション
    │                │                  │                      │
    │  keepalive     │ proxy_connect    │                      │
    │  _timeout      │ _timeout         │                      │
    │  (75s)         │ (5s)             │                      │
    │                │                  │                      │
    │                │ proxy_read       │  --timeout           │
    │                │ _timeout         │  (75s)               │
    │                │ (60s)            │                      │
    │                │                  │                      │
    │                │ upstream         │  --keep-alive /      │
    │                │ keepalive        │  --timeout-keep-     │
    │                │                  │  alive (75s)         │
    │                │                  │                      │

タイムアウトの設定は、各層が独立に決めるものではなく、層と層の間の「約束」として整合を取るべきものです。 どちらが先に諦めるかという順序を意識的に設計することが、安定した本番環境の実現につながります。

次節では、これらの設定を含めた本番環境の構成全体を、具体的な構成例として組み立てていきます。

3.8. graceful restart / shutdown

前節で、タイムアウトの設定が各層の間の「約束」であることを確認しました。 本節では、時間の話をもう一歩進めて、「アプリケーションを止めるとき」に何が起きるかを考えます。

本番環境では、コードの更新、設定の変更、サーバのメンテナンスなど、アプリケーションを再起動する場面が日常的に発生します。13-1 で触れたように、開発環境であれば Ctrl+C で止めて再度起動すればよいだけの話ですが、本番環境ではユーザーのリクエストが常に流れています。

警告

再起動の瞬間に処理中のリクエストが途切れれば、ユーザーはエラーを目にします。 注文の確定処理が途中で中断されれば、データの不整合が起きるかもしれません。 こうした問題を避けるための仕組みが graceful(優雅な)restart / shutdown です。

graceful restart / shutdown とは、処理中のリクエストを最後まで完了させてから、ワーカーを停止・交代させる仕組みです。以降では、既存接続の扱い、デプロイ時の注意点、そして ASGI の lifespan プロトコルによる終了処理の 3 つの観点から見ていきます。

diagram

3.8.1. 既存接続の扱い

graceful shutdown の核心は、「新しいリクエストの受け付けを止めつつ、処理中のリクエストは完了させる」という二段構えの動作です。

Gunicorn は UNIX シグナルを使ってプロセス間の制御を行います。 シャットダウンに関わるシグナルは 2 種類あり、明確に意味が異なります。

シグナル

動作

TERM

graceful shutdown。処理中のリクエストの完了を待ってから終了します。待ち時間の上限は --graceful-timeout(デフォルト 30 秒)です

QUIT または INT

quick shutdown。処理中のリクエストを中断して即時停止します

# graceful shutdown: 処理中のリクエスト完了を待つ
kill -TERM $(cat /var/run/gunicorn.pid)

# quick shutdown: 処理中のリクエストを中断する
kill -QUIT $(cat /var/run/gunicorn.pid)

graceful restart は HUP シグナルで実現します。 HUP を受け取った arbiter は、新しいワーカーを起動してから古いワーカーを graceful に停止させます。 新しいワーカーがリクエストの受け付けを開始した後に古いワーカーが終了していくため、全体としてサービスが中断する瞬間は生じません。

# graceful restart: 新ワーカー起動 → 旧ワーカーを graceful に停止
kill -HUP $(cat /var/run/gunicorn.pid)

HUP による再起動では、アプリケーションが --preload で事前にロードされていなければ、アプリケーションコードも再読み込みされます。 つまり、コードを更新してから HUP を送るだけでデプロイが完了する、というシンプルな運用が可能です。

Uvicorn の場合も、SIGTERM を受け取ると graceful shutdown が始まります。新しいリクエストの受け付けを停止し、keep-alive 接続を閉じ、処理中のリクエストの完了を待つという順序で動作します。特筆すべきは、HTTP レスポンスは送信済みでも asyncio のタスクがまだ完了していないケース――たとえばバックグラウンド処理が走っている場合――でも、そのタスクの完了を待ってからプロセスを終了するという点です。

# Uvicorn の graceful shutdown の猶予期間を 30 秒に設定
uvicorn myapp:app --timeout-graceful-shutdown 30

注釈

Gunicorn の --graceful-timeout--timeout を混同しないようにしましょう。

  • --timeout(worker timeout): 通常運用中にワーカーが沈黙したら異常とみなす閾値

  • --graceful-timeout: 意図的な停止指示に対する猶予期間

名前が似ているために混同しやすいですが、まったく別の目的を持つ設定です。

3.8.2. デプロイ時の注意

graceful restart の仕組みを理解した上で、実際のデプロイフローにどんな落とし穴があるかを考えましょう。

HUP による再起動では、新しいワーカーと古いワーカーが一時的に共存します。 新しいワーカーは更新後のコードで動いていますが、古いワーカーはまだ更新前のコードで処理中のリクエストを仕上げています。

多くの場合、これは問題になりません。しかし、データベースのスキーマ変更を伴うデプロイでは話が変わります

注意

カラムを削除するマイグレーションを先に実行してしまうと、古いワーカーがまだそのカラムを参照しようとしてエラーになります。

マイグレーションを後方互換に保つことが原則です。

  • カラムの追加: 安全です

  • カラムの削除やリネーム: 新しいコードのデプロイが完全に完了し、古いワーカーが一つも残っていない状態を確認してから、別のステップとして実行します

コンテナ環境(Kubernetes)でのデプロイでは、さらに別の考慮が必要です。 Kubernetes は Pod を終了するとき、まず SIGTERM を送り、terminationGracePeriodSeconds(デフォルト 30 秒)の猶予を与えた後、猶予期間を過ぎてもプロセスが残っていれば SIGKILL で強制終了します。

# Kubernetes の Pod 定義(抜粋)
spec:
  terminationGracePeriodSeconds: 60
  containers:
    - name: web
      command: ["gunicorn", "myproject.wsgi:application",
                "--timeout", "75",
                "--graceful-timeout", "45"]

重要

タイムアウトの大小関係を必ず守りましょう。

Kubernetes の terminationGracePeriodSeconds(60 秒)が Gunicorn の --graceful-timeout(45 秒)よりも長くなければなりません。 Gunicorn がワーカーの完了を待っている最中に Kubernetes が SIGKILL を送ってしまうと、graceful shutdown の意味がなくなります。

もうひとつ、Kubernetes 特有の注意点として preStop フックがあります。 Kubernetes は Pod を終了する際、Service のエンドポイントリストから Pod を削除する処理と SIGTERM の送信をほぼ同時に行います。 しかし、エンドポイントの更新が各ノードの kube-proxy に伝播するまでには若干のタイムラグがあります。 このため、preStop フックで数秒の sleep を挟むことで、エンドポイント更新の伝播を待ってからシャットダウンを開始する、という実務的なパターンがよく使われます。

lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]

この 5 秒は terminationGracePeriodSeconds に含まれるため、先ほどの大小関係は「terminationGracePeriodSeconds > preStop の sleep + --graceful-timeout」と、もう少し精密に考える必要があります。

3.8.3. lifespan と終了処理

ここまでは OS のシグナルとプロセス管理の話でしたが、ASGI アプリケーションにはアプリケーションレベルの終了処理の仕組みがあります。それが、Vol.2「最小の ASGI HTTP アプリ」で触れた lifespan プロトコル です。

lifespan プロトコルは、ASGI サーバがアプリケーションに「起動してよい」「終了してよい」と通知するための仕組みです。 アプリケーションは lifespan.startup イベントでリソースの初期化を行い、lifespan.shutdown イベントでリソースの解放を行います。 具体的には次のような処理がここに該当します。

  • データベースの接続プールの作成と破棄

  • 機械学習モデルのロードとアンロード

  • 外部サービスへの登録と解除

FastAPI では、lifespan パラメータに async context manager を渡すことで、起動処理と終了処理を一箇所にまとめて記述できます。

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

engine: AsyncEngine | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    # yield の前: 起動時に実行される
    global engine
    engine = create_async_engine(
        "postgresql+asyncpg://user:pass@localhost/myapp",
        pool_size=5,
    )
    yield
    # yield の後: 終了時に実行される
    await engine.dispose()

app = FastAPI(lifespan=lifespan)

yield の前がアプリケーション起動時の処理、yield の後が終了時の処理です。 Uvicorn が SIGTERM を受け取って graceful shutdown を開始すると、処理中のリクエストの完了を待った後に、この yield の後のコードが実行されます。

注釈

以前は @app.on_event("startup")@app.on_event("shutdown") という 2 つのデコレータで起動・終了処理を別々に書く方法がありましたが、この API は非推奨になっています。 lifespan の context manager を使う方法であれば、起動で確保したリソースと終了で解放するリソースが同じスコープに閉じるため、コードの見通しがよくなります。

Django の場合、ASGI の lifespan プロトコルへの対応状況はやや複雑です。 Django のコア開発者 Andrew Godwin 氏は「Django にはすでに AppConfig.ready() のような独自のライフサイクル制御があり、lifespan を追加する前に、それが本当に必要かを考えるべきだ」と述べています。 実際、Django アプリケーションの起動時の初期化は AppConfig.ready() で行うのが定石であり、これは WSGI でも ASGI でも動作します。

ただし、AppConfig.ready() は起動時のフックであって、終了時のクリーンアップ処理には対応していません。 Django の ASGI ハンドラは lifespan イベントを受け取っても処理せずに無視するため、Uvicorn のログに「ASGI ‘lifespan’ protocol appears unsupported」という警告が表示されることがあります。

注意

重要なデータの整合性は、クリーンアップ処理に頼るのではなく、トランザクションやジャーナリングなどの仕組みで担保すべきです。 SIGKILL で強制終了された場合には atexit や Gunicorn の on_exit フックは実行されないため、完全な保証にはなりません。

lifespan の終了処理で最後に注意すべきは、この処理自体にもタイムアウトが適用されるということです。 Uvicorn の --timeout-graceful-shutdown は、処理中のリクエストの完了と lifespan の shutdown 処理を含めた全体の猶予期間です。 理想的には、shutdown 処理は数秒以内に完了するよう設計しましょう。


graceful restart / shutdown は、プロセス管理のシグナル、デプロイ戦略での新旧コード共存、アプリケーションレベルの lifespan 処理という 3 つの層にまたがる問題です。 Kubernetes の terminationGracePeriodSecondspreStop の sleep と Gunicorn の --graceful-timeout の合計を上回ること、--graceful-timeout 内にアプリケーションの lifespan shutdown が完了すること――こうした層をまたいだ大小関係を意識的に設計することが、ユーザーにエラーを見せない再起動を実現します。

次節では、本章で扱ってきた構成要素――リバースプロキシ、静的ファイル配信、タイムアウト設計、graceful shutdown――を踏まえて、本番デプロイのトラブルシューティングの観点を見ていきます。

3.9. logging と monitoring の基本

13-1 で、本番環境では可観測性が「あると便利」なものではなく必須のインフラであると述べました。 しかし、これらの情報は黙っていても手に入るものではありません。 ログを出力し、メトリクスを収集し、トレースを記録する仕組みを、意図的に設計してアプリケーションに組み込む必要があります。

以降では、可観測性を構成する 3 つの柱――ログ、メトリクス、トレース――を、Python の Web アプリケーションの文脈で見ていきます。ログについては access log、application log、error log の 3 種類に分けて、それぞれの役割と設定方法を解説します。

diagram

3.9.1. access log

access log は、サーバが処理したリクエストの記録です。 いつ、誰が、どの URL に、どのメソッドでアクセスし、どのステータスコードが返り、何ミリ秒かかったか――1 リクエストにつき 1 行の記録が残ります。

本番環境では、access log は複数の層で出力される可能性があります。

コンポーネント

記録タイミング

特徴

nginx

クライアントにレスポンスを返した時点

リバースプロキシ全体の所要時間を把握できる

Gunicorn

アプリケーションサーバがレスポンスを生成した時点

デフォルトでは無効

Uvicorn

デフォルトで標準出力に出力

レスポンスタイムが含まれない

nginx のデフォルトのログフォーマットにはリクエスト時間が含まれないため、$request_time 変数を明示的に追加する必要があります。

log_format main '$remote_addr - $remote_user [$time_local] '
                '"$request" $status $body_bytes_sent '
                '"$http_referer" "$http_user_agent" '
                '$request_time';

access_log /var/log/nginx/access.log main;

Gunicorn のアクセスログは --access-logfile オプションで出力先を指定します。

gunicorn myproject.wsgi:application \
    --access-logfile - \
    --access-logformat '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s %(D)s'

%(D)s はリクエスト処理にかかったマイクロ秒数です。 nginx のログと比較することで、「アプリケーションの処理時間は 30ms なのに、クライアントに返るまで 200ms かかっている」といった、リバースプロキシ層でのオーバーヘッドを切り分けることができます。

Tip

本番環境では、nginx と Gunicorn/Uvicorn のどちらのアクセスログを主に使うかを決めておくとよいでしょう。 両方を有効にすると同じリクエストが二重に記録されてストレージを消費します。 nginx のアクセスログだけを有効にし、Gunicorn/Uvicorn のアクセスログは無効にする(--no-access-log)という構成が実務的です。

3.9.2. application log

application log は、アプリケーションのコードの中から開発者が意図的に出力するログです。 access log がリクエストの「外形」を記録するのに対し、application log はリクエスト処理の「中身」を記録します。

import logging

logger = logging.getLogger(__name__)

def process_payment(order_id, amount):
    logger.info("Payment processing started",
                extra={"order_id": order_id, "amount": amount})
    try:
        result = payment_gateway.charge(amount)
        logger.info("Payment succeeded",
                    extra={"order_id": order_id, "transaction_id": result.id})
        return result
    except PaymentError as e:
        logger.error("Payment failed",
                     extra={"order_id": order_id, "error": str(e)},
                     exc_info=True)
        raise

ここで重要なのは、logging.getLogger(__name__) でモジュール名に対応したロガーを取得している点です。 Python の logging モジュールはロガーの名前をドット区切りの階層として扱います。 この階層構造により、「myapp 全体は INFO レベル、myapp.views.payment だけ DEBUG レベル」といった細やかな制御が可能になります。

本番環境の application log で意識すべきことは、構造化ログ(structured logging) です。 extra={"order_id": order_id} のように付加情報をメタデータとして渡し、JSON 形式で出力すれば、ログ収集基盤でフィールドごとにフィルタリングや集計ができるようになります。

# Django settings.py でのログ設定例
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json": {
            "()": "pythonjsonlogger.jsonlogger.JsonFormatter",
            "format": "%(asctime)s %(name)s %(levelname)s %(message)s",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "json",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "INFO",
    },
}

この設定を使うと、ログは次のような JSON 形式で出力されます。

{"asctime": "2026-05-02 10:15:30", "name": "myapp.views.payment", "levelname": "ERROR", "message": "Payment failed", "order_id": "ORD-12345", "error": "Card declined"}

注釈

人間が目で読むには不便ですが、機械が解析するには理想的な形式です。 本番環境のログは最終的に人間が読むとしても、まず機械がフィルタリングし、集計し、アラートを発する段階を経るため、構造化されていることが重要です。

3.9.3. error log

error log は、サーバレベルで発生したエラーの記録です。 「アプリケーションコードの外側で発生するエラー」に焦点を当てます。

nginx のエラーログは、502 Bad Gateway や 504 Gateway Timeout が発生したとき、まず確認すべき場所です。

# nginx エラーログの例
2026/05/02 10:15:30 [error] 1234#1234: *5678 upstream prematurely closed
connection while reading response header from upstream, client: 192.168.1.100,
server: example.com, request: "GET /api/reports/ HTTP/1.1",
upstream: "http://127.0.0.1:8000/api/reports/"

このメッセージから、「nginx はアプリケーションサーバと通信できていたが、レスポンスヘッダを受け取る前に接続が切れた」という状況が読み取れます。 13-4 で解説したように、これは Gunicorn の worker timeout が nginx の proxy_read_timeout より短い場合に起きる典型的な症状です。

Uvicorn のサーバレベルのログは uvicorn.error という名前のロガーから出力されます。 名前に反して、エラーだけでなく起動・終了の通知や設定情報も含むサーバの一般的なログです。

Tip

本番環境では、nginx のエラーログ、Gunicorn/Uvicorn のエラーログ、そして application log のエラーレベルの出力を、すべて同一のログ収集基盤に集約することが理想です。 「nginx のログにはエラーがないが、Gunicorn のログにはワーカーがタイムアウトしている記録がある」といった層をまたいだ突き合わせが、同じ画面上でできるかどうかが、障害対応の速度を左右します。

3.9.4. メトリクス

ログは「何が起きたか」を個別のイベントとして記録するのに対し、メトリクスは「どのような状態か」を数値として継続的に記録します。 リクエスト数、レスポンスタイムの平均と p95(上位 5% の値)、エラー率、CPU 使用率、メモリ使用量、データベースコネクション数――これらは時系列データとして蓄積し、ダッシュボードで可視化し、閾値を超えたらアラートを発する対象です。

Python の Web アプリケーションでメトリクスを収集する手段として、もっとも広く使われているのは Prometheus 形式のメトリクスを公開する方法です。

# FastAPI での Prometheus メトリクス設定例
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator

app = FastAPI()
Instrumentator().instrument(app).expose(app)

この数行で、リクエスト数、レスポンスタイムのヒストグラム、処理中のリクエスト数といった基本的なメトリクスが /metrics エンドポイントから取得可能になります。 Prometheus がこのエンドポイントを定期的にスクレイピングし、Grafana のダッシュボードで可視化する、という構成が広く普及しています。

Django であれば django-prometheus、FastAPI であれば prometheus-fastapi-instrumentator といったライブラリが利用できます。

3.9.5. トレース

ログは個別のイベント、メトリクスは集約された数値、そしてトレースは、1 つのリクエストがシステム内のどの処理を通過し、各処理にどれだけの時間がかかったかを可視化するものです。

たとえば、あるリクエストに 3 秒かかっていることがメトリクスから分かったとします。 しかし、その 3 秒がアプリケーションの処理に費やされたのか、データベースクエリの応答待ちなのか、外部 API の呼び出しなのかは、メトリクスだけでは分かりません。 トレースは、このリクエストの内部を時間軸に沿って分解し、各処理(「スパン」と呼ばれます)の所要時間を可視化します。

リクエスト GET /api/orders/123/  (合計: 3.2s)
├── Django middleware chain         (0.002s)
├── View: get_order                 (3.1s)
│   ├── DB: SELECT order            (0.05s)
│   ├── DB: SELECT order_items      (0.08s)
│   └── HTTP: payment-service API   (2.9s)  ← ボトルネック
└── Response serialization          (0.01s)

この図から、ボトルネックが外部の payment-service API の応答待ちにあることが一目で分かります。

Python の Web アプリケーションでトレースを導入する際の事実上の標準は OpenTelemetry です。 Django と FastAPI の両方に対して自動計装(auto-instrumentation)のライブラリが提供されています。

# FastAPI での OpenTelemetry 自動計装の例
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from fastapi import FastAPI

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)

注釈

トレースは強力ですが、すべてのリクエストを記録するとデータ量が膨大になります。 本番環境ではサンプリング(一定割合のリクエストだけ記録する)を行うのが一般的です。 全リクエストの 10% だけをトレースする、あるいはエラーが発生したリクエストは 100% トレースする、といった戦略を取ります。


可観測性の 3 つの柱は、それぞれ補完的な役割を持っています。 メトリクスのダッシュボードで「p95 レスポンスタイムが急上昇した」ことに気づき、トレースで「特定の外部 API 呼び出しが遅い」ことを特定し、ログで「その API が 429 Too Many Requests を返している」ことを確認する――この 3 つの柱を横断する調査が、本番環境のトラブルシューティングの実際の姿です。

次節では、本章の締めくくりとして、本番デプロイにまつわるトラブルシューティングの観点を取り上げます。

3.10. ヘルスチェックと readiness

前節で、ログ・メトリクス・トレースという可観測性の 3 つの柱を確認しました。 これらはアプリケーションの内部で「何が起きているか」を記録する仕組みでしたが、本節ではもう一つの視点――アプリケーションの外側から「このプロセスは正常に動いているか」「リクエストを受け付ける準備ができているか」を確認する仕組み――を扱います。

開発環境では、アプリケーションが止まれば開発者がすぐに気づきます。 しかし本番環境では、ワーカーがハングしていることに誰も気づかないまま、ユーザーがエラーを経験し続ける事態が起こりえます。 ヘルスチェックは、プロセス管理やオーケストレーションの仕組みがアプリケーションの状態を定期的に問い合わせ、異常を検知したら自動的にトラフィックを切り離したりプロセスを再起動したりするためのものです。

3.10.1. liveness / readiness

ヘルスチェックには大きく分けて 2 つの問いがあります。

diagram

プローブ

問いかけ

失敗時の動作

liveness

プロセスが死んでいないか、ハングしていないか

プロセスを再起動

readiness

リクエストを処理できる準備ができているか

トラフィックを停止(プロセスは生存)

一見すると同じことを聞いているようですが、この 2 つは明確に異なります。

重要

liveness チェックに外部依存を含めてしまうと、データベースの一時的な障害でアプリケーションのプロセスが次々と再起動される「再起動の嵐」が起きます。 再起動のたびに初期化処理でデータベースへの接続が試みられ、すでに過負荷のデータベースにさらに接続要求が殺到する――状況を悪化させるだけです。

liveness チェックには外部依存を含めないようにしましょう。

アプリケーション側に必要なのは、これらのチェックに応答するためのエンドポイントを用意することです。

from fastapi import FastAPI, Response, status
from sqlalchemy import text

app = FastAPI()

@app.get("/healthz")
async def liveness():
    # プロセスが生きていて HTTP レスポンスを返せることだけを確認する
    return {"status": "alive"}

@app.get("/readyz")
async def readiness(response: Response):
    # データベースへの接続が正常であることを確認する
    try:
        async with engine.connect() as conn:
            await conn.execute(text("SELECT 1"))
        return {"status": "ready"}
    except Exception:
        response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
        return {"status": "not ready"}

Django の場合は、django-health-checkdjango-alive といったサードパーティパッケージを使うのが一般的です。

# Django urls.py
from django.urls import path, include

urlpatterns = [
    # django-alive を使った例
    path("healthz/", include("django_alive.urls")),
    # ...
]

Kubernetes でのプローブ設定は次のようになります。

spec:
  containers:
    - name: web
      ports:
        - containerPort: 8000
      livenessProbe:
        httpGet:
          path: /healthz
          port: 8000
        initialDelaySeconds: 10
        periodSeconds: 15
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8000
        initialDelaySeconds: 5
        periodSeconds: 10
        failureThreshold: 3
  • periodSeconds: チェックの間隔

  • failureThreshold: 連続何回失敗したら異常と判断するか

liveness の場合、15 × 3 = 45 応答がなければプロセスが再起動されます。 この値は、13-4 で議論した worker timeout や 13-5 の graceful timeout との整合を考慮して設定する必要があります。

3.10.2. アプリ起動直後の注意

ヘルスチェックの設計で特に注意が必要なのは、アプリケーションの起動直後の時間帯です。

Python の Web アプリケーションは、起動時にさまざまな初期化処理を行います。 大規模なアプリケーションや、重いモデルファイルを読み込むアプリケーションでは、この初期化に数秒から数十秒かかることがあります。

警告

この初期化が完了する前に liveness チェックが始まると、プロセスが「応答しない」と判断されて再起動されます。 再起動するとまた初期化が始まり、またチェックに失敗し、また再起動される――永遠に起動できない無限ループに陥ります。

initialDelaySeconds を適切に設定して、初期化が完了する猶予を与えましょう。

Kubernetes はこの問題に対処するために startup probe という 3 つ目のプローブを提供しています。 startup probe が成功するまで、liveness probe と readiness probe は開始されません。

spec:
  containers:
    - name: web
      startupProbe:
        httpGet:
          path: /healthz
          port: 8000
        periodSeconds: 5
        failureThreshold: 30
      livenessProbe:
        httpGet:
          path: /healthz
          port: 8000
        periodSeconds: 15
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8000
        periodSeconds: 10
        failureThreshold: 3

この設定では、startup probe は 5 秒ごとにチェックを行い、最大 30 回(150 秒)まで待ちます。 150 秒以内に初期化が完了して /healthz が 200 を返せば、以降は liveness probe と readiness probe が通常どおり動作します。

Tip

startup probe を使うことで、「起動完了を待つ」と「運用中の死活監視」を別々のプローブに分離できます。 liveness probe の initialDelaySeconds を削除するか最小限にでき、設定の意図がより明確になります。

もう一つ、起動直後に見落とされがちな問題があります。 Gunicorn の --preload オプションを使わない場合は、各ワーカーが個別にアプリケーションコードを読み込むため、ワーカーの起動に時間がかかります。 ヘルスチェックの猶予期間は、「すべてのワーカーが起動を完了するまでの時間」を見積もって設定する必要があります。


liveness と readiness の区別は、「何をチェックするか」ではなく「チェック失敗時に何が起きるか」で考えるのが分かりやすいでしょう。

  • liveness 失敗: プロセスの再起動を引き起こします

  • readiness 失敗: トラフィックの停止を引き起こしますが、プロセスは生かしておきます

危険

この違いを理解していないと、readiness チェックに入れるべき外部依存の確認を liveness チェックに入れてしまい、カスケード障害の引き金を自ら仕込むことになります。

ヘルスチェックの設計は、本書で繰り返し強調してきた「どの層が何の責務を持つか」の延長にあります。 プロセスの生死判定はプロセス管理の層の責務であり、外部リソースの可用性判定はアプリケーションの層の責務です。 ヘルスチェックのエンドポイントは、この 2 つの判定をそれぞれ明確に分離して提供するものです。

次節では、本章の締めくくりとして、本番デプロイにまつわるトラブルシューティングの観点を取り上げます。

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

本章では、開発環境と本番環境の違いから始めて、リバースプロキシ、静的ファイル配信、タイムアウト設計、graceful shutdown、可観測性、ヘルスチェックと、本番デプロイの構成要素を一通り見てきました。 以降では、これらの知識を「問題が起きたときにどう使うか」という実践的な視点から見ていきます。

Tip

本番環境で問題が起きたとき、最初にやるべきことは症状を正確に観察することです。 「サイトが動かない」ではなく、次の情報を確認しましょう。

  • どのステータスコードが返っているか

  • どの URL で起きているか

  • いつから起きているか

  • すべてのリクエストで起きているか、一部だけか

症状を正確に記述できれば、原因の層を絞り込む作業は半分終わったようなものです。

3.11.1. 502/503/504 の意味

本番環境でもっとも頻繁に遭遇する 5xx 系エラーは 502、503、504 の 3 つです。

diagram

Vol.1「HTTP は何をやりとりしているのか」で HTTP ステータスコードの基礎を学びましたが、この 3 つの違いを本番環境の文脈で正確に理解しておくことは、障害対応の速度を大きく左右します。

502 Bad Gateway は、nginx がアプリケーションサーバに接続を試みたが、接続できなかったか、異常な応答を受け取ったことを意味します。

502 を見たとき、最初に確認すべきは「アプリケーションサーバのプロセスは生きているか」です。

# Gunicorn のプロセスを確認する
ps aux | grep gunicorn

# Uvicorn のプロセスを確認する
ps aux | grep uvicorn

プロセスが存在していれば、次に nginx のエラーログを確認します。

tail -f /var/log/nginx/error.log

nginx のエラーログには、502 の具体的な原因が記録されています。

  • 「Connection refused」: プロセスが停止しているかポートが違います

  • 「upstream prematurely closed connection」: 13-4 で解説した worker timeout と proxy_read_timeout の不整合が疑われます

503 Service Unavailable は、サーバはリクエストを受け取ったが、現在処理できる状態にないことを意味します。 readiness チェックが失敗してトラフィックが切り離されている場合や、同時接続数の上限に達した場合に返されます。 502 とは異なり、プロセス自体は生きています。

504 Gateway Timeout は、nginx がアプリケーションサーバからの応答を待ち続けたが、proxy_read_timeout の制限時間内に応答が返ってこなかったことを意味します。

504 を見たときは、「なぜアプリケーションの処理に時間がかかっているか」が問いの核心です。

  • CPU 使用率が高ければ、ビューの中で重い計算が走っている可能性があります

  • CPU 使用率が低ければ、データベースクエリや外部 API の応答を待っている可能性が高いでしょう

  • 13-6 で導入したトレースのデータがあれば、リクエスト内部のどの処理に時間がかかっているかを直接確認できます

これら 3 つのステータスコードに対する思考の流れは次のようになります。

5xx エラーが発生
  │
  ├── 502: 接続に失敗している
  │     → プロセスは生きているか?
  │     → ソケット/ポートは正しいか?
  │     → worker timeout で接続が切れていないか?
  │
  ├── 503: 処理できる状態にない
  │     → readiness チェックは成功しているか?
  │     → 同時接続数の上限に達していないか?
  │     → 依存サービスは正常か?
  │
  └── 504: 応答が時間内に返ってこない
        → ボトルネックは CPU か I/O か?
        → データベースクエリが遅くないか?
        → 外部 API が応答していないか?
        → proxy_read_timeout と worker timeout の整合は取れているか?

3.11.2. upstream timeout

504 の原因として最も多い「タイムアウトの不整合」を、もう少し具体的に掘り下げます。

問題の特定で重要なのは、「どの層のタイムアウトが発動したか」を見極めることです。

nginx の proxy_read_timeout(デフォルト 60 秒)が先に発動した場合、nginx のエラーログに次のようなメッセージが記録されます。

upstream timed out (110: Connection timed out) while reading
response header from upstream

クライアントには 504 が返ります。

一方、Gunicorn の --timeout(デフォルト 30 秒)が先に発動した場合、Gunicorn のログに次のメッセージが記録されます。

[CRITICAL] WORKER TIMEOUT (pid:3438)

そして nginx のエラーログには「upstream prematurely closed connection」と記録され、クライアントには 502 が返ります。

重要

同じ「リクエストが遅い」という根本原因に対して、タイムアウトの大小関係によって返されるステータスコードが変わります。 502 なのか 504 なのかを確認し、nginx のエラーログと Gunicorn のログを突き合わせることで、どの層のタイムアウトが発動したかを特定できます。

13-4 で示した「nginx の proxy_read_timeout を Gunicorn の --timeout よりもわずかに短くする」という原則に従っていれば、nginx が先に 504 を返す形になり、ステータスコードだけで原因の層が推測しやすくなります。

3.11.3. proxy header 設定ミス

13-2 でリバースプロキシのヘッダ転送を解説しましたが、この設定が正しくない場合に起きる問題は、エラーとして目立つものから、気づきにくいものまでさまざまです。

もっとも分かりやすい症状は、Django の CSRF 検証が失敗するケースです。 X-Forwarded-Proto ヘッダが正しく転送されていない場合、Django はリクエストを HTTP と判定します。 ログには Forbidden (CSRF cookie not set.)Referer checking failed といったメッセージが記録されます。

# Django settings.py で必要な設定
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

次に気づきにくい問題として、すべてのリクエストのリモートアドレスが 127.0.0.1 になるケースがあります。 X-Forwarded-For ヘッダが転送されていないか、アプリケーション側でこのヘッダを読み取る設定がなければ、IP ベースのレート制限が効かなくなり、不正アクセスの追跡もできなくなります。

この問題をデバッグするには、アプリケーションが実際に受け取っているヘッダを確認するのがもっとも直接的です。

# Django のビューで受け取っているヘッダを確認する(デバッグ用)
def debug_headers(request):
    headers = {
        "REMOTE_ADDR": request.META.get("REMOTE_ADDR"),
        "HTTP_X_FORWARDED_FOR": request.META.get("HTTP_X_FORWARDED_FOR"),
        "HTTP_X_FORWARDED_PROTO": request.META.get("HTTP_X_FORWARDED_PROTO"),
        "HTTP_HOST": request.META.get("HTTP_HOST"),
    }
    return JsonResponse(headers)
# FastAPI で同様の確認を行う
from fastapi import FastAPI, Request

app = FastAPI()

@app.get("/debug/headers")
async def debug_headers(request: Request):
    return {
        "client_host": request.client.host,
        "x_forwarded_for": request.headers.get("x-forwarded-for"),
        "x_forwarded_proto": request.headers.get("x-forwarded-proto"),
        "host": request.headers.get("host"),
    }

このエンドポイントにアクセスして、期待するヘッダが届いているかを確認します。

  • x-forwarded-forNone: nginx の proxy_set_header の設定が漏れています

  • x-forwarded-protohttp: nginx の設定で $scheme が正しく渡されていないか、TLS 終端が別の場所で行われている可能性があります

注意

本番環境にこのデバッグエンドポイントを残すべきではありません。 デプロイ直後の動作確認や障害時のデバッグに使ったら、必ず削除しましょう。

3.11.4. 静的ファイルが返らない

「CSS が適用されない」「JavaScript が読み込まれない」「画像が表示されない」――デプロイ直後にこうした報告を受けることがあります。 13-3 で解説した static files の配信設定に問題があるケースです。

まず、ブラウザの開発者ツールのネットワークタブを開き、どのファイルがどのステータスコードで失敗しているかを確認します。

ステータスコード

考えられる原因

確認すべき点

404 Not Found

nginx が静的ファイルを見つけられない

alias パスと collectstatic の出力先が一致しているか

403 Forbidden

パーミッションの問題

nginx のワーカープロセスのユーザーにファイルの読み取り権限があるか

200 なのに表示が崩れる

ブラウザや CDN のキャッシュが古い

ハードリロード(Ctrl+Shift+R)、または CDN のキャッシュパージ

nginx の alias ディレクティブでは末尾のスラッシュが意味を持ちます。

# 確認すべき nginx の設定
location /static/ {
    alias /var/www/myproject/staticfiles/;  # 末尾の / を忘れていないか
}

コンテナ環境では、ビルドステップで collectstatic を実行しているかを確認します。

# コンテナ内で静的ファイルが存在するか確認する
ls -la /var/www/myproject/staticfiles/

Tip

13-3 で解説した ManifestStaticFilesStorage のようにファイル名にハッシュを含める仕組みを使っていれば、ブラウザや CDN のキャッシュによる古いファイルの問題を構造的に回避できます。

もう一つ見落とされがちなのは、Django の STATIC_URL と nginx の location の不一致です。 STATIC_URL = "/static/" なのに nginx の設定が location /assets/ になっていれば、Django が生成する HTML には /static/style.css への参照が埋め込まれますが、nginx はそのパスに対する設定を持っておらず、リクエストがアプリケーションサーバに転送されます。 DEBUG = False の Django はそのリクエストに対して 404 を返します。


本章では、開発環境と本番環境の違いから出発し、リバースプロキシ、静的ファイル配信、タイムアウト設計、graceful shutdown、可観測性、ヘルスチェック、そしてトラブルシューティングまでを一通り歩いてきました。

重要

これらのテーマに共通しているのは、「アプリケーションのコードを書く」だけでは本番環境は成立しない、ということです。 アプリケーションコードは本番環境の構成要素のひとつにすぎません。 リバースプロキシの設定、タイムアウトの整合、ログの設計、ヘルスチェックのエンドポイント――これらすべてが組み合わさって、初めてアプリケーションは本番環境で安定して動きます。

Vol.1「本書の対象読者とゴール」で「ブラウザからレスポンスまでの全体像」を描いたとき、リクエストがどの層を通過するかを俯瞰しました。 本章で学んだのは、その各層の設定が本番環境でどう噛み合うかという話です。 次章では、エラー処理とセキュリティという、本番環境でアプリケーションを「守る」ための観点を扱います。