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, dasDockerfileselbst 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/venvmit 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
venvaus derbuilder-Stufe und unseren App-Code. - Produktions-Server: Wir starten die App mit
gunicornund 2uvicorn-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
appService: Die direkte Port-Exponierung wurde entfernt. Stattdessen nutzt Traefik dielabels, 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 Hostlocalhostan diesen Dienst geleitet werden sollen.traefik.http.routers.app-router.entrypoints=web: Verknüpft diesen Router mit demweb-Entrypoint von Traefik (Port 80).traefik.http.services.app-service.loadbalancer.server.port=8000: Informiert Traefik über den internen Port des FastAPI-Dienstes.
traefikService: 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.
- Er konfiguriert das Dashboard (
networks: web: Erstellt ein gemeinsames Netzwerk, in dem Traefik und dieappkommunizieren 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