【10日目】Django CMSを本番相当構成へ――Docker・PostgreSQL・Redis・nginx・バックアップ復元

【10日目】Django CMSを本番相当構成へ――Docker・PostgreSQL・Redis・nginx・バックアップ復元
目次

連載「10日で作る Django CMS」の10日目です。
成果物: https://github.com/kurumonn/DjangoCMS
対応タグ: day-10

9日目までで、CMSそのものの機能は完成しました。

10日目で行うのは、CMSを**「自分の開発PCでだけ動くプログラム」から「本番環境へ持っていける構成」へ変えること**です。

今回追加するのは次の要素です。

  • 設定ファイルを環境別に分割する
  • PostgreSQLへ移行する
  • Redisを共有キャッシュとして使う
  • GunicornでDjangoを実行する
  • nginxをリバースプロキシ・静的ファイル配信に使う
  • Docker Composeで再現可能な実行環境を作る
  • HTTPSを前提としたDjango本番設定を入れる
  • HSTSを段階的に導入する
  • liveness / readinessを分離する
  • バックアップを取得する
  • 別データベースへ実際に復元する

ただし、この10日目のDocker構成にはTLS証明書そのものはまだ入りません

そのため、ここで完成するのは、

「HTTPS終端を前段へ追加すれば本番公開できるアプリケーション側の本番相当構成」

です。

インターネットへ公開する最終構成では、後続のデプロイ編でLet's EncryptなどのTLS終端を追加します。


1. 結論・要点


10日目で最も重要なのは、DockerでもPostgreSQLでもRedisでもありません。

バックアップを実際に復元することです。

バックアップは、

バックアップファイルが存在する

だけでは価値を証明できません。

確認すべきなのは、

バックアップを取得
        ↓
別のデータベースへ復元
        ↓
記事・ユーザー・コメントなどを確認
        ↓
本番へ戻せることを確認

です。

もう1つ重要なのは、開発環境が動いていることは依存関係の宣言が正しい証拠にならないという点です。

この連載では、開発PCではテストがすべて通っていたにもかかわらず、Dockerで空の環境を作ったことで、

requests が requirements.txt に無い

という問題が発覚しました。

つまり、

手元で動く
≠
第三者が同じ環境を再現できる

です。


この記事の要点

  • Djangoの設定は base / local / test / production に分割する。
  • production設定では必須環境変数に安全でない既定値を持たせない。
  • .env.example のダミー値も、本番起動時に拒否する。
  • PostgreSQL・Redis・Djangoはインターネットへ直接ポート公開しない。
  • Redisは高速化だけでなく、複数ワーカー間でレート制限を共有するために使う。
  • django-allauthのIP判定にはCMS独自設定とは別にallauth用のProxy設定が必要である。
  • Composeの service_healthy は依存サービスのhealthcheck成功を待てる。
  • entrypointのDB待機処理は、Compose外や再接続時も考えた追加防御として扱う。
  • livenessとreadinessは分離し、readinessではPostgreSQLとRedisを確認する。
  • SECURE_SSL_REDIRECT=True でもTLS証明書が無ければインターネット公開は完成していない。
  • HSTSは段階的に伸ばし、HTTPSが正常な間なら max-age=0 で解除できるが、HTTPS自体が壊れた状態では解除ヘッダーを届けられない。
  • server_tokens off は脆弱性対策そのものではなく、バージョン情報露出を減らす補助策である。
  • バックアップは取得確認だけでなく、別DBへの復元訓練まで行う。

2. この記事で分かること

この記事を読むと、次の内容が分かります。

  • Django設定を環境別に分離する方法
  • 本番で環境変数不足時に起動を止める方法
  • PostgreSQLをDjangoへ接続する方法
  • RedisをDjango標準キャッシュとして使う方法
  • cached_db セッションの意味
  • django-allauthのレート制限を複数Gunicornワーカーで共有する理由
  • allauthのクライアントIP判定をProxy構成へ合わせる方法
  • Docker Composeの depends_on とhealthcheckの関係
  • livenessとreadinessの違い
  • nginxからDjangoへHTTPS情報を正しく渡す方法
  • HSTSを段階的に導入する理由
  • Dockerイメージを非rootで動かす理由
  • PostgreSQLバックアップを検査する方法
  • 本番DBを壊さずに復元訓練する方法
  • 依存関係をクリーン環境で確認する方法

3. 対象読者・前提環境

この記事は、次の読者を対象にしています。

  • Djangoアプリを本番環境へ移したい
  • SQLiteからPostgreSQLへ移行したい
  • Docker Composeを使いたい
  • Redisを導入したい
  • nginxとGunicornを組み合わせたい
  • バックアップ・復元まで含めた運用を学びたい

主な構成は次のとおりです。

Django              5.2系
Python              3.12系
PostgreSQL          17系
Redis               8系
Gunicorn            26系
nginx               1.29系
django-allauth      65.18.0
psycopg             3系
Docker Compose

この記事のコードは次のタグを基準にしています。

day-10

成果物:

https://github.com/kurumonn/DjangoCMS


4. 用語と全体像

4.1 今回作る構成

