Docker für LLM-Anwendungen

Track KI · M5 Betrieb, Baustein 01 · ca. 50 Min.

Worum es geht

Eine LLM-Anwendung beim Kunden muss überall gleich laufen. Dafür packst du Code, Abhängigkeiten und Laufzeit in ein Container-Image (container image, gebaut mit Docker). Die zweite Fähigkeit, sich erklären, wenn sie langsam oder falsch ist (Observability, Beobachtbarkeit), folgt in Teil 2. Dort steht auch die Projektaufgabe, die beides lokal zusammenführt.

Die Grundbegriffe stehen schon da: Container und Orchestrierung in konzepte/09a (Schritt 2). Das wiederholen wir nicht. Hier wendest du es auf LLM-Anwendungen an: Was ist an einem Image mit einem API-Schlüssel gefährlich, warum entscheidet die Reihenfolge der Zeilen über die Bauzeit, und wie liest du ein Dockerfile mit einem Checker.

Docker läuft nicht im Browser. Dockerfile, Layer-Cache, Multi-Stage, Umgebungsvariablen und Healthcheck siehst du darum als Textblöcke, die nicht im Browser ausführbar sind. Was im Browser geht, ist das Denken darüber: Du liest ein Dockerfile als Text, ein kleiner Checker (den du hier bekommst) findet Fehler, und ein Simulator sagt, welche Layer neu gebaut werden. Das echte Bauen ist die Projektaufgabe in Teil 2, lokal.

Zum Modulziel M5 (“Fertig, wenn: containerisiert, Kosten und Latenz sichtbar, einmal komplett lokal gelaufen”) trägt dieser Teil den ersten Punkt bei: ein reproduzierbares, schlüsselfreies Image. Kosten, Latenz und das lokale Modell folgen in ki/17.

Zeitplan ehrlich: etwa 20 Minuten Lesen, 30 Minuten für die drei Übungen.

Von JS/TS her gedacht

Idee JS/TS (Node) Python-Projekt
Abhängigkeiten exakt wie im Lockfile npm ci uv sync --frozen
Dateien, die nicht ins Image sollen .dockerignore mit node_modules, .env .dockerignore mit .venv, .env, .git
Konfiguration zur Laufzeit process.env.API_KEY os.environ["API_KEY"]
Nur das Ergebnis ins Image Multi-Stage: Build-Stufe mit npm run build, Laufzeit-Stufe nur mit dist/ Multi-Stage: Build-Stufe installiert, Laufzeit-Stufe kopiert die fertige .venv

Zwei Dinge sind neu gegenüber einem Node-Projekt. Erstens ist die Reihenfolge der Zeilen im Dockerfile eine Performance-Entscheidung, weil Docker nach dem ersten geänderten Layer alles Weitere neu baut. Zweitens ist ein Image unveränderlich und lesbar: Was einmal in einem Layer steckt, bleibt auch dann drin, wenn eine spätere Zeile es löscht.

Konzept

Schritt 1: Das Dockerfile der Quelle, Zeile für Zeile

Das ist das Dockerfile aus dem Kursbuch (Baustein 01). Nicht im Browser ausführbar, du siehst es nur als Text:

FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ src/
CMD ["uv", "run", "uvicorn", "mein_paket.main:app", "--host", "0.0.0.0"]
Zeile Was sie tut
FROM python:3.12-slim Basis-Image: ein Linux mit Python 3.12 in der schlanken Variante
COPY --from=ghcr.io/astral-sh/uv:latest ... holt das Programm uv aus einem anderen Image in dieses
WORKDIR /app ab hier arbeiten alle Befehle im Ordner /app
COPY pyproject.toml uv.lock ./ nur die zwei Dateien, die die Abhängigkeiten festlegen
RUN uv sync --frozen --no-dev installiert genau die Versionen aus uv.lock; --frozen verändert das Lockfile nie, --no-dev lässt Test- und Lint-Werkzeuge weg
COPY src/ src/ jetzt erst der eigene Code
CMD [...] der Befehl, der beim Start des Containers läuft

Jedes COPY und RUN erzeugt einen Layer (Schicht) des Images, FROM bringt die Layer des Basis-Images mit (Anweisungen wie WORKDIR oder CMD ändern nur Metadaten oder Kleinigkeiten, das Modell unten zählt sie trotzdem als Positionen). Das Image ist ein Stapel dieser Schichten. Die Quelle sagt es so: --frozen erzwingt exakt die Versionen aus dem Lockfile, kein stilles Update beim Bauen. Ein Image, das beim Bauen ohne festgepinnte Versionen nachlädt, ist morgen ein anderes Image als heute.

