도커 실전 강좌 #2 Django + PostgreSQL compose — 두 컨테이너 한 묶음
#1에서 컨테이너 한 개를 다듬었다면, 이번 글은 두 개를 한 묶음으로 띄우는 단계입니다. 가장 흔한 조합 — 웹 앱과 DB.
도커 실전 강좌에서 이번 글의 위치:
- #1 FastAPI 컨테이너화
- #2 Django + PostgreSQL compose — 두 컨테이너 한 묶음 ← 이번 글
- #3 React/Next.js 빌드 컨테이너
- #4 CI에서 이미지 빌드 — GitHub Actions
- #5 레지스트리 푸시와 태그 전략
- #6 클라우드 배포 — Fly.io / Railway / ECS
이번 글의 도메인은 장고 기초 시리즈에서 다루는 표준 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 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 세 단계 구성, NEXT_PUBLIC 환경변수의 빌드/런타임 차이, 정적 호스팅 옵션까지 정리합니다.