Zum Inhalt springen

3. Auslieferung mit Containern

Zu Zen-Modus wechseln

Der Server aus dem letzten Kapitel läuft und ist abgesichert, aber er tut nichts. Auf Ihrem Notebook liegt ein Projekt, das startet, aber nur dort. Dazwischen fehlt die Kette, um die es in diesen zwei Wochen geht: Aus dem Code im Repository wird ein Image, aus dem Image ein laufender Container, davor ein Reverse Proxy mit gültigem Zertifikat, und darüber ein Name, den man jemandem sagen kann.

Am Ende des Kapitels ist Ihr Projekt öffentlich erreichbar, auch wenn es noch aus einer Startseite besteht. Wichtiger als der Inhalt ist an dieser Stelle der Weg: Ein git push löst eine neue Version aus, und wenn diese Version kaputt ist, kommen Sie mit zwei Klicks zur vorherigen zurück.

Diese Kette wird früh im Jahr gebaut und danach nicht mehr grundlegend verändert. Alles, was in den folgenden Kapiteln entsteht, entsteht in sie hinein.

Das Problem, das Container lösen, kennen Sie aus SEW als Halbsatz beim Abgeben: „Bei mir läuft es.” Und das stimmt sogar. Auf Ihrem Rechner liegt Node in Version 22, eine bestimmte Systembibliothek ist da, die Zeitzone steht auf Europe/Vienna, irgendwann haben Sie ein Paket global installiert und vergessen. Auf dem Server ist nichts davon so. Die Software ist dieselbe, die Umgebung nicht, und ein Programm läuft immer in beidem.

Ein Container packt die Umgebung mit ein. Was dabei entsteht, hat drei Begriffe, die man auseinanderhalten muss, weil sonst die halbe Fehlersuche im Nebel stattfindet.

Ein Image ist der Bauplan: unveränderlich, versioniert, aus einem Dockerfile erzeugt. Es enthält Betriebssystemschicht, Laufzeitumgebung, Ihren gebauten Code.

Ein Container ist eine laufende Instanz dieses Images. Er ist ein Prozess mit eigener Sicht auf Dateisystem, Netzwerk und Prozessliste, keine virtuelle Maschine. Wenn er stirbt, ist alles weg, was in ihm geschrieben wurde.

Ein Volume ist der Ort, an dem Daten das überleben. Alles, was länger leben soll als der Container, gehört dorthin: Datenbankdateien, hochgeladene Bilder, das Datenverzeichnis des CMS.

Daraus folgt die Regel, die den Rest des Kapitels trägt: Ein Container ist jederzeit ersetzbar. Beim nächsten Deployment wird er weggeworfen und durch einen neuen aus einem neuen Image ersetzt. Wer sich darauf verlässt, dass eine Datei im Container liegen bleibt, verliert sie beim übernächsten Push. Eine SQLite-Datei neben der Anwendung, ein Upload-Ordner ohne Volume, ein von Hand im laufenden Container installiertes Paket: alles drei ist beim nächsten Deployment verschwunden.

Ein Dockerfile beschreibt, wie aus Ihrem Quellcode ein Image wird. Es in einem Schritt zu tun, funktioniert und erzeugt ein Image von über einem Gigabyte, in dem der komplette Quellcode, alle Entwicklungsabhängigkeiten und der Compiler liegen. Deshalb baut man in mehreren Stufen und nimmt am Ende nur mit, was zur Laufzeit gebraucht wird.

# 1. Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 2. Build
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 3. Runtime
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S nextjs
COPY --from=build /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Vier Entscheidungen in dieser Datei sind erklärungsbedürftig.

Die Reihenfolge der COPY-Anweisungen ist kein Zufall. Docker speichert jede Schicht zwischen und baut ab der ersten geänderten Zeile neu. Weil package.json seltener geändert wird als der Quellcode, wird sie zuerst kopiert; npm ci läuft dann nur, wenn sich die Abhängigkeiten tatsächlich geändert haben. Wer stattdessen COPY . . an den Anfang setzt, installiert bei jedem Tippfehler alle Pakete neu und wartet drei Minuten statt zwanzig Sekunden.

Die standalone-Ausgabe von Next.js muss in next.config.js mit output: 'standalone' eingeschaltet werden. Next.js legt dann einen Ordner an, der nur die tatsächlich benutzten Teile von node_modules enthält, samt eigenem server.js. Der Unterschied liegt bei einem üblichen Projekt zwischen etwa 1,2 GB und 180 MB Image-Größe. Das ist keine Kosmetik: Dieses Image wird bei jedem Deployment gebaut, übertragen und gestartet.

