# Leitfaden: Best Practices zur Containerisierung von Python-Backends Dieser Leitfaden zeigt einen professionellen Workflow zur Containerisierung einer Python-Backend-Anwendung mit Docker. Wir fokussieren uns auf die Integration in eine bereits bestehende Reverse-Proxy-Infrastruktur wie 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 in eine bestehende Traefik-Infrastruktur für dynamisches Routing. ## 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 (integriert sich in externen 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. ```python 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`** ```dockerfile 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)** ```dockerfile 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, Integration in externen Traefik) Diese Datei definiert unsere lokale Entwicklungsumgebung für die Anwendung und integriert sie in eine **bereits bestehende und extern verwaltete Traefik-Instanz**. **Konzept der Integration:** Anstatt Traefik bei jedem Start der Anwendung neu zu deployen, gehen wir davon aus, dass Traefik bereits als zentraler Reverse Proxy auf dem Host-System oder in einem separaten `docker-compose`-Setup läuft. Unsere Anwendung verbindet sich einfach mit dem von Traefik bereitgestellten Docker-Netzwerk und Traefik erkennt die Anwendung über Labels. ```yaml 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 networks: web: external: true # Dieses Netzwerk wird von einer externen Traefik-Instanz bereitgestellt ``` - **`app` Service**: Konfiguriert unsere Anwendung. Die Labels sind entscheidend für die Erkennung durch Traefik: - `traefik.enable=true`: Aktiviert die Erkennung durch Traefik. - `traefik.http.routers.app-router.rule=Host('localhost')`: Sagt Traefik, dass Anfragen an den Host `localhost` an diesen Dienst geleitet werden sollen. Dies kann auf einen spezifischen Domainnamen angepasst werden, wenn Traefik entsprechend konfiguriert ist. - `traefik.http.routers.app-router.entrypoints=web`: Verknüpft diesen Router mit dem `web`-Entrypoint von Traefik (üblicherweise Port 80). - `traefik.http.services.app-service.loadbalancer.server.port=8000`: Informiert Traefik über den internen Port des FastAPI-Dienstes. - **`networks: web`**: Deklariert ein **externes Netzwerk**. Das bedeutet, dass dieses Netzwerk nicht von diesem `docker-compose.yml` erstellt, sondern von einem anderen Dienst (in unserem Fall Traefik) bereitgestellt wird. Unsere Anwendung tritt diesem Netzwerk bei. --- **Beispiel für eine zentrale Traefik-Bereitstellung (separates `docker-compose.yml`)** Damit die obige Konfiguration funktioniert, muss eine Traefik-Instanz laufen, die das `web`-Netzwerk bereitstellt. Hier ist ein Beispiel, wie eine solche zentrale Traefik-Instanz bereitgestellt werden könnte (dies ist ein *separates* `docker-compose.yml`, das an anderer Stelle ausgeführt wird): ```yaml # docker-compose-traefik.yml (Beispiel für zentrale Traefik-Bereitstellung) version: '3.8' services: traefik: image: "traefik:v2.10" container_name: traefik command: - "--api.dashboard=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" # Nur Dienste mit traefik.enable=true exposen - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" # Optional: Für HTTPS ports: - "80:80" # HTTP - "443:443" # Optional: HTTPS - "8080:8080" # Traefik Dashboard volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" # Traefik braucht Zugriff auf den Docker-Daemon # - "./traefik-certs:/etc/traefik/certs" # Optional: Für HTTPS Zertifikate networks: - web networks: web: external: false # Dieses Netzwerk wird von dieser Traefik-Instanz erstellt und verwaltet ``` Dieses `docker-compose-traefik.yml` würde in einem eigenen Verzeichnis gestartet (`docker-compose up -d`) und würde das `web`-Netzwerk erzeugen, in das sich dann unsere Anwendungs-`docker-compose.yml` einklinken kann. --- ### `Makefile` (Der Kommando-Runner) Das `Makefile` gibt uns einfache Befehle für komplexe Aktionen. Es wurde angepasst, um die Integration in einen externen Traefik widerzuspiegeln. Die spezifischen Traefik-Port-Checks wurden entfernt, da diese außerhalb der Verantwortung dieser Anwendung liegen. ```makefile # --- Configuration --- # Use the current directory name as the default image name IMAGE_NAME := $(shell basename $(CURDIR)) # Default tag TAG := latest # Placeholder for a remote registry (e.g., your Docker Hub username or Gitea instance) # For Gitea: REGISTRY ?= your-gitea-host:port/owner # Example: REGISTRY ?= localhost:3000/myuser REGISTRY ?= your-registry-username # Default internal application port (exposed to Traefik) PORT := 8000 # 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: @echo "Starting development environment with Docker Compose (integrating with external Traefik)..." 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 (integrates with external 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