Files
audio-engine-hub/guide.md
2025-12-04 11:58:36 +01:00

11 KiB

Leitfaden: Best Practices zur Containerisierung von Python-Backends

Dieser Leitfaden zeigt einen professionellen Workflow zur Containerisierung einer Python-Backend-Anwendung mit Docker, inklusive Integration eines Reverse Proxys (Traefik) für die lokale Entwicklung. Wir verwenden eine minimale FastAPI-Anwendung als Beispiel.

Die vorgestellten Methoden umfassen:

  • Optimierte, sichere Docker-Images durch Multi-Stage-Builds.
  • Einen einfachen Workflow durch die Verwendung eines Makefile.
  • Trennung von Entwicklungs- und Produktionsumgebungen.
  • Korrekte Verwaltung von Code und Images mit Git und einer Container Registry.
  • Integration von Traefik für dynamisches Routing in der lokalen Entwicklung.

1. Projektstruktur

Wir haben die folgende Struktur erstellt:

/
├── .dockerignore      # Listet Dateien auf, die Docker ignorieren soll.
├── .gitignore         # Listet Dateien auf, die Git ignorieren soll.
├── Dockerfile         # Die Blaupause für unser produktives Docker-Image.
├── Makefile           # Vereinfacht die Ausführung von Docker-Befehlen.
├── app/
│   ├── __init__.py    # Macht 'app' zu einem Python-Paket.
│   └── main.py        # Unser FastAPI-Anwendungscode.
├── docker-compose.yml # Definiert die lokale Entwicklungsumgebung mit Traefik.
├── guide.md           # Dieser Leitfaden.
└── requirements.txt   # Liste der Python-Abhängigkeiten.

2. Die Komponenten im Detail

requirements.txt

Hier definieren wir die Python-Bibliotheken, die unser Projekt benötigt.

fastapi
uvicorn[standard]
gunicorn
  • fastapi: Das Web-Framework.
  • uvicorn: Ein schneller ASGI-Server, ideal für die Entwicklung.
  • gunicorn: Ein robuster WSGI-Produktionsserver, der Uvicorn-Worker zur Ausführung unserer ASGI-Anwendung nutzt.

.gitignore & .dockerignore

  • .gitignore: Verhindert, dass sensible Daten (.env), temporäre Dateien (__pycache__) oder lokale Konfigurationen (.vscode/) in Git eingecheckt werden.
  • .dockerignore: Verhindert, dass unnötige oder sensible Dateien in das Docker-Image kopiert werden. Dies hält das Image klein und sicher. Wir schließen z.B. das .git-Verzeichnis, das Dockerfile selbst und lokale Umgebungsdateien aus.

app/main.py

Eine minimale FastAPI-Anwendung mit zwei Endpunkten.

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Hello World"}

@app.get("/health")
def health_check():
    return {"status": "ok"}

Dockerfile (Multi-Stage-Build)

Dies ist das Herzstück unserer Containerisierung. Ein Multi-Stage-Build trennt die Build-Umgebung von der Laufzeitumgebung.

Stufe 1: builder

FROM python:3.11 as builder
WORKDIR /opt/venv
RUN python -m venv .
COPY requirements.txt .
RUN . /opt/venv/bin/activate && pip install --no-cache-dir -r requirements.txt
  • Wir starten mit einem vollständigen Python-Image.
  • Erstellen ein virtuelles Environment (venv) und installieren die Abhängigkeiten hinein.
  • Das Ergebnis ist ein Ordner /opt/venv mit einer sauberen Python-Umgebung.

Stufe 2: runner (Das finale Image)

FROM python:3.11-slim
RUN useradd --create-home --shell /bin/bash appuser
WORKDIR /home/appuser/app
COPY --from=builder /opt/venv /opt/venv
COPY app/ .
ENV PATH="/opt/venv/bin:$PATH"
EXPOSE 8000
USER appuser
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-w", "2", "-b", "0.0.0.0:8000", "main:app"]
  • Wir starten mit einem minimalen -slim-Image.
  • Sicherheit: Wir erstellen einen unprivilegierten Benutzer appuser. Die Anwendung wird als dieser Benutzer ausgeführt.
  • Wir kopieren das venv aus der builder-Stufe und unseren App-Code.
  • Produktions-Server: Wir starten die App mit gunicorn und 2 uvicorn-Workern. Dies ist ein stabiles Setup für die Produktion.