10日目の構成は次のようになります。

                 インターネット
                       │
                       ▼
               ┌─────────────┐
               │    nginx    │
               │ HTTP入口     │
               │ static/media│
               └──────┬──────┘
                      │
                      ▼
               ┌─────────────┐
               │     web     │
               │   Django    │
               │  Gunicorn   │
               └────┬───┬────┘
                    │   │
          ┌─────────┘   └─────────┐
          ▼                       ▼
   ┌─────────────┐         ┌─────────────┐
   │ PostgreSQL  │         │    Redis    │
   │ DB          │         │ Cache       │
   │ Session DB  │         │ Rate Limit  │
   └─────────────┘         └─────────────┘

ホストへポートを公開するのはnginxだけです。

nginx      80:80

web        公開しない
db         公開しない
redis      公開しない

4.2 HTTPSについて

重要な注意があります。

10日目の nginx/default.conf は、

listen 80;

のみです。

TLS証明書や、

listen 443 ssl;

はまだ扱いません。

一方Djangoのproduction設定では、

SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = (
    "HTTP_X_FORWARDED_PROTO",
    "https",
)

を設定します。

つまり、

Django側
    ↓
HTTPS前提の設定

nginx側
    ↓
TLS終端は後続のデプロイ編

という段階です。

10日目は「公開可能な本番アプリ設定を作る日」であり、TLS証明書まで含めたインターネット公開完成日ではありません。


5. 問題・仕組み・原因

5.1 開発設定をそのまま本番へ持っていけない

開発では、

DEBUG=True
SQLite
LocMemCache
console email
localhost

で十分です。

しかし本番で同じ設定を使うと、

  • DEBUG=True で内部情報が漏れる
  • SQLiteへ複数ワーカーからアクセスする
  • LocMemCacheがワーカーごとに分裂する
  • メールが利用者へ届かない
  • HTTPS設定が無い

といった問題が起きます。

そこで、設定を環境ごとに分離します。


6. 完成コード

6.1 設定ファイルを分割する

構成を次のようにします。

config/
└── settings/
    ├── __init__.py
    ├── base.py
    ├── local.py
    ├── test.py
    └── production.py

config/settings/__init__.py は空にします。

"""Django settings package.

必ず次のいずれかを指定する。

config.settings.local
config.settings.test
config.settings.production
"""

config.settings だけで動作する設定は作りません。

目的は、

どの環境設定で起動しているか分からない

状態を防ぐことです。


6.2 base.py

import os
from pathlib import Path


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


def env_bool(name: str, default: bool = False) -> bool:
    raw = os.environ.get(name)

    if raw is None:
        return default

    return raw.strip() == "1"


def env_list(name: str, default: str = "") -> list[str]:
    return [
        value.strip()
        for value in os.environ.get(name, default).split(",")
        if value.strip()
    ]

真偽値は、

1 = True
それ以外 = False

へ統一します。

自由に、

true
True
yes
YES

などを許すより、設定方法を1種類へ絞る方が運用ミスを減らせます。


6.3 local.py

from .base import *  # noqa: F401,F403
from .base import BASE_DIR


DEBUG = True

SECRET_KEY = "django-insecure-dev-only-do-not-use-in-production"

ALLOWED_HOSTS = [
    "localhost",
    "127.0.0.1",
    "[::1]",
]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
        "LOCATION": "kururucms-local",
    }
}

EMAIL_BACKEND = (
    "django.core.mail.backends.console.EmailBackend"
)

MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = False

通常のモダンブラウザーでは localhost はSecure Contextとして扱われるため、WebAuthnのinsecure-origin緩和を必ずしも有効にする必要はありません。

利用しているブラウザー・FIDO2ライブラリで問題がある開発環境だけ、明示的に切り替えます。


6.4 本番環境変数は不足時に起動を止める

本番用設定では、安全でない既定値へフォールバックしません。

import os


def require(name: str) -> str:
    value = os.environ.get(name, "").strip()

    if not value:
        raise RuntimeError(
            f"{name} が未設定です。"
        )

    return value

さらに、.env.example のダミー値をコピーしただけで起動してしまう問題も防ぎます。

FORBIDDEN_SECRET_VALUES = {
    "ここに生成した値を貼る",
    "changeme",
    "change-me",
    "your-secret-key-here",
    "your-password-here",
}


def require_secret(
    name: str,
    *,
    min_length: int = 32,
) -> str:
    value = require(name)

    if value in FORBIDDEN_SECRET_VALUES:
        raise RuntimeError(
            f"{name} がダミー値のままです。"
        )

    if len(value) < min_length:
        raise RuntimeError(
            f"{name} が短すぎます。"
        )

    return value

使用します。

SECRET_KEY = require_secret(
    "DJANGO_SECRET_KEY",
    min_length=50,
)

POSTGRES_PASSWORD = require_secret(
    "POSTGRES_PASSWORD",
    min_length=24,
)

なぜ必要なのか

.env.example に、

DJANGO_SECRET_KEY=ここに生成した値を貼る

と書くだけではfail-safeではありません。

元の require() が確認するのは、

空かどうか

だけだからです。

日本語のダミー値でも、空でなければ起動します。

ダミーであることそのものを拒否する必要があります。


6.5 production.py

import os

from .base import *  # noqa: F401,F403
from .base import env_bool, env_list


DEBUG = False

SECRET_KEY = require_secret(
    "DJANGO_SECRET_KEY",
    min_length=50,
)

ALLOWED_HOSTS = env_list(
    "DJANGO_ALLOWED_HOSTS"
)

if not ALLOWED_HOSTS:
    raise RuntimeError(
        "DJANGO_ALLOWED_HOSTS が未設定です。"
    )