Drei Dinge fallen an diesem Dockerfile auf, die in der Quelle nicht angesprochen werden (allgemeines Fachwissen, deine Entscheidung): Der Container läuft als root (das meldet der Checker aus Schritt 3), uv:latest ist kein festgepinnter Tag (das prüft der Checker bei COPY --from nicht), und uv sync installiert standardmäßig auch das eigene Projekt, bevor src/ kopiert ist. Bei einem src-Layout mit uv_build bricht das ab (lokal mit uv 0.12.10 ausgeführt: Expected a Python module at: src/mein_paket/__init__.py). Dafür gibt es --no-install-project, siehe Projektaufgabe. Die ersten beiden kommen gleich, der dritte bei der Projektaufgabe.

Schritt 2: Layer-Cache, die Reihenfolge entscheidet

Docker baut Layer von oben nach unten. Bevor es einen Layer neu baut, schaut es, ob es genau diese Anweisung mit genau diesem Inhalt schon gebaut hat (Layer-Cache). Bei COPY zählt der Inhalt der kopierten Dateien. Sobald ein Layer neu gebaut wird, sind alle Layer darunter ebenfalls neu, auch wenn sie sich nicht geändert haben. Das ist der Grund für die Reihenfolge im Dockerfile oben: Die Abhängigkeiten ändern sich selten, der Code ständig. Also zuerst die zwei Dateien, dann die Installation, dann erst der Code.

Das Modell, mit dem du gleich selbst rechnest (stark vereinfacht, echtes Docker vergleicht auch den Befehlstext und Prüfsummen):

  1. Du gehst die Anweisungen von oben nach unten durch, Position 0 ist die erste.
  2. Ein COPY (ohne --from) löst aus, wenn eine seiner Quellen eine geänderte Datei enthält. Quelle . enthält alles. Quelle src/ enthält Dateien unter src/. Eine Quelle, die eine einzelne Datei ist, enthält genau diese Datei.
  3. Ab der ersten Anweisung, die auslöst, wird diese und jede weitere Anweisung neu gebaut. Davor gibt es Cache-Treffer.

Das Modell gilt für ein Dockerfile mit einer Stufe. Bei Multi-Stage (Schritt 4) hat jede Stufe ihren eigenen Cache, und COPY --from hängt eine Stufe an die andere. Das rechnest du hier nicht nach.

Durchgerechnet am Dockerfile der Quelle. Die Positionen sind: 0 FROM, 1 COPY --from, 2 WORKDIR, 3 COPY pyproject.toml uv.lock, 4 RUN uv sync, 5 COPY src/, 6 CMD.

Geänderte Datei Löst aus bei Neu gebaut (Positionen)
src/mein_paket/main.py Position 5 (src/ enthält die Datei) 5, 6
uv.lock Position 3 3, 4, 5, 6
README.md keine Anweisung kopiert sie keine

Eine Code-Änderung baut also nur den billigen Rest neu, die Installation bleibt im Cache. Jetzt dieselbe Rechnung für ein schlechtes Dockerfile, in dem COPY . . vor der Installation steht (Positionen: 0 FROM, 1 WORKDIR, 2 COPY . ., 3 RUN pip install, 4 CMD): Jede geänderte Datei, auch eine README.md, löst bei Position 2 aus, also werden 2, 3 und 4 neu gebaut, und pip install läuft bei jeder Zeile Code von vorn. Das ist der Fehler, den der Checker COPY_VOR_DEPS nennt.

Schritt 3: Einen Dockerfile-Checker lesen

Hier der Checker, mit dem du in den Übungen arbeitest. Erst der Parser: Er macht aus dem Text eine Liste von Anweisungen (Zeilennummer, Befehl, Argumente), überspringt Kommentare und Leerzeilen (auch mitten in einer Fortsetzung, wie Docker es tut) und verbindet Fortsetzungszeilen mit \.

Probe an der Quelle:

Dann die Regeln. Jede steht für einen Fehler, den du im nächsten Schritt erklärt bekommst:

