第3章 開発環境と本番環境は何が違うのか
python manage.py runserver や uvicorn main:app --reload で手元のブラウザに画面が表示されると、「動いた」という達成感があります。
しかし、この「動いた」はあくまで開発者のマシン上での話です。
本番環境でアプリケーションを安定して動かし続けることは、開発環境で動かすこととは根本的に異なる営みです。
本章では、ここまで学んできた HTTP、WSGI、ASGI、並行処理、サーバ選定の知識を、本番環境にどう持ち込むかを考えていきます。 その出発点として、「開発環境と本番環境は具体的に何が違うのか」を 4 つの観点から見ていきます。
図3-1 開発環境と本番環境の構成の違い
注釈
Vol.1「WSGI が生まれた背景」で WSGI の責務を学んだとき、「どの層が何を担っているか」を意識することの重要性を確認しました。 本番環境の設計でも同じ思考法が必要です。違いを漠然と感じているだけではなく、何がどう違うのかを言語化できることが、適切な設計判断の第一歩になります。
3.1 1プロセス前提ではない
開発環境と本番環境のもっとも根本的な違いは、アプリケーションが動くプロセスの数です。
python manage.py runserverは 1 つのプロセスで動作します(既定ではマルチスレッドで、--nothreadingを付けると単一スレッドになります)。uvicorn main:app --reloadも同様に、デフォルトでは 1 つのワーカープロセスで動作します。
開発中はこれで十分です。リクエストを送るのは開発者自身だけですし、同時アクセスは事実上ありません。
本番環境では、第2章(なぜ Web 開発で並行処理が重要なのか)で詳しく見たように、複数のワーカープロセスが同時にリクエストを処理します。
Gunicorn であれば pre-fork モデルで複数のワーカーが起動し、Uvicorn であれば --workers オプションやプロセスマネージャを通じて複数プロセスが並行稼働します。
これは単にスループットを上げるためだけではなく、1 つのワーカーが異常終了しても他のワーカーがリクエストを処理し続けられるようにするためでもあります。
この「複数プロセス」という前提は、アプリケーションの設計に直接影響します。 開発環境で動いていたコードが、本番環境で予想外の挙動を見せることがあるのです。 たとえば、モジュールレベルの変数にデータをキャッシュしているコードを考えてみましょう。
_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 は別物です。あるワーカーでキャッシュに書き込んだ値が、別のワーカーからは見えません。
管理画面で設定を変更したのに反映されないワーカーがある、という症状はこれが原因です。
2.4 節(process, thread, coroutine の違い)で「プロセスはメモリ空間が独立している」と学びましたが、その知識がここで実務に直結します。
同様に、ファイルへの書き込みも注意が必要です。 複数のワーカーが同じファイルに同時に書き込むと、内容が混在したり、一方の書き込みがもう一方に上書きされたりします。 開発環境では起きなかった競合が、本番環境のマルチプロセス構成で初めて顕在化するのです。
3.2 外部からのアクセス
開発環境では、アプリケーションにアクセスするのは localhost からの開発者自身です。
本番環境では、インターネット経由で不特定多数のクライアントがアクセスしてきます。
この違いは、アプリケーションの手前に何を置くかという構成の違いとして現れます。
runserver や uvicorn --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 つに分けて見ていきます。
TLS 終端
静的ファイル配信
パス振り分け
ヘッダ転送
図3-2 リバースプロキシの基本構成
例として 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 リクエストであり、暗号化のことを一切意識する必要がありません。
図3-3 TLS 終端の位置(nginx が HTTPS を平文に変換)
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 30d と Cache-Control ヘッダは、ブラウザに「このファイルは 30 日間キャッシュしてよい」と伝えるものです。
静的ファイルは内容が変わらない(変わる場合はファイル名にハッシュが付与される)ため、積極的にキャッシュさせることでネットワーク帯域とサーバ負荷の両方を削減できます。
FastAPI の場合は、StaticFiles をマウントして静的ファイルを配信する機能がありますが、これも本番環境ではリバースプロキシに任せるのが定石です。
3.5.3 パス振り分け
リバースプロキシは、リクエストの URL パスに基づいて転送先を振り分けることができます。
図3-4 リバースプロキシによるパス振り分け
これは、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-For、X-Forwarded-Proto、X-Forwarded-Host といった転送ヘッダです。