Django DRF #6 テストとデプロイ — Docker、gunicorn、nginx
Django DRFシリーズの最後 — テストとデプロイ です。#1 ~ #5 で作った API が本当に動作するかを自動で検証し、1 つのコマンドでコンテナをビルドしてプロダクションに上げる流れまで一カ所に整理します。
DRF テスト — APIClient と APITestCase
#
Django の Client (中級 #7 テスト) はフォームエンコードがデフォルトで JSON API のテストにはぎこちないです。DRF の APIClient が JSON をデフォルトで扱い、認証 helper も一緒にくれます。
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.test import APITestCase
User = get_user_model()
class PostAPITests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_authenticate(user=self.user)
def test_create_post(self):
response = self.client.post(
"/api/posts/",
{"title": "最初の記事", "body": "本文の内容"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["title"], "最初の記事")
self.assertEqual(response.data["author"], self.user.id)
def test_list_posts(self):
response = self.client.get("/api/posts/")
self.assertEqual(response.status_code, 200)
self.assertIn("results", response.data) # ページネーションの envelopeAPITestCase の位置づけ
#
APITestCase は Django の TestCase を継承しつつ self.client を APIClient に 差し替えてくれます。DB トランザクションの自動ロールバックもそのまま。
force_authenticate — 認証バイパス
#
テストごとにトークンを作ってヘッダを付けるのは煩わしいです。force_authenticate(user=...) 一行で認証状態を強制 — 認証ミドルウェアをスキップして request.user だけを差し込みます。
def test_with_real_token(self):
response = self.client.post("/api/auth/token/",
{"username": "alice", "password": "pw"}, format="json")
token = response.data["access"]
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# 以後すべてのリクエストに自動でヘッダ追加force_authenticate が速く、credentials は認証フロー自体を検証するとき (ログインテストなど)。
pytest-django — よりモダンな流れ #
unittest ベースの APITestCase も十分ですが、pytest がより軽量で fixture が強力です。
uv add --dev pytest pytest-django pytest-cov[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "mysite.settings"
python_files = ["test_*.py", "*_test.py"]
addopts = "--strict-markers --reuse-db"--reuse-db オプションが核心 — 毎回マイグレーションを再適用しないのでテスト開始が速くなります。マイグレーション自体を変えた後は --create-db で 1 度作り直せば OK。
関数スタイルのテスト #
import pytest
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username="alice", password="pw")
@pytest.fixture
def auth_client(user):
client = APIClient()
client.force_authenticate(user=user)
return client
@pytest.mark.django_db
def test_create_post(auth_client, user):
response = auth_client.post(
"/api/posts/",
{"title": "最初の記事", "body": "本文"},
format="json",
)
assert response.status_code == 201
assert response.data["author"] == user.id
@pytest.mark.django_db
def test_unauthenticated_blocked():
client = APIClient()
response = client.post("/api/posts/", {"title": "x", "body": "y"}, format="json")
assert response.status_code == 401@pytest.mark.django_db が DB アクセスを許可。fixture が依存性注入のように動作 — 同じ fixture が複数のテストに自動で共有。
factory_boy — fixture データ #
テストごとに同じモデルを直接作るのは散らかります。factory_boy がその出番。
uv add --dev factory-boyimport factory
from django.contrib.auth import get_user_model
from blog.models import Post, Comment
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = get_user_model()
username = factory.Sequence(lambda n: f"user{n}")
email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
password = factory.PostGenerationMethodCall("set_password", "pw")
class PostFactory(factory.django.DjangoModelFactory):
class Meta:
model = Post
author = factory.SubFactory(UserFactory)
title = factory.Faker("sentence", nb_words=4)
body = factory.Faker("paragraph", nb_sentences=5)
published = True
class CommentFactory(factory.django.DjangoModelFactory):
class Meta:
model = Comment
post = factory.SubFactory(PostFactory)
author = factory.SubFactory(UserFactory)
body = factory.Faker("sentence")@pytest.mark.django_db
def test_post_with_comments(auth_client):
post = PostFactory()
CommentFactory.create_batch(3, post=post)
response = auth_client.get(f"/api/posts/{post.id}/")
assert response.status_code == 200Sequence— 自動増加カウンタで unique な値LazyAttribute— 他フィールドベースの動的な値SubFactory— FK 自動生成 (なければ新規作成)Faker— フェイクのテキスト / 名前 / メールcreate_batch(N)— N 件を一度に
テストデータのセットアップコードが 宣言的 に短くなります。
レスポンス検証 — status / body / headers #
@pytest.mark.django_db
def test_post_detail_shape(auth_client):
post = PostFactory(title="検証用")
response = auth_client.get(f"/api/posts/{post.id}/")
# status
assert response.status_code == 200
# body
data = response.data
assert data["id"] == post.id
assert data["title"] == "検証用"
assert "author" in data
assert "created_at" in data
# 意図しないフィールド露出の防止
assert "secret_field" not in data
# headers
assert response["Content-Type"].startswith("application/json")API テストは レスポンス構造 の検証が核心です。フィールド名、型、抜けているもの、意図せず露出したもの — クライアントとの contract が壊れていないかを捕まえます。
流れのテスト — シナリオ #
複数のリクエストが絡んだシナリオ:
@pytest.mark.django_db
def test_full_post_lifecycle(auth_client, user):
# Create
create = auth_client.post("/api/posts/",
{"title": "lifecycle", "body": "x"}, format="json")
assert create.status_code == 201
post_id = create.data["id"]
# Read
read = auth_client.get(f"/api/posts/{post_id}/")
assert read.data["published"] is False
# Publish action
publish = auth_client.post(f"/api/posts/{post_id}/publish/")
assert publish.status_code == 200
read2 = auth_client.get(f"/api/posts/{post_id}/")
assert read2.data["published"] is True
# Delete
delete = auth_client.delete(f"/api/posts/{post_id}/")
assert delete.status_code == 204
# Verify
final = auth_client.get(f"/api/posts/{post_id}/")
assert final.status_code == 404権限テスト — 別ユーザー #
@pytest.mark.django_db
def test_other_user_cannot_edit():
owner = UserFactory()
other = UserFactory()
post = PostFactory(author=owner)
client = APIClient()
client.force_authenticate(user=other)
response = client.patch(f"/api/posts/{post.id}/",
{"title": "悪意のある修正"}, format="json")
assert response.status_code == 403#2 の IsOwnerOrReadOnly がきちんと動作するかを検証する回帰テスト。
Celery task のテスト #
#4 の task もテストが必要です。2 つのモード。
1) Eager モード — 同期実行 #
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = Truetask.delay() が呼ばれた瞬間に同期的に実行されます。テストで task が実際に実行されるかを検証するのに良いです。
2) Mocking #
from unittest.mock import patch
@pytest.mark.django_db
def test_publish_triggers_notification(auth_client):
post = PostFactory(author=auth_client.handler._force_user)
with patch("blog.views.notify_subscribers.delay") as mock_task:
response = auth_client.post(f"/api/posts/{post.id}/publish/")
assert response.status_code == 200
mock_task.assert_called_once_with(post.id)task の内部動作ではなく 呼び出し自体 だけを検証するとき。
Schema 回帰テスト #
#5 で見た schema diff をテストで。
import json
from django.core.management import call_command
from io import StringIO
def test_schema_unchanged():
out = StringIO()
call_command("spectacular", "--validate", stdout=out)
current = json.loads(out.getvalue() or "{}")
with open("schema.snapshot.json") as f:
snapshot = json.load(f)
assert current == snapshot, "API schema が変わりました — snapshot 更新が必要"このようなテストがあると、うっかり breaking change をマージしづらくなります。意図した変更なら snapshot を更新する別 PR で。
カバレッジ #
uv run pytest --cov=blog --cov-report=term-missingName Stmts Miss Cover Missing
--------------------------------------------------------
blog/models.py 42 0 100%
blog/serializers.py 28 2 93% 45-46
blog/views.py 67 4 94% 89-92
blog/permissions.py 18 0 100%
blog/tasks.py 23 3 87% 34-36
--------------------------------------------------------
TOTAL 178 9 95%100% が目標ではなく、重要な経路 が検証されているかが目標です。API は統合テストの比重が高く 80~90% が自然に出るところです。
ここまでがテストパート。これからデプロイパートに進みます。
デプロイ — Django + DRF の標準スタック #
運用環境では manage.py runserver を使ってはいけません。標準の組み合わせは:
| レイヤー | ツール | 位置 |
|---|---|---|
| WSGI サーバー | gunicorn (または uWSGI) | Python アプリ実行 |
| リバースプロキシ | nginx (または Caddy、Traefik) | TLS 終端、静的ファイル、ロードバランシング |
| コンテナ | Docker | 環境の一貫性 |
| DB | PostgreSQL | 本データ |
| キャッシュ / キュー | Redis | キャッシュ + Celery ブローカー |
| ワーカー | Celery worker / beat | バックグラウンド作業 (#4) |
上級 #7 デプロイのセキュリティ、中級 #6 Static/Media の運用 のパターンがそのまま入ります。
Dockerfile — マルチステージビルド #
# === 1) ビルドステージ ===
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
# 依存関係だけ先に (キャッシュ効率)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
# コード
COPY . .
# 静的ファイルの収集 (DEBUG=False で collectstatic)
ENV DJANGO_SETTINGS_MODULE=mysite.settings
ENV SECRET_KEY=build-time-dummy
RUN uv run python manage.py collectstatic --noinput
# === 2) ランタイムステージ ===
FROM python:3.13-slim AS runtime
# システムパッケージ (psycopg のような C 依存)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# 非 root ユーザー
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
# ビルダーから venv + コード + 静的ファイルをコピー
COPY --from=builder --chown=appuser:appuser /app /app
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8000
CMD ["gunicorn", "mysite.wsgi", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--access-logfile", "-", \
"--error-logfile", "-"]核心パターン:
- マルチステージ — ビルドツールが最終イメージから外れる
- 依存関係を先、コードを後 — コードだけ変わったら依存関係レイヤーのキャッシュを再利用
- 非 root ユーザー — セキュリティ
--frozen --no-dev—uv.lockのまま、開発依存を除外collectstatic— 静的ファイルを 1 カ所にまとめる (中級 #6)
.dockerignore
#
.venv/
__pycache__/
*.pyc
.pytest_cache/
.git/
.github/
*.md
.env
.env.*
db.sqlite3
media/
node_modules/イメージに入らなくてよいもの。ビルド速度 + セキュリティ。
gunicorn — WSGI サーバー #
gunicorn mysite.wsgi --bind 0.0.0.0:8000 \
--workers 4 \ # CPU コア数 × 2 + 1 程度
--worker-class sync \ # デフォルト (または gthread、gevent)
--timeout 30 \ # ワーカータイムアウト
--max-requests 1000 \ # ワーカー再起動 (メモリリーク防止)
--max-requests-jitter 50 \ # 同時再起動の防止
--access-logfile - \
--error-logfile -ワーカー数の決め方 #
| トラフィック | 推奨 |
|---|---|
| 小トラフィック / 単一マシン | 2 × CPU + 1 |
| 外部 IO が多い | gthread worker、--threads 4-8 |
| 非同期 view が多い | uvicorn (ASGI) に切り替え — --worker-class uvicorn.workers.UvicornWorker |
上級 #1 Async views が多ければ ASGI (uvicorn) に行くのが自然です。
nginx — リバースプロキシ #
nginx が前段で受けて、gunicorn にプロキシします。役割分担:
| レイヤー | 担当 |
|---|---|
| nginx | TLS 終端、静的ファイル配信、gzip、rate limit、ヘッダ調整 |
| gunicorn | Django/DRF 実行 |
upstream django {
server web:8000;
}
server {
listen 80;
server_name api.example.com;
# HTTPS リダイレクトは普通クラウド LB が処理
# 自前で運用するなら letsencrypt + listen 443 ssl
client_max_body_size 10M;
# 静的ファイル — Django ではなく nginx が直接
location /static/ {
alias /app/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /app/media/;
expires 7d;
}
# API は gunicorn にプロキシ
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 60s;
proxy_connect_timeout 5s;
}
}X-Forwarded-Proto と Django
#
nginx が HTTPS 終端をしましたが gunicorn は HTTP で受けます。Django にも「このリクエストは HTTPS」と認識させたければ。
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
ALLOWED_HOSTS = ["api.example.com"]上級 #7 デプロイのセキュリティ のヘッダ設定と一緒に行く必要があります。
docker-compose — フルスタックを一度に #
services:
web:
build: .
command: >
sh -c "python manage.py migrate &&
gunicorn mysite.wsgi --bind 0.0.0.0:8000 --workers 4
--access-logfile - --error-logfile -"
environment:
DJANGO_SETTINGS_MODULE: mysite.settings
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://blog:blog@db:5432/blog
REDIS_URL: redis://redis:6379/0
ALLOWED_HOSTS: api.example.com,localhost
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
- static-files:/app/staticfiles
- media-files:/app/media
expose: ["8000"]
worker:
build: .
command: celery -A mysite worker -l info --concurrency=4
environment:
DJANGO_SETTINGS_MODULE: mysite.settings
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://blog:blog@db:5432/blog
REDIS_URL: redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
beat:
build: .
command: celery -A mysite beat -l info
environment:
DJANGO_SETTINGS_MODULE: mysite.settings
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://blog:blog@db:5432/blog
REDIS_URL: redis://redis:6379/0
depends_on: [db, redis]
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- static-files:/app/staticfiles:ro
- media-files:/app/media:ro
ports: ["80:80"]
depends_on: [web]
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: blog
POSTGRES_USER: blog
POSTGRES_PASSWORD: blog
healthcheck:
test: ["CMD-SHELL", "pg_isready -U blog"]
interval: 5s
timeout: 3s
retries: 5
volumes:
- pg-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
pg-data:
redis-data:
static-files:
media-files:docker compose up -d1 コマンドで web + worker + beat + nginx + db + redis が一緒に立ち上がります。nginx が静的ファイルボリュームを web と共有 — Django の collectstatic の結果を nginx が直接配信。
環境変数 — django-environ
#
コードに秘密を埋め込まないために環境変数で。
uv add django-environimport environ
env = environ.Env(
DEBUG=(bool, False),
ALLOWED_HOSTS=(list, []),
)
environ.Env.read_env()
SECRET_KEY = env("SECRET_KEY")
DEBUG = env("DEBUG")
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
DATABASES = {"default": env.db()} # DATABASE_URL=postgres://...
CACHES = {"default": env.cache()} # REDIS_URL=redis://...
CELERY_BROKER_URL = env("REDIS_URL")
CELERY_RESULT_BACKEND = env("REDIS_URL").env ファイルは git から除外、プロダクションはシークレットマネージャ (AWS Secrets Manager / GCP Secret Manager / Doppler など) またはプラットフォームの環境変数。
マイグレーション — デプロイ時点で自動化 #
最もよくある 2 パターン。
オプション 1 — コンテナ起動時に適用 #
#!/usr/bin/env bash
set -e
# DB が ready になるまで待機 (compose の healthcheck と一緒に)
python manage.py migrate --noinput
# 静的ファイル (ビルド時点でやったが更新用)
python manage.py collectstatic --noinput
exec "$@"COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "mysite.wsgi", "--bind", "0.0.0.0:8000", "--workers", "4"]小さなサービスはこのパターンがシンプルです。短所 — 複数のインスタンスが同時にマイグレーションを試行する可能性があります。
オプション 2 — CI/CD ステージで分離 #
- name: Run migrations
run: |
docker run --rm \
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
${{ env.IMAGE }} \
python manage.py migrate --noinput
- name: Deploy
run: |
# 新しいイメージで web/worker をローリングアップデート大きなサービスはオプション 2 が安全です — マイグレーションが 1 度だけ実行され、失敗時にデプロイが中断。
ヘルスチェック #
LB / オーケストレータがコンテナの状態を知る必要があります。
from django.db import connection
from django.http import JsonResponse
def health(request):
return JsonResponse({"status": "ok"})
def ready(request):
"""DB / Redis まで正常で ready。"""
try:
with connection.cursor() as cur:
cur.execute("SELECT 1")
except Exception as exc:
return JsonResponse({"status": "db_down", "error": str(exc)}, status=503)
return JsonResponse({"status": "ready"})urlpatterns = [
path("health/", health),
path("ready/", ready),
...
]/health/— liveness — プロセスが生きているか (失敗時に再起動)/ready/— readiness — トラフィックを受ける準備ができたか (失敗時に LB から除外)
Kubernetes / Fargate / クラウド LB がこの 2 つのエンドポイントを定期的に呼び出します。
デプロイ先 — 軽量な選択肢 #
| 選択肢 | |
|---|---|
| Railway | 最も軽量。GitHub 接続 → 自動デプロイ。Postgres/Redis 自動プロビジョニング。素早いプロトタイプに最適。 |
| Fly.io | グローバル分散。flyctl CLI でデプロイ。fly.toml 1 ファイル。 |
| Render | Heroku の代替。render.yaml で web/worker/cron を定義。 |
| AWS ECS Fargate | 大企業の標準。ECR + ECS + ALB + RDS + ElastiCache。運用負担あり。 |
| GCP Cloud Run | コンテナサーバーレス。トラフィック 0 のとき費用 0。大きなメモリ / 長い作業には不適。 |
| 自前 VPS | DigitalOcean / Hetzner。Docker Compose で一度に。最も安く、運用負担が大きい。 |
小さなサイドプロジェクトは Railway / Fly、会社のサービスは AWS / GCP がよくある選択です。
プロダクションチェックリスト #
デプロイ前の確認 — 上級 #7 のセキュリティチェックリストと一緒に。
-
DEBUG=False -
SECRET_KEY— 環境変数、64+ バイトのランダム -
ALLOWED_HOSTS— 明示的なドメイン (ワイルドカード X) - HTTPS の強制 (
SECURE_SSL_REDIRECT、HSTS ヘッダ) - CORS — 明示的な origin (
*X) - DB パスワード — 強いランダム
- JWT
SIGNING_KEY— 別途の強いキー - ログレベル — INFO 以上
- Sentry — エラー追跡
- ヘルスチェック —
/health、/ready - マイグレーション — 自動化
- バックアップ — DB の自動バックアップを有効化
- モニタリング — APM (DataDog / New Relic / Grafana)
- rate limiting — DRF throttling + nginx
- 静的ファイル — nginx または CDN
- メディアファイル — S3 / GCS (django-storages)
このチェックリストは出発点で、トラフィックが増えるとさらに追加されます。
シリーズの振り返り #
6 編を経て Django DRF — DRF の流れが完成しました。
| # | 扱ったもの |
|---|---|
| 1 | DRF はじめ方 — Serializer、ModelSerializer、ViewSet、Router |
| 2 | 認証 / 権限 — Token、JWT (simplejwt)、permission classes、IsOwner |
| 3 | Filtering / Ordering / Pagination (PageNumber/LimitOffset/Cursor) |
| 4 | Celery — task、retry、Beat、Flower、transaction.on_commit |
| 5 | drf-spectacular — OpenAPI、Swagger UI、schema 回帰テスト |
| 6 | テスト (APITestCase、pytest-django、factory_boy) + Docker/gunicorn/nginx |
ブログ API という 1 つのドメインを掴んで段階的に積み上げて — モデル → API → 認証 → フィルタ / ページネーション → 非同期 → ドキュメント → テスト / デプロイ — が一カ所に集まりました。
Django トラックの振り返り (4 シリーズ / 27 編) #
| シリーズ | 編数 | 核心 |
|---|---|---|
| Django 基礎 | 7 | セットアップ、ORM、URL/View、Template、Form、Admin/Auth |
| Django 中級 | 7 | CBV、ORM 中級、Signals、ユーザー / 権限、メッセージ / セッション、Static、テスト |
| Django 上級 | 7 | Async/ASGI、カスタムコマンド、クエリ最適化、キャッシング、トランザクション、Channels、デプロイのセキュリティ |
| Django DRF (DRF) | 6 | DRF フルスタック |
このトラックを最初の 基礎 #1 Django とは に戻って見ると、最初に見たときとは違う視点で同じ記事が再び読めます — 1 つのモデルを作る瞬間、その後ろに ORM 最適化、キャッシュ、シグナル、async、デプロイまでつながる流れが見えるようになります。それが 1 つのトラックを最後までたどった収穫です。
次の学習方向 #
- Django Channels の深さ (上級 #6 で開始) — WebSocket、リアルタイム通知、presence
- DRF + GraphQL — Strawberry / Graphene で GraphQL ゲートウェイ
- マイクロサービス vs モノリシック — Django モノリシック + 一部 FastAPI マイクロサービス (FastAPI トラック と比較)
- DataOps / ML 統合 — Celery + scikit-learn / LangChain
- パフォーマンス — Django プロファイリング、Postgres の深さ、分散キャッシュ
- 運用 — Kubernetes デプロイ、observability (OpenTelemetry)、GitOps
それぞれの方向は別トラックで扱う価値のある領域です。
長いトラックに付いてきてくださってありがとうございます。コードを直接打ち込みながらこのシリーズのパターンが自分のプロジェクトにどう当てはまるかを 1 度移してみると、文章で読んだものが手に馴染む瞬間が来ます — そこでお会いしましょう。