Code Regel
SCHLUESSEL_IM_IMAGE ENV oder ARG mit einem Namen, der den Teil KEY, TOKEN, SECRET oder PASSWORD enthält (an Unterstrichen getrennt: API_KEY zählt, TOKENIZERS_PARALLELISM nicht), und einem festen Wert, oder COPY .env
COPY_VOR_DEPS vor der Installation (uv sync, pip install) wird etwas anderes als Manifest-Dateien (pyproject.toml, uv.lock, requirements.txt) kopiert
ROOT_USER in der letzten Build-Stufe (oder der Stufe, von der sie erbt) fehlt USER oder es steht root
UNGEPINNT FROM ohne Tag oder mit :latest (scratch und Verweise auf eigene Stufen sind ausgenommen)
KEINE_DOCKERIGNORE ein COPY . oder ADD . ohne .dockerignore, die .env ausschließt

Den Code darunter musst du nicht Zeile für Zeile lesen. Wichtig sind die Regeln in der Tabelle und die Form des Ergebnisses: lint(dockerfile, dockerignore) liefert eine Liste von (Zeile, Code). Führe die Zelle einfach aus.

Zuerst die Quelle, dann ein schlechtes Dockerfile mit festem Schlüssel (der Wert ist ein Platzhalter, kein echter Schlüssel):

Die Quelle selbst bekommt einen Befund (ROOT_USER, Zeile 0 heißt “keine bestimmte Zeile”), und uv:latest bleibt unbeanstandet, weil es ein COPY --from ist und kein FROM. Die Quelle sagt, dass nachgeladene Pakete ohne Versionen morgen ein anderes Image ergeben. Dasselbe gilt für ein Werkzeug-Image mit :latest (bitte prüfen: einen festen Tag aus der Doku des Werkzeugs wählen).

Schritt 4: Was nicht ins Image gehört, und was hinein muss

Schlüssel nie ins Image. Ein Image ist eine Datei, die kopiert, in eine Registry hochgeladen und von vielen gelesen wird. Ein Schlüssel in ENV, in einer kopierten .env oder in einem RUN-Befehl steckt im Layer. Eine spätere Zeile wie RUN rm .env macht den früheren Layer nicht kleiner, die Datei liegt dort weiter. docker history zeigt außerdem die Befehle der Layer an, dazu gehören ENV-Zeilen (allgemeines Fachwissen, bitte mit der Docker-Doku prüfen). Der Schlüssel kommt zur Laufzeit von außen. Das Python-Programm liest ihn wie bisher aus der Umgebung:

import os
schluessel = os.environ["ANTHROPIC_API_KEY"]  # fehlt die Variable: KeyError, ein klarer Fehler beim Start

Beim Start übergibst du ihn (nicht im Browser ausführbar, Beispiel):

docker run --rm --env-file .env mein-image:0.1

--env-file liest die Datei auf deinem Rechner beim Start, sie landet nicht im Image. Auf einem Server nutzt man dafür die Secret-Verwaltung der Plattform. Die .env gehört zusätzlich in die .dockerignore, damit ein COPY . . sie nie mitnimmt.

.dockerignore liegt neben dem Dockerfile und funktioniert wie .gitignore, nur für den Build. Eine brauchbare Mindestfassung für ein Python-Projekt:

.env
.git
.venv
__pycache__/

Sie hält Schlüssel aus dem Image, verhindert, dass dein lokales .venv ins Image kopiert wird (es passt nicht zu einem anderen Betriebssystem), und hält den Build-Kontext klein.

Nicht als root laufen. Ohne USER läuft der Prozess im Container als root. Wird der Dienst über einen Fehler kompromittiert, hat der Angreifer im Container mehr Rechte als nötig. Zwei Zeilen genügen (nicht im Browser ausführbar):

RUN useradd --create-home app
USER app

Multi-Stage-Build. Zum Bauen braucht man Werkzeuge (Compiler, Paketmanager), zum Laufen nicht. Im Multi-Stage-Dockerfile gibt es mehrere FROM, und die letzte Stufe kopiert nur das Ergebnis der ersten. Das Image wird kleiner, und es steckt weniger darin, was ein Angreifer nutzen könnte. Schema (nicht ausgeführt, die Pfade hängen von deinem Projekt ab, bitte prüfen):

FROM python:3.12-slim AS bauen
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim
COPY --from=bauen /install /usr/local
WORKDIR /app
COPY src/ src/
USER nobody
CMD ["python", "-m", "src.main"]