Der eigene Benutzer. Ohne USER läuft der Prozess im Container als root. Das ist bequem und dieselbe Nachlässigkeit wie auf dem Server selbst, nur eine Ebene tiefer. Wer aus der Anwendung ausbricht, steht dann als root im Container statt als Benutzer ohne Rechte.

Die .dockerignore gehört daneben, sonst kopiert COPY . . alles mit, was nicht ins Image gehört:

node_modules
.next
.git
.env
.env.*
docs
Dockerfile

Der Eintrag .env ist der wichtigste. Ohne ihn landen Ihre Zugangsdaten im Image, und ein Image wird weitergegeben, veröffentlicht und in einer Registry abgelegt.

Bauen und lokal ausprobieren, bevor irgendetwas auf den Server geht:

Terminal-Fenster
docker build -t project:test .
docker run --rm -p 3000:3000 --env-file .env project:test
docker images | grep project # how big is the result?

Ihr Projekt besteht nicht aus einem Prozess. Es braucht mindestens eine Datenbank, später ein CMS. docker compose beschreibt diese Dienste zusammen mit ihren Netzwerken und Volumes in einer Datei.

services:
app:
build: .
environment:
DATABASE_URL: postgres://project:${DB_PASSWORD}@db:5432/project
depends_on:
db:
condition: service_healthy
networks: [internal]
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: project
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: project
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U project"]
interval: 10s
timeout: 5s
retries: 5
networks: [internal]
restart: unless-stopped
volumes:
dbdata:
networks:
internal:

An dieser Datei lohnt sich ein genauer Blick, weil sie mehrere Punkte aus dem letzten Kapitel einlöst.

Die Datenbank hat kein ports:. Sie ist damit aus dem Internet nicht erreichbar, und zwar wirklich, nicht nur laut ufw. Erreichbar ist sie ausschließlich für Container im Netz internal, und dort unter ihrem Dienstnamen db. Genau das war die Falle aus Kapitel 2: Ein veröffentlichter Port umgeht die Hostfirewall. Der sauberste Umgang damit ist, ihn gar nicht erst zu veröffentlichen.

Das Volume dbdata ist der Grund, warum die Daten das nächste Deployment überleben. Ohne diese drei Zeilen liegt die Datenbank im Container, und der Container wird ersetzt.

Der Healthcheck sorgt dafür, dass die Anwendung erst startet, wenn die Datenbank Anfragen annimmt. depends_on allein wartet nur darauf, dass der Container gestartet wurde, nicht darauf, dass PostgreSQL innen bereit ist. Der Unterschied zeigt sich als Verbindungsfehler in den ersten Sekunden nach jedem Deployment.

Das Passwort steht nicht in der Datei, sondern kommt als ${DB_PASSWORD} aus der Umgebung. docker-compose.yml gehört ins Repository, .env nicht.

Damit ist auch die allgemeine Regel beantwortet, welche Konfiguration wohin gehört. Ins Image kommt, was zum Programm gehört und für alle Umgebungen gleich ist. In die Umgebung kommt, was sich zwischen lokal, Vorschau und Produktion unterscheidet: Adressen, Zugangsdaten, Schalter. In die Dokumentation kommt, welche Variablen es gibt und was sie bedeuten, und zwar in .env.example mit allen Namen und ohne einen einzigen echten Wert.

Auf Ihrem Server laufen bald mehrere Container, aber ins Internet zeigen nur zwei Türen: Port 80 und Port 443. Der Reverse Proxy steht hinter diesen beiden Türen und verteilt eingehende Anfragen anhand des angefragten Namens auf die passenden Container. Nebenbei erledigt er die zweite Aufgabe, für die man ihn haben will: Er terminiert TLS, kümmert sich also um die Zertifikate, sodass die Container selbst nur einfaches HTTP im internen Netz sprechen.

In diesem Kapitel übernimmt das Traefik. Der Unterschied zu nginx liegt in der Konfigurationsweise: Traefik liest die Docker-Schnittstelle mit und erkennt neue Container an ihren Labels, statt auf eine von Hand gepflegte Konfigurationsdatei zu warten.

labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`projekt.example.at`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.services.app.loadbalancer.server.port=3000"

Diese fünf Zeilen sagen: Für Anfragen an projekt.example.at ist dieser Container zuständig, sie kommen über HTTPS herein, das Zertifikat besorgt der ACME-Resolver, und intern lauscht der Container auf Port 3000.

