1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Djangoのデプロイ — gunicorn + Nginx + Dockerで本番環境を作った

1
Posted at

はじめに

Djangoの開発環境はpython manage.py runserverで動くが、本番環境ではこれを使ってはいけない。

公式ドキュメントにも「runserverは本番用ではない」と明記されている。本番ではgunicornでDjangoアプリを動かして、その前段にNginxを置く構成が定番。FastAPIのデプロイ記事でDockerの基本は書いたので、今回はDjango固有の部分を中心に整理した。


FastAPIデプロイとの構成の違い

まず全体像を整理する。

# FastAPI
Internet → uvicorn(HTTPサーバー + アプリ)→ DB

# Django
Internet → Nginx → gunicorn(WSGIサーバー)→ Django → DB
                ↓
          staticfiles(CSS/JS/画像)

FastAPIはuvicornがHTTPサーバーとアプリを兼ねるが、DjangoはNginxが必要。理由は2つ。

staticファイルの配信 — Djangoは開発環境では静的ファイルを自分で配信できるが、本番ではDEBUG=Falseにすると配信しない。Nginxが代わりに配信する。

WSGIとASGIの違い — DjangoはデフォルトでWSGI(同期)。gunicornがWSGIサーバーとして動く。FastAPIはASGI(非同期)なのでuvicornを使う。


プロジェクト構成

myproject/
├── Dockerfile
├── compose.yaml
├── nginx/
│   ├── Dockerfile
│   └── default.conf
├── myproject/
│   ├── settings/
│   │   ├── __init__.py
│   │   ├── base.py       # 共通設定
│   │   ├── development.py
│   │   └── production.py
│   ├── urls.py
│   └── wsgi.py
├── apps/
│   └── users/
├── static/               # 開発時の静的ファイル
├── staticfiles/          # collectstaticの出力先
├── requirements/
│   ├── base.txt
│   ├── development.txt
│   └── production.txt
└── manage.py

設定ファイルを環境ごとに分割するのがDjangoのベストプラクティス。


settings.pyの分割

# myproject/settings/base.py(共通設定)
from pathlib import Path
import os

BASE_DIR = Path(__file__).resolve().parent.parent.parent

SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "apps.users",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "myproject.urls"

DATABASES = {
    "default": {
        "ENGINE":   "django.db.backends.postgresql",
        "NAME":     os.environ.get("DB_NAME", "mydb"),
        "USER":     os.environ.get("DB_USER", "user"),
        "PASSWORD": os.environ.get("DB_PASSWORD", "password"),
        "HOST":     os.environ.get("DB_HOST", "localhost"),
        "PORT":     os.environ.get("DB_PORT", "5432"),
    }
}

LANGUAGE_CODE = "ja"
TIME_ZONE     = "Asia/Tokyo"
USE_I18N      = True
USE_TZ        = True

STATIC_URL  = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"  # collectstaticの出力先
MEDIA_URL   = "/media/"
MEDIA_ROOT  = BASE_DIR / "mediafiles"
# myproject/settings/development.py
from .base import *

DEBUG = True

ALLOWED_HOSTS = ["localhost", "127.0.0.1"]

# 開発用にデバッグツールバーを追加
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE    += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
INTERNAL_IPS   = ["127.0.0.1"]
# myproject/settings/production.py
from .base import *

DEBUG = False

ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")

# セキュリティ設定
SECURE_SSL_REDIRECT         = True
SECURE_HSTS_SECONDS         = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SESSION_COOKIE_SECURE       = True
CSRF_COOKIE_SECURE          = True

# キャッシュ(本番はRedisを使う)
CACHES = {
    "default": {
        "BACKEND":  "django.core.cache.backends.redis.RedisCache",
        "LOCATION": os.environ.get("REDIS_URL", "redis://localhost:6379"),
    }
}

# ログ設定
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "WARNING",
    },
}
# 環境変数で使う設定を切り替える
export DJANGO_SETTINGS_MODULE=myproject.settings.production

requirements.txtの分割

# requirements/base.txt
Django>=5.0
djangorestframework>=3.15
psycopg2-binary>=2.9
gunicorn>=21.0
# requirements/development.txt
-r base.txt
django-debug-toolbar>=4.0
pytest-django>=4.8
# requirements/production.txt
-r base.txt
whitenoise>=6.6   # staticファイルの配信(オプション)
sentry-sdk>=1.40  # エラー監視

Dockerfile

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=myproject.settings.production

WORKDIR /app

# 依存関係をインストール
COPY requirements/production.txt requirements/production.txt
RUN pip install --no-cache-dir -r requirements/production.txt

# アプリのコードをコピー
COPY . .

# staticファイルを収集
RUN python manage.py collectstatic --noinput

# 非rootユーザーで実行
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000

CMD ["gunicorn", "myproject.wsgi:application",
     "--bind", "0.0.0.0:8000",
     "--workers", "4",
     "--timeout", "120",
     "--access-logfile", "-",
     "--error-logfile", "-"]

PYTHONDONTWRITEBYTECODE=1.pycファイルを生成しない、PYTHONUNBUFFERED=1でログをバッファリングしないように設定。Dockerコンテナでは定番の設定。

collectstaticをビルド時に実行してNginxが配信できるようにする。

gunicornのワーカー数

# 推奨: (CPUコア数 × 2) + 1
--workers 4  # 2コアなら (2×2)+1 = 5、余裕を見て4

FastAPIのuvicornと同じ発想。


Nginxの設定