Der Checker prüft ROOT_USER nur in der letzten Stufe, weil nur sie das fertige Image bestimmt, und COPY_VOR_DEPS in jeder Stufe einzeln.

Healthcheck. Ein HEALTHCHECK fragt den Container regelmäßig, ob er noch gesund ist. Schema (nicht im Browser ausführbar, nicht ausgeführt, bitte prüfen):

HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

Für LLM-Dienste ein wichtiger Punkt (allgemeines Fachwissen): Der Healthcheck darf keinen Modellaufruf auslösen. Er läuft alle 30 Sekunden, jeder Aufruf kostet Geld und zählt gegen das Rate Limit. Er prüft nur, ob der Prozess antwortet, höchstens ob die Datenbank erreichbar ist.

Falle

  1. COPY . . ganz oben. Funktioniert, ist aber bei jeder Code-Änderung ein voller Neubau mit Installation. Erst die Manifest-Dateien, dann installieren, dann der Code.
  2. Der Schlüssel “nur kurz” im Image. Eine spätere Löschzeile entfernt ihn nicht aus den alten Layern. Ein Schlüssel, der in einem Image war, gilt als bekannt: widerrufen und erneuern.
  3. Nicht festgepinnte Versionen (Quelle): Ein Image, das beim Bauen nachlädt, ist morgen ein anderes. Darum uv sync --frozen und feste Tags (python:3.12-slim, nicht python:latest).

Übungen

Übung 1: Ein Dockerfile reparieren (leicht bis mittel, ca. 10 Min.)

Ein Worker, der Dokumente in Vektoren verwandelt, hat dieses Dockerfile (echte Schlüssel gehören in eine .env auf deinem Rechner, hier geht es nur um das Dockerfile). Es verletzt fünf verschiedene Regeln des Checkers aus Schritt 3. Repariere es so, dass lint(...) eine leere Liste liefert, und der Worker weiter funktioniert: Python-Basis-Image bleibt, die Abhängigkeiten werden weiter mit pip install -r requirements.txt installiert, der Worker-Code kommt ins Image, und CMD startet worker.main. Im Tupel am Ende stehen das Dockerfile und die .dockerignore als Strings. Du bearbeitest beide Texte direkt.

Gehe die Befunde einzeln durch und frage jeweils: Welche Zeile ist gemeint, und was soll an ihrer Stelle stehen? Zwei Befunde hängen an derselben Zeile mit ADD. Auch ein repariertes Dockerfile muss noch bauen können: Welche Datei muss im Image sein, bevor pip install sie liest, und wer legt einen Nutzer an, bevor USER ihn nutzt?

dockerfile = """FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY worker/ worker/
RUN useradd --create-home app
USER app
CMD ["python", "-m", "worker.main"]
"""

dockerignore = """.env
.git
.venv
__pycache__/
"""

(dockerfile, dockerignore)

Übung 2: Welche Layer werden neu gebaut? (mittel, ca. 12 Min.)

Schreibe den Cache-Simulator aus Schritt 2 selbst. neu_gebaut(anweisungen, geaendert) bekommt die Anweisungen (Ausgabe von parse_dockerfile) und eine Liste geänderter Dateien (Pfade wie "worker/main.py"). Sie gibt die Positionen (0 ist die erste Anweisung, nicht die Zeilennummer, denn Kommentare, Leerzeilen und Fortsetzungszeilen verschieben die Zeilen) der neu gebauten Anweisungen zurück, aufsteigend als Liste. Das Dockerfile hat eine Stufe. Es gelten die drei Regeln aus Schritt 2, und nur einfache Quellen kommen vor: ., ein Ordner mit Schrägstrich am Ende (worker/) oder eine Datei. Ein COPY kann mehrere Quellen haben (COPY a.txt b.txt ziel/), das letzte Wort ist das Ziel. Ein Ordner worker/ enthält worker/main.py, aber nicht worker_tools/x.py.

Beispiel für dieses Dockerfile: Bei ["worker/main.py"] kommt [4, 5, 6, 7] heraus.

Du brauchst einen Merker, der beim ersten auslösenden COPY auf wahr springt und danach nie zurück. Vergleiche eine Quelle mit einer Datei nicht nur per Textanfang: Welches Zeichen steht zwischen Ordnername und Dateiname?

DOCKERFILE = """FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY worker/ worker/
COPY config/ config/
RUN python -m compileall worker
CMD ["python", "-m", "worker.main"]
"""