Das Zertifikat holt Traefik automatisch bei Let’s Encrypt. Der Ablauf dahinter ist eine Besitzprüfung: Let’s Encrypt stellt eine Aufgabe, Traefik hinterlegt die Antwort unter dem angefragten Namen, Let’s Encrypt ruft sie ab. Nur wer den Namen tatsächlich kontrolliert, kann das. Zwei Voraussetzungen folgen daraus, und beide sind der häufigste Grund für ein ausbleibendes Zertifikat: Der DNS-Eintrag muss auf Ihren Server zeigen, und Port 80 muss von außen erreichbar sein.

Dazu kommen zwei Einstellungen, die kein Extra sind. Anfragen auf Port 80 werden dauerhaft auf HTTPS umgeleitet, damit niemand versehentlich unverschlüsselt arbeitet. Und der Header Strict-Transport-Security (HSTS) weist den Browser an, diesen Namen künftig gar nicht erst unverschlüsselt anzufragen. Beides taucht in Kapitel 9 bei den Security-Headern erneut auf, dort dann mit Begründung aus Angreifersicht.

sequenceDiagram
  participant B as Browser
  participant D as DNS
  participant T as Traefik
  participant C as Container
  B->>D: Wo ist projekt.example.at?
  D->>B: 203.0.113.10
  B->>T: HTTPS-Anfrage an 203.0.113.10, Host: projekt.example.at
  T->>T: TLS beenden, Regel nach Host auswerten
  T->>C: HTTP-Anfrage im internen Netz, Port 3000
  C->>T: Antwort
  T->>B: Antwort über TLS

Dieses Diagramm sollten Sie erklären können, und zwar an jeder Station. Es ist die Standardfrage bei der praktischen Leistungsfeststellung zu diesem Kapitel, und es ist auch die Landkarte für die Fehlersuche: Jede Station kann ausfallen, und jede fällt anders auf.

Abspielen
Proxy vs Reverse Proxy vs Load Balancer | Simply Explained
TechWorld with Nana

Ein Name wird auf eine Adresse abgebildet, mehr macht DNS nicht. Für dieses Jahr brauchen Sie drei Eintragsarten.

EintragBedeutungBeispiel
AName zeigt auf eine IPv4-Adresseprojekt.example.at → 203.0.113.10
AAAAName zeigt auf eine IPv6-Adresseprojekt.example.at → 2a01:4f8::1
CNAMEName ist ein Verweis auf einen anderen Namenwww.example.at → example.at

Der Wert, der in der Praxis am meisten Verwirrung stiftet, ist die TTL: Sie sagt, wie lange andere Server die Antwort zwischenspeichern dürfen. Eine Änderung wirkt deshalb nicht sofort, sondern in einem Zeitraum, der von der alten TTL abhängt. Wer einen Umzug plant, setzt die TTL einen Tag vorher herunter, stellt dann um und setzt sie danach wieder hoch. Wer das vergisst, wartet vier Stunden und ändert in der Zwischenzeit nervös Dinge, die in Ordnung waren.

Für das Projekt legen Sie am besten gleich Unternamen an, auch wenn Sie zunächst nur einen brauchen:

projekt.example.at die Anwendung
api.projekt.example.at der Webservice, falls getrennt (Kapitel 5)
cms.projekt.example.at der Inhaltsbereich (Kapitel 14)
dokploy.example.at die Dokploy-Oberfläche

Diagnose läuft über drei Befehle, und zwar in dieser Reihenfolge:

Terminal-Fenster
dig +short projekt.example.at # what does DNS actually say?
curl -vI https://projekt.example.at # does a response arrive? which certificate?
sudo journalctl -u docker -f # what does the server say about it?

Der erste Befehl trennt zuverlässig die beiden Fälle, die sich für den Benutzer gleich anfühlen: Die Seite ist nicht erreichbar, weil der Name nicht auflöst, oder sie ist nicht erreichbar, weil dahinter nichts läuft.

Alles bisher Beschriebene ließe sich von Hand betreiben: Compose-Dateien auf den Server kopieren, Traefik selbst konfigurieren, bei jedem Deployment einloggen und docker compose up -d --build tippen. Das funktioniert und ist nach dem dritten Mal lästig, fehleranfällig und schlecht nachvollziehbar.

