도커 실전 강좌 #1 FastAPI 컨테이너화 — uv,멀티스테이지,non-root

9 분 소요

도커 트랙의 마지막 시리즈 — 실전 입니다. 기초,중급,고급 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 한 파일.

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을 먼저 만들어 봅니다.

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 참고).

이 이미지로도 동작은 합니다. 그러나 운영에 올리기엔 세 가지가 거슬립니다.

  1. pip installDockerfile 안에 적혀 있어서 의존성이 코드와 동기화되지 않음
  2. 빌드 도구(gcc 같은 게 깔리진 않았지만, 캐시 효율이 나쁨)와 런타임이 한 레이어에 섞임
  3. 컨테이너가 root로 돌고 있고 헬스체크가 없음

하나씩 잡아 갑니다.

uv로 의존성 잠그기 — uv.lock 활용 #

uv init이 이미 pyproject.tomluv.lock을 만들어 줬습니다. 이걸 컨테이너 빌드에 그대로 가져가면 의존성이 자연스럽게 잠깁니다.

프로젝트 구조
fastapi-docker-demo/
├── app/
│   └── main.py
├── pyproject.toml
├── uv.lock
└── Dockerfile

uv는 도커 안에서도 잘 동작합니다. 공식 이미지(ghcr.io/astral-sh/uv)도 있지만, 여기서는 python:3.14-slim 위에 uv 바이너리만 복사해서 쓰는 작은 트릭을 씁니다 — 베이스가 익숙한 파이썬 이미지라 디버깅이 쉽습니다.

Dockerfile (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"]

핵심은 두 단계로 나눠서 COPYRUN을 배치한 것입니다.

  1. 먼저 pyproject.toml + uv.lock만 복사하고 의존성을 설치 — 이 레이어는 의존성이 안 바뀌면 캐시됩니다.
  2. 그 다음 코드를 복사 — 코드만 바뀌면 의존성 설치 단계는 캐시에서 그대로.

이 순서를 뒤집으면 코드 한 줄만 바뀌어도 매번 uv sync가 다시 돕니다. 빌드 시간이 분 단위로 길어집니다. 이 문제는 중급 #2 빌드 캐시에서 깊이 다룬 주제이며, 실전에서도 같은 원리가 적용됩니다.

--no-dev는 dev 의존성(테스트, 린터)을 제외하는 옵션이고, --frozen은 락 파일과 정확히 일치하는 버전을 쓰겠다는 뜻입니다. CI/프로덕션에선 항상 켜둡니다.

멀티스테이지로 슬리밍 #

지금은 uv sync까지 한 이미지가 그대로 런타임이 됩니다. 빌드 시 캐시(~/.cache/uv)도 같이 들어가서 이미지가 불필요하게 큽니다. 멀티스테이지로 빌드와 런타임을 분리하면 깔끔해집니다. (중급 #1의 패턴.)

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

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에 사용자 하나를 만들고 그걸로 전환합니다.

non-root 추가
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 — 이 시점부터 실행되는 모든 명령(이후 RUNCMD 모두)이 app으로.

여기서 자주 막히는 지점을 미리 짚어 둡니다.

  • 1024 미만 포트(80, 443)는 비루트가 못 엽니다. uvicorn을 8000 같은 높은 포트로 띄우고, 80/443은 LB / 프록시에 맡기는 게 정석입니다.
  • 앱이 디스크에 뭔가 쓴다면(로그 파일, 업로드) 그 디렉터리도 chown app:app 해야 합니다. chown -R보다 필요한 디렉터리만 콕 짚는 편이 좋습니다 — 레이어가 비대해지지 않게.

HEALTHCHECK — 살아 있는지 응답하는지 구분 #

도커는 컨테이너의 PID 1이 살아 있으면 “실행 중” 으로 봅니다. 그런데 PID 1이 떠 있어도 응답을 못 하는 상태가 있을 수 있습니다 — 데드락, DB 연결 끊김, 외부 API 무한 대기. HEALTHCHECK는 이걸 구분해 줍니다.

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 의존성으로 넣고 짧은 헬스체크 스크립트를 두는 방법도 있습니다.

상태 확인:

health 상태 보기
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가 됐다고 컨테이너를 자동 재시작해주지 않습니다. 재시작은 고급 #6restart 정책 쪽 일이고, 보통은 ECS/Kubernetes/Compose의 healthcheck가 이 신호를 받아 컨테이너를 교체합니다.

완성된 Dockerfile #

지금까지의 조각을 모은 최종형:

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 한 파일이 더해지면 빌드 컨텍스트가 정리됩니다.

.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 historydocker 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,볼륨까지 운영 형태로 정리합니다.

X