docker-compose.yml (Für die lokale Entwicklung mit Traefik)

Diese Datei definiert unsere lokale Entwicklungsumgebung, die nun auch Traefik als Reverse Proxy enthält.

Was ist Traefik? Traefik ist ein moderner Edge Router und Reverse Proxy, der dynamisch Dienste auf der Grundlage ihrer Konfiguration (z.B. Docker-Labels) entdeckt. Er leitet Anfragen an die richtigen Container weiter, ohne dass man manuelle Konfigurationen in einer separaten Datei vornehmen muss.

version: '3.8'

services:
  app:
    build: .
    volumes:
      - ./app:/home/appuser/app
    command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
    labels:
      - "traefik.enable=true"
      # Definiert einen Router für den App-Dienst
      - "traefik.http.routers.app-router.rule=Host(`localhost`)"
      - "traefik.http.routers.app-router.entrypoints=web"
      # Definiert den internen Port des Dienstes (im Container)
      - "traefik.http.services.app-service.loadbalancer.server.port=8000"
    networks:
      - web

  traefik:
    image: "traefik:v2.10"
    command:
      - "--api.dashboard=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false" # Nur Dienste mit traefik.enable=true exposen
      - "--entrypoints.web.address=:80"
    ports:
      - "80:80"     # Der HTTP-Port, über den Traefik lauscht
      - "8080:8080" # Das Web UI (Dashboard) von Traefik
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro" # Traefik braucht Zugriff auf den Docker-Daemon
    networks:
      - web

networks:
  web:
    external: false
  • app Service: Die direkte Port-Exponierung wurde entfernt. Stattdessen nutzt Traefik die labels, um das Routing zu konfigurieren.
    • traefik.enable=true: Aktiviert die Erkennung durch Traefik.
    • traefik.http.routers.app-router.rule=Host('localhost'): Sagt Traefik, dass Anfragen an die Host localhost an diesen Dienst geleitet werden sollen.
    • traefik.http.routers.app-router.entrypoints=web: Verknüpft diesen Router mit dem web-Entrypoint von Traefik (Port 80).
    • traefik.http.services.app-service.loadbalancer.server.port=8000: Informiert Traefik über den internen Port des FastAPI-Dienstes.
  • traefik Service: Dies ist der Traefik-Container selbst.
    • Er konfiguriert das Dashboard (--api.dashboard=true) und aktiviert den Docker-Provider (--providers.docker=true), der Container anhand ihrer Labels entdeckt.
    • Er lauscht auf Port 80 (HTTP) und 8080 (Dashboard).
    • /var/run/docker.sock: Ermöglicht Traefik die Kommunikation mit dem Docker-Daemon, um Container und deren Labels zu erkennen.
  • networks: web: Erstellt ein gemeinsames Netzwerk, in dem Traefik und die app kommunizieren können.

Makefile (Der Kommando-Runner)

Das Makefile gibt uns einfache Befehle für komplexe Aktionen. Es wurde angepasst, um die Traefik-Ports zu berücksichtigen.

# --- Configuration ---
# Default internal application port (exposed to Traefik)
PORT := 8000
# Traefik's external entrypoints
TRAEFIK_WEB_PORT := 80
TRAEFIK_DASHBOARD_PORT := 8080

# --- Port Checking Functions ---

# Function to check if required Traefik ports are free
# Exits if any are busy, does not suggest alternatives.
define check_traefik_ports_free
    @echo "Checking if Traefik ports ($(TRAEFIK_WEB_PORT}, $(TRAEFIK_DASHBOARD_PORT}) are free..." >&2
    @local busy_ports=""; \
    if ss -tulnp | grep ":$(TRAEFIK_WEB_PORT) " > /dev/null; then \
        busy_ports="$$busy_ports $(TRAEFIK_WEB_PORT)"; \
    fi; \
    if ss -tulnp | grep ":$(TRAEFIK_DASHBOARD_PORT) " > /dev/null; then \
        busy_ports="$$busy_ports $(TRAEFIK_DASHBOARD_PORT)"; \
    fi; \
    if [ -n "$$busy_ports" ]; then \
        echo "ERROR: The following Traefik ports are already in use: $$busy_ports. Please free them or stop Traefik if already running." >&2; \
        exit 1; \
    fi; \
    @echo "Traefik ports are free." >&2
