9.6 KiB
9.6 KiB
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/ | https://uvicorn.dev/deployment/
Canonical Multi-Stage Dockerfile (uv + FastAPI + uvicorn)
# 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)
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:
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.pycfiles 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)
# ✅ 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
RUN groupadd --system --gid 1001 appgroup && \
useradd --system --uid 1001 --gid appgroup --no-log-init --home /app appuser
USER appuser
- Use
--systemfor service accounts (no shell, no home by default). - Use
--no-log-initto avoid/var/log/faillogdisk 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
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 evencurl.
Cloud-Native Twelve-Factor Principles Applied
Factor III — Config (Environment Variables)
- All config comes from environment variables, never from code.
pydantic-settingsreads from env automatically.- Provide
.env.examplewith documentation; never commit.env.
# 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:
# 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
SIGTERMto the Python process when using exec formCMD. - The process should be fully ready to serve requests within 10 seconds.
- Kubernetes
terminationGracePeriodSecondsshould be >= your timeout.
Kubernetes Readiness / Liveness Probes
Expose two endpoints:
@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:
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 reproducibilitytiangolo/uvicorn-gunicorn-fastapi— deprecated by FastAPI team- Full
python:3.x(non-slim) — 300+ MB unnecessary overhead
Build Optimizations
# 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-onlydocker run flag orsecurityContext.readOnlyRootFilesystem: truein K8s) - No secrets in
ENVDockerfile instructions - Minimal base image (slim/alpine)
- Multi-stage build (no build tools in runtime image)
- Pin base image and uv version (not
:latest) .dockerignoreexcludes.env,.git,.venvEXPOSEonly the port actually used- Regular base image updates in CI (
docker build --pull)