Dokploy ist eine Oberfläche, die diese Handgriffe zusammenfasst. Sie läuft auf Ihrem eigenen Server, nicht in einer fremden Cloud, und übernimmt den Traefik samt Zertifikatsverwaltung, den Build aus dem Git-Repository, die Verwaltung der Umgebungsvariablen, Datenbanken als eigene Dienste, den Zugriff auf Logs, geplante Sicherungen und die Deployment-Historie.

  1. Installieren. Das Installationsskript richtet Docker, Traefik und die Oberfläche ein:

    Terminal-Fenster
    curl -sSL https://dokploy.com/install.sh -o install.sh
    less install.sh # read it first
    sudo sh install.sh

    Der Zwischenschritt ist Absicht. Ein Skript aus dem Netz direkt in eine Shell zu leiten, ist genau der Umgang, vor dem Kapitel 2 warnt, und für einen Server, den Sie gerade abgesichert haben, eine seltsame erste Handlung.

  2. Oberfläche absichern. Nach der Installation ist die Oberfläche auf einem Port erreichbar und mit dem ersten angelegten Konto verbunden. Legen Sie das Administratorkonto sofort an, bevor es jemand anderer tut, hängen Sie die Oberfläche an den Unternamen dokploy. mit eigenem Zertifikat, und schließen Sie den direkten Port danach in der Firewall.

  3. Anwendung anlegen. Repository verbinden, Branch wählen, Bauart festlegen (Ihr Dockerfile), Domain eintragen, Zertifikat aktivieren.

  4. Umgebungsvariablen eintragen. Alle Werte aus .env.example, jetzt mit echten Werten. Sie liegen damit auf dem Server und nicht im Repository.

  5. Datenbank anlegen. PostgreSQL als eigener Dienst, mit Volume und Sicherung. Die Verbindungszeichenfolge trägt Dokploy in die Umgebung der Anwendung ein.

  6. Deployment auslösen und die Ausgabe des Builds mitlesen. Beim ersten Mal geht selten alles durch, und das ist der lehrreiche Teil.

Ist das eingerichtet, wird ein Webhook im Repository hinterlegt: Bei jedem Push auf den Produktionsbranch baut und startet Dokploy neu. Damit ist die Kette geschlossen, die dieses Kapitel verspricht, von git push bis zur öffentlich erreichbaren Version.

Jedes Deployment hinterlässt einen Eintrag in der Historie samt Image. Zurück auf die vorige Version zu gehen heißt deshalb, ein vorhandenes Image wieder zu starten, und das dauert Sekunden statt eines Neubaus.

Testen Sie das, solange es unwichtig ist. Liefern Sie absichtlich eine kaputte Version aus, rollen Sie zurück, und schreiben Sie in docs/betrieb.md, wie lange es gedauert hat und was genau Sie geklickt haben. Ein Rollback, den man zum ersten Mal unter Druck durchführt, ist kein Rollback, sondern ein Experiment zum ungünstigsten Zeitpunkt.

Eine Einschränkung gehört dazu, und sie wird in der 5. Klasse ausführlicher behandelt: Ein Rollback holt den Code zurück, nicht die Datenbank. Wenn die kaputte Version bereits eine Migration ausgeführt hat, die Spalten entfernt, dann läuft die alte Version danach gegen ein Schema, das sie nicht kennt. Deshalb sind Migrationen, die nur hinzufügen, im Betrieb deutlich angenehmer als solche, die wegnehmen. In Kapitel 6 kommen wir darauf zurück.

Sobald das Projekt läuft, brauchen Sie einen Ort zum Ausprobieren, der nicht der öffentliche ist. Üblich sind zwei Umgebungen auf demselben Server: die Produktion am Hauptnamen mit dem Branch main, und eine Vorschau unter test.projekt.example.at mit dem aktuellen Arbeitsbranch. Beide bekommen eigene Umgebungsvariablen und, das ist der wichtige Teil, eigene Datenbanken. Eine Vorschauumgebung, die auf der Produktionsdatenbank arbeitet, ist keine Vorschau, sondern eine gut getarnte Produktion.

Fehlersuche in einer Kette funktioniert nur der Reihe nach. Die Reihenfolge ist immer dieselbe, und sie folgt dem Weg der Anfrage rückwärts.

  1. Build-Log. Ist das Image überhaupt entstanden? Fehler hier sind Kompilierfehler, fehlende Abhängigkeiten oder eine Variable, die zur Bauzeit gebraucht wird und fehlt.

  2. Container-Log. Ist der Container gestartet und geblieben? Ein Container, der sofort wieder beendet ist, hat fast immer eine sprechende letzte Zeile.

  3. Proxy-Log. Kommt die Anfrage bei Traefik an, und findet er eine Regel dafür? Hier zeigen sich falsche Namen, fehlende Labels und Zertifikatsprobleme.

  4. Healthcheck und Netzwerk. Erreicht der Proxy den Container auf dem angegebenen Port, und antwortet der Dienst dort?

Die Fehlerbilder wiederholen sich, deshalb lohnt sich eine Tabelle:

