Docker 実戦 #2 Django + PostgreSQL compose — 二つのコンテナを一束に

読了 8分

#1 でコンテナ 一個 を整えたとすれば、この記事は 二個 を一束で立ち上げる段階です。最も典型的な組み合わせ — ウェブアプリと DB。

Docker 実戦 でこの記事の位置:

  • #1 FastAPI コンテナ化
  • #2 Django + PostgreSQL compose — 二つのコンテナを一束に ← この記事
  • #3 React/Next.js ビルドコンテナ
  • #4 CI でのイメージビルド — GitHub Actions
  • #5 レジストリ push とタグ戦略
  • #6 クラウドデプロイ — Fly.io / Railway / ECS

この記事で扱うのは Django 基礎シリーズ で登場するような標準的な Django プロジェクトです。核心は Django よりも compose の構成 なので、モデル・ビューは最小限にとどめます。

なぜ compose か #

docker run だけでも二つのコンテナを立ち上げられます。しかし以下を一行ずつ手でやることになります。

compose なし — 手動で
docker network create app-net
docker run -d --name db --network app-net \
    -e POSTGRES_PASSWORD=secret \
    -v pg-data:/var/lib/postgresql/data \
    postgres:17
docker run -d --name web --network app-net \
    -e DATABASE_URL=postgres://postgres:secret@db:5432/postgres \
    -p 8000:8000 \
    myapp

ここに healthcheck、開始順序、自動マイグレーション、環境変数の整理まで重ねるとシェルスクリプトになります。compose がその役割を引き受けます。

compose の絵 — 一ファイルで
services:
  db:    # postgres コンテナ
  web:   # django コンテナ
volumes:
  pg-data:    # db データ永続

この記事ではこの絵に肉付けしていきます。

出発点 — Django プロジェクト #

新しいプロジェクトを作って PostgreSQL ドライバまで入れます。

プロジェクトセットアップ
uv init blog-docker
cd blog-docker
uv add django 'psycopg[binary]' gunicorn
uv run django-admin startproject blog .
uv run python manage.py startapp posts

blog/settings.py の DB 設定だけ環境変数で外に出します。

blog/settings.py 一部
import os
from pathlib import Path

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

DEBUG = os.getenv("DJANGO_DEBUG", "0") == "1"
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only")
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",")

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.getenv("POSTGRES_DB", "blog"),
        "USER": os.getenv("POSTGRES_USER", "blog"),
        "PASSWORD": os.getenv("POSTGRES_PASSWORD", ""),
        "HOST": os.getenv("POSTGRES_HOST", "db"),
        "PORT": os.getenv("POSTGRES_PORT", "5432"),
    }
}

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