def trifft(datei, quelle):
    if quelle in (".", "./"):
        return True
    q = quelle.rstrip("/")
    return datei == q or datei.startswith(q + "/")

def neu_gebaut(anweisungen, geaendert):
    ergebnis, ausgeloest = [], False
    for pos, (zeile, befehl, args) in enumerate(anweisungen):
        if befehl in ("COPY", "ADD") and "--from" not in args:
            quellen = [t for t in args.split() if not t.startswith("--")][:-1]
            if any(trifft(d, q) for d in geaendert for q in quellen):
                ausgeloest = True
        if ausgeloest:
            ergebnis.append(pos)
    return ergebnis

neu_gebaut

Übung 3: Ein Schlüssel im Image (leicht, ca. 5 Min.)

Ein Kollege hat vor drei Tagen das Image rag-api:1.4 in die interne Registry gepusht, aus der 40 Personen lesen dürfen. Heute fällt auf: In Zeile 4 des Dockerfiles stand ENV ANTHROPIC_API_KEY= mit dem echten Schlüssel der Firma. Die Zeile ist inzwischen gelöscht, ein neues Image rag-api:1.5 ist gebaut, aber noch nicht gepusht. Was machst du als Erstes?

  • A: rag-api:1.4 aus der Registry löschen, denn ohne das Image kommt niemand mehr an den Schlüssel, danach in Ruhe 1.5 pushen.
  • B: Den Schlüssel beim Anbieter sperren und einen neuen ausstellen. Der alte gilt als bekannt, was auch mit dem Image passiert.
  • C: Nur 1.5 pushen und den Tag latest darauf setzen, damit jeder, der das Image zieht, die neue Fassung ohne Schlüssel bekommt.
  • D: Eine Zeile RUN unset ANTHROPIC_API_KEY ergänzen, damit der Wert aus dem Image verschwindet, und 1.4 damit überschreiben.

Frage bei jeder Option: Was passiert mit den Kopien des alten Images, die schon gezogen wurden? Und: Wie viel Kontrolle hast du über alles, was in diesen drei Tagen gelesen wurde?

antwort = "B"
antwort

Lokal: ein Image bauen

Die lokale Übung zu diesem Teil (Dockerfile bauen, zweimal bauen und Paketversionen vergleichen) steht in der Projektaufgabe am Ende von Teil 2, weil dieselbe Datei im Lernlabor auch den Trace-Teil enthält.

Merksatz

Ein Container-Image ist ein reproduzierbarer, lesbarer Stapel aus Layern: feste Versionen, billige Schichten oben und nie ein Schlüssel darin.

Prüfstein

Im Review fällt dir auf, dass in einem Dockerfile COPY . . an dritter Stelle steht, kein .dockerignore existiert und eine ENV-Zeile einen Schlüssel enthält. Was änderst du in welcher Reihenfolge, und was tust du mit dem Schlüssel, wenn das Image schon in einer Registry liegt?

Weiter mit Teil 2: Observability, Traces und Logs.


Quelle: quellen/kursbuch-lerninhalte.md, Modul M5, Baustein “01 Containerisierung mit Docker” (Warum, Kernidee, Dockerfile, Stolperfalle, Merksatz, Übung). Aus der Quelle stammen: das Dockerfile mit python:3.12-slim, uv, --frozen --no-dev und CMD mit uvicorn, die Aussage zu --frozen und zum Lockfile-Prinzip, die Stolperfalle zu nicht festgepinnten Versionen. Über die Quelle hinaus (allgemeines Fachwissen, soweit nicht anders vermerkt; Docker und uv wurden nicht ausgeführt, bitte prüfen): Layer-Cache und das vereinfachte Modell, .dockerignore, USER und useradd, Multi-Stage-Schema, Healthcheck-Beispiel und die Regel “kein Modellaufruf im Healthcheck”, docker run --env-file, docker history zeigt ENV-Zeilen, der Hinweis zu uv sync --no-install-project. Der Dockerfile-Parser, der Checker und der Cache-Simulator sind eigene, vereinfachte Nachbauten (reines Python 3.13 ausgeführt), keine Docker-Werkzeuge. Die Schlüsselwerte in den Beispielen sind Platzhalter. Das Image ghcr.io/astral-sh/uv:latest steht so in der Quelle, ein fester Tag wäre besser (bitte prüfen).