Files
PyContainer-Handbuch/guide.md
2025-12-04 11:54:41 +01:00

12 KiB

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.

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, 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.

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):

# 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.

# --- 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