ホストがデフォルト値 db という点が大事。compose で自分のサービス名が db なら、同じネットワークの他のコンテナから db という名前でアクセス可能。(基礎 #4 ネットワーク 参考。)

Django 用 Dockerfile #

#1 で作った FastAPI Dockerfile の構造をほぼそのまま持ってきます。違うのは gunicorn で立ち上げ、マイグレーションと collectstatic を entrypoint で処理する点。

Dockerfile
FROM python:3.14-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .
RUN uv sync --frozen --no-dev


FROM python:3.14-slim AS runtime

RUN groupadd --system app && useradd --system --gid app --no-create-home app

WORKDIR /app
COPY --from=builder --chown=app:app /app /app
COPY --chown=app:app docker-entrypoint.sh /usr/local/bin/

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=blog.settings

USER app
EXPOSE 8000

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["gunicorn", "blog.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

核心は ENTRYPOINT + CMD の分業です。

  • ENTRYPOINT — コンテナが何をしようと 常に最初に回る 部分。マイグレーション・collectstatic のようなブート作業をここに入れます。
  • CMD — entrypoint が最後に exec で渡す本コマンド。運用時は gunicorn、開発時は runserver のように変えられます。
docker-entrypoint.sh
#!/bin/sh
set -e

echo ">>> applying migrations"
python manage.py migrate --noinput

if [ "${DJANGO_COLLECTSTATIC:-1}" = "1" ]; then
    echo ">>> collecting static files"
    python manage.py collectstatic --noinput
fi

echo ">>> starting: $*"
exec "$@"

exec "$@" が核心。exec なしで普通に実行すると entrypoint シェルが PID 1 を持って、本プロセスはその子になります。SIGTERM のような信号が本プロセスに伝わりません。(上級 #6 PID 1 で扱った話です。) exec はシェル自身を本プロセスに 置き換え て PID 1 を渡します。

スクリプトには実行権限が必要です。

権限付与
chmod +x docker-entrypoint.sh

ビルドコンテキストで整理しておく項目。

.dockerignore
.git
.venv
__pycache__/
*.pyc
.env
.env.*
db.sqlite3
staticfiles
node_modules

compose ファイル — 最初の骨格 #

いよいよ compose.yaml。ファイル名は新規プロジェクトでは compose.yaml が推奨名 (docker-compose.yml も互換)。

compose.yaml — 最初のバージョン
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
    volumes:
      - pg-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  web:
    build: .
    environment:
      DJANGO_DEBUG: "1"
      DJANGO_SECRET_KEY: dev-only
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
      POSTGRES_HOST: db
    ports:
      - "8000:8000"
    depends_on:
      - db

volumes:
  pg-data:

立ち上げてみます。

compose 起動
docker compose up --build

最初の実行では:

  1. db イメージを取得してコンテナを起動 — 空のデータベース初期化に数秒。
  2. web イメージをビルド (Dockerfile 使用)。
  3. web コンテナの entrypoint がマイグレーションを試みる。
  4. ところが十中八九 — DB がまだ接続を受けられずマイグレーションが壊れます。

ここが次の節の論点です。

depends_on だけでは足りない — healthcheck が必要 #

depends_on: [db] は単に コンテナ開始順序 だけを保証します。db コンテナが立ち上がったことと、db が接続を受ける準備ができたことは別。Postgres は pg_ctl start の後も数秒間初期化が進行します。

解決は dbhealthcheck を付けて、webdepends_on をその healthcheck に紐付けること。(中級 #4 compose 深掘り で扱った話です。)

compose.yaml — healthcheck で結ぶ
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: secret
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U blog -d blog"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 5s

  web:
    build: .
    env_file: .env
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

これで webdb の health が healthy になる前は起動もしません。最初の起動でのマイグレーション衝突が消えます。

pg_isready は Postgres イメージにデフォルトで含まれるコマンドで、「接続を受ける準備ができたか」を尋ねる軽量なチェックです。

.env で秘密を分離 #

上の compose にはパスワードが平文で刻まれています。compose は env_file または変数置換で外部ファイルを読めます。

.env (gitignore 対象)
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=dev-only-change-in-prod
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=secret
POSTGRES_HOST=db
compose.yaml — env_file 使用
services:
  db:
    image: postgres:17
    env_file: .env
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 3s
      retries: 10

  web:
    build: .
    env_file: .env
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pg-data:

$$POSTGRES_USER$$ は compose の変数置換を回避してシェル変数としてそのまま渡すためです。compose が $VAR を自前で置換するので、コンテナ内部のシェルに渡すには一度エスケープします。

.env.gitignore に必ず入れて、リポジトリには .env.example だけが入るように。

.env.example
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=
POSTGRES_HOST=db

これは 中級 #5 環境変数と secrets で深く扱ったパターンで、実戦でも全く同じ構図です。

データ永続化 — named volume #

db サービスの volumes: - pg-data:/var/lib/postgresql/data が核心。抜くとコンテナを下ろすたびに DB が初期化されます。

pg-data は named volume で Docker が管理する領域に保存されます。docker volume ls で確認できます。

ボリューム確認
docker volume ls
# DRIVER    VOLUME NAME
# local     blog-docker_pg-data

docker volume inspect blog-docker_pg-data
# Mountpoint、CreatedAt など

ホストディレクトリに直接マウント (./data:/var/lib/postgresql/data) もできますが、お勧めしません。macOS/Windows ではホストファイルシステムとコンテナの間の IO が遅く、権限衝突が起きやすい。データは named volume、コードは bind mount — が自然な分業です。

開発モード — コード変更を即時反映 #

上の compose は web イメージをビルドして立ち上げます。コードを修正するには毎回 docker compose up --build をやり直す必要があります。開発の流れには合いません。

override ファイルを置けば開発時にだけ違う動作を適用できます。

compose.override.yaml — 開発専用
services:
  web:
    volumes:
      - .:/app             # ホストのコードをコンテナにマウント
      - /app/.venv         # しかし venv はコンテナのものをそのまま
    command:
      ["python", "manage.py", "runserver", "0.0.0.0:8000"]

docker compose up だけで compose.yaml + compose.override.yaml が自動マージされます。CI / デプロイでは override がないので運用モード (gunicorn) のまま。

/app/.venv を空のボリュームにしたトリックは、ホストの .venv (または venv 不在) がコンテナの venv を上書きしないように防ぎます。

profiles で管理ツールを分離 #

運用コマンド (makemigrationsshelldbshell) は普段は立ち上げないのが良い。profiles を使えば明示的に呼ぶときだけ動きます。

compose.yaml — profiles
services:
  # ... db, web ...

  manage:
    build: .
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    profiles: ["tools"]
    entrypoint: ["python", "manage.py"]
管理コマンドを呼ぶ
docker compose run --rm manage migrate
docker compose run --rm manage createsuperuser
docker compose run --rm manage shell

--rm はコマンド終了後にコンテナを自動整理。profiles に入っているサービスは docker compose up で自動開始されません — --profile tools を明示しなければ立ち上がりません。普段は一回限りで呼ぶ流れなので、わざわざ立ち上げる必要がありません。

collectstatic と静的ファイル #

docker-entrypoint.shcollectstatic を毎ブートで回すのが綺麗ですが、コンテナが立ち上がるたびに数千ファイルを動かすのが負担になることもあります。二つの分かれ道。

  • ブート時に実行 — ファイルはイメージに入らず、コンテナが立ち上がりながら作られる。水平拡張時に各コンテナが同じ作業を繰り返す。
  • ビルド時に実行 — Dockerfile の RUN python manage.py collectstatic でイメージに刻む。すると起動が速くなり、全コンテナが同じファイルを持つ。

運用では普通後者の方が綺麗。ただしビルド時に SECRET_KEY のようなものが必要ないように collectstatic 用に settings を分ける必要があるかもしれません。この記事のシンプル設定では entrypoint で回すので十分。

実行とよくある落とし穴 #

docker compose up 一回で終わり。

起動 / 停止
docker compose up -d        # バックグラウンド
docker compose logs -f web  # ログ追跡
docker compose ps           # 状態確認
docker compose down         # 停止 + コンテナ / ネットワーク削除 (ボリュームは残る)
docker compose down -v      # ボリュームまで削除 (注意)

よく出会う問題:

connection refused — db ホストweb の環境変数で POSTGRES_HOST=localhost と誤って設定されているか、POSTGRES_HOST=db が正しく渡っていないケースです。コンテナの中の localhost はコンテナ自身を指します。

psycopg / OperationalError が最初の起動で発生 — healthcheck が抜けたり短すぎる。start_period を 5~10 秒くらい置いてください。

マイグレーションが二度回るように見えるweb コンテナを複数で水平拡張すると entrypoint が各々回ります。マイグレーションは冪等なので同時実行で大きな問題はないですが、運用では別の one-shot job に分けるのが定石。

compose.override.yaml のコードが反映されない — Docker Desktop のファイル共有設定で該当ディレクトリが外れている可能性。Settings → Resources → File Sharing 確認。

docker compose down でデータが消えたdown だけでは named volume は消えません。消えたなら down -v を使ったということ。運用では絶対 -v 禁止。

まとめ #

  • compose は複数コンテナの 関係 を一ファイルで宣言。servicesvolumesnetworks が骨組み。
  • DB のような外部依存は healthcheck + condition: service_healthy で結ばないと最初の起動が安定しません。
  • 秘密は .env に出して、リポジトリには .env.example だけ。compose が env_file で読む。
  • データは named volume で永続化。host bind mount はコード用だけ。
  • ENTRYPOINT にマイグレーション / collectstatic のようなブート作業を入れるが、exec "$@" で PID 1 を本プロセスに渡す。
  • 開発モードは compose.override.yaml で分離。コードは bind mount、venv はコンテナのもの維持。
  • 管理コマンド (makemigrationsshell) は profiles + docker compose run --rm で一回限り呼び出し。

次の記事 (#3 React/Next.js ビルドコンテナ) ではバックエンドを離れて フロントエンド に移ります。Next.js の standalone 出力、deps → build → runner の三 stage、NEXT_PUBLIC 環境変数のビルド/ランタイムの違い、静的ホスティングオプションまで見ます。

X