# Docker and Cloud-Native Patterns Source: https://docs.docker.com/build/building/best-practices/ | https://docs.astral.sh/uv/guides/integration/docker/ | https://fastapi.tiangolo.com/deployment/docker/ --- ## Canonical Multi-Stage Dockerfile (uv + FastAPI + uvicorn) ```dockerfile # syntax=docker/dockerfile:1 # ── builder stage ───────────────────────────────────────────────────────────── FROM python:3.12-slim-bookworm AS builder # Pin uv version — never use :latest in production COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /uvx /bin/ WORKDIR /app # Layer: install dependencies only (cached until pyproject.toml or uv.lock changes) RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --locked --no-install-project --no-dev --no-editable # Layer: copy source and install project COPY src/ /app/src/ COPY pyproject.toml uv.lock /app/ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-dev --no-editable # ── runtime stage ───────────────────────────────────────────────────────────── FROM python:3.12-slim-bookworm AS runtime # Non-root user with explicit UID/GID RUN groupadd --system --gid 1001 appgroup && \ useradd --system --uid 1001 --gid appgroup --no-log-init --home /app appuser WORKDIR /app # Copy only the venv (uv binary stays in builder) COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv # Copy app source COPY --from=builder --chown=appuser:appgroup /app/src /app/src # Activate the venv via PATH — do not rely on `uv run` at runtime ENV PATH="/app/.venv/bin:$PATH" \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ UV_PYTHON_DOWNLOADS=0 USER appuser EXPOSE 8000 # Liveness probe — no extra tools needed; uses stdlib urllib HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ CMD python -c \ "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" \ || exit 1 # Exec form (NOT shell form) — required for SIGTERM → graceful shutdown CMD ["uvicorn", "my_app.main:app", \ "--host", "0.0.0.0", \ "--port", "8000", \ "--workers", "1", \ "--proxy-headers", \ "--forwarded-allow-ips", "*"] ``` --- ## .dockerignore ``` # Python artifacts .venv/ __pycache__/ *.pyc *.pyo .pytest_cache/ .mypy_cache/ .ruff_cache/ dist/ *.egg-info/ # Environment and secrets .env .env.* !.env.example # Git .git/ .gitignore # Docs and local tooling README.md docs/ *.md ``` **Always add `.venv/` to `.dockerignore`** — it is platform-specific and will cause subtle failures if copied into the image. --- ## docker-compose.yml (Local Development) ```yaml services: app: build: context: . target: runtime # build only up to the runtime stage ports: - "8000:8000" environment: DEBUG: "true" LOG_LEVEL: "debug" env_file: - .env restart: unless-stopped # Docker Compose Watch — hot reload without rebuilding image develop: watch: - action: sync path: ./src target: /app/src ignore: - .venv/ - __pycache__/ - action: rebuild path: ./pyproject.toml - action: rebuild path: ./uv.lock ``` Run with: ```bash docker compose up # start normally docker compose watch # start with live sync docker compose up --build # force rebuild ``` --- ## Key Dockerfile Rules ### Multi-Stage Builds - **Stage 1 (`builder`)**: Has uv, build tools, compiles `.pyc` files if needed. - **Stage 2 (`runtime`)**: Minimal; contains only the venv and source. No uv, no build tools. - Final image size: typically 150–200 MB for a FastAPI app (vs 500+ MB with a single stage). ### Layer Caching Strategy Copy files in order of change frequency (least → most): ``` 1. uv.lock + pyproject.toml → install deps (cached for days) 2. Source code → install project (invalidated on every code change) ``` This means dependency installation is only re-run when `uv.lock` or `pyproject.toml` changes. ### CMD Exec Form (Critical) ```dockerfile # ✅ Exec form — process receives SIGTERM directly → graceful shutdown CMD ["uvicorn", "my_app.main:app", "--host", "0.0.0.0", "--port", "8000"] # ❌ Shell form — process is a child of /bin/sh → SIGTERM goes to shell, not uvicorn CMD uvicorn my_app.main:app --host 0.0.0.0 --port 8000 ``` Shell form breaks graceful shutdown and FastAPI lifespan shutdown events. ### Non-Root User ```dockerfile RUN groupadd --system --gid 1001 appgroup && \ useradd --system --uid 1001 --gid appgroup --no-log-init --home /app appuser USER appuser ``` - Use `--system` for service accounts (no shell, no home by default). - Use `--no-log-init` to avoid `/var/log/faillog` disk exhaustion (Go runtime bug in older kernels). - Use explicit UID/GID (1001) — deterministic, scanners can reason about it. - Never run as UID 0 (root) in production. ### HEALTHCHECK ```dockerfile HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ CMD python -c \ "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" \ || exit 1 ``` - `--start-period`: grace period on startup before health checks begin. - `--retries`: mark as unhealthy only after N consecutive failures. - Uses stdlib `urllib` — no extra tools needed, not even `curl`. --- ## Cloud-Native Twelve-Factor Principles Applied ### Factor III — Config (Environment Variables) - All config comes from environment variables, never from code. - `pydantic-settings` reads from env automatically. - Provide `.env.example` with documentation; never commit `.env`. ```bash # Runtime injection docker run -e DATABASE_URL="postgresql://..." -e SECRET_KEY="..." my-app # or via env_file in compose / Kubernetes Secret ``` ### Factor XI — Logs (Treat as Event Streams) - Set `PYTHONUNBUFFERED=1` — logs are flushed immediately to stdout/stderr. - Never write logs to files inside the container. - Configure uvicorn to log JSON in production: ```python # In create_app() lifespan or a logging setup module import logging import json class JSONFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: return json.dumps({ "level": record.levelname, "message": record.getMessage(), "logger": record.name, }) ``` Or use `structlog` with the JSON renderer for production. ### Factor IX — Disposability (Fast Startup, Graceful Shutdown) - FastAPI lifespan handles startup/shutdown. - Uvicorn forwards `SIGTERM` to the Python process when using exec form `CMD`. - The process should be fully ready to serve requests within 10 seconds. - Kubernetes `terminationGracePeriodSeconds` should be >= your timeout. --- ## Kubernetes Readiness / Liveness Probes Expose two endpoints: ```python @router.get("/healthz", include_in_schema=False) # liveness async def health(): return {"status": "ok"} @router.get("/readyz", include_in_schema=False) # readiness async def readiness(request: Request): # Check DB, cache, etc. return {"status": "ready"} ``` Kubernetes manifest snippet: ```yaml livenessProbe: httpGet: path: /healthz port: 8000 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: httpGet: path: /readyz port: 8000 initialDelaySeconds: 5 periodSeconds: 10 ``` --- ## Scaling: Workers vs. Replicas | Deployment | Workers setting | Reasoning | |---|---|---| | Kubernetes / Cloud Run | `--workers 1` | Orchestrator handles replication; 1 process per container for predictable memory | | Docker Compose (single host) | `--workers 4` | No external replication; use CPU cores | | Local dev | `--reload` (single process) | Hot reload only works with single process | **Never use `--reload` in production.** --- ## Base Image Selection | Use case | Recommended base | |---|---| | Standard production | `python:3.12-slim-bookworm` | | Smallest possible image | `python:3.12-alpine3.20` (musl; watch for C extension compat) | | uv-managed Python | `ghcr.io/astral-sh/uv:python3.12-bookworm-slim` | | Security-hardened | `cgr.dev/chainguard/python:latest` (distroless) | **Do not use:** - `python:latest` — unpinned, breaks reproducibility - `tiangolo/uvicorn-gunicorn-fastapi` — deprecated by FastAPI team - Full `python:3.x` (non-slim) — 300+ MB unnecessary overhead --- ## Build Optimizations ```bash # Enable BuildKit (default in Docker >= 23) export DOCKER_BUILDKIT=1 # Build with cache mount (fastest for repeated local builds) docker build -t my-app . # CI: force fresh base image + no layer cache docker build --pull --no-cache -t my-app . # Multi-platform build (for ARM deployment from x86 CI) docker buildx build --platform linux/amd64,linux/arm64 -t my-app . ``` --- ## Security Hardening Checklist - [ ] Non-root user with explicit UID/GID - [ ] Read-only filesystem where possible (`--read-only` docker run flag or `securityContext.readOnlyRootFilesystem: true` in K8s) - [ ] No secrets in `ENV` Dockerfile instructions - [ ] Minimal base image (slim/alpine) - [ ] Multi-stage build (no build tools in runtime image) - [ ] Pin base image and uv version (not `:latest`) - [ ] `.dockerignore` excludes `.env`, `.git`, `.venv` - [ ] `EXPOSE` only the port actually used - [ ] Regular base image updates in CI (`docker build --pull`)