
> Mehrstufige Python Docker Images mit UV
Schnellere Builds und kleinere Images dank mehrstufiger Dockerfiles und uv
Moderne Python-Anwendungen sind oft von aufgeblähten Docker Images betroffen, die langsam zu erstellen und groß im Umfang sind. Im Hinblick auf die Reduzierung der Build Zeiten hat sich uv schnell als Paketmanager der Wahl für viele Python-Entwickler etabliert. Die Erstellung eines produktionsbereiten Docker Images auf Basis von uv ist jedoch mit einigen Herausforderungen verbunden, die häufig übersehen werden. Der Einsatz eines Multi Stage Dockerfiles kann die Größe des finalen Images reduzieren, während der Build Prozess schnell bleibt. In diesem Post erklären wir Schritt für Schritt, wie du ein produktionsbereites Docker Image für eine Python REST API erstellen kannst und welche Herausforderungen bei einem uv-basierten Multi Stage Build zu beachten sind.
Initialisieren eines FastAPI-Projekts mit UV
Beginnen wir mit dem Anlegen eines minimalen FastAPI Projektes. Dies bildet die Grundlage für unseren mehrstufigen Build Prozess. Mit dem uv init
Befehl kannst du ein neues Python Projekt aufsetzen:
uv init --lib --python 3.13 --build-backend setuptools restapipy
Dies initialisiert ein Git Repository mit folgender Struktur:
$ tree restapipy
restapipy
├── pyproject.toml # Projektmetadaten und Abhängigkeiten
├── README.md # Projektdokumentation
└── src # Quellcode-Verzeichnis
└── restapipy
├── __init__.py # Markiert das Verzeichnis als Python-Paket
└── py.typed # Spezifiziert, dass Statische Typisierung vorhanden ist
Neben der Angabe der Python-Version 3.13 über das --python
Flag sorgt das --lib
Flag für die Erstellung eines Bibliotheksprojekts. Dies organisiert das Projekt in eine typische Python-Paketstruktur und fügt eine py.typed
Datei hinzu, um zu signalisieren, dass das Paket statische Typenhinweise enthält. Standardmäßig verwendet --lib
hatchling als Build-Backend, was wir mit --build-backend setuptools
durch unser bevorzugtes Build-System (setuptools) ersetzen können.
Navigiere nun in das Projektverzeichnis, da alle folgenden Befehle von hier aus ausgeführt werden.
cd restapipy
Als Nächstes installieren wir die Abhängigkeiten, die wir für das Projekt brauchen mit dem Befehl uv add
. Dadurch wird automatisch eine uv.lock
Datei erstellt, die die Paketversionen fixiert.
uv add fastapi uvicorn
Jetzt, wo unser Projekt eingerichtet ist, schauen wir uns mal die pyproject.toml
Datei an, die erstellt wurde:
[project]
name = "restapipy"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Jendrik Potyka", email = "jpotyka@digon.io" },
{ name = "Fabian Preiß", email = "fpreiss@digon.io" },
]
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.116.1",
"uvicorn>=0.35.0",
]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
Die pyproject.toml
ist zentral für jedes moderne Python-Projekt. Paketmanager wie pip
und uv
verwenden diese Datei, um die Abhängigkeiten und Metadaten Deines Projekts zu verwalten. Im project
-Bereich findest Du die wichtigsten Informationen zu unserem Projekt: Name, Version, Beschreibung und die Abhängigkeiten, die wir für den Betrieb benötigen.
Nachdem das Projekt initialisiert wurde, müssen wir noch ein paar zusätzliche Dateien erstellen, um unsere Anwendung und den Docker Build Prozess zu strukturieren. Dazu gehören das Dockerfile
für den Multi Stage Build, die main Datei für die -FastAPI-Anwendung (main.py
) und eine Datei für den Entrypoint (__main__.py
) zum Ausführen der API.
touch Dockerfile
touch src/restapipy/main.py
touch src/restapipy/__main__.py
Nachdem Du die notwendigen Dateien erstellt hast, sollte Dein Projekt so aussehen:
$ tree -I '__pycache__|*.egg-info'
.
├── Dockerfile # Multistage Docker Build Skript
├── pyproject.toml # Projektmetadaten und Abhängigkeiten
├── README.md # Projektdokumentation
├── uv.lock # Fixierte Versionen der Abhängigkeiten
└── src # Quellcode-Verzeichnis
└── restapipy
├── __init__.py # Markiert das Verzeichnis als Python-Paket
├── __main__.py # Entry Point für uvicorn
├── main.py # FastAPI Anwendungslogik
└── py.typed # Spezifiziert, dass Statische Typisierung vorhanden ist
Erstellen der FastAPI App und der OpenAPI-Spezifikation
Jetzt erstellen wir eine einfache FastAPI-App, die das Kernstück unserer REST-API bildet. Die Datei main.py
definiert einen einzelnen Endpunkt, der einen String zurückgibt. Zusätzlich implementieren wir eine Hilfsfunktion, die die OpenAPI JSON Spezifikation für unsere API generiert. Das ist besonders nützlich, wenn Du eine Multi Service Anwendung planst, bei der ein anderer Container die REST API Spezifikation während des Build Prozesses benötigt.
import json
from fastapi import FastAPI
app = FastAPI()
@app.get("/pythonic_breakfast")
def _() -> str:
return "spam ham eggs"
def generate_openapi_json() -> None:
openapi_dict = app.openapi()
openapi_json = json.dumps(openapi_dict, indent=4)
with open("openapi.json", "w") as f:
f.write(openapi_json)
Um die OpenAPI JSON-Generierung einfach über die Kommandozeile aufrufen zu können, ergänzen wir pyproject.toml
um einen [project.scripts]
-Bereich. Das ermöglicht es Dir, uv run generate_openapi_json
direkt im Terminal auszuführen. Dadurch wird ein Befehl erstellt, der die Funktion zum Generieren der JSON-Spezifikation aufruft.
[project]
name = "restapipy"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Jendrik Potyka", email = "jpotyka@digon.io" },
{ name = "Fabian Preiß", email = "fpreiss@digon.io" },
]
requires-python = ">=3.13"
dependencies = ["fastapi>=0.116.1", "uvicorn>=0.35.0"]
[project.scripts]
generate_openapi_json = "restapipy.main:generate_openapi_json"
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
Schreiben des Multistage Dockerfiles für Python mit UV
In dem Dockerfile
teilen wir den Build Prozess in zwei Hauptphasen auf: die builder
Phase und die runtime
Phase. Beide Images basieren auf einem schlanken Alpine Linux Python Image (hier konkret 3.13.5-alpine3.22
).
# 3.13.5-alpine3.22
FROM python@sha256:610020b9ad8ee92798f1dbe18d5e928d47358db698969d12730f9686ce3a3191 AS builder
# 0.8.3
COPY --from=ghcr.io/astral-sh/uv@sha256:ef11ed817e6a5385c02cd49fdcc99c23d02426088252a8eace6b6e6a2a511f36 /uv /uvx /bin/
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --no-dev --locked --no-editable --no-install-project
COPY README.md src ./
RUN uv sync --no-dev --locked --no-editable
RUN uv run --no-sync generate_openapi_json
# 3.13.5-alpine3.22
FROM python@sha256:610020b9ad8ee92798f1dbe18d5e928d47358db698969d12730f9686ce3a3191 AS runtime
COPY --from=builder --chown=root:root /app/.venv /app/.venv
COPY --from=builder --chown=root:root /app/openapi.json /app/openapi.json
RUN chmod -R o=rx /app
RUN adduser -D -H worker
USER worker
EXPOSE 9876
ENV PYTHONPATH=/app/.venv/lib/python3.13/site-packages
CMD ["python", "-m", "restapipy"]
Was in der builder
Phase passiert
In der builder
Phase installieren wir uv
aus einem vorgefertigten Container von Astral (der Firma hinter uv). Wir haben die Versionen des Basis Images und von uv
mit sha256
-Hashes festgelegt, um reproduzierbare Builds zu gewährleisten. Du findest die passenden Images unter uv’s Container-Registry. Achte darauf, Images zu wählen, die nur die uv
-Version angeben (z.B. latest
, 0.8.3
, 0.8
) und keine Python-Version oder Distribution (z.B. 0.8.3-debian-slim
, 0.8-python3.13-alpine
oder python3.11-bookworm
).
Wir setzen außerdem folgende Umgebungsvariablen:
UV_LINK_MODE=copy
- Unterdrückt die Warnung von UV, dass Hardlinks nicht verfügbar sind (wir können keine Hardlinks zwischen dem Host-System und dem Container erstellen).UV_COMPILE_BYTECODE=1
- Aktiviert die Bytecode-Kompilierung (.py
Dateien werden in.pyc
Dateien umgewandelt), um den Start der Anwendung zu beschleunigen.UV_PYTHON_DOWNLOADS=never
- Verhindert, dass UV von Astral vorgefertigte Python-Binärdateien herunterlädt. Das bedeutet, dass die Verantwortung für die Bereitstellung der richtigen Python Binärdatei beim Basis Image liegt.
In dem nächsten Schritten kopieren wir die pyproject.toml
und uv.lock
Dateien in den Container und führen uv sync
mit den Flags --no-dev
, --locked
, --no-editable
und --no-install-project
aus. Das stellt sicher, dass der Container keine vollständige Entwicklungsumgebung enthält, sondern nur die notwendigen Abhängigkeiten. Mit dem Flag --locked
sagen wir UV, dass es nur die Abhängigkeiten installieren soll, die in der uv.lock
Datei aufgeführt sind, und keine zusätzlichen Pakete, die möglicherweise in der pyproject.toml
angegeben sind.
COPY pyproject.toml uv.lock ./
RUN uv sync --no-dev --locked --no-editable --no-install-project
Danach kopieren wir die README.md
und das src
-Verzeichnis in den Container und führen uv sync
erneut aus (jetzt wird auch dein Quellcode in das Image integriert). Wenn Du Änderungen in Deinem Projekt machst, führt das erneute Ausführen von uv sync
nicht zu einer kompletten Neuinstallation aller Abhängigkeiten, da die zwischengespeicherten Layer aus einem vorherigen Build wiederverwendet werden können, solange pyproject.toml
und uv.lock
unverändert sind.
COPY README.md src ./
RUN uv sync --no-dev --locked --no-editable
Im letzten Schritt der builder
Phase führen wir den Befehl generate_openapi_json
aus, um die OpenAPI-Spezifikationsdatei (openapi.json
) basierend auf der FastAPI-Anwendung im app
Verzeichnis zu generieren. Die --no-sync
Flag verhindert, dass uv die Umgebung erneut synchronisiert, bevor das Skript ausgeführt wird, was unnötige Abhängigkeitsinstallationen vermeidet.
RUN uv run --no-sync generate_openapi_json
Was in der runtime
Phase des Dockerfiles passiert
In der runtime
Phase bauen wir das finale, schlanke Produktions Image für das Deployment auf. Wir starten wieder mit dem gleichen alpine
Basis Image und kopieren nur die notwendigsten Dateien aus der builder
Phase: Die virtuelle Umgebung (.venv
) und die generierte openapi.json
Datei.
Für mehr Sicherheit ändern wir die Berechtigungen der kopierten Dateien und legen einen unprivilegierten Benutzer (worker
) an. Der Befehl USER worker
legt den Benutzer worker
als Standardbenutzer für die nachfolgenden Befehle und Prozesse in dem Container fest. EXPOSE 9876
macht die API auf Port 9876
verfügbar und CMD
legt fest, wie die Anwendung gestartet wird.
Das Docker Image lokal bauen und starten
Probieren wir das Docker Image jetzt lokal aus. Bau es mit folgendem Befehl:
docker build --tag restapipy:latest .
Mit der --tag
-Option gibst Du dem Image einen Namen, damit Du es später leichter wiederfindest. Der Punkt “.
” am Ende des Befehls bedeutet, dass das aktuelle Verzeichnis als Build-Kontext verwendet wird.
Sobald das Image erstellt ist, kannst du es mit folgendem Befehl starten:
$ docker run -p 8000:9876 restapipy:latest
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:9876 (Press CTRL+C to quit)
Mit der Option -p 8000:9876
leitest du Port 8000
deines Rechners auf Port 9876
des Containers weiter. Wenn der Container erfolgreich läuft, kannst du die API mit curl
oder einem Browser testen:
curl http://127.0.0.1:8000/pythonic_breakfast
Du solltest folgende Antwort erhalten:
"spam ham eggs"
Weitere nützliche Infos zu UV
Dieses Tutorial hat Dir die wichtigsten Funktionen von UV für die Arbeit mit Docker gezeigt. Wenn Du noch mehr wissen möchtest, schau Dir diese Ressourcen an:
- uv Dokumentation: Using uv in Docker
- uv Dokumentation: CLI Reference
- Should you use uv’s managed Python in production? - Blog post by Itamar Turner-Trauring
Was wir gelernt haben
In diesem Artikel haben wir Schritt für Schritt ein minimales, reproduzierbares und produktionsbereites Docker Image für Deine FastAPI-Anwendung erstellt. Mit uv und einem mehrstufigen Dockerfile hast Du gelernt, wie Du Dein Python-Projekt optimal strukturierst, um schnelle Builds zu erreichen. Außerdem haben wir gesehen, wie Du Abhängigkeiten mit uv sync
verwaltest und die Laufzeitumgebung isolierst, um die Sicherheit und Performance Deiner Anwendung zu verbessern. So erhältst Du ein schnelles, leichtgewichtiges Image, das perfekt für den Einsatz in der Cloud oder in containerisierten Umgebungen geeignet ist.
Der vollständige Quellcode ist auf GitHub verfügbar. Teile uns mit, welche Art von Inhalten du dir in Zukunft wünschst. Wir freuen uns auf dein Feedback und deine Vorschläge - kontaktiere uns, indem du eine Issue im GitHub Repository öffnest oder eine E-Mail sendest.