# nginx/Dockerfile
FROM nginx:alpine
COPY default.conf /etc/nginx/conf.d/default.conf
# nginx/default.conf
upstream django {
    server web:8000;  # compose.yamlのサービス名
}

server {
    listen 80;
    server_name localhost;

    # staticファイルの配信
    location /static/ {
        alias /app/staticfiles/;
        expires 30d;
        add_header Cache-Control "public, no-transform";
    }

    # mediaファイルの配信
    location /media/ {
        alias /app/mediafiles/;
        expires 7d;
    }

    # Djangoアプリへのプロキシ
    location / {
        proxy_pass          http://django;
        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;
        proxy_read_timeout  90;
        client_max_body_size 10M;
    }
}

/static/へのリクエストはNginxが直接ファイルを返す。それ以外はgunicornにプロキシする。FastAPIではuvicornが全部処理するのでNginxは不要だったが、DjangoはstaticファイルをNginxで配信するのがほぼ必須。


compose.yaml

services:
  nginx:
    build:
      context: ./nginx
    ports:
      - "80:80"
    volumes:
      - static_volume:/app/staticfiles  # staticファイルを共有
      - media_volume:/app/mediafiles
    depends_on:
      web:
        condition: service_healthy

  web:
    build: .
    env_file:
      - .env
    volumes:
      - static_volume:/app/staticfiles
      - media_volume:/app/mediafiles
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s  # マイグレーションに時間がかかるので長めに

  db:
    image: postgres:16
    environment:
      POSTGRES_DB:       mydb
      POSTGRES_USER:     user
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
  static_volume:   # NginxとDjangoコンテナ間でstaticファイルを共有
  media_volume:

ポイントはstatic_volumemedia_volumeをNginxとwebコンテナで共有していること。Djangoがcollectstaticでファイルをボリュームに書き込み、Nginxがそのボリュームから直接配信する。


マイグレーションの実行タイミング

本番でのマイグレーションをどのタイミングで実行するかは悩む部分。

パターン① entrypointスクリプト

# entrypoint.sh
#!/bin/bash
set -e

echo "マイグレーションを実行中..."
python manage.py migrate --noinput

echo "アプリを起動..."
exec "$@"
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]
# compose.yaml
web:
  build: .
  entrypoint: ["/entrypoint.sh"]
  command: ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]

パターン② 初期化コンテナ(推奨)

services:
  migrate:
    build: .
    command: python manage.py migrate --noinput
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    restart: "no"  # 一回実行したら終了

  web:
    build: .
    depends_on:
      migrate:
        condition: service_completed_successfully

マイグレーションと本番サーバーを分けることで、複数のwebコンテナが同時にmigrateを実行してしまう問題を避けられる。


開発環境のcompose.override.yaml

# compose.override.yaml(Gitignore)
services:
  web:
    build:
      context: .
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - .:/app
    environment:
      - DJANGO_SETTINGS_MODULE=myproject.settings.development
      - DEBUG=true
    ports:
      - "8000:8000"

  nginx:
    profiles:
      - production  # 開発環境ではNginxを起動しない

開発環境ではrunserverでホットリロードを使い、Nginxは使わない。profilesでサービスの起動条件を制御できる。


.env

# .env(Gitignore)
SECRET_KEY=your-production-secret-key-minimum-50-chars
DEBUG=false
ALLOWED_HOSTS=example.com,www.example.com

DB_NAME=mydb
DB_USER=user
DB_PASSWORD=secure-password
DB_HOST=db
DB_PORT=5432

DJANGO_SETTINGS_MODULE=myproject.settings.production

REDIS_URL=redis://redis:6379

ヘルスチェックエンドポイント

# apps/health/views.py
from django.http     import JsonResponse
from django.db       import connection
from django.db.utils import OperationalError

def health_check(request):
    try:
        connection.ensure_connection()
        db_status = "ok"
    except OperationalError:
        db_status = "error"

    status_code = 200 if db_status == "ok" else 503
    return JsonResponse(
        {"status": "ok" if db_status == "ok" else "error", "db": db_status},
        status=status_code,
    )
# urls.py
from django.urls import path
from apps.health.views import health_check

urlpatterns = [
    path("health/", health_check),
    ...
]

デプロイ手順まとめ

# 1. イメージをビルド
docker compose build

# 2. DBを起動してマイグレーション
docker compose up -d db
docker compose run --rm migrate

# 3. 全サービスを起動
docker compose up -d

# ログを確認
docker compose logs -f web
docker compose logs -f nginx

# コンテナの状態を確認
docker compose ps

FastAPIデプロイとの比較

項目 Django FastAPI
HTTPサーバー gunicorn(WSGI) uvicorn(ASGI)
Nginxが必要か 必須(staticファイル) 任意
staticファイル collectstatic必要 基本なし
ワーカー管理 gunicorn uvicorn / gunicorn
マイグレーション manage.py migrate alembic upgrade head
コンテナ数 3(Nginx + web + DB) 2(web + DB)
設定の分割 settings/本番・開発 pydantic-settings

まとめ

  • 本番ではrunserverの代わりにgunicornを使う
  • Nginxはstaticファイルの配信とリバースプロキシが役割
  • NginxとDjangoコンテナでstaticファイルをVolumeで共有する
  • マイグレーションは初期化コンテナで実行するのが安全
  • settings.pyは環境ごとに分割してDJANGO_SETTINGS_MODULEで切り替える

FastAPIはNginxなしで動くシンプルな構成だったが、DjangoはNginxが必要な分コンテナが一つ増える。staticファイルの扱いを最初に設計しておかないと後で詰まるポイントだった。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?