M-Bus Gateway
← Tilbage til blog
· Docker· FastAPI· Python· multi-stage· uv· production· CI/CD· GitHub Actions· sikkerhed

Docker multi-stage build til Python FastAPI — optimeret til produktion

Docker multi-stage build til FastAPI: builder stage med uv, slim runtime image, non-root bruger, health check, secrets og GitHub Actions CI integration.

Af M-Bus Gateway

Docker multi-stage builds reducerer image-størrelse fra ~1GB til under 200MB for en FastAPI server. Her er produktionsopsætningen til M-Bus Gateway.


Multi-stage Dockerfile

# server/Dockerfile

# ─── Stage 1: Dependencies ────────────────────────────────────────────────────
FROM python:3.11-slim AS builder

# uv: ultrafast Python package installer (10-100× hurtigere end pip)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

# Kopier kun dependency-filer først (caching)
COPY pyproject.toml uv.lock ./

# Installer dependencies i isoleret virtualenv
RUN uv sync --frozen --no-dev --no-editable

# ─── Stage 2: Runtime ─────────────────────────────────────────────────────────
FROM python:3.11-slim AS runtime

# Sikkerhed: non-root bruger
RUN groupadd --gid 1001 mbus && \
    useradd --uid 1001 --gid mbus --shell /bin/bash --create-home mbus

WORKDIR /app

# Installer kun runtime system-deps (WeasyPrint kræver Cairo og Pango)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpango-1.0-0 \
    libpangoft2-1.0-0 \
    libcairo2 \
    libgdk-pixbuf2.0-0 \
    fonts-dejavu-core \
    && rm -rf /var/lib/apt/lists/*

# Kopier virtualenv fra builder (ikke pip, ikke build-tools)
COPY --from=builder /app/.venv /app/.venv

# Kopier applikationskode
COPY --chown=mbus:mbus server/src ./src

# Aktiver virtualenv
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Health check endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
  CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"

USER mbus
EXPOSE 8000

CMD ["uvicorn", "src.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--workers", "2", \
     "--log-config", "/app/src/logging.json"]

pyproject.toml med uv

# server/pyproject.toml
[project]
name = "mbus-server"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.111",
    "uvicorn[standard]>=0.29",
    "sqlmodel>=0.0.18",
    "asyncpg>=0.29",
    "pydantic>=2.7",
    "python-jose[cryptography]>=3.3",
    "passlib[bcrypt]>=1.7",
    "celery[redis]>=5.3",
    "structlog>=24.1",
    "weasyprint>=62.3",
    "httpx>=0.27",
    "brevo-python>=3.0",
    "stripe>=9.0",
    "msgpack>=1.0",
]

[tool.uv]
dev-dependencies = [
    "pytest>=8.0",
    "pytest-asyncio>=0.23",
    "httpx>=0.27",
    "mypy>=1.10",
    "ruff>=0.4",
]

.dockerignore

# server/.dockerignore
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
.eggs/
dist/
build/
.git/
.gitignore
.env*
*.env
.venv/
tests/
docs/
*.md
.mypy_cache/
.ruff_cache/
.pytest_cache/

Health check endpoint

# server/src/health.py

from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
import redis.asyncio as aioredis

router = APIRouter()

@router.get("/health")
async def health(
    session: AsyncSession = Depends(get_session),
    redis: aioredis.Redis = Depends(get_redis),
):
    checks = {}

    # Database connectivity
    try:
        await session.execute(text("SELECT 1"))
        checks["database"] = "ok"
    except Exception as e:
        checks["database"] = f"error: {e}"

    # Redis connectivity
    try:
        await redis.ping()
        checks["redis"] = "ok"
    except Exception as e:
        checks["redis"] = f"error: {e}"

    all_ok = all(v == "ok" for v in checks.values())
    return {
        "status": "healthy" if all_ok else "degraded",
        "checks": checks,
    }

Docker Compose med secrets

# docker-compose.prod.yml

services:
  server:
    image: ghcr.io/kirken20/mbus-gateway/server:${IMAGE_TAG}
    build:
      context: .
      dockerfile: server/Dockerfile
      target: runtime
    secrets:
      - db_password
      - jwt_secret
      - stripe_key
    environment:
      DATABASE_URL: postgresql+asyncpg://mbus:DOCKER-SECRET[db_password]@timescaledb:5432/mbus
      JWT_SECRET_KEY: DOCKER-SECRET[jwt_secret]
      STRIPE_SECRET_KEY: DOCKER-SECRET[stripe_key]
      ENVIRONMENT: production
    healthcheck:
      test: ["CMD-SHELL", "python -c \"import httpx; httpx.get('http://localhost:8000/health').raise_for_status()\""]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      replicas: 2
      update_config:
        order: start-first    # Zero-downtime: start ny inden stop gammel
        failure_action: rollback
      restart_policy:
        condition: on-failure
        delay: 10s
        max_attempts: 3

secrets:
  db_password:
    external: true
  jwt_secret:
    external: true
  stripe_key:
    external: true

GitHub Actions: build og push

# .github/workflows/build-server.yml

name: Build server image

on:
  push:
    branches: [main]
    paths: ["server/**", "server/Dockerfile"]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: server/Dockerfile
          target: runtime
          push: true
          tags: |
            ghcr.io/kirken20/mbus-gateway/server:latest
            ghcr.io/kirken20/mbus-gateway/server:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BUILD_SHA=${{ github.sha }}

Image størrelse sammenligning

Før multi-stage (python:3.11 + alle deps):
  Image størrelse: ~1.4 GB
  Layer count: 28
  Build tid: 4m 30s

Efter multi-stage (python:3.11-slim + uv + selektive deps):
  Image størrelse: 187 MB
  Layer count: 12
  Build tid: 1m 45s (cached)
  Build tid (ingen cache): 2m 10s

Breakdown af 187 MB:
  python:3.11-slim base: 130 MB
  WeasyPrint system deps (Cairo/Pango): 28 MB
  Python virtualenv (uv): 29 MB
  Application code: under 1 MB

Konklusion

Docker multi-stage builds med python:3.11-slim og uv reducerer image-størrelse fra 1.4GB til 187MB — hurtigere deploy og mindre angrebsflade. uv sync --frozen --no-dev installerer kun produktionsdependencies. Non-root bruger, health checks og Docker secrets er obligatoriske for produktionssikkerhed. GitHub Container Registry med ghcr.io giver gratis opbevaring til open-source og private projekter.

Se Docker Compose produktion guide eller GitHub Actions CI guide.