296 lines
11 KiB
Markdown
296 lines
11 KiB
Markdown
# 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.
|
|
```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 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.
|
|
|
|
```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
|
|
|
|
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.
|
|
|
|
```makefile
|
|
# --- 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 |