CSRF_TRUSTED_ORIGINS = (
    env_list(
        "DJANGO_CSRF_TRUSTED_ORIGINS"
    )
    or [
        f"https://{host}"
        for host in ALLOWED_HOSTS
    ]
)

6.6 PostgreSQL

DATABASES = {
    "default": {
        "ENGINE": (
            "django.db.backends.postgresql"
        ),
        "NAME": require("POSTGRES_DB"),
        "USER": require("POSTGRES_USER"),
        "PASSWORD": require_secret(
            "POSTGRES_PASSWORD",
            min_length=24,
        ),
        "HOST": os.environ.get(
            "POSTGRES_HOST",
            "db",
        ),
        "PORT": os.environ.get(
            "POSTGRES_PORT",
            "5432",
        ),
        "CONN_MAX_AGE": int(
            os.environ.get(
                "POSTGRES_CONN_MAX_AGE",
                "60",
            )
        ),
        "CONN_HEALTH_CHECKS": True,
    }
}

CONN_MAX_AGE はDB接続を一定時間再利用します。

CONN_HEALTH_CHECKS=True にすると、再利用する接続がまだ利用可能か確認できます。


6.7 Redis

REDIS_URL = os.environ.get(
    "REDIS_URL",
    "redis://redis:6379/0",
)

CACHES = {
    "default": {
        "BACKEND": (
            "django.core.cache.backends.redis.RedisCache"
        ),
        "LOCATION": REDIS_URL,
    }
}

Redisを入れる理由は速度だけではありません。

例えばGunicornを3ワーカーにします。

Worker 1
Worker 2
Worker 3

LocMemCache は各Pythonプロセスに独立した辞書を持ちます。

すると、

Worker 1 → ログイン失敗2回
Worker 2 → ログイン失敗2回
Worker 3 → ログイン失敗2回

でも、各ワーカーから見ると上限へ達していません。

共有キャッシュなら、

Worker 1 ─┐
Worker 2 ─┼→ Redis
Worker 3 ─┘

となります。


6.8 allauthのProxy設定も別途必要

このCMSでは独自処理用に、

TRUSTED_PROXY_COUNT = 1

という設定を使っています。

しかしdjango-allauthが、その名前を自動的に読むわけではありません。

allauth側にも設定します。

nginxが1段だけ存在する構成なら、

ALLAUTH_TRUSTED_PROXY_COUNT = int(
    os.environ.get(
        "DJANGO_ALLAUTH_TRUSTED_PROXY_COUNT",
        "1",
    )
)

とします。

.env.example にも追加します。

DJANGO_TRUSTED_PROXY_COUNT=1
DJANGO_ALLAUTH_TRUSTED_PROXY_COUNT=1

役割を分けます。

TRUSTED_PROXY_COUNT
→ このCMS独自のIP判定

ALLAUTH_TRUSTED_PROXY_COUNT
→ django-allauth内部のIP判定

Proxy段数を実際より大きくすると、利用者が追加した偽の X-Forwarded-For 値まで信用する危険があります。

実際の構成と一致させてください。


6.9 cached_dbセッション

SESSION_ENGINE = (
    "django.contrib.sessions.backends.cached_db"
)

cached_db は、

Redis
+
Database

を組み合わせます。

キャッシュが使える間は高速に読み出せます。

DBにもセッションを残すため、Redisだけを永続ストレージとして使う構成より復旧しやすくなります。

なお、この構成ではRedisのAOF永続化は必須ではありません。

Redisが消えてもセッションのDB側コピーは残ります。

Redis AOFは、キャッシュ復旧速度など別の運用要件として判断します。


6.10 HTTPS設定

SECURE_PROXY_SSL_HEADER = (
    "HTTP_X_FORWARDED_PROTO",
    "https",
)

SECURE_SSL_REDIRECT = True

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

nginxやCDNでTLS終端する場合、Django自身へ届く通信はHTTPになることがあります。

そこで、

X-Forwarded-Proto: https

を信頼する構成にします。

ただし、オリジンサーバーへ信頼できるProxy以外から直接アクセスできる構成で、このヘッダーを無条件に信用してはいけません。


6.11 HSTS

最初は短い値から始めます。

SECURE_HSTS_SECONDS = int(
    os.environ.get(
        "DJANGO_SECURE_HSTS_SECONDS",
        "3600",
    )
)

SECURE_HSTS_INCLUDE_SUBDOMAINS = (
    env_bool(
        "DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS",
        False,
    )
)

SECURE_HSTS_PRELOAD = env_bool(
    "DJANGO_SECURE_HSTS_PRELOAD",
    False,
)

推奨する段階は次です。

3600
  ↓
HTTPS全体を確認

604800
  ↓
数日~1週間運用

31536000
  ↓
長期HTTPS運用を確認

includeSubDomains
  ↓
全サブドメインを確認

preload
  ↓
最後に検討

HSTSは「絶対に取り消せない」のか

正確には少し違います。

HTTPS接続が正常に成立している間なら、

Strict-Transport-Security: max-age=0

をHTTPS経由で返して、HSTSポリシーを解除できます。

問題は、

証明書が壊れた
        ↓
HTTPS接続そのものが成立しない
        ↓
max-age=0 を届けられない

ケースです。

この場合、利用者側に残っているHSTSが復旧を難しくします。

また preload は別問題で、ブラウザー側のpreload listから削除されるまで時間が掛かります。

だからこそ段階的に導入します。


