Blog Post Header Image

> Mehrstufige Python Docker Images mit UV

Schnellere Builds und kleinere Images dank mehrstufiger Dockerfiles und uv

7 min read
Last updated:
Source code

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:

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.