連載「10日で作る Django CMS」の10日目です。
成果物: https://github.com/kurumonn/DjangoCMS
対応タグ:day-109日目までで、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 = Truenginxや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=FalseDB backendがPostgreSQL
Cache backendがRedis
Session backendが
cached_dbSMTP 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/が200PostgreSQL停止時に
/readyz/が503Redis停止時に
/readyz/が503Redis停止時でも
/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-cache23.3
depends_onは一切待たないと思う現在のComposeでは、
condition: service_healthyを指定できます。
この構成でも実際に使っています。
ただし、
DBが一度healthyになった後 ↓ 再起動など、サービス稼働中の依存障害までComposeがすべて解決してくれるわけではありません。
アプリ側の再接続設計も必要です。
23.4 Redisが落ちてもhealthcheckが正常だった
DBだけを確認しているのが原因です。
livezとreadyzを分け、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-ProtoやX-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-1010日目へ切り替える場合:
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.txt37. 次回予告
これで第1部「10日で作る Django CMS」は終了です。
次からは第2部、
「10日で学ぶ Django 本番デプロイ」
へ進みます。
最初に扱うのはLinuxサーバーの初期設定です。
- 初回ログイン
- root運用を避ける
- 管理ユーザー作成
- パッケージ更新
- 時刻設定
- 不要サービス確認
- ログ確認
次回:
https://kurutann.com/diary/detail/6242/(公開予定)(公開予定)
連載「10日で作る Django CMS」(第1部)
- 1日目: 環境構築とカスタムユーザー
- 2日目: モデルとリレーション入門
- 3日目: CRUDと権限設計
- 4日目: 画像・コメント・検索
- 5日目: 下書き・予約投稿・履歴
- 6日目: SEO・OGP・サイトマップ
- 7日目: 管理画面とブロックエディター
- 8日目: allauthとワンタイムコード
- 9日目: パスキーとTOTPの多要素認証
- 10日目: Docker・PostgreSQL・本番設定(この記事)
連載「10日で学ぶ Django 本番デプロイ」(第2部)
- 11日目: Linuxサーバー初期設定(公開予定)(公開予定)
- 12日目: SSHを鍵認証だけにする(公開予定)(公開予定)
コメント(0件)
まだコメントはありません。最初のコメントを投稿してください!