6.12 静的ファイル

STORAGES = {
    "default": {
        "BACKEND": (
            "django.core.files.storage.FileSystemStorage"
        ),
    },
    "staticfiles": {
        "BACKEND": (
            "django.contrib.staticfiles.storage."
            "ManifestStaticFilesStorage"
        ),
    },
}

例えば、

site.css

が、

site.4b626d10f0f2.css

のようになります。

内容が変わるとURLも変わるため、長期キャッシュしやすくなります。


6.13 メール

EMAIL_BACKEND = (
    "django.core.mail.backends.smtp.EmailBackend"
)

EMAIL_HOST = require(
    "DJANGO_EMAIL_HOST"
)

EMAIL_PORT = int(
    os.environ.get(
        "DJANGO_EMAIL_PORT",
        "587",
    )
)

EMAIL_HOST_USER = os.environ.get(
    "DJANGO_EMAIL_HOST_USER",
    "",
)

EMAIL_HOST_PASSWORD = os.environ.get(
    "DJANGO_EMAIL_HOST_PASSWORD",
    "",
)

EMAIL_USE_TLS = env_bool(
    "DJANGO_EMAIL_USE_TLS",
    True,
)

EMAIL_TIMEOUT = 10

メール確認・ログインコード・パスワード再設定を使うため、本番でconsole backendを使ってはいけません。


7. livenessとreadinessを分ける

元の /healthz/ はDBへ SELECT 1 する構成でした。

しかしこのCMSではRedisも、

  • キャッシュ
  • allauthレート制限
  • cached_dbセッションのキャッシュ側

として使います。

そこで監視を2種類に分けます。

7.1 liveness

プロセスがHTTPへ応答できるかだけ確認します。

# core/views.py

from django.http import JsonResponse
from django.views.decorators.cache import never_cache


@never_cache
def livez(request):
    return JsonResponse({
        "status": "ok",
    })

7.2 readiness

サービスとして仕事ができるか確認します。

from django.core.cache import cache
from django.db import connection
from django.http import JsonResponse
from django.views.decorators.cache import never_cache


@never_cache
def readyz(request):
    checks = {
        "database": False,
        "redis": False,
    }

    try:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
            cursor.fetchone()

        checks["database"] = True

    except Exception:
        pass

    try:
        probe_key = "healthcheck:redis"

        cache.set(
            probe_key,
            "ok",
            timeout=5,
        )

        checks["redis"] = (
            cache.get(probe_key) == "ok"
        )

    except Exception:
        pass

    if all(checks.values()):
        return JsonResponse({
            "status": "ok",
            **checks,
        })

    return JsonResponse(
        {
            "status": "error",
            **checks,
        },
        status=503,
    )

URLを追加します。

from core.views import livez, readyz


urlpatterns = [
    path(
        "livez/",
        livez,
        name="livez",
    ),
    path(
        "readyz/",
        readyz,
        name="readyz",
    ),

    # ...
]

なぜ分けるのか

Redisだけ停止した場合、

Djangoプロセス
→ 生きている

DB
→ 生きている

Redis
→ 停止

という状態があります。

このとき、

/livez/  → 200
/readyz/ → 503

とできます。


8. Dockerfile

# --- builder -------------------------------------------------

FROM python:3.12-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

RUN apt-get update \
 && apt-get install \
      --no-install-recommends \
      -y \
      build-essential \
      libpq-dev \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /wheels

COPY requirements.txt .

RUN pip wheel \
    --wheel-dir /wheels \
    -r requirements.txt


# --- runtime -------------------------------------------------

FROM python:3.12-slim

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