SymptomWahrscheinliche UrsacheWo nachsehen
Build bricht abAbhängigkeit oder Bauzeit-Variable fehltBuild-Log, .env.example
Container startet und endet sofortfalscher CMD, fehlende Variable, Absturz beim StartContainer-Log, letzte Zeile
502 vom ProxyContainer läuft nicht oder lauscht auf anderem PortContainer-Log, Label server.port
404 vom ProxyHost-Regel passt nicht zum angefragten NamenTraefik-Log, Router-Regel
Kein ZertifikatDNS zeigt woanders hin oder Port 80 ist zudig, Firewall, ACME-Log
Seite lädt, Daten fehlenfalsche DATABASE_URL oder Netz getrenntContainer-Log, Compose-Netzwerke
Daten nach Deployment wegVolume fehltCompose-Datei
  1. Geheimnisse im Image oder im Repository. Ein Image wird weitergegeben und ausgepackt. Zugangsdaten gehören in die Umgebung, sonst nirgendwohin.
  2. Datenbank ohne Volume. Funktioniert tadellos bis zum nächsten Deployment, dann sind die Daten weg. Ohne Sicherung sind sie endgültig weg.
  3. Ein Geheimnis mit NEXT_PUBLIC_-Präfix. Es steht damit im ausgelieferten JavaScript und ist öffentlich, unabhängig davon, wie gut der Server abgesichert ist.
  4. Zertifikatsversuche in Schleife, bis Let’s Encrypt sperrt. Erst DNS prüfen, dann Staging, dann Produktion.
  5. Container als root. Funktioniert sofort und macht aus jeder Lücke in der Anwendung mehr, als sie sein müsste.
  6. Port der Datenbank veröffentlichen, damit man lokal mit einem Werkzeug draufschauen kann. Damit steht sie im Internet, an ufw vorbei. Der richtige Weg ist ein SSH-Tunnel.
  7. COPY . . vor der Abhängigkeitsinstallation, wodurch der Cache bei jeder Änderung verfällt und jedes Deployment Minuten kostet.
  8. Rollback nie ausprobiert. Der erste Versuch findet dann während eines Ausfalls statt.
  9. Vorschau auf der Produktionsdatenbank. Der Testdatensatz landet in echten Daten, und meistens fällt es erst später auf.
  • Erklären: Image, Container und Volume unterscheiden und begründen, warum ein Container jederzeit ersetzbar sein muss.
  • Anwenden: ein mehrstufiges Dockerfile für das eigene Projekt schreiben, das ohne root läuft und ein schlankes Laufzeit-Image erzeugt.
  • Erklären: den Unterschied zwischen Bauzeit- und Laufzeitvariablen erklären und die Folgen für Geheimnisse benennen.
  • Anwenden: mehrere Dienste mit Compose beschreiben, inklusive internem Netz, Volume und Healthcheck.
  • Erklären: die Aufgabe eines Reverse Proxy beschreiben und den Weg einer Anfrage von der Domain über TLS bis zum Container an jeder Station erklären.
  • Anwenden: DNS-Einträge für das eigene Projekt anlegen und mit dig und curl prüfen.
  • Anwenden: Dokploy installieren, absichern, das Projekt aus dem Repository ausliefern und eine Domain mit gültigem Zertifikat verbinden.
  • Anwenden: ein fehlgeschlagenes Deployment anhand von Build-, Container- und Proxy-Log eingrenzen und ein Rollback durchführen.
  • Beurteilen: entscheiden, welche Konfiguration ins Image, welche in die Umgebung und welche in die Dokumentation gehört.
  • Beurteilen: die Grenzen eines Rollbacks im Zusammenspiel mit Datenbankmigrationen einschätzen.

Am Ende dieses Kapitels ist das Projekt öffentlich erreichbar, auch wenn es inhaltlich noch aus einer Startseite besteht. Abzugeben sind:

  • Dockerfile und .dockerignore im Repository, das Image baut lokal und auf dem Server.
  • docker-compose.yml mit Anwendung und Datenbank, ohne veröffentlichte Datenbankports, mit Volume.
  • Die Anwendung läuft unter der eigenen Domain mit gültigem Zertifikat, HTTP leitet auf HTTPS um.
  • Ein Push auf den Produktionsbranch löst ein Deployment aus.
  • Ein durchgeführtes und dokumentiertes Rollback in docs/betrieb.md, mit Zeitangabe.
  • .env.example vollständig, .env nirgends im Repository und in keinem Image.
  • Aufgabe 09 - Vom lokalen Projekt zum Image
  • Aufgabe 10 - Compose mit Anwendung und Datenbank
  • Aufgabe 11 - Dokploy, Domain und Zertifikat
  • Aufgabe 12 - Deployment kaputt: Fehlersuche und Rollback