Docker 実戦 #2 Django + PostgreSQL compose — 二つのコンテナを一束に
#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 だけでも二つのコンテナを立ち上げられます。しかし以下を一行ずつ手でやることになります。
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 がその役割を引き受けます。
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 postsblog/settings.py の DB 設定だけ環境変数で外に出します。
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 で処理する点。
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のように変えられます。
#!/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ビルドコンテキストで整理しておく項目。
.git
.venv
__pycache__/
*.pyc
.env
.env.*
db.sqlite3
staticfiles
node_modulescompose ファイル — 最初の骨格 #
いよいよ compose.yaml。ファイル名は新規プロジェクトでは compose.yaml が推奨名 (docker-compose.yml も互換)。
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:立ち上げてみます。
docker compose up --build最初の実行では:
dbイメージを取得してコンテナを起動 — 空のデータベース初期化に数秒。webイメージをビルド (Dockerfile 使用)。webコンテナの entrypoint がマイグレーションを試みる。- ところが十中八九 — DB がまだ接続を受けられずマイグレーションが壊れます。
ここが次の節の論点です。
depends_on だけでは足りない — healthcheck が必要
#
depends_on: [db] は単に コンテナ開始順序 だけを保証します。db コンテナが立ち上がったことと、db が接続を受ける準備ができたことは別。Postgres は pg_ctl start の後も数秒間初期化が進行します。
解決は db に healthcheck を付けて、web の depends_on をその healthcheck に紐付けること。(中級 #4 compose 深掘り で扱った話です。)
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これで web は db の health が healthy になる前は起動もしません。最初の起動でのマイグレーション衝突が消えます。
pg_isready は Postgres イメージにデフォルトで含まれるコマンドで、「接続を受ける準備ができたか」を尋ねる軽量なチェックです。
.env で秘密を分離
#
上の compose にはパスワードが平文で刻まれています。compose は env_file または変数置換で外部ファイルを読めます。
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=dev-only-change-in-prod
POSTGRES_DB=blog
POSTGRES_USER=blog
POSTGRES_PASSWORD=secret
POSTGRES_HOST=dbservices:
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 だけが入るように。
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 ファイルを置けば開発時にだけ違う動作を適用できます。
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 で管理ツールを分離 #
運用コマンド (makemigrations、shell、dbshell) は普段は立ち上げないのが良い。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.sh で collectstatic を毎ブートで回すのが綺麗ですが、コンテナが立ち上がるたびに数千ファイルを動かすのが負担になることもあります。二つの分かれ道。
- ブート時に実行 — ファイルはイメージに入らず、コンテナが立ち上がりながら作られる。水平拡張時に各コンテナが同じ作業を繰り返す。
- ビルド時に実行 — 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 は複数コンテナの 関係 を一ファイルで宣言。
services、volumes、networksが骨組み。 - 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 はコンテナのもの維持。 - 管理コマンド (
makemigrations、shell) はprofiles+docker compose run --rmで一回限り呼び出し。
次の記事 (#3 React/Next.js ビルドコンテナ) ではバックエンドを離れて フロントエンド に移ります。Next.js の standalone 出力、deps → build → runner の三 stage、NEXT_PUBLIC 環境変数のビルド/ランタイムの違い、静的ホスティングオプションまで見ます。