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

301 lines
12 KiB
Markdown

# 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