endef

# Function to find next free host port for the app and ask user
# This function will echo the chosen port if successful, or exit with an error.
define check_app_port_free
    @local start_port=$(PORT); \
    local found_port=$$start_port; \
    local is_free=false; \
    \
    if ! ss -tulnp | grep ":$$start_port " > /dev/null; then \
        echo "$$start_port"; \
        exit 0; \
    fi; \
    \
    echo "Port $$start_port is busy. Searching for a free port for the app..." >&2; \
    while ! $$is_free; do \
        if ! ss -tulnp | grep ":$$found_port " > /dev/null; then \
            is_free=true; \
        else \
            ((found_port++)); \
            if [ "$$found_port" -gt 65535 ]; then \
                echo "ERROR: No free ports found up to 65535. Aborting." >&2; \
                exit 1; \
            fi; \
        fi; \
    done; \
    \
    echo "Port $$start_port is busy. I found port $$found_port to be free for the app." >&2; \
    read -p "Do you want to use port $$found_port for the app? (y/N): " choice; \
    case "$$choice" in \
        y|Y ) \
            echo "$$found_port"; \
            ;; \
        * ) \
            echo "Operation cancelled by user." >&2; \
            exit 1; \
            ;; \
    esac;
endef

# --- Docker Commands ---

.PHONY: build
build:
	@echo "Building Docker image: $(IMAGE_NAME):$(TAG)"
	docker build -t $(IMAGE_NAME):$(TAG) .

.PHONY: run
run:
	@export SELECTED_HOST_PORT=$$(bash -c 'func() { $(check_app_port_free) }; func') && \
	echo "Using host port $$SELECTED_HOST_PORT for single app container" && \
	docker run -d -p $$SELECTED_HOST_PORT:$(PORT) --name $(IMAGE_NAME) $(IMAGE_NAME):$(TAG)

.PHONY: stop
stop:
	@echo "Stopping Docker container: $(IMAGE_NAME)"
	docker stop $(IMAGE_NAME) || true
	docker rm $(IMAGE_NAME) || true

.PHONY: logs
logs:
	@echo "Showing logs for container: $(IMAGE_NAME)"
	docker logs -f $(IMAGE_NAME)

.PHONY: shell
shell:
	@echo "Accessing shell in container: $(IMAGE_NAME)"
	docker exec -it $(IMAGE_NAME) /bin/bash

# --- Docker Compose Commands ---

.PHONY: up
up:
	$(call check_traefik_ports_free) # Check Traefik ports before starting
	@echo "Starting development environment with Docker Compose (Traefik enabled)..."
	docker-compose up --build -d

.PHONY: down
down:
	@echo "Stopping development environment with Docker Compose..."
	docker-compose down

# --- Image Management ---

.PHONY: tag
tag:
	@echo "Tagging image $(IMAGE_NAME):$(TAG) as $(REGISTRY)/$(IMAGE_NAME):$(TAG)"
	docker tag $(IMAGE_NAME):$(TAG) $(REGISTRY)/$(IMAGE_NAME):$(TAG)

.PHONY: push
push: tag
	@echo "Pushing image $(REGISTRY)/$(IMAGE_NAME):$(TAG) to registry..."
	docker push $(REGISTRY)/$(IMAGE_NAME):$(TAG)

# --- Cleanup ---

.PHONY: clean
clean:
	@echo "Cleaning up stopped containers and dangling images..."
	docker container prune -f
	docker image prune -f

.PHONY: help
help:
	@echo "Available commands:"
	@echo "  build   - Build the Docker image"
	@echo "  run     - Run the Docker container (single app, no Traefik)"
	@echo "  stop    - Stop and remove the Docker container"
	@echo "  logs    - Follow the logs of the container"
	@echo "  shell   - Get a shell inside the running container"
	@echo "  up      - Start the dev environment with docker-compose (with Traefik)"
	@echo "  down    - Stop the dev environment with docker-compose"
	@echo "  tag     - Tag the image for a registry"
	@echo "  push    - Push the image to a registry (after tagging)"
	@echo "  clean   - Clean up unused containers and images"
	@echo "  help    - Show this help message"

.DEFAULT_GOAL := help