RUN apt-get update \
 && apt-get install \
      --no-install-recommends \
      -y \
      libpq5 \
 && rm -rf /var/lib/apt/lists/*

RUN useradd \
    --create-home \
    --uid 10001 \
    kururu

WORKDIR /app

COPY --from=builder /wheels /wheels

COPY requirements.txt .

RUN pip install \
    --no-index \
    --find-links=/wheels \
    -r requirements.txt \
 && rm -rf /wheels

COPY --chown=kururu:kururu . .

RUN mkdir -p \
      /app/staticfiles \
      /app/media \
 && chown -R \
      kururu:kururu \
      /app/staticfiles \
      /app/media

USER kururu

EXPOSE 8000

ENTRYPOINT [
    "/app/docker/entrypoint.sh"
]

CMD [
    "gunicorn",
    "config.wsgi:application",
    "--bind",
    "0.0.0.0:8000",
    "--workers",
    "3",
    "--timeout",
    "60",
    "--access-logfile",
    "-",
    "--error-logfile",
    "-"
]

非rootで動かす

USER kururu

を使います。

ただし非root化は「コンテナからホストへの脱出を防ぐ万能対策」ではありません。

コンテナ侵害時に利用できる権限を減らす被害軽減策です。


9. requirements.txtを正しく書く

重要なのは、allauthの依存を自分で手書きしないことです。

Django==5.2.15
argon2-cffi==25.1.0
Pillow==12.3.0

django-allauth[mfa,socialaccount]==65.18.0

psycopg[binary]==3.3.4
redis==8.1.0
gunicorn==26.0.0

以前は、

django-allauth==65.18.0
qrcode==8.2
fido2==2.2.1

と書いていました。

しかしこれでは、

allauthが必要とするsocialaccount依存

まで自分で把握し続ける必要があります。

extrasへ任せます。

django-allauth[mfa,socialaccount]

10. Docker Compose

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped

    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

    volumes:
      - pgdata:/var/lib/postgresql/data

    healthcheck:
      test:
        [
          "CMD-SHELL",
          "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"
        ]
      interval: 5s
      timeout: 3s
      retries: 10


  redis:
    image: redis:8-alpine
    restart: unless-stopped

    volumes:
      - redisdata:/data

    healthcheck:
      test:
        [
          "CMD",
          "redis-cli",
          "ping"
        ]
      interval: 5s
      timeout: 3s
      retries: 10


  web:
    build: .
    restart: unless-stopped

    env_file:
      - .env

    depends_on:
      db:
        condition: service_healthy

      redis:
        condition: service_healthy

    volumes:
      - staticfiles:/app/staticfiles
      - media:/app/media

    healthcheck:
      test:
        [
          "CMD",
          "python",
          "/app/docker/healthcheck.py"
        ]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 40s


  nginx:
    image: nginx:1.29-alpine
    restart: unless-stopped

    depends_on:
      web:
        condition: service_healthy

    ports:
      - "80:80"

    volumes:
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - staticfiles:/var/www/static:ro
      - media:/var/www/media:ro


volumes:
  pgdata:
  redisdata:
  staticfiles:
  media:

service_healthy の意味

ここは重要です。

単純な、

depends_on:
  - db

では、

コンテナ起動

までしか依存関係を表せません。

しかし、

depends_on:
  db:
    condition: service_healthy

とすると、DBのhealthcheckが成功してからwebの起動へ進めます。

したがって、

ComposeはDBが接続可能になるまで絶対に待たない

という説明は正確ではありません。

この構成では service_healthy を使っています。


11. entrypoint.sh

#!/bin/sh

set -e

echo "[entrypoint] production checks..."

python manage.py check \
    --deploy \
    --fail-level WARNING

echo "[entrypoint] migrate..."

python manage.py migrate \
    --noinput

echo "[entrypoint] collectstatic..."

python manage.py collectstatic \
    --noinput

echo "[entrypoint] start: $*"

exec "$@"

DB接続待機ループは必要か

Composeでは、

condition: service_healthy

を使っています。

そのため、Composeだけを前提にするなら、起動前DB待機の多くはCompose側でも担えます。

それでもアプリ側でDB再接続待機を残す設計には、

  • Compose以外からコンテナを起動する
  • DB再起動直後への耐性を持たせる
  • インフラ実装へ完全依存したくない

という意味があります。

ただし、

「Composeが待たないから絶対必要」ではありません。


12. migrateをentrypointで毎回実行することの限界

この連載ではwebコンテナが1つなので、entrypointで、

python manage.py migrate --noinput

を実行しても運用できます。

しかし将来、

web-1
web-2
web-3

へ水平スケールすると、3コンテナが同時にmigrationを開始する可能性があります。

商用構成では、

deploy
  ↓
migration job
  ↓ 成功
web rollout

のように、migrationを1回だけ実行するデプロイ工程へ分離する方が安全です。

10日目では、

1レプリカ教材

という前提でentrypoint方式を使います。


13. nginx

upstream django {
    server web:8000;
}

server {
    listen 80;
    server_name _;

    client_max_body_size 6m;

    server_tokens off;

    location /static/ {
        alias /var/www/static/;

        access_log off;

        expires 1y;

        add_header Cache-Control
            "public, immutable";
    }

    location /media/ {
        alias /var/www/media/;

        access_log off;

        expires 7d;

        add_header
            X-Content-Type-Options
            nosniff;

        add_header
            Content-Security-Policy
            "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'";

        location ~ \.(php|phtml|py|pl|cgi|sh)$ {
            return 404;
        }
    }

    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_redirect off;

        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
    }
}

server_tokens off は何をするのか

これは、

nginx/1.xx.x

のようなバージョン番号露出を減らします。

ただし、

脆弱なnginxを安全にする機能ではありません。

本当の対策は、

  • サポート中バージョンを使う
  • セキュリティ更新を適用する
  • 不要モジュールを減らす
  • 外部露出を限定する

ことです。

server_tokens off は補助策です。


14. media配下について

このCMSは4日目で画像ファイルの実体検証をしています。

さらにnginxでも、

.php
.py
.sh
.cgi

などを拒否します。

ただしnginxの通常構成では、単にファイルが置かれているだけでPythonやPHPが勝手に実行されるわけではありません。

この設定の目的は、

将来FastCGIなどのハンドラを追加したときにもmedia配下を巻き込ませない多層防御

です。


15. ローカルでproduction設定を検証する

production設定は、

SECURE_SSL_REDIRECT = True

です。

しかし10日目にはまだローカルTLS証明書がありません。

そのため検証用nginxだけ、

proxy_set_header X-Forwarded-Proto https;

として、

TLS終端済みProxyから来た

状態を模擬します。

これは本番では絶対に使いません。

HTTPで来たリクエストまでDjangoがHTTPS扱いしてしまうからです。

ローカル検証専用です。


16. Dockerイメージの再現性

Python依存は、

== version

で固定しています。

しかしCompose側は、

postgres:17-alpine
redis:8-alpine
nginx:1.29-alpine

です。

これは同じタグでも後日中身が更新される可能性があります。

より厳密な再現性が必要なら、

image@sha256:...

のdigest固定を検討します。

ただしdigest固定すると、セキュリティ修正版も自動では入らなくなります。

そのため、

自動で変わらない
+
定期的にdigestを更新
+
CIで検証

という運用が必要です。


17. Docker healthcheck

webコンテナからDjangoへ直接アクセスするとnginxを通りません。

そのため、

Host
X-Forwarded-Proto

を監視側で再現します。

import os
import sys
import urllib.request


URL = (
    "http://127.0.0.1:8000/readyz/"
)


def first_allowed_host() -> str:
    hosts = [
        item.strip()
        for item in os.environ.get(
            "DJANGO_ALLOWED_HOSTS",
            "",
        ).split(",")
        if item.strip()
    ]

    if not hosts:
        raise RuntimeError(
            "DJANGO_ALLOWED_HOSTS is empty"
        )

    return hosts[0]


def main() -> int:
    request = urllib.request.Request(
        URL,
        headers={
            "Host": first_allowed_host(),
            "X-Forwarded-Proto": "https",
        },
    )

    try:
        with urllib.request.urlopen(
            request,
            timeout=5,
        ) as response:
            return (
                0
                if response.status == 200
                else 1
            )

    except Exception as exc:
        print(
            f"readiness failed: {exc}",
            file=sys.stderr,
        )

        return 1


if __name__ == "__main__":
    sys.exit(main())

18. バックアップ

バックアップをカスタム形式で取得します。

docker compose exec -T db \
    pg_dump \
    -U "$POSTGRES_USER" \
    -d "$POSTGRES_DB" \
    -Fc \
    > "$DB_DUMP"

-Fc はPostgreSQLのcustom形式です。

pg_restore で扱えます。


18.1 空ファイルを拒否する

if [ ! -s "$DB_DUMP" ]; then
    echo "backup is empty" >&2
    exit 1
fi

成功終了しただけでなく、サイズも確認します。


18.2 ダンプを解析できるか確認する

docker compose exec -T db \
    pg_restore --list \
    < "$DB_DUMP" \
    > /dev/null

ここで重要なのは、

pg_restore --list

ファイル名を省略することです。

標準入力から読み取ります。

次は避けます。

pg_restore --list /dev/stdin

カスタムアーカイブではseekを必要とする処理との組み合わせで問題になる場合があります。


19. 復元訓練

本番DBへ直接復元してはいけません。

専用DBを作ります。

DRILL_DB="kururucms_restore_drill"

本番DBと同名でないことを確認します。

if [ "$DRILL_DB" = "$POSTGRES_DB" ]; then
    echo "refusing production restore" >&2
    exit 1
fi

途中で失敗しても削除します。

cleanup() {
    docker compose exec -T db \
        psql \
        -U "$POSTGRES_USER" \
        -d postgres \
        -c \
        "DROP DATABASE IF EXISTS $DRILL_DB;" \
        > /dev/null
}

trap cleanup EXIT

復元します。

docker compose exec -T db \
    createdb \
    -U "$POSTGRES_USER" \
    "$DRILL_DB"

docker compose exec -T db \
    pg_restore \
    -U "$POSTGRES_USER" \
    -d "$DRILL_DB" \
    --clean \
    --if-exists \
    < "$DB_DUMP"

20. 復元後に確認するもの

最低限、件数を比較します。

articles
users
comments
media

ただし件数一致だけでは完全な検証ではありません。

実務ではさらに、

  • 最新記事を開ける
  • ユーザーと記事の外部キーが正しい
  • Migration状態が整合する
  • ファイルバックアップとDB参照が一致する
  • 管理画面へログインできる

まで確認すると強くなります。


21. 確認方法

Docker Composeを起動する

目的

本番相当の4サービスを起動します。

実行場所

compose.yaml があるディレクトリです。

docker compose up -d --build

正常例

db       healthy
redis    healthy
web      healthy
nginx    Up

異常例

env file .env not found

判断方法

docker compose ps

で確認します。


ローカル検証構成

TLS終端だけを模擬する場合は、

docker compose \
  -f compose.yaml \
  -f compose.local-check.yaml \
  up -d --build

を使います。

このoverrideは本番で使いません。


本番設定チェック

docker compose exec web \
    python manage.py check \
    --deploy

--fail-level WARNING をCIやentrypointで使う場合は、意図的に段階導入しているHSTS警告との扱いを明確にします。


readiness

curl -i http://localhost/readyz/

正常例:

{
  "status": "ok",
  "database": true,
  "redis": true
}

liveness

curl -i http://localhost/livez/

正常例:

{
  "status": "ok"
}

22. 動作確認

コンテナ

  • docker compose up -d --build が成功する

  • db がhealthyになる

  • redisがhealthyになる

  • webがhealthyになる

  • dbの5432番をホストへ公開していない

  • Redisの6379番をホストへ公開していない

  • Djangoの8000番をホストへ公開していない

Django

  • DEBUG=False

  • DB backendがPostgreSQL

  • Cache backendがRedis

  • Session backendがcached_db

  • SMTP backendを使用する

  • collectstatic が成功する

allauth / Proxy

  • CMS独自のProxy段数が実構成と一致する

  • ALLAUTH_TRUSTED_PROXY_COUNT も設定されている

  • 不正な X-Forwarded-For を信用しない

  • ログインレート制限が複数Gunicornワーカーで共有される

秘密情報

  • DJANGO_SECRET_KEY が空でない

  • ダミー値ではない

  • 十分な長さがある

  • POSTGRES_PASSWORD がダミーではない

  • .env がGitへ入っていない

  • Docker build contextから.envを除外している

監視

  • /livez/ が200

  • /readyz/ が200

  • PostgreSQL停止時に /readyz/ が503

  • Redis停止時に /readyz/ が503

  • Redis停止時でも /livez/ 自体は200を返せる

バックアップ

  • DBダンプが0バイトではない

  • pg_restore --list が成功する

  • 壊したダンプを検出できる

  • 空ダンプを検出できる

復元

  • 別DBへ復元できる

  • 本番DBを復元訓練で触らない

  • 記事件数を確認する

  • ユーザー件数を確認する

  • 記事を実際に開ける

  • 訓練終了後にDBを削除する


23. よくあるエラー

23.1 requirements.txtが不足していた

症状

ModuleNotFoundError:
No module named 'requests'

原因

allauthの依存を自分で手書きしていました。

対処

django-allauth[mfa,socialaccount]

を使います。


23.2 「テストが全部通るからrequirementsも正しい」と思う

これは成立しません。

開発PCには、

別のパッケージが依存として入れたもの
過去にpip installしたもの

が残っています。

クリーン環境で確認します。

docker compose build --no-cache

23.3 depends_on は一切待たないと思う

現在のComposeでは、

condition: service_healthy

を指定できます。

この構成でも実際に使っています。

ただし、

DBが一度healthyになった後
↓
再起動

など、サービス稼働中の依存障害までComposeがすべて解決してくれるわけではありません。

アプリ側の再接続設計も必要です。


23.4 Redisが落ちてもhealthcheckが正常だった

DBだけを確認しているのが原因です。

livezreadyz を分け、readinessでRedisも確認します。


23.5 allauthのIPレート制限が想定どおり効かない

独自の、

TRUSTED_PROXY_COUNT

だけを設定してもallauthは読みません。

ALLAUTH_TRUSTED_PROXY_COUNT

も設定します。


23.6 .env.example をコピーしただけで起動した

DJANGO_SECRET_KEY=ここに生成した値を貼る

は空文字ではありません。

require() だけでは通ります。

ダミー値を明示的に拒否します。


23.7 SECURE_SSL_REDIRECT=True なのにブラウザーで開けない

10日目のnginxにはTLS証明書がありません。

これは正常です。

ローカルでは compose.local-check.yaml でTLS終端を模擬します。

本番ではTLS終端を実装してから公開します。


23.8 HSTSは永久に解除できないと思う

有効なHTTPS経由なら、

Strict-Transport-Security: max-age=0

で解除できます。

ただし証明書障害でHTTPS自体が使えないと、解除ヘッダーを届けられません。

preload も別途解除工程が必要です。


23.9 server_tokens off でnginxの脆弱性対策が完了したと思う

バージョン露出が減るだけです。

更新されていないnginxが安全になるわけではありません。


23.10 webコンテナを増やしたらmigrateが同時実行された

1レプリカではentrypoint migrationでも動きます。

水平スケールするならmigrationをデプロイジョブへ分離します。


24. セキュリティ上の注意

24.1 外部へ公開するポートを減らす

この構成では、

nginx

だけをホストへ公開します。

PostgreSQL・Redis・GunicornへはComposeネットワークから接続します。


24.2 Redisをインターネットへ直接公開しない

RedisはこのCMSで、

  • Cache
  • Rate Limit
  • Session cache

を扱います。

認証に関連する情報も含むため、直接公開してはいけません。


24.3 本番秘密情報をDockerイメージへ焼き込まない

.dockerignore へ、

.env
.env.*
!.env.example

などを追加します。

.env.example に本物の秘密情報を書かないことも重要です。


24.4 HSTSは「チェックを消す」だけにしない

段階導入のためDjango標準警告をsilenceする場合は、代わりに、

現在のmax-age
次に上げる値
includeSubDomains状態
preload状態

を独自system checkで表示します。

警告を消しただけでは、単に忘れやすくなります。


24.5 Proxyヘッダーを信頼できる経路だけから受ける

X-Forwarded-ProtoX-Forwarded-For は、ネットワーク設計とセットです。

オリジンサーバーへ直接接続できる利用者が自由にヘッダーを付けられる環境では、安全に信用できません。


25. この構成の弱点・割り切り

10日目の構成にも弱点があります。

1. TLS終端はまだ完成していない

アプリ設定はHTTPS前提ですが、nginxは80番のみです。

実インターネット公開はまだ完成ではありません。

2. Migrationをweb entrypointで実行している

単一webコンテナでは扱いやすい一方、複数replicaではmigration競合の設計が必要になります。

3. Docker imageをdigest固定していない

タグは可読性が高い反面、完全なbit-for-bit再現ではありません。

4. /readyz/ で毎回Redis writeを行う

簡潔ですが、監視頻度が高い大規模環境では専用の軽量ping方式へ変える余地があります。

5. バックアップ先が同一ホストなら災害耐性が弱い

VPS自体を失った場合に備えて、本番では別ストレージへのコピーも必要です。


26. 再発防止

クリーン環境で依存を検査する

CIで、

docker build --no-cache .

を定期的に実行します。

check --deploy を飛ばせない工程へ置く

CI
 ↓
check --deploy
 ↓
migration
 ↓
deploy

とします。

復元訓練を定期実行する

バックアップだけを毎日作って、復元確認を一度もしていない状態を避けます。

例えば、

毎日   バックアップ
毎週   ダンプ検査
毎月   復元訓練

のように分けます。

頻度はサービス要件に合わせて決めます。

本番環境変数を自動検査する

  • 未設定
  • 空文字
  • ダミー値
  • 短すぎる秘密値

を起動前に拒否します。


27. まとめ

10日目では、Django CMSを本番相当の実行構成へ変更しました。

設定分離
   ↓
PostgreSQL
   ↓
Redis
   ↓
Gunicorn
   ↓
nginx
   ↓
Docker Compose
   ↓
Production Security Settings
   ↓
Liveness / Readiness
   ↓
Backup
   ↓
Restore Drill

この中で最も重要なのは、

バックアップを取った

ではありません。

実際に戻せた

です。

また、Dockerを使ったことで、

開発PCでは偶然入っていたrequests

のような依存宣言の不備も発見できました。

クリーン環境で再構築できること自体がテストになります。

ただし10日目ではTLS証明書までは設定していません。

次のデプロイ編で、

  • Linux
  • SSH
  • Firewall
  • nginx
  • TLS
  • Let's Encrypt

を追加して、実際のインターネット公開へ進みます。


28. FAQ

Q1. Redisはセッション保存のために絶対必要ですか?

必須ではありません。

DjangoはDBセッションだけでも動きます。

このCMSでは、複数ワーカー間でキャッシュとallauthレート制限を共有するためRedisを使います。


Q2. Redisが再起動すると全員ログアウトしますか?

cached_db ならDBにもセッションを保存します。

Redisキャッシュが消えても、DBから読み直せます。


Q3. Redis AOFは必須ですか?

cached_db セッション構成だけを理由に必須とは言えません。

Redisをどこまで永続データとして扱うかで判断します。


Q4. HSTSは解除できますか?

正常なHTTPS接続が使えるなら、

max-age=0

をHTTPSで返して解除できます。

ただしHTTPSそのものが証明書障害などで使えなければ、解除指示をブラウザーへ届けられません。


Q5. server_tokens off でセキュリティは上がりますか?

情報露出は減ります。

しかし脆弱なnginxそのものを修正する機能ではありません。

アップデートが必要です。


Q6. depends_on があればDB待機コードはいりませんか?

condition: service_healthy を使えばCompose起動時の依存待ちはできます。

ただし運用中のDB再起動などは別問題です。

アプリ側にも適切な再接続設計が必要です。


Q7. healthcheckはDBだけ見れば十分ですか?

このCMSでは不十分です。

Redisが認証レート制限などにも使われるため、readinessではRedisも確認します。


Q8. Docker Composeだけで本番公開できますか?

構成によります。

この記事の10日目構成はTLS証明書を含まないため、そのままインターネット公開する完成構成ではありません。


Q9. .env.example のダミー値は日本語なら安全ですか?

安全とは限りません。

アプリが「空でない」だけを確認していれば、そのまま本番値として受け入れます。

起動時にダミー値を明示的に拒否してください。


29. 参考情報

Django Deployment Checklist

URL:

https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/

Django Settings

URL:

https://docs.djangoproject.com/en/5.2/ref/settings/

Django Sessions

URL:

https://docs.djangoproject.com/en/5.2/topics/http/sessions/

Django Cache Framework

URL:

https://docs.djangoproject.com/en/5.2/topics/cache/

Docker Compose Services

URL:

https://docs.docker.com/reference/compose-file/services/

PostgreSQL pg_dump

URL:

https://www.postgresql.org/docs/current/app-pgdump.html

PostgreSQL pg_restore

URL:

https://www.postgresql.org/docs/current/app-pgrestore.html

nginx Core Module

URL:

https://nginx.org/en/docs/http/ngx_http_core_module.html

django-allauth Rate Limits

URL:

https://docs.allauth.org/en/latest/common/rate_limits.html

DjangoCMS

URL:

https://github.com/kurumonn/DjangoCMS

day-10

URL:

https://github.com/kurumonn/DjangoCMS/tree/day-10


36. Gitの差分

タグ:

day-10

前日との差分:

git diff day-09 day-10

10日目へ切り替える場合:

git checkout day-10

主な追加内容:

config/settings/
Dockerfile
compose.yaml
compose.local-check.yaml
docker/
core/views.py
core/checks.py
scripts/backup.sh
scripts/restore_drill.sh
.env.example
.dockerignore
requirements.txt

37. 次回予告

これで第1部「10日で作る Django CMS」は終了です。

次からは第2部、

「10日で学ぶ Django 本番デプロイ」

へ進みます。

最初に扱うのはLinuxサーバーの初期設定です。

  • 初回ログイン
  • root運用を避ける
  • 管理ユーザー作成
  • パッケージ更新
  • 時刻設定
  • 不要サービス確認
  • ログ確認

次回:

https://kurutann.com/diary/detail/6242/(公開予定)(公開予定)


連載「10日で作る Django CMS」(第1部)

連載「10日で学ぶ Django 本番デプロイ」(第2部)

  • 11日目: Linuxサーバー初期設定(公開予定)(公開予定)
  • 12日目: SSHを鍵認証だけにする(公開予定)(公開予定)
読んだ内容を10問練習と実技で確認

記事で理解した用語を、StudyQuestの演習とクラウド実技ラボで定着させます。

10問練習 実技ラボ

コメント(0件)

まだコメントはありません。最初のコメントを投稿してください!

コメントを投稿