도커 실전 강좌 #1 FastAPI 컨테이너화 — uv,멀티스테이지,non-root
도커 트랙의 마지막 시리즈 — 실전 입니다. 기초,중급,고급 18편에서 다진 도구를 한 프로젝트씩에 적용해 보면서, 실제로 배포에 올릴 만한 모습으로 다듬습니다.
이번 시리즈는 도커 실전 6편입니다.
- #1 FastAPI 컨테이너화 — uv,멀티스테이지,non-root ← 이번 글
- #2 Django + PostgreSQL compose 셋업
- #3 React/Next.js 빌드 컨테이너
- #4 CI에서 이미지 빌드 — GitHub Actions
- #5 레지스트리 푸시와 태그 전략
- #6 클라우드 배포 — Fly.io / Railway / ECS
이번 글은 FastAPI 앱을 컨테이너로 묶는 흐름을 처음부터 끝까지 따라갑니다. 시작은 가장 단순한 Dockerfile, 끝은 멀티스테이지 + non-root + HEALTHCHECK가 붙은 운영 친화적인 이미지입니다. 모던 파이썬 실전 — FastAPI 시리즈의 코드를 직접 짚지 않더라도 따라올 수 있도록, 최소한의 앱을 새로 짭니다.
출발점 — 작은 FastAPI 앱 #
먼저 컨테이너화할 앱을 손에 쥡니다. uv로 새 프로젝트를 만들고 FastAPI와 uvicorn만 깝니다.
uv init fastapi-docker-demo
cd fastapi-docker-demo
uv add fastapi 'uvicorn[standard]'그리고 app/main.py 한 파일.
from fastapi import FastAPI
app = FastAPI(title="docker demo")
@app.get("/")
def root() -> dict[str, str]:
return {"message": "hello from container"}
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}/healthz는 일부러 분리했습니다. HEALTHCHECK가 호출하는 엔드포인트이자, 클라우드 LB가 보는 엔드포인트입니다.
로컬에서 한 번 띄워봐서 동작을 확인해 두면 컨테이너에서 막힐 때 비교가 쉬워집니다.
uv run uvicorn app.main:app --reload
# http://127.0.0.1:8000/ → {"message":"hello from container"}가장 단순한 Dockerfile — 시작점 #
먼저 운영 친화 같은 건 모두 잊고, 한 번에 돌아가는 가장 짧은 Dockerfile을 먼저 만들어 봅니다.
FROM python:3.14-slim
WORKDIR /app
COPY . .
RUN pip install fastapi 'uvicorn[standard]'
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]빌드 → 실행 → 응답 확인.
docker build -t fastapi-demo .
docker run -p 8000:8000 fastapi-demo
# 다른 터미널에서
curl localhost:8000/
# {"message":"hello from container"}여기서 짚어둘 두 가지.
- **
--host 0.0.0.0**이 빠지면 컨테이너 안의 127.0.0.1에만 묶여서 호스트에서 접근이 안 됩니다. 컨테이너에서 외부로 열어줄 땐 항상 0.0.0.0으로 지정해야 합니다. - 포트 매핑
-p 8000:8000은 “호스트의 8000 → 컨테이너의 8000”.EXPOSE 8000만으로는 자동 매핑되지 않습니다 (기초 #4 참고).
이 이미지로도 동작은 합니다. 그러나 운영에 올리기엔 세 가지가 거슬립니다.
pip install이Dockerfile안에 적혀 있어서 의존성이 코드와 동기화되지 않음- 빌드 도구(
gcc같은 게 깔리진 않았지만, 캐시 효율이 나쁨)와 런타임이 한 레이어에 섞임 - 컨테이너가 root로 돌고 있고 헬스체크가 없음
하나씩 잡아 갑니다.
uv로 의존성 잠그기 — uv.lock 활용
#
uv init이 이미 pyproject.toml과 uv.lock을 만들어 줬습니다. 이걸 컨테이너 빌드에 그대로 가져가면 의존성이 자연스럽게 잠깁니다.
fastapi-docker-demo/
├── app/
│ └── main.py
├── pyproject.toml
├── uv.lock
└── Dockerfileuv는 도커 안에서도 잘 동작합니다. 공식 이미지(ghcr.io/astral-sh/uv)도 있지만, 여기서는 python:3.14-slim 위에 uv 바이너리만 복사해서 쓰는 작은 트릭을 씁니다 — 베이스가 익숙한 파이썬 이미지라 디버깅이 쉽습니다.
FROM python:3.14-slim
# uv 바이너리만 복사
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
# 1) 의존성 정의만 먼저 복사 → 캐시 효율
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
# 2) 그다음 코드
COPY . .
RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]핵심은 두 단계로 나눠서 COPY와 RUN을 배치한 것입니다.
- 먼저
pyproject.toml+uv.lock만 복사하고 의존성을 설치 — 이 레이어는 의존성이 안 바뀌면 캐시됩니다. - 그 다음 코드를 복사 — 코드만 바뀌면 의존성 설치 단계는 캐시에서 그대로.
이 순서를 뒤집으면 코드 한 줄만 바뀌어도 매번 uv sync가 다시 돕니다. 빌드 시간이 분 단위로 길어집니다. 이 문제는 중급 #2 빌드 캐시에서 깊이 다룬 주제이며, 실전에서도 같은 원리가 적용됩니다.
--no-dev는 dev 의존성(테스트, 린터)을 제외하는 옵션이고, --frozen은 락 파일과 정확히 일치하는 버전을 쓰겠다는 뜻입니다. CI/프로덕션에선 항상 켜둡니다.
멀티스테이지로 슬리밍 #
지금은 uv sync까지 한 이미지가 그대로 런타임이 됩니다. 빌드 시 캐시(~/.cache/uv)도 같이 들어가서 이미지가 불필요하게 큽니다. 멀티스테이지로 빌드와 런타임을 분리하면 깔끔해집니다. (중급 #1의 패턴.)
# ─── 1. builder ─────────────────────────────
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
# ─── 2. runtime ─────────────────────────────
FROM python:3.14-slim AS runtime
WORKDIR /app
# builder에서 만들어진 venv와 코드만 가져온다
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]앞 버전과 달라진 점을 짚으면 다음과 같습니다.
- 두 stage 모두 같은 베이스(
python:3.14-slim)를 씁니다. venv 안의 파이썬 인터프리터가 가리키는 경로가 같아야 하기 때문입니다. 베이스가 다르면 venv가 깨질 수 있습니다. UV_LINK_MODE=copy— uv가 캐시에서 venv로 hardlink를 만드는 게 기본인데, 멀티스테이지에서 stage 간에는 hardlink가 깨지므로copy로 강제.UV_COMPILE_BYTECODE=1—.pyc를 미리 만들어 두면 첫 요청 응답 시간이 짧아집니다.- runtime stage에는
uv자체가 없습니다. 런타임에 의존성을 다시 안 깔 거니까 굳이 들고 갈 필요 없습니다. PYTHONUNBUFFERED=1은 도커 로그가 즉시 흘러나오게 — 안 켜면 print/logging이 버퍼에 묶여 안 보이는 일이 생깁니다.
빌드해 보고 사이즈를 비교하면 차이가 체감됩니다.
docker build -t fastapi-demo:multi .
docker images fastapi-demo
# fastapi-demo multi ... ~120MB (cache 빠지면서 줄어듦)non-root로 돌리기 #
기본 동작상 컨테이너 안의 프로세스는 root로 돕니다. 호스트에 직접 닿진 않지만, 컨테이너가 뚫렸을 때 루트 권한을 그대로 쓸 수 있다는 점이 문제입니다. (고급 #3 이미지 보안에서 다룬 주제.)
runtime stage에 사용자 하나를 만들고 그걸로 전환합니다.
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
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]세 가지가 추가됐습니다.
groupadd+useradd—--system은 UID를 시스템 범위(< 1000)에서 잡고/etc/passwd에 들어갑니다.COPY --chown=app:app— 복사하면서 소유권을 함께 바꿉니다. 이렇게 안 하면 root 소유 파일을 비루트가 못 건드립니다.USER app— 이 시점부터 실행되는 모든 명령(이후RUN과CMD모두)이app으로.
여기서 자주 막히는 지점을 미리 짚어 둡니다.
- 1024 미만 포트(
80,443)는 비루트가 못 엽니다. uvicorn을 8000 같은 높은 포트로 띄우고, 80/443은 LB / 프록시에 맡기는 게 정석입니다. - 앱이 디스크에 뭔가 쓴다면(로그 파일, 업로드) 그 디렉터리도
chown app:app해야 합니다.chown -R보다 필요한 디렉터리만 콕 짚는 편이 좋습니다 — 레이어가 비대해지지 않게.
HEALTHCHECK — 살아 있는지 응답하는지 구분 #
도커는 컨테이너의 PID 1이 살아 있으면 “실행 중” 으로 봅니다. 그런데 PID 1이 떠 있어도 응답을 못 하는 상태가 있을 수 있습니다 — 데드락, DB 연결 끊김, 외부 API 무한 대기. HEALTHCHECK는 이걸 구분해 줍니다.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz').read()"옵션 의미:
--interval=30s— 30 초마다 체크--timeout=3s— 3 초 안에 응답 못 하면 실패로 간주--start-period=10s— 컨테이너 시작 후 10 초간은 실패해도 카운트하지 않음 (워밍업 시간)--retries=3— 3 회 연속 실패하면 unhealthy 상태로 전환
curl이 설치되지 않은 slim 이미지라 python -c로 HTTP 호출을 짭니다. 더 깔끔하게 하려면 httpx 같은 걸 dev 의존성으로 넣고 짧은 헬스체크 스크립트를 두는 방법도 있습니다.
상태 확인:
docker run -d --name api -p 8000:8000 fastapi-demo
docker ps
# STATUS 컬럼에 (health: starting) → (healthy)로 바뀜
docker inspect --format='{{.State.Health.Status}}' api
# healthy도커 자체는 unhealthy가 됐다고 컨테이너를 자동 재시작해주지 않습니다. 재시작은 고급 #6의 restart 정책 쪽 일이고, 보통은 ECS/Kubernetes/Compose의 healthcheck가 이 신호를 받아 컨테이너를 교체합니다.
완성된 Dockerfile #
지금까지의 조각을 모은 최종형:
# ─── 1. builder ─────────────────────────────
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
# ─── 2. runtime ─────────────────────────────
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
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz').read()"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]여기에 .dockerignore 한 파일이 더해지면 빌드 컨텍스트가 정리됩니다.
.git
.venv
__pycache__/
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
node_modules
.DS_Store
.env
.env.local.venv는 특히 중요합니다. 안 빼면 호스트의 venv가 통째로 컨테이너에 복사되면서 빌드가 깨지거나 느려집니다. (기초 #6 .dockerignore 참고.)
환경변수 다루기 — ENV는 빌드 시점, --env는 실행 시점
#
설정값을 컨테이너에 주는 경로는 두 군데입니다.
| 경로 | 시점 | 용도 |
|---|---|---|
ENV (Dockerfile) | 빌드 / 런타임 | 변하지 않는 기본값 (PYTHONUNBUFFERED 등) |
--env / -e (docker run) | 런타임 | 환경별로 달라지는 값 (DATABASE_URL, API_KEY) |
--env-file | 런타임 | .env 파일을 통째로 |
DATABASE_URL 같은 시크릿은 절대 ENV에 박지 마세요. 이미지 레이어에 평문으로 남고, 누구든 docker history 나 docker inspect로 볼 수 있습니다.
docker run -d -p 8000:8000 \
-e DATABASE_URL="postgresql://..." \
--env-file .env.production \
fastapi-demo빌드 컨텍스트와 흔한 함정 #
여기까지 한 번에 잘 됐다면, 마지막으로 자주 터지는 지점만 짚어 둡니다.
컨테이너가 즉시 종료된다 — 십중팔구 --host 0.0.0.0 누락이거나, CMD가 어떤 이유로 즉시 끝나는 경우. docker logs <container>로 stderr 확인.
uv sync가 매번 처음부터 — pyproject.toml이나 uv.lock을 코드보다 먼저 복사했는지 확인. 아니면 캐시가 깨지는 위치(예: 앞쪽 RUN의 변경)이 있는지.
non-root 인데 권한 에러 — 앱이 쓰려는 디렉터리 소유권이 app으로 안 잡혔을 때. COPY --chown=app:app 또는 RUN chown -R app:app /필요한/경로.
Apple Silicon에서 빌드한 이미지가 클라우드(amd64)에서 안 뜸 — 빌드 타깃 플랫폼 차이. docker buildx build --platform linux/amd64,linux/arm64 ... (고급 #2 멀티 아키 참고).
정리 #
- FastAPI 컨테이너화의 운영 친화적 형태는 uv + 멀티스테이지 + non-root + HEALTHCHECK의 조합.
- 의존성 정의(
pyproject.toml/uv.lock)와 코드를 두 단계로 분리해COPY하면 빌드 캐시가 살아납니다. - builder stage에서 venv를 만들고 runtime stage로 venv와 코드만 복사합니다. uv 자체는 runtime에 없어도 됩니다.
USER app한 줄이면 비루트로 전환됩니다. 1024 미만 포트는 못 여는 점만 기억하면 됩니다.HEALTHCHECK는 도커 자체가 자동 복구를 해주진 않지만 ECS/Compose/K8s가 이 신호를 받아 컨테이너를 교체합니다.- 시크릿은
ENV가 아니라--env/--env-file/--secret쪽으로.
다음 글(#2 Django + PostgreSQL compose)에서는 컨테이너 한 개를 다루는 단계에서 — 여러 컨테이너를 한 묶음으로 띄우는 단계로 넘어갑니다. Django 앱과 PostgreSQL 두 컨테이너를 docker compose 한 파일에 묶고, 마이그레이션,healthcheck,볼륨까지 운영 형태로 정리합니다.