← Übersicht  ·  Skripte & Dateien  ·  Querschnitt · Nur Zeichnungen

Diagramme: Hetzner-Landschaft und CRC-Cluster

Die anderen Anleitungen erklären Schritt für Schritt. Diese hier zeigt das Zusammenspiel – achtunddreißig Zeichnungen, kein einziger Befehl. Zuerst die Klammer: dieselbe Anwendung, zwei Ziele, und was zwischen den beiden Welten tatsächlich fließt. Danach die Hetzner-Landschaft, danach der CRC-Cluster mit der Bibliothek darauf, dann die Bauteile von innen, zuletzt vier zum Nachschlagen. Jede Zeichnung beantwortet genau eine Frage; was darunter steht, ist die Antwort in einem Satz.

Die Farben sind kein Schmuck – derselbe Ton bedeutet in allen Zeichnungen dasselbe:

Stand: 2. September 2026 38 Diagramme, 0 Befehle Werte aus den Anleitungen und dem Projekt-Repository

01 Das Zusammenspiel

Bereit: CRC, GitHub, ghcr.io, Tailnet und Hetzner

Die einzige Frage, die diese Seite überhaupt zusammenhält: Was verbindet den Laptop-Cluster mit den Servern in Finnland?

ghcr.io Container-Registry · oeffentlich lesbar Laptop · Windows hinter dem eigenen Anschluss Podman in WSL2 baut die Images CRC-Registry verlaesst den Laptop nie HTTPS, --tls-verify=false CRC-VM · OKD 4.22 · 6 vCPU, 16 GB 8 Spring-Dienste + Frontend Postgres · Redis Kafka Keycloak Acht JVMs gleichzeitig kalt gestartet: Last 157. Deshalb gestaffelt hochfahren. Gestrichelt = die beiden Kandidaten, die nach Hetzner umziehen koennten. Hetzner · Rechenzentrum oeffentliche IP, Cloud-Firewall davor Cloud-Firewall 22 nur von /32 · 80+443 offen Storage Box restic-Repository Server · cpx32 · 4 vCPU, 8 GB k3s – dieselben Helm-Charts Postgres mit PVC Ingress nginx Einziger Unterschied im Code: eine Zeile Umgebungs-Overlay. persistence bleibt hier eingeschaltet – ein neuer Pod darf keine Daten kosten. podman push · Tag = sha-<commit> HTTPS · OCI-Distribution-API · Token-Anmeldung pull beim Rollout nur dann – nicht zur Laufzeit HTTPS · ohne Anmeldung, solange oeffentlich Tailnet SSH 22 + kubectl 6443 im WireGuard-Tunnel, UDP, ohne offenen Port geplant: auslagern selten gerufen, latenztolerant restic SFTP · taeglich 03:15, dedupliziert Postgres und Redis bleiben, wo die Anwendung ist: jede Ausleihe macht mehrere Abfragen nacheinander, ueber WAN vervielfacht sich das pro Query. Ein Cache am anderen Ende einer Leitung ist keiner mehr. Voraussetzung fuer alles Gestrichelte: nichts davon offen im Internet – der Weg ist das Tailnet, nicht eine Portfreigabe.
In diesem Betriebsmodell haben die beiden Welten zwei technische Haupt-Beruehrungspunkte: den Build auf dem Laptop, der dasselbe Image in zwei getrennte Registries schiebt – ghcr.io für Hetzner, die clusterinterne Registry für CRC, die den Laptop nie verlässt – und das Tailnet, durch das der Laptop den Server bedient. Alles andere laeuft getrennt. Deshalb kann ein Bauteil ueberhaupt umziehen, ohne dass der Rest davon erfaehrt – und deshalb entscheidet allein die Latenztoleranz, welches.

Teil A – die Hetzner-Landschaft

02 Netz & Ports

Bereit: Firewall, Tailnet und offene Dienste

Wer darf womit reden – und warum die Firewall vor dem Server sitzt und nicht darauf.

Internet jeder, auch Scanner Dein Anschluss eine einzige IP Cloud-Firewall eigenes Objekt, nicht am Server 80 + 443 0.0.0.0/0 und ::/0 8443 Verwaltung nur eigene IP /32 22 SSH nur eigene IP /32 alles Uebrige: gesperrt Erlaubnisliste, keine Reihenfolge, keine Prioritaeten Server ufw / nftables optional darauf – zweite Schicht, nicht die erste Docker umgeht ufw! TCP 80 / 443 TCP 22 und 8443 durchgelassen durchgelassen Tailnet – geht an der Firewall vorbei, ohne einen Port zu oeffnen WireGuard ueber UDP · von innen aufgebaut, deshalb braucht es keine eingehende Regel
Die Firewall filtert im Netz, bevor der Verkehr den Server erreicht – sie kostet deshalb keine Rechenleistung und wirkt weiter, wenn auf dem Server etwas kaputt ist. Sie ist eine reine Erlaubnisliste ohne Reihenfolge. Der gruene Weg ist der interessante: das Tailnet baut die Verbindung von innen nach aussen auf, also braucht es dafuer gar keine eingehende Regel.

03 Ein Host, mehrere Stacks

Bereit: Reverse Proxy und getrennte Compose-Netze

Warum keiner der Dienste einen Port auf dem Wirt veroeffentlicht – und was das gemeinsame Netz damit zu tun hat.

von aussen: nur diese zwei Ports 80 / 443 TCP – die einzigen Ports am Wirt Reverse Proxy einziger mit ports: Docker-Netz „proxy“ – muss existieren, bevor der erste Stack startet wordpress /opt/stacks/wordpress vaultwarden /opt/stacks/vaultwarden blog /opt/stacks/blog HTTP ueber Dienstnamen – Dockers interner Resolver (127.0.0.11) unverschluesselt – TLS endet beim Proxy Netz „internal“ – nur wordpress MariaDB – ein echter Netzdienst TCP 3306, nur im internal-Netz SQLite – nur eine Datei im Volume kein Netz, kein Server mountet, spricht nicht Der Trick gilt nur fuer echte Netzdienste: wordpress steckt in zwei Netzen – erreichbar vom Proxy, die MariaDB fuer niemanden sonst. vaultwarden braucht kein zweites Netz, weil seine Datenbank kein Dienst ist, sondern eine SQLite-Datei, die der Container einhaengt.
Nur der Proxy veroeffentlicht Ports. Alle anderen Container sind ausschliesslich ueber ihren Dienstnamen im gemeinsamen Netz erreichbar – Docker haelt dafür einen eigenen DNS-Resolver bereit, keine externe Konfiguration nötig. Die MariaDB hängt zusätzlich in einem zweiten Netz, das der Proxy nicht kennt: damit ist sie für ihren Stack da und für sonst niemanden, auch nicht für die anderen Stacks. Vaultwarden braucht das nicht – seine Datenbank ist kein Dienst, sondern eine Datei.

04 Der Deploy-Weg

Vom git push bis zum laufenden Container – und an welcher Stelle das Geheimnis liegt.

Bereit: git push auf main
Laptop GitHub Actions ghcr.io Server Container git push auf main HTTPS oder SSH zu GitHub checkout + build push sha-<commit> HTTPS · Registry-API Cache: type=gha, mode=max SSH 22 ueber das Tailnet – kein offener Port WireGuard/UDP, von innen aufgebaut compose pull HTTPS zu ghcr.io up -d --remove-orphans healthy? – sonst Abbruch Das Geheimnis verlaesst nie GitHub: der Runner meldet sich selbst an der Registry an. Der Server bekommt nur den fertigen Tag zu sehen.
Der Server zieht das Image, er baut es nicht. Damit braucht er weder Quellcode noch Build-Werkzeuge noch Zugangsdaten zum Repository. Der Tag ist der Commit-Hash – das ist die einzige Stelle, an der der Bezug zwischen laufendem Container und Quellstand entsteht; latest waere bequemer und wuerde genau diese Zuordnung zerstoeren.

05 Server-Lebenszyklus

Bereit: Snapshot prüfen, erst dann löschen

Der Zyklus, mit dem ein Lernserver nur dann Geld kostet, wenn er laeuft.

laeuft ~0,0116 EUR/h Dienste gestoppt + sync Snapshot laeuft 1–4 Minuten Exit-Code = 0? geloescht 0 EUR/h down preDown-Hook · SSH 22 je Server sync Puffer auf die Platte schreiben create-image Hetzner-API · HTTPS pruefen ja → server delete nein Server bleibt stehen und wird NICHT geloescht up: neuer Server aus dem neuesten Snapshot – Konfiguration identisch bleibt ueber den Zyklus hinweg reservierte Primary-IP (~0,50 EUR/Mon) Firewall, SSH-Key, Snapshots Pruning: je Server nur die N neuesten Snapshots behalten (~0,0143 EUR/GB/Monat)
Die Raute traegt den ganzen Zyklus. Erst wenn der Snapshot nachweislich gelungen ist, wird der Server geloescht – scheitert er, bleibt alles stehen. Ohne diese Pruefung waere das Skript eine Maschine zum Datenverlust. Die reservierte IP ist der zweite Grund, warum der Zyklus im Alltag funktioniert: DNS, fremde Firewall-Regeln und known_hosts stimmen nach dem Wiederaufbau weiter.

06 Backup-Wege

Bereit: Dumps und Dateien in restic

Warum eine laufende Datenbank nie als Datei gesichert wird – und wo der Dump entlanglaeuft.

Postgres im Container laeuft weiter MariaDB im Container --single-transaction SQLite (Vaultwarden) .backup, nicht kopieren Dateien & Volumes /opt, /etc/docker Dump ueber die Pipe --stdin --stdin-filename landet nie auf der Platte --tag db docker exec – lokaler Unix-Socket, Ausgabe direkt in die Pipe konsistente Kopie liegt im Volume und wird mitgesichert restic-Repository Storage Box, dedupliziert, verschluesselt --keep-daily 7 · --keep-weekly 5 --keep-monthly 12 · --prune Regeln wirken zusammen, nicht nacheinander SFTP ueber SSH wer prueft den Waechter? montags: check --read-data-subset=5% taeglich: Totmann-Schalter meldet den Lauf 12:00: zweiter Timer prueft das Alter Der dritte Punkt rechts ist der wichtigste: der Totmann-Schalter meldet, wenn ein Backup laeuft. Ein Backup, das gar nicht erst startet, schweigt auch beim Melden – genau diese Luecke schliesst die Altersprufung.
Datenbanken werden gedumpt, nie als Datei kopiert. Eine laufende Datenbank hat Dateien in einem Zustand, aus dem sich nichts zuverlaessig wiederherstellen laesst. Der Dump geht direkt in die Pipe nach restic, also ohne Zwischendatei – kein Platzbedarf und nichts, was jemand liegen laesst.

07 DNS & Zertifikate

Bereit: direkt zum Server oder über Cloudflare

Der Unterschied zwischen grauer und oranger Wolke – und wann die orange schadet.

Besucher DNS – A-Record blog, vault, @ UDP 53 graue Wolke DNS zeigt direkt auf den Server TLS endet am Server orange Wolke DNS zeigt auf Cloudflare TLS endet dort, echte IP verborgen nur HTTP/HTTPS – sonst nichts A-Record A-Record auf Cloudflare Server (Origin) Reverse Proxy + Let's Encrypt braucht Port 80 fuer die Zertifikatspruefung HTTPS 443 · eine TLS-Strecke HTTPS 443 · zweite TLS-Strecke Die orange Wolke verbirgt die Server-IP und faengt Angriffe ab – aber sie spricht nur HTTP. Ein SSH- oder Mailserver dahinter ist schlicht nicht mehr erreichbar. Deshalb wird sie pro Record gesetzt, nicht pro Domain.
Der Proxy ist eine Eigenschaft des einzelnen Records, nicht der Domain. Fuer blog und www ist er sinnvoll, fuer einen Record, hinter dem SSH oder Mail liegt, macht er den Dienst unerreichbar – und der Fehler sieht dann nach einem Serverproblem aus.

08 Zugangswege

Bereit: SSH, Tailnet und Hetzner-Konsole

Drei Wege hinein – und welcher noch funktioniert, wenn die anderen versagen.

Server PermitRootLogin no PasswordAuthentication no SSH von der eigenen IP Firewall-Regel /32, Schluessel Port 22, offen nur fuer /32 Tailnet von innen aufgebaut, kein Port WireGuard, ausgehend aufgebaut Hetzner-Konsole im Browser haengt am Bildschirm, nicht am Netz VNC am virtuellen Bildschirm – ohne Netz des Servers faellt aus, sobald der Anschluss eine neue Adresse bekommt (Zwangstrennung) faellt aus, wenn der Tailscale-Dienst auf dem Server nicht laeuft funktioniert auch dann noch – der Rettungsweg
Wer SSH auf die eigene Adresse begrenzt, sperrt sich irgendwann selbst aus. Das ist kein Argument gegen die Regel – sondern eines dafuer, den dritten Weg zu kennen, bevor man ihn braucht. Die Hetzner-Konsole haengt am virtuellen Bildschirm der Maschine und ist von Firewall, Netz und SSH-Konfiguration voellig unabhaengig.

Teil B – der CRC-Cluster und die Bibliothek darauf

09 Netz & Ports der CRC-VM

Bereit: Windows, WSL2, CRC-VM und Ports

Drei Maschinen auf einem Laptop, die sich gegenseitig fuer entfernte Rechner halten.

Windows-Host CRC-VM · Hyper-V OKD 4.22 · Router · interne Registry image-registry.openshift-image-registry .svc:5000 – zum Ziehen, clusterintern default-route-openshift-image-registry .apps-crc.testing – zum Schieben, von aussen Zwei Adressen fuer dieselbe Registry. Wer sie verwechselt, sucht lange. WSL2 · podman-machine-default baut die Images, schiebt sie in die Registry NAT, kein Mirrored-Networking – also eine eigene Maschine mit eigenem Loopback /etc/hosts auf 127.0.0.1 zeigt hier ins Leere Port 443 alle Interfaces Port 6443 nur Loopback TCP ueber die WSL-Gateway-Adresse, z. B. 172.25.224.1 HTTPS auf 443 · Adresse aus der Default-Route der WSL-Maschine und kann sich nach einem WSL-Neustart aendern × Der rote Strich ist die Falle: Podman in WSL sieht das 127.0.0.1 des Windows-Hosts nicht – es ist aus dieser Sicht eine andere Maschine. Dass Port 443 auf allen Interfaces bindet, ist der Grund, warum der gruene Weg ueberhaupt existiert; 6443 bliebe auch so unerreichbar. Deshalb steht in der /etc/hosts der WSL-Maschine die Gateway-Adresse – und ein alter Eintrag auf 127.0.0.1 muss dort weg, sonst gewinnt er.
Drei Netzwelten auf einem Geraet. Die CRC-VM, der Windows-Host und die WSL-Maschine sind fuereinander entfernte Rechner – localhost bedeutet in jeder etwas anderes. Der gruene Weg funktioniert nur, weil CRC den Port 443 nicht auf Loopback beschraenkt.

10 Was wo laeuft

Bereit: Routen, Services und Infrastruktur

Zwoelf Pods in einem Namespace, dazu das Frontend hinter seiner Route – und warum sie nicht gleichzeitig starten duerfen.

Namespace „library“ library.apps-crc.testing keycloak.apps-crc.testing library-frontend.apps-crc… 3 Routen – alles Uebrige nur clusterintern api-gateway :8080 – einziger Eingang config-server :8888 – muss zuerst laufen catalog:8081 member:8082 lending:8083 reservation:8084 fine:8085 notification:8086 Bitnami-Infrastruktur postgres7 Datenbanken, emptyDir redis-masterCache, ohne Passwort kafkaKRaft, 1 Controller keycloakQuarkus-Build beim Start: 5 min 34 leitet weiter, prueft das Token HTTP im Cluster · Servicename als Hostname jeder Dienst holt hier beim Start seine Konfiguration HTTP config-server:8888 · einmal beim Start Route → Service → Pod, TLS endet am Router Acht JVM-Kaltstarts gleichzeitig auf sechs vCPUs trieben die Last auf 157. Deshalb wird mit replicas 0 ausgerollt und danach einzeln hochgefahren – config-server zuerst, weil alle anderen beim Start ihre Konfiguration von ihm holen und sonst in eine Neustart-Schleife laufen.
Nur drei Adressen zeigen nach draussen. Die sechs Fachdienste und der config-server sind ausschliesslich clusterintern erreichbar, ueber ihren Servicenamen; von den acht Spring-Diensten hat allein das api-gateway eine Route. Der gruen markierte config-server ist die einzige echte Startreihenfolge im ganzen Stack – alles andere darf sich sortieren, wie es mag.

11 Eine Ausleihe von vorn bis hinten

Fuenf Dienste, sieben Aufrufe, ein Kompensationsschritt – und die Reihenfolge ist nicht beliebig.

Bereit: POST /loans mit Token
Browser api-gateway lending catalog member Kafka POST /loans – Token im Header HTTPS an der Route · danach HTTP im Cluster weiterleiten HTTP lending:8083 findByIsbn HTTP GET catalog:8081 · Feign Buch + Exemplare erstes freies Exemplar checkEligibility – mit Anzahl aktiver Ausleihen HTTP GET member:8082 · Zaehler als Query berechtigt ja/nein markCopyLoaned – hier sass das Rennen HTTP POST catalog:8081/…/mark-loaned · der einzige schreibende Fremdaufruf Ausleihe speichern library.lending.loan-created – danach 201 an den Browser Kafka-Protokoll · TCP 9092, PLAINTEXT · niemand wartet schlaegt das Speichern fehl, wird die Reservierung zurueckgenommen (markCopyAvailable) Die Reihenfolge ist bewusst: erst pruefen, dann reservieren, dann speichern. Waere das Reservieren zuerst, muesste jede abgelehnte Berechtigung ebenfalls kompensiert werden – ein zweiter Weg, auf dem etwas schiefgehen kann.
Zwei Dienste werden gefragt, bevor irgendetwas geschrieben wird. Der einzige Schritt, der einen fremden Dienst veraendert, ist markCopyLoaned – und weil danach noch das lokale Speichern scheitern kann, gibt es dafuer eine Ruecknahme. Diese eine Zeile ist der Grund, warum die Ausleihe ohne verteilte Transaktion auskommt.

12 Zustaende: Exemplar und Ausleihe

Bereit: AVAILABLE, LOANED, LOST, ACTIVE und RETURNED

Zwei kleine Automaten in zwei verschiedenen Diensten – die zusammenpassen muessen, ohne sich zu kennen.

CopyStatus – catalog-service AVAILABLE LOANED LOST RESERVED · IN_REPAIR im Enum, aber kein Codepfad setzt sie je ausleihen zurueckgeben auch von LOANED Real gibt es nur zwei Zustaende im Betrieb: AVAILABLE und LOANED. markCopyLost prueft den Quellzustand nicht – LOANED kann direkt nach LOST, ohne AVAILABLE. LoanStatus – lending-service ACTIVE RETURNED zurueckgeben Nur zwei Zustaende – ueberfaellig ist kein Zustand, sondern ein Datumsvergleich. Genau hier zaehlt die Abfrage, die den Fehler sichtbar macht: zwei ACTIVE auf demselben Exemplar darf es nie geben.
Die beiden Automaten kennen einander nicht. Dass ein Exemplar LOANED ist und genau eine Ausleihe darauf ACTIVE, ist keine Datenbankbedingung, sondern eine Zusage zwischen zwei Diensten. Wo eine solche Zusage nur in Anwendungscode steht, kann sie brechen – die naechste Zeichnung zeigt, wie. Nebenbei sichtbar: RESERVED und IN_REPAIR sind im Enum vorgesehen, aber kein Pfad setzt sie – und markCopyLost prueft den Ausgangszustand nicht.

13 Das Rennen – vorher und nachher

Die Zeichnung, um die es auf dieser Seite eigentlich geht.

Bereit: A und B lesen AVAILABLE
vorher – Lesen, Pruefen, Schreiben Anfrage A Anfrage B DB SELECT → AVAILABLE SELECT → AVAILABLE Pruefung besteht Pruefung besteht UPDATE → LOANED UPDATE → LOANED Beide bekommen HTTP 201. Der zweite Schreibvorgang gewinnt still – ohne Fehler, ohne Meldung, ohne Spur im Protokoll. Gemessen: 61 von 126 Exemplaren, eines an zehn Mitglieder. Das Zeitfenster zwischen Lesen und Schreiben ist die Luecke. Es wird kleiner, wenn die Maschine schnell ist – und verschwindet nie. Unter Last wird es gross. nachher – bedingtes UPDATE Anfrage A Anfrage B DB UPDATE … SET status = LOANED WHERE id = ? AND status = AVAILABLE 1 Zeile geaendert dieselbe Anweisung 0 Zeilen geaendert Die Bedingung steckt in der Anweisung selbst. Die Datenbank serialisiert die beiden – nicht der Code. A bekommt 201, B bekommt 422. „0 Zeilen“ heisst: inzwischen vergeben – und loest dieselbe Ausnahme aus wie zuvor die Statuspruefung. Nach aussen aendert sich nichts. Kein Sperren, keine Transaktionsklammer ueber Dienste hinweg, keine zusaetzliche Abfrage. Eine WHERE-Bedingung. Der Unterschied ist nicht, dass rechts geprueft wird und links nicht – links wird auch geprueft. Der Unterschied ist, wo: links zwischen zwei Datenbankzugriffen, wo eine zweite Anfrage dazwischenpasst; rechts innerhalb der einen Anweisung, wo nichts dazwischenpasst. Nachgewiesen wird das mit: SELECT copy_id, count(*) FROM loans WHERE status = 'ACTIVE' GROUP BY copy_id HAVING count(*) > 1 – muss leer bleiben.
Links passt zwischen Lesen und Schreiben eine zweite Anfrage, rechts nicht. Das ist der gesamte Unterschied zwischen den beiden Fassungen – eine Bedingung, die von der Anwendung in die UPDATE-Anweisung wandert. Die Datenbank kann zwei Anweisungen serialisieren; zwei getrennte Zugriffe kann sie nicht zusammenbinden.

14 Anmeldung – und die Falle im Token

Bereit: Keycloak-Token und issuer-Claim

Warum ein einziges fehlendes „s“ jede Anfrage mit 401 beendet.

Frontend im Browser Keycloak erreichbar ueber die Route https://keycloak.apps-crc.testing edge-terminiert: TLS endet am Router Anmeldung HTTPS ueber die Route Token HTTPS · als Bearer weiterverwendet im Token: iss-Claim https://keycloak.apps-crc.testing/realms/library folgt dem Host-Header des Requests api-gateway / Dienst Resource Server Anfrage mit Token Der Dienst vergleicht issuer-uri (Konfiguration) https://keycloak.apps-crc.testing/… gegen den iss-Claim im Token Zeichenkettenvergleich – exakt jwk-set-uri – anderer Weg http://keycloak:8080/…/certs clusterintern, kein TLS noetig HTTP GET clusterintern Die beiden Adressen unten und rechts sehen aus wie ein Widerspruch, sind aber richtig: der issuer muss zeichengenau zu dem passen, was im Token steht – also https. Die Schluessel holt der Dienst clusterintern ab, dort genuegt http. Wer beides gleichsetzt, bekommt 401.
Zwei Adressen fuer dieselbe Keycloak, und beide muessen unterschiedlich sein. Der iss-Claim entsteht aus dem Host-Header der Anmeldung, also mit https. Steht in der Konfiguration http, schlaegt der Vergleich fehl – und die Fehlermeldung spricht von einem ungueltigen Token, nicht von einem Tippfehler.

15 Circuit Breaker – und was er faelschlich gemessen hat

Bereit: CLOSED, OPEN, HALF_OPEN und 422→503

Ein Schutzmechanismus, der eine fachliche Ablehnung fuer einen Ausfall hielt.

so ist er gedacht CLOSED OPEN HALF_OPEN Fehlerquote ueberschritten nach Wartezeit Probe erfolgreich OPEN heisst: Aufrufe gehen gar nicht erst raus, der Fallback antwortet sofort. so lief es tatsaechlich catalog antwortet 422 – Exemplar inzwischen vergeben Feign macht daraus eine Ausnahme Resilience4j zaehlt sie als Fehler – Breaker oeffnet Fallback wirft CatalogServiceUnavailableException Nutzer sieht: 503 „Dienst nicht erreichbar“ Der Dienst war die ganze Zeit erreichbar. Was er sagte, war eine voellig normale Fachregel – nur kam sie als Ausfall an, und je mehr Konflikte es gab, desto sicherer oeffnete der Breaker und machte aus einem seltenen Konflikt einen flaechendeckenden Ausfall. Kurzfristige Abhilfe: ignoreExceptions. Richtig waere, den Konflikt als fachliches Ergebnis zurueckzugeben – dann entsteht gar keine Ausnahme, der Retry greift wieder nur bei echten Netzwerkfehlern, und der Breaker misst wieder das, wofuer er gedacht ist. Noch offen.
Ein Circuit Breaker misst, was man ihm als Fehler hinstellt. Hier war das eine fachliche Ablehnung – und die Rueckmeldung an den Nutzer wurde dadurch nicht nur falsch, sondern irrefuehrend: sie zeigte auf die Infrastruktur, waehrend der Fehler im Anwendungscode sass.

16 Datenhoheit

Bereit: sieben Datenbanken, Kafka und HTTP

Sieben Datenbanken in einer Instanz – und die einzige erlaubte Querverbindung.

catalog member lending reservation fine notification eine Postgres-Instanz catalog_db member_db lending_db reservation_db fine_db notification_db keycloak_db jeder Dienst kennt genau eine Datenbank – eigene Zugangsdaten, eigenes Schema, kein Blick in die Nachbarschaft Kafka – Ereignisse library.lending.loan-created · loan-returned · loan-overdue library.fine.fine-issued · library.reservation.reservation-created asynchron, niemand wartet HTTP – synchrone Aufrufe lending → catalog (Buch suchen, Exemplar reservieren) lending → member (Berechtigung pruefen) nur hier kann ein Dienst den anderen mit herunterreissen Kein Dienst liest die Tabellen eines anderen – auch nicht „nur kurz“. Genau das ist der Unterschied zwischen sieben Datenbanken in einer Instanz und einer gemeinsamen Datenbank mit sieben Schemata: Letztere waere bequemer – und die Dienste waeren nicht mehr unabhaengig.
Eine Instanz, sieben Eigentuemer. Die gemeinsame Postgres spart Speicher und Startzeit; die Trennung nach Datenbanken haelt trotzdem durch, dass jeder Dienst nur seine eigenen Tabellen kennt. Informationen fliessen ueber Kafka oder HTTP – und nur der rote Kasten ist der Weg, auf dem ein Dienst einen anderen mit herunterreissen kann.

Teil C – die Bauteile von innen

In den Zeichnungen davor sind Container, Kafka, Keycloak, DNS und TLS einzelne Kästen. Hier wird jeder davon aufgeklappt – erst die Grundbegriffe, auf denen alles andere steht, dann die Bauteile. Nicht als Lehrbuchkapitel, sondern genau so weit, wie es nötig ist, um die Fehlermeldungen zu verstehen, die dieser Stack tatsächlich produziert.

17 VM, Container, WSL – was wirklich getrennt ist

Der Unterschied, aus dem sich fast alles Weitere ergibt: wie viele Kerne laufen hier eigentlich?

Bereit: VM, Container und WSL2
Virtuelle Maschine Anwendung Anwendung vollstaendiges Gast-Betriebssystem eigener Kernel virtuelle Hardware (Hypervisor) Kernel des Wirts Trennung durch den Hypervisor: hart. Start in Minuten, GB an Speicher. Container Anwendung Anwendung nur Bibliotheken nur Bibliotheken Namespaces + cgroups getrennte Sicht, geteilte Maschine EIN gemeinsamer Kernel Kein Betriebssystem im Container – nur Dateien. Start in Sekunden, MB an Speicher. Eine Kernel-Luecke betrifft alle gleichzeitig. Deshalb: nie --privileged. WSL2 – und damit dein Laptop deine Container Namespaces + cgroups echter Linux-Kernel Hyper-V – also doch eine VM Windows-Kernel WSL2 ist eine leichtgewichtige VM – sie startet schnell und teilt Speicher dynamisch, bleibt aber eine eigene Maschine. Die Folge, die in dieser Sammlung immer wieder auftaucht Ein Linux-Container braucht einen Linux-Kernel. Windows hat keinen. Also steht unter Windows immer eine VM dazwischen – bei Podman die WSL-Maschine, bei CRC eine Hyper-V-VM. Beides sind eigenstaendige Rechner mit eigenem Netz. Daraus folgt unmittelbar Zeichnung 09: 127.0.0.1 bedeutet in der WSL-Maschine, in der CRC-VM und auf dem Windows-Host jeweils etwas anderes – drei Loopback-Adressen, drei verschiedene Maschinen. Auf dem Hetzner-Server entfaellt die Zwischenschicht: dort laeuft Linux, und die Container laufen direkt auf dessen Kernel.
Ein Container ist keine kleine VM. Er ist ein Prozess auf dem Kernel des Wirts, dem man eine eigene Sicht auf Dateisystem, Netz und Prozessliste gegeben hat. Das macht ihn schnell und klein – und erklärt zugleich, warum ein Zugriff auf den Container-Socket gleichbedeutend mit Root auf dem Wirt ist.

18 Image und Container

Warum Daten im Container verschwinden, was ein Tag wirklich ist, und wieso ein Rebuild oft Sekunden dauert.

Bereit: Dockerfile → Image → Container
Dockerfile FROM eclipse-temurin:21 WORKDIR /app COPY target/*.jar app.jar EXPOSE 8081 ENTRYPOINT ["java","-jar",…] Jede Anweisung, die etwas am Dateisystem aendert, wird eine Schicht. build Image – unveraenderlicher Stapel 4 · ENTRYPOINT (nur Metadaten) 3 · app.jar – 40 MB 2 · /app angelegt – wenige Bytes 1 · Java-Laufzeit – 180 MB Nur Schicht 3 aendert sich beim naechsten Build – der Rest kommt aus dem Cache. run Container – laufender Prozess schreibbare Schicht weg, sobald der Container geloescht wird dieselben vier Schichten – nur lesend geteilt mit jedem anderen Container aus dem Image Volume – ausserhalb des Stapels ueberlebt Loeschen und Neuerstellen Tag gegen Digest ghcr.io/nursude/app:latest ein Etikett – kann jederzeit auf ein anderes Image zeigen …@sha256:9f2c4a… der Inhalt selbst – aendert sich der Inhalt, aendert sich der Digest Deshalb der Commit-Hash als Tag: ein Etikett, das nie wandert. Zwei Fallen, die daraus folgen 1. Ein gepushtes Image unter demselben Tag zieht Kubernetes nicht neu – der Tag ist ja bekannt. Der alte Container laeuft weiter, und man sucht den Fehler im Code. Der Tag muss sich aendern. 2. Ein Passwort in einer Schicht bleibt darin, auch wenn eine spaetere Schicht es loescht. Schichten werden gestapelt, nie ueberschrieben. Warum der zweite Build so viel schneller ist: jede Schicht ist ueber ihren Inhalt adressiert. Aendert sich nur das jar, bleiben die 180 MB Java-Laufzeit unberuehrt – weder neu gebaut noch neu uebertragen. Genau deshalb steht COPY app.jar im Dockerfile nach allem, was sich selten aendert. Und deshalb gibt es Volumes: alles, was einen Neustart des Containers ueberleben soll, darf nicht in der schreibbaren Schicht liegen. In Kubernetes heisst dasselbe Prinzip emptyDir (weg mit dem Pod) gegen PersistentVolumeClaim (bleibt) – die ganze Matrix dazu ist Zeichnung 36.
Das Image ist unveränderlich, der Container ist vergänglich. Alles, was ein Container schreibt, landet in einer dünnen Schicht obendrauf, die mit ihm verschwindet. Wer das verinnerlicht hat, versteht ohne weitere Erklärung, warum es Volumes gibt, warum ein Tag sich ändern muss und warum Container-Images so klein sein können.

19 Registries: wo die Images liegen

Was ghcr.io ist, warum drei verschiedene Registries im Spiel sind – und warum eine davon zwei Adressen hat.

Bereit: Name, Registry und Pull
Ein Image-Name besteht aus vier Teilen ghcr.io / nursude / catalog-service : sha-a1b2c3d Host der Registry fehlt er, ergaenzt Docker still docker.io – Podman verweigert Konto oder Organisation bei ghcr.io identisch mit dem GitHub-Konto Name des Images meist der Dienst Tag – verschiebbares Etikett hier der Commit-Hash, damit es nie wandert ohne Angabe: :latest – und damit unbestimmt Drei Registries, drei Aufgaben ghcr.io GitHub Container Registry Dein eigenes Lager. Die Images liegen beim selben Anbieter wie der Quellcode – eine Anmeldung weniger. Anmeldung: in Actions das automatische GITHUB_TOKEN, von Hand ein PAT mit write:packages. Oeffentliches Ziehen ohne. Oeffentlich kostenlos, privat auf das Kontingent. docker.io Docker Hub – die Vorgabe der Welt Von hier kommen die fremden Images: Postgres, Redis, Kafka, Keycloak. Anonymes Ziehen ist gedeckelt – in einer CI, die oft baut, laeuft man dagegen. Genau deshalb liegen die eigenen Images woanders. Hier verschwanden 2025 auch die versionsgetaggten Bitnami-Images – daher bitnamilegacy. die Registry im Cluster bei CRC und OpenShift mitgeliefert Verlaesst den Laptop nie. Fuer den lokalen Cluster reicht sie – fuer den Hetzner-Server nicht, der kommt nicht heran. Selbst unterschriebenes Zertifikat, daher beim Schieben --tls-verify=false. Und sie hat zwei Adressen – siehe unten. Warum die Cluster-Registry zwei Adressen hat default-route-openshift-image-registry.apps-crc.testing von aussen – zum Schieben vom Laptop. Geht ueber den Router, also mit TLS. image-registry.openshift-image-registry.svc:5000 von innen – zum Ziehen durch die Pods. Cluster-interner Name, kein Router beteiligt. Was beim Ziehen passiert 1 · Manifest holen – die Liste der Schichten 2 · nachsehen, welche schon lokal liegen 3 · nur die fehlenden herunterladen Deshalb ist der zweite Pull fast umsonst. Eine Registry ist nichts weiter als ein Dateilager mit HTTP-Schnittstelle: sie kennt Manifeste (welche Schichten gehoeren zu diesem Namen) und Schichten (adressiert ueber ihre Pruefsumme). Sie fuehrt nichts aus und weiss nichts von Containern. Fuer Hetzner ist sie das Ziel des Builds. Der CRC-Cluster zieht dagegen nie von hier, sondern aus seiner eigenen internen Registry – ohne dass Laptop und Server je miteinander sprechen muessten.
Ein Lager, kein Dienst. Die Registry speichert Schichten unter ihrer Prüfsumme und Manifeste unter einem Namen – mehr nicht. Welche man nimmt, ist eine Frage von Anmeldung, Erreichbarkeit und Abrufgrenzen: ghcr.io für die eigenen Images, docker.io für fremde, die Cluster-Registry für alles, was den Laptop nicht verlassen soll.
Bereit: Docker-Daemon gegen rootless Podman

20 Docker und Podman

Derselbe Befehlssatz, ein grundlegend anderer Aufbau – und warum das für den Jenkins-Server wichtig war.

Docker – mit Daemon docker run … (dein Befehl) ueber den Socket Unix-Socket, kein Netz dockerd – typische rootful-Installation /var/run/docker.sock – wer darauf schreiben darf, ist faktisch root Container A Container B Container C Alle Container sind Kinder des Daemons, nicht deiner Sitzung. Stirbt der Daemon, ist der Ueberblick weg – und beim Systemstart bringt er die Container mit restart: always von selbst zurueck. Podman – normaler CLI-Aufruf ohne Daemon podman run … (dein Befehl) startet den Container direkt als eigenes Kind rootless: als du selbst, ueber User-Namespaces Container A Container B Im rootless-CLI-Fall kein dauerhafter Root-Prozess und damit keine zentrale Stelle, an der alles haengt. Autostart: systemd oder Socket-Service konfigurieren. Was in dieser Sammlung daran haengt 1. systemctl enable podman-restart.service aus der Jenkins-Anleitung – ohne Daemon bringt --restart=always die Container nach einem Neustart des Servers nicht zurueck. Bei einem Server, der per Snapshot regelmaessig neu entsteht, ist das entscheidend. 2. podman system service stellt trotzdem einen Docker-kompatiblen Socket bereit – fuer Werkzeuge, die einen Daemon erwarten. Genau den bekommt Jenkins ueber CONTAINER_HOST und DOCKER_HOST hineingereicht. 3. Podman verlangt vollstaendige Image-Namen: jenkins/jenkins allein ergibt short-name did not resolve. Docker ergaenzt still docker.io.
Derselbe Befehlssatz, ein anderer Aufbau. alias docker=podman funktioniert für den Alltag – die Unterschiede zeigen sich erst dort, wo es um Rechte, Neustarts und Werkzeuge geht, die einen Daemon erwarten. Genau diese drei Stellen sind es, an denen die Anleitungen dieser Sammlung nachhelfen müssen.
Bereit: Compose-Dienst gegen Kubernetes-Objekte

21 Compose und Kubernetes

Dieselbe Idee in zwei Größen – und die eine Zeile, für die es in Kubernetes bewusst keine Entsprechung gibt.

compose.yaml – ein Host services: catalog: image: ghcr.io/…/catalog:sha-a1b2 ports: ["8081:8081"] environment: SPRING_PROFILES_ACTIVE: cloud volumes: ["data:/var/lib/data"] depends_on: [db] deploy: { replicas: 1 } restart: always Eine Datei, ein Rechner, ein Befehl. Faellt der Rechner aus, faellt alles aus – und genau das ist oft voellig in Ordnung. Kubernetes – verteilt auf mehrere Objekte Deployment image, replicas, Neustartverhalten Service der Port – clusterintern erreichbar Ingress / Route erst hiermit von aussen erreichbar PersistentVolumeClaim aus volumes ConfigMap / Secret aus environment depends_on – gibt es nicht Absicht, kein Versaeumnis image, replicas ports volumes environment depends_on Warum es kein depends_on gibt Weil eine Startreihenfolge in einem verteilten System nichts garantiert. Die Datenbank kann nach dem Start jederzeit wieder wegfallen – wer nur beim Start prueft, hat davon nichts. Kubernetes verlangt stattdessen, dass jeder Dienst mit einem fehlenden Gegenueber umgehen kann: Pod stirbt, wird neu gestartet, versucht es erneut – und die Readiness-Probe haelt ihn so lange aus dem Verkehr. In der Praxis kostet das etwas: der config-server aus Zeichnung 10 muss zuerst laufen, sonst gehen acht Dienste in eine Neustart-Schleife und brauchen Minuten, um sich zu fangen. Deshalb steht im Rollout-Skript ein rollout status genau an dieser einen Stelle – von Hand nachgebaut, was compose mit einer Zeile geloest haette.
Compose beschreibt einen Rechner, Kubernetes einen Sollzustand. Fast jede Zeile hat eine Entsprechung – nur wird sie auf mehrere Objekte verteilt, die man einzeln ändern kann. Die Ausnahme ist depends_on: was dort eine Zeile ist, wird hier zur Anforderung an den Dienst selbst.

22 Kubernetes von innen

Keine Befehle, die ausgeführt werden – nur Schleifen, die vergleichen. Das ist der ganze Trick.

Bereit: Sollzustand an API-Server geben
kubectl / helm auf deinem Rechner „so soll es sein“ HTTPS, 6443 Steuerungsebene API-Server prueft, nimmt an, benachrichtigt etcd der einzige Ort, an dem etwas steht Nur der API-Server spricht mit etcd. Alles andere fragt ihn – deshalb ist er der einzige Punkt, an dem Rechte, Pruefungen und Protokollierung greifen. Scheduler sucht fuer jeden Pod einen Node Controller Deployment, ReplicaSet, Node … Der Regelkreis – jeder Controller macht nur das Soll lesen → Ist ansehen → Differenz ausgleichen → von vorn Node kubelet fragt: was soll auf mir laufen? CRI-O – die Container-Runtime bei OKD; anderswo containerd deine Container Das kubelet bekommt keine Befehle geschickt – es fragt selbst und gleicht selbst ab. Auch das ist ein Regelkreis, nur einer pro Node. Was aus dem Regelkreis folgt – und alles erklaert, was einen anfangs verwirrt kubectl apply kommt sofort zurueck Es hat nur den Wunsch hinterlegt. Ob er erfuellt wird, entscheidet sich danach – und vielleicht nie. Deshalb gibt es rollout status und --wait: sie warten, bis das Ist dem Soll entspricht. Ein geloeschter Pod kommt zurueck Nicht aus Trotz: das ReplicaSet zaehlt weiter eins und sieht null. Also legt es einen an. Wirklich weg ist er erst, wenn das Soll sich aendert – also am Deployment. Handaenderungen verschwinden Der Controller kennt nur sein Soll. Was du am Objekt aenderst, gleicht er beim naechsten Durchlauf wieder aus. Dasselbe gilt fuer Helm – siehe Zeichnung 26. Warum das auf CRC besonders weh tut Steuerungsebene und Node sind hier dieselbe Maschine. Die acht JVM-Kaltstarts aus Zeichnung 10 haben also nicht nur die Anwendung ausgebremst, sondern API-Server und etcd gleich mit – und damit genau die Instanz, die man braucht, um nachzusehen, was los ist. Der Ausweg in genau dieser Lage: crictl auf dem Node. Es spricht direkt mit CRI-O und geht am API-Server vorbei – man sieht die laufenden Container also auch dann noch, wenn kubectl in Zeitueberschreitungen laeuft. Es kann dafuer nichts vom Soll wissen. Umgekehrt gilt: ein Node unter Druck bekommt vom Node-Controller eine Markierung wie DiskPressure – und der Scheduler plant dann nichts Neues mehr dorthin. Pods bleiben in Pending, ohne dass an ihnen selbst etwas falsch waere.
Kubernetes führt nichts aus – es gleicht ab. Du hinterlegst einen Sollzustand, und mehrere unabhängige Schleifen vergleichen ihn unablässig mit dem Ist. Wer das einmal verinnerlicht hat, wundert sich nicht mehr, warum ein gelöschter Pod zurückkommt, warum ein Befehl sofort zurückkehrt, obwohl nichts fertig ist, und warum Handarbeit am Objekt nicht hält.

23 Wie eine Anfrage im Cluster ihr Ziel findet

Vom Hostnamen bis zum Prozess – und die Stelle, an der die Readiness-Probe darüber entscheidet, ob es klappt.

Bereit: Route → Service → nur bereite Endpoints
Der Weg der Anfrage Browser library.apps-crc.testing Route Host → Service, TLS endet hier Service feste ClusterIP, waehlt per Label Endpoints nur die BEREITEN Pods catalog-7d9f-x2k · bereit 10.128.2.14:8081 catalog-7d9f-9ab · startet nicht in den Endpoints kein Verkehr Wer die Pods erzeugt Deployment „ich will 1 davon, dieses Image“ erzeugt je Image-Version eines ReplicaSet zaehlt nach und gleicht ab Pod eine IP, ein oder mehrere Container, gemeinsames Netz Die drei Proben – und was sie steuern startupProbe solange sie laeuft, pausieren die anderen beiden – genau das rettete Keycloak mit 5 min 34 Startzeit readinessProbe entscheidet allein ueber die Endpoints-Mitgliedschaft – schlaegt sie fehl, bekommt der Pod keinen Verkehr mehr livenessProbe schlaegt sie fehl, wird der Container GETOETET zu knapp gesetzt = endlose Neustart-Schleife Warum ein Rollout ohne Ausfall funktioniert: der neue Pod entsteht, ist aber nicht bereit – also nicht in den Endpoints. Erst wenn seine Readiness-Probe anschlaegt, bekommt er Verkehr, und erst dann verschwindet der alte. Der Service-Name bleibt derselbe, die Pod-IPs wechseln. Und warum „Pod laeuft, trotzdem 503“: laufen und bereit sind zwei verschiedene Dinge. oc get pods zeigt Running, die Spalte READY aber 0/1 – dann steht der Pod in keinem Endpoint, und die Route hat niemanden, an den sie weiterreichen kann. Der Servicename ist zugleich der Hostname im Cluster: postgres, kafka, redis-master. Genau deshalb ist fullnameOverride in den Helm-Werten kein Schoenheitsfehler, sondern die Voraussetzung dafuer, dass die Anwendung ihre Gegenstellen ueberhaupt findet.
Der Service ist ein Name mit einer Mitgliederliste. Was in dieser Liste steht, entscheidet allein die Readiness-Probe – nicht ob der Pod läuft. Diese eine Trennung erklärt sowohl das Ausrollen ohne Ausfall als auch die häufigste Verwirrung im Betrieb: ein Pod, der läuft, und trotzdem antwortet niemand.

24 Ingress und Route

Zwei Wege, dasselbe zu erreichen – und in diesem Projekt laufen beide: Routes auf CRC, Ingress auf k3s.

Bereit: zwei Eingänge, derselbe Service-Weg
Route – OpenShift, also CRC und OKD kind: Route spec: host: library.apps-crc.testing to: { kind: Service, name: api-gateway } port: { targetPort: 8080 } tls: { termination: edge } Ein Objekt, ein Host, ein Ziel. TLS steht mit drin. Der Router besorgt das Zertifikat fuer *.apps-crc.testing selbst – deshalb gibt es hier kein Secret und kein Let's Encrypt. Aelter als Ingress, und nur bei OpenShift zu haben. Ingress – der Kubernetes-Standard, also k3s kind: Ingress spec: ingressClassName: nginx rules: [{ host: library.seb4u.com, http: paths: [{ path: /, pathType: Prefix, backend: {service: {name: api-gateway}}}]}] tls: [{ secretName: library-tls }] Mehrere Hosts und Pfade in einem Objekt – dafuer verschachtelt. Das Zertifikat kommt aus einem Secret, das jemand fuellen muss. Laeuft ueberall, wo ein Ingress-Controller installiert ist. liest: OpenShift-Router (HAProxy) liest: ingress-nginx Ab hier ist beides identisch: Service → Endpoints → Pod Der Unterschied betrifft nur den Eintritt – nicht den Weg dahinter. Siehe Zeichnung 23. Was man wissen muss, wenn man zwischen beiden wechselt OpenShift versteht beides Ein Ingress-Objekt wird dort automatisch in eine Route uebersetzt. Deshalb laufen dieselben Helm-Charts auf CRC und auf k3s, ohne dass man sie anfassen muss. Umgekehrt gilt das nicht: eine Route kennt nur OpenShift. Wer portabel bleiben will, schreibt Ingress. ingressClassName muss passen Steht dort ein Name, fuer den kein Controller da ist, passiert gar nichts – kein Fehler, keine Warnung. Das Objekt steht da und bleibt ohne Adresse. k3s bringt Traefik mit, nicht nginx. Genau deshalb steht in der cloud-init --disable traefik. Annotationen sind nicht portabel Alles Interessante – Zeitlimits, Umschreibungen, Groessenbegrenzungen – steht in Annotationen, und die heissen bei jedem Controller anders. Eine nginx.ingress.kubernetes.io/*-Angabe wird von Traefik stillschweigend ignoriert. Wieder ohne Fehler. Das gemeinsame Muster der beiden roten Kaesten: ein Ingress ist nur ein Wunsch. Erfuellt wird er von einem Controller, der zufaellig zuhoert – oder eben nicht. Kubernetes prueft dabei nur, ob das Objekt formal gueltig ist, nicht ob es jemanden gibt, der es umsetzt. Erste Frage bei „mein Ingress tut nichts“: kubectl get ingress – steht in der Spalte ADDRESS etwas? Wenn sie leer bleibt, hat sich kein Controller gemeldet.
Der Unterschied betrifft nur die Tür, nicht das Haus. Route und Ingress beschreiben dasselbe – welcher Hostname zu welchem Service gehört – und ab dem Service ist der Weg identisch. Die Fallen liegen beide darin, dass ein Objekt gültig sein kann und trotzdem niemanden hat, der es ausführt.

25 ConfigMap und Secret

Derselbe Aufbau, ein Unterschied – und drei Wege hinein, von denen nur einer Aktualisierungen mitbekommt.

Bereit: ConfigMap, Secret und drei Pod-Zugriffe
ConfigMap data: SPRING_PROFILES_ACTIVE: crc LOG_LEVEL: INFO Klartext. Steht in describe und in jedem Log, das die Objekte ausgibt. Hoechstens 1 MiB – es liegt in etcd. Secret data: SPRING_DATASOURCE_PASSWORD: bGlicmFyeQ== Derselbe Aufbau – nur Base64 statt Klartext. Das ist keine Verschluesselung. Der echte Unterschied: eigene Rechte in RBAC. Was sie wirklich trennt 1 · Zugriff laesst sich getrennt vergeben 2 · Werte erscheinen nicht in describe 3 · etcd kann sie verschluesselt ablegen – wenn      der Cluster dafuer eingerichtet ist Sonst: gleiche Groessengrenze, gleiche Handhabung, gleiche drei Wege in den Pod. Drei Wege in den Pod 1 · einzelne Variable env: [{name: PW, valueFrom: secretKeyRef}] Ein Schluessel, ein Name im Prozess. Wird beim Start eingesetzt – und nie wieder. In diesem Projekt der Weg fuer die DB-Zugangsdaten. 2 · envFrom envFrom: [{secretRef: {name: …}}] Alle Schluessel auf einmal als Variablen. Ebenfalls nur beim Start. Bequem – und man sieht nicht mehr, was ankommt. 3 · als Datei eingehaengt volumeMounts: [{mountPath: /config}] Jeder Schluessel wird eine Datei. Der einzige Weg, der Aenderungen mitbekommt das kubelet gleicht sie nach etwa einer Minute ab. Ausser bei subPath – dann wieder nicht. Die Falle, die jeden einmal erwischt Eine ConfigMap oder ein Secret zu aendern startet keinen einzigen Pod neu. Die laufenden Prozesse behalten die Werte, die sie beim Start bekommen haben – und zwar unbegrenzt. Kein Ereignis, keine Warnung, kein Hinweis in describe. Das Objekt sieht richtig aus, der Pod laeuft mit dem alten Wert weiter, und man sucht den Fehler im Code. Abhilfe: kubectl rollout restart deploy/<name> – oder im Chart eine Pruefsumme der Werte als Annotation am Pod, dann erledigt Helm es von selbst. Warum die Trennung trotzdem sinnvoll ist: nicht weil das Secret sicherer waere – sondern weil man ihm getrennte Rechte geben kann. Wer die Konfiguration lesen darf, muss nicht auch das Passwort lesen duerfen. Diese eine Eigenschaft ist der ganze Gewinn. Was nicht hineingehoert: alles ueber 1 MiB, alles Binaere in Groesse, und alles, was nie in etcd stehen soll. Fuer Letzteres gibt es externe Tresore, die zur Laufzeit einspeisen – dann steht im Cluster nur noch ein Verweis. Zusammenhang mit Zeichnung 38: ein per --set uebergebenes Passwort landet zusaetzlich im Helm-Release-Verlauf – also an einer zweiten Stelle, an die man beim Aendern nicht denkt.
Ein Secret ist eine ConfigMap mit anderen Rechten. Base64 versteckt nichts; der Gewinn liegt allein darin, dass sich der Zugriff getrennt vergeben lässt. Wichtiger als der Unterschied zwischen beiden ist der Unterschied zwischen den drei Wegen hinein – denn nur der eingehängte bekommt mit, wenn sich etwas ändert. Für vertrauliche Daten braucht es zusätzlich Verschlüsselung-at-Rest in etcd und restriktives RBAC.
Bereit: Templates, Werte, set und Release-Secret

26 Helm von innen

Eine Schablone, mehrere Wertedateien, ein Ergebnis – und die Stelle, an der ein Tippfehler wortlos verschluckt wird.

auf dem Laptop – Helm ist nur ein Programm Chart templates/deployment.yaml templates/service.yaml Chart.yaml · values.yaml Die Werte stapeln sich – der Letzte gewinnt 1 · values.yaml des Charts (Vorgabe) 2 · -f catalog-service-values.yaml 3 · -f env/crc-values.yaml 4 · --set image.tag=sha-a1b2 Deshalb genuegt fuer einen zweiten Cluster eine weitere Datei auf Stufe 3 – und der Tag, der sich bei jedem Deployment aendert, steht auf Stufe 4. zusammensetzen (rendern) fertige Manifeste gewoehnliches YAML, keine Schablonen mehr Deployment · Service · ConfigMap … Vorher ansehen: helm template oder --dry-run --debug apply HTTPS 6443 im Cluster API-Server Helm 3 hat hier keinen Gegenpart mehr. Kein Tiller, keine Cluster-Rechte fuer Helm – es benutzt deine kubeconfig wie kubectl. Release-Verlauf liegt als Secret im Namespace: sh.helm.release.v1.catalog-service.v7 Darin: die gerenderten Manifeste und die verwendeten Werte, je Revision. Nur deshalb kann helm rollback ueberhaupt. Was der Cluster sieht Ganz normale Objekte. Kubernetes weiss nichts von Helm, Charts oder Werten. Wer ein Objekt von Hand aendert, hat es beim naechsten upgrade wieder verloren – Helm kennt nur seinen eigenen Stand. Die Falle, die am meisten Zeit kostet Ein --set auf einen Namen, den das Chart gar nicht kennt, wird wortlos angenommen. Kein Fehler, keine Warnung – der Wert landet nur nirgends. Wer persistance statt persistence schreibt, bekommt ein erfolgreiches Deployment und die Vorgabe. Gegenmittel: helm get values <release> zeigt, was wirklich ankam, und helm template zeigt, was daraus geworden ist. Nur Charts mit values.schema.json pruefen die Namen selbst.
Helm läuft auf deinem Rechner, nicht im Cluster. Es setzt Schablonen und Werte zu gewöhnlichem YAML zusammen und schickt nur das Ergebnis – plus eine Kopie davon als Secret, damit es später weiß, was es zuletzt getan hat. Alles, was Helm „kann“, ist Textersetzung und Buchführung.
Bereit: YAML, Trigger, Socket und Build

27 Jenkins von innen

Wo die Konfiguration herkommt, wo der Build wirklich läuft – und warum Klicks in der Oberfläche nicht halten.

Git-Repository enthaelt das Jenkinsfile Webhook HTTPS, eingehend oder Abfrage im Takt Jenkins-Container auf dem Server Controller – hier laeuft auch jeder Build casc.yaml – Configuration as Code wird bei jedem Start eingelesen und angewandt Deshalb halten Klicks in der Oberflaeche nicht: der naechste Start setzt alles auf den Stand der Datei zurueck. Zugangsdaten-Speicher verschluesselt abgelegt in den Logs durch Sterne ersetzt Arbeitsverzeichnis /var/jenkins_home/workspace/<job> je Job eines, bleibt zwischen Laeufen Pipeline aus dem Jenkinsfile Build & Push Deploy post / failure Die Datei liegt im Repository – die Pipeline aendert sich also mit dem Code, nicht per Klick. Podman-Socket des Wirts hineingereicht als Volume Der Build laeuft auf der Engine des Servers – nicht im Jenkins-Container. Wer hier Befehle ausfuehren kann, ist faktisch root auf dem Server. Unix-Socket, kein Netz Registry ghcr.io – Image mit Commit-Tag Anmeldung ueber --password-stdin HTTPS Zielcluster helm upgrade --install je Dienst kubeconfig aus dem Zugangsdaten-Speicher HTTPS 6443 Was diesen Aufbau ausmacht 1. Nichts Wichtiges lebt in Jenkins. Die Pipeline steht im Repository, die Konfiguration in casc.yaml. Beides ist versioniert. Deshalb ueberlebt der Aufbau das Loeschen und Neuanlegen des Servers – genau das, was der Snapshot-Zyklus aus Zeichnung 05 taeglich tut. 2. Jenkins baut nicht selbst. Er reicht die Arbeit an die Container-Engine des Wirts weiter. Das spart eine verschachtelte Engine (die auf overlay-on-overlay ohnehin scheitert) und kostet den Preis rechts: der Socket ist ein Vollzugriff auf den Server. 3. Was bleibt, ist der Zustand: Build-Verlauf und Arbeitsverzeichnisse unter /opt/jenkins-home. Das Verzeichnis muss UID 1000 gehoeren, sonst startet Jenkins und scheitert daran, seine eigene Konfiguration zu schreiben.
Jenkins ist hier bewusst austauschbar gehalten. Pipeline und Konfiguration liegen als Dateien im Repository, der Build läuft auf der Engine des Wirts, der Zustand beschränkt sich auf ein Verzeichnis. Was das kostet, steht rot rechts: der hineingereichte Socket ist gleichbedeutend mit Root auf dem Server – tragbar, solange dort nur eigene Pipelines laufen.
Bereit: Commit, Parent, Branch und Fast-Forward

28 Git von innen

Ein Commit ist kein Unterschied, sondern ein vollständiger Stand – und ein Branch ist nur ein Zettel mit einer Nummer darauf.

Was in einem Commit steht tree 9f2c4a… parent 1deaac4… author Aydin Polat, 02.09.2026 message "anleitungen: …" Der tree ist der ganze Dateibaum, nicht die Aenderung. Der Unterschied wird erst beim Ansehen berechnet – aus diesem Stand und dem des Vorgaengers. Der Hash ist der Inhalt Die Nummer wird aus allem oben berechnet – Dateien, Vorgaenger, Autor, Zeit, Text. Also: gleicher Hash = garantiert gleicher Stand. Genau deshalb taugt er als Image-Tag: catalog-service:sha-a1b2c3d Ein Etikett, das nie auf etwas anderes zeigen kann. Ein Branch ist nur ein Zeiger Eine Datei mit einer Zeile darin: dem Hash. .git/refs/heads/main → 186e085… Deshalb kostet ein Branch nichts und ist sofort da. Und deshalb ist „Branch loeschen“ harmlos: die Commits bleiben, nur der Zettel ist weg. HEAD wiederum zeigt auf den Branch, auf dem du stehst. Fast-Forward – was beim Mergen dieser Sammlung passiert ist vorher 1deaac4 e366b64 cfcf553 186e085 jeder zeigt auf seinen Vorgaenger – die Pfeile laufen rueckwaerts main crc-anleitungen nachher main Der Zettel wandert – mehr passiert nicht. Kein neuer Commit, keine Verschmelzung, nichts zu loesen. Wann es nicht vorspulen kann Sobald auf main ein eigener Commit liegt, der nicht in der Kette des Zweigs steht, gibt es keinen geraden Weg mehr. Dann bleiben zwei Moeglichkeiten: merge – ein neuer Commit mit zwei Vorgaengern rebase – die eigenen Commits neu aufbauen (neue Hashes!) Warum rebase nach dem Pushen weh tut Beim Neuaufbau aendert sich der Inhalt jedes Commits – also auch sein Hash. Fuer alle anderen sind das neue Commits, waehrend die alten bei ihnen weiterleben. Deshalb: vor dem Pushen gern, danach nie. Der Zusammenhang mit dem Rest dieser Sammlung: weil der Hash den Inhalt bestimmt, ist er das einzige Etikett, das sich nicht heimlich verschieben kann. Genau deshalb steht er als Image-Tag in Zeichnung 04 und 19 – und deshalb bedeutet latest in der Praxis „irgendetwas“.
Commits sind Stände, keine Änderungen. Jeder enthält den vollständigen Dateibaum und zeigt rückwärts auf seinen Vorgänger; ein Branch ist nur ein Zeiger darauf. Sobald man das so sieht, ist Fast-Forward keine Zauberei mehr, sondern das Naheliegende: der Zeiger wandert, weil es einen geraden Weg gibt.
Bereit: Partition, Offset, Producer und Consumer-Gruppe

29 Kafka von innen

Was „asynchron“ konkret bedeutet – und warum ein gelesenes Ereignis nicht verschwindet.

lending-service Producer send – und weiter TCP 9092 · PLAINTEXT wartet nicht auf den Leser Topic „library.lending.loan-created“ Partition 0 – hier die einzige, so konfiguriert loan #41Offset 0 loan #42Offset 1 loan #43Offset 2 loan #44Offset 3 naechstes waechst nur hinten Die Reihenfolge gilt innerhalb einer Partition – sonst nirgends. Mehr Partitionen bringen mehr Durchsatz und kosten genau diese Garantie; der Schluessel des Ereignisses entscheidet, in welche es faellt. Gruppe „notification-service“ gelesen bis Offset 3 committed die Position selbst, nach dem Verarbeiten Absturz davor → liest ab 3 erneut: mindestens einmal, nicht genau einmal. Der Empfaenger muss das aushalten. Gruppe „fine-service“ gelesen bis Offset 1 voellig unabhaengig von der anderen Gruppe Genau das unterscheidet Kafka von einer Warteschlange: Lesen entfernt nichts. fetch · TCP 9092 · jede Gruppe holt selbst ab controller.replicaCount=1 Ein KRaft-Controller. Kein ZooKeeper mehr noetig – aber auch keine Ausfallsicherheit. Was daraus folgt 1. Der Producer ist sofort fertig. Eine langsame oder abgestuerzte Benachrichtigung verzoegert keine einzige Ausleihe – anders als beim HTTP-Aufruf an catalog-service, wo lending wartet und mit haengt. 2. Ereignisse bleiben liegen (Vorgabe: 7 Tage). Ein neu gestarteter Dienst kann von vorn lesen. Deshalb ist persistence hier nicht nur Komfort: mit emptyDir ist nach einem Pod-Wechsel der ganze Verlauf weg, und keiner merkt es. 3. Mindestens einmal, nicht genau einmal. Wer zweimal dieselbe Mahnung verschicken darf, hat kein Problem; wer zweimal abbucht, schon. 4. Faellt der eine Controller aus, steht der gesamte Ereignisfluss. Fuer einen Lern-Cluster in Ordnung – in Produktion waeren es drei.
Kafka ist ein Protokoll, keine Warteschlange. Ereignisse werden hinten angehängt und bleiben liegen; jede Lesergruppe merkt sich nur ihre eigene Position. Daraus folgt beides: der Sender wird nie ausgebremst – und ein Fehler beim Empfänger fällt niemandem auf, solange keiner die Leseposition beobachtet.
Bereit: Browser, Code, Verifier und Tokens

30 Keycloak: der Anmeldefluss, Schritt für Schritt

Neun Schritte, zwei Umleitungen – und die eine Stelle, an der die redirectUris aus Zeichnung 14 geprüft werden.

Browser Frontend (SPA) Keycloak api-gateway 1 · Seite oeffnen, kein Token vorhanden 2 · verifier wuerfeln, merken challenge = SHA-256(verifier) 3 · Umleitung auf /auth – client_id, redirect_uri, code_challenge Keycloak prueft die redirect_uri hier zeichengenau – passt sie nicht, erscheint statt der Maske: Invalid parameter: redirect_uri HTTP 302 · der Browser folgt, alles ueber HTTPS 4 · Anmeldemaske, Nutzer meldet sich an 5 · Umleitung zurueck auf redirect_uri?code=… nur wenn die redirect_uri aus Schritt 3 gueltig war – sonst kam hier nie ein Code an 6 · Browser laedt die Seite mit dem Code neu 7 · POST /token – code + verifier (nicht die challenge!) HTTPS · Hintergrundaufruf, keine Umleitung 8 · access_token, refresh_token, id_token 9 · jede Anfrage mit: Authorization: Bearer <access_token> HTTPS an der Route · danach HTTP im Cluster einmalig: JWKS holen – danach lokal geprueft HTTP GET keycloak:8080 · clusterintern Der Dienst fragt Keycloak nicht bei jeder Anfrage. Warum der Umweg ueber Schritt 2 und 7 (PKCE): ein Frontend im Browser kann kein Geheimnis aufbewahren – jeder kann den Quelltext lesen. Wer den Code aus der Adresszeile abfaengt, koennte ihn also einloesen. Mit PKCE nuetzt der Code allein nichts: nur wer den zufaelligen verifier kennt, bekommt Tokens. Der Code ist einmalig und lebt Sekunden; das access_token Minuten; erneuert wird ueber das refresh_token, ohne dass sich jemand neu anmeldet.
Das Passwort erreicht die Anwendung nie. Es geht ausschliesslich an Keycloak; zurück kommt erst ein kurzlebiger Code und dafür dann ein Token. Die beiden Umleitungen sind der Grund, warum redirectUris und webOrigins stimmen müssen – Schritt 3 prüft die redirect_uri zeichengenau, noch vor der Anmeldemaske. Passt sie nicht, erscheint Invalid parameter: redirect_uri statt des Logins, und es kommt gar kein Code zurück.
Bereit: Header, Payload, Signatur und JWKS

31 Das Token von innen

Drei Teile, ein Punkt dazwischen – und warum jeder es lesen kann, aber niemand es fälschen.

So kommt es an – drei Base64-Bloecke, durch Punkte getrennt eyJhbGciOiJSUzI1NiIsImtpZCI6… . eyJpc3MiOiJodHRwczovL2tleWNs… . QW5Ub25pb1NpZ25hdHVyZUJ5dGVz… Base64 dekodieren Base64 dekodieren nachrechnen, nicht lesen 1 · Header alg: RS256 kid: "aB3x…" typ: JWT Der kid sagt, welcher Schluessel geprueft werden muss – Keycloak hat mehrere und tauscht sie aus. 2 · Payload – die Behauptungen iss: https://keycloak…/realms/library sub: 8f2c… (der Nutzer) aud: account exp: 1757… preferred_username: aydin realm_access.roles: [librarian] Base64 ist keine Verschluesselung – das liest jeder mit. Nichts Geheimes hinein. 3 · Signatur Keycloak unterschreibt Teil 1 + 2 mit seinem privaten Schluessel. Jeder Dienst prueft mit dem oeffentlichen – frei abrufbar. Ein geaendertes Byte in Teil 2 macht die Signatur ungueltig. JWKS-Endpunkt /realms/library/protocol/openid-connect/certs liefert die oeffentlichen Schluessel, nach kid sortiert wird einmal geholt und zwischengespeichert HTTP GET · einmal, dann gemerkt Was der Dienst prueft Signatur · exp nicht abgelaufen iss gleich der eigenen issuer-uri – und genau hier scheiterte http gegen https Der praktische Nutzen: die Fachdienste brauchen keine Nutzerverwaltung und keine Verbindung zu Keycloak im laufenden Betrieb. Sie halten nur die oeffentlichen Schluessel vor und rechnen selbst nach. Faellt Keycloak aus, koennen sich zwar keine neuen Nutzer anmelden – bestehende Tokens gelten aber weiter bis exp. Dieselbe Eigenschaft ist die Kehrseite: ein einmal ausgestelltes Token laesst sich nicht ohne Weiteres zurueckziehen.
Ein Token ist eine unterschriebene Behauptung, kein Geheimnis. Jeder kann lesen, was drinsteht – niemand kann es ändern, ohne die Unterschrift zu zerstören. Deshalb kommt in die Nutzlast nichts hinein, was niemanden angeht, und deshalb kommen die Fachdienste ohne Rückfrage bei Keycloak aus.

32 DNS: wie ein Name zur Adresse wird

Drei Fragen, bis der Browser weiss, wohin – und warum eine Änderung nicht sofort wirkt.

Bereit: blog.seb4u.com? an UDP 53
dein Rechner Stub-Resolver rekursiver Resolver 1.1.1.1 oder der des Providers blog.seb4u.com? UDP 53 1 · Root-Server (.) „fuer .com frag die da“ 2 · TLD-Server (.com) „fuer seb4u.com frag Cloudflare“ 3 · autoritativer Server der Einzige, der die Antwort kennt hier stehen deine Records UDP 53 · drei Fragen, eine je Ebene A 203.0.113.10, TTL 300 Antwort · wird 300 s gemerkt Was dort stehen kann A eine IPv4-Adresse AAAA eine IPv6-Adresse CNAME ein anderer Name statt Adresse TXT freier Text – ACME, SPF MX wohin die Post geht Am Apex (seb4u.com selbst) ist ein CNAME nicht erlaubt – nur A / AAAA TTL – warum die Aenderung nicht wirkt Jede Antwort traegt eine Haltbarkeit. Bis die ablaeuft, antwortet jeder Resolver auf dem Weg aus dem Gedaechtnis – ohne zu fragen. Bei TTL 3600 sehen manche noch eine Stunde die alte Adresse, waehrend andere schon die neue haben. Beides gleichzeitig. Deshalb: TTL vor einem Umzug senken, danach wieder anheben. Und beim Pruefen den Resolver angeben: -Server 1.1.1.1. nip.io – der Trick ohne eigene Domain Ein autoritativer Server, der die Adresse aus dem Namen liest: api.lab.203.0.113.10.nip.io → 203.0.113.10 Damit loest jede Subdomain auf, ohne dass ein Record angelegt wird. Genau das braucht OpenShift, das api.<cluster> und *.apps.<cluster> aufloesbar verlangt. Fuer ein Lab ideal – fuer Produktion nicht: die IP steht im Namen, und der Dienst ist fremd. Der lokale Sonderfall: apps-crc.testing loest kein DNS-Server der Welt auf. CRC traegt dafuer einen Eintrag in die Windows-hosts-Datei ein – und die wird vor jeder DNS-Frage gelesen. Deshalb funktioniert der Name auf dem Host und nirgends sonst. Und deshalb ist die WSL-Falle aus Zeichnung 09 genau hier verwurzelt: die WSL-Maschine hat ihre eigene hosts-Datei. Was Windows kennt, kennt sie nicht – man muss es dort noch einmal eintragen, und zwar mit der Gateway-Adresse statt mit 127.0.0.1. Reihenfolge der Aufloesung, immer gleich: hosts-Datei → Zwischenspeicher → rekursiver Resolver → die Kette oben.
Niemand kennt die ganze Antwort – jeder kennt den Nächsten. Das macht DNS robust und zugleich träge: zwischen Änderung und Wirkung liegt die TTL, und in dieser Zeit sehen verschiedene Leute verschiedene Wahrheiten. Wer das einkalkuliert, senkt die TTL vorher – wer nicht, sucht den Fehler im Server.

33 TLS: Handshake und Zertifikatskette

Was in den ersten Millisekunden passiert – und warum der Browser dem Zertifikat überhaupt glaubt.

Bereit: ClientHello mit SNI
Der Handshake – eine Rundreise Browser Router / Server ClientHello TCP 443 · noch unverschluesselt SNI = keycloak.apps-crc.testing · sichtbar ohne ECH · Verfahren · halber Schluessel Der Hostname geht im Klartext hinaus – nur so weiss der Router, welches Zertifikat er nehmen soll. ServerHello, dann Zertifikat + Signatur bei TLS 1.3 ist ab dem ServerHello schon verschluesselt – Zertifikat und Signatur sieht ein Mithoerer nicht gewaehltes Verfahren · anderer halber Schluessel · Beweis, dass er den privaten Schluessel hat beide rechnen denselben Schluessel aus ab hier verschluesselt – erst jetzt geht die eigentliche Anfrage hinaus TLS 1.3 braucht dafuer eine Rundreise; das Aushandeln kostet also Zeit, aber nur beim Verbindungsaufbau. Warum der Browser glaubt Serverzertifikat keycloak.apps-crc.testing · gueltig 90 Tage unterschrieben von Zwischenzertifikat liefert der Server mit – vergisst er es, bricht die Kette unterschrieben von Wurzelzertifikat liegt schon im Browser – der einzige Vertrauensanker niemand liefert es mit; es ist von Anfang an da CRC: selbst unterschrieben Keine dieser Stufen – daher die Browser-Warnung und --tls-verify=false beim Push in die CRC-Registry. Drei Arten, wo TLS endet – und was das fuer die Anwendung heisst edge – hier verwendet Router entschluesselt, spricht dann unverschluesselt mit dem Pod. Der Pod sieht http – und wuesste nichts von https, gaebe es nicht die X-Forwarded-*-Kopfzeilen. passthrough Router reicht durch, ohne zu lesen. Der Pod haelt das Zertifikat selbst. Noetig fuer Client-Zertifikate – und der Router kann dann nicht mehr nach Pfad verteilen. reencrypt Router entschluesselt und verschluesselt zum Pod neu. Auch im Cluster nichts im Klartext – dafuer braucht jeder Pod ein eigenes Zertifikat samt Erneuerung. Genau hier sass die Keycloak-Falle: bei edge kommt die Anfrage als http am Pod an. Ohne proxyHeaders=xforwarded baut Keycloak seine Adressen aus dem, was es selbst sieht – also http – und der iss-Claim passt dann nicht mehr zu dem, was die Dienste erwarten.
Verschlüsselung ist der leichte Teil – die Frage ist, wem man glaubt. Die Kette endet immer bei einem Zertifikat, das schon im Gerät liegt. Wo TLS endet, ist keine Formalie: bei edge spricht der Pod danach unverschlüsselt, und alles, was er über die ursprüngliche Anfrage wissen soll, muss ihm in Kopfzeilen mitgegeben werden.

34 Let's Encrypt: wie ein Zertifikat entsteht

Warum Port 80 offen sein muss, obwohl alles auf 443 umgeleitet wird.

Bereit: Zertifikat für blog.seb4u.com anfordern
dein Server (Proxy) Let's Encrypt 1 · „Ich haette gern ein Zertifikat fuer blog.seb4u.com“ HTTPS · ACME-API 2 · „Beweise, dass er dir gehoert – hier ist ein Token“ 3 · legt ihn ab unter /.well-known/acme-challenge/<token> 4 · holt ihn ab – ueber Port 80, von mehreren Standorten der Welt Genau deshalb muss Port 80 aus dem Internet erreichbar sein. Eine Weiterleitung auf https ist erlaubt – der Pruefserver folgt ihr. Entscheidend ist nur, dass 80 nicht gefiltert wird. 5 · Zertifikatsanfrage (der private Schluessel bleibt hier) HTTPS · ACME-API 6 · unterschriebenes Zertifikat, 90 Tage gueltig 90 Tage sind Absicht. Eine kurze Laufzeit zwingt zur Automatik – und ein abhandengekommener Schluessel ist schneller wertlos. Der Proxy erneuert ab Tag 60 selbsttaetig; scheitert das unbemerkt, faellt es erst am 90. Tag auf. Ein Blick auf das Ablaufdatum gehoert deshalb ins Monitoring. Die Alternative ohne Port 80: DNS-01. Statt einer Datei wird ein TXT-Record gesetzt, den die Zertifizierungsstelle abfragt. Aufwendiger – der Proxy braucht dafuer Schreibrechte auf die DNS-Zone – dafuer der einzige Weg zu Wildcard-Zertifikaten und zu Servern ohne offenen Web-Port. Beim Ausprobieren die Testumgebung nehmen: die Produktivumgebung erlaubt nur wenige Versuche pro Woche und Domain, dann ist eine Woche Pause.
Der Beweis ist die ganze Zeremonie. Ein Zertifikat bekommt nur, wer zeigen kann, dass ihm der Name gehört – und der einfachste Beweis ist eine Datei, die unter genau diesem Namen abrufbar ist. Deshalb hängt an Port 80 mehr als eine Umleitung.

Teil D – Zahlen und Nachschlagen unter Druck

Die Zeichnungen davor zeigen Aufbau. Diese vier zeigen Zeit, Haltbarkeit, Symptome und Leckstellen – und sie arbeiten mit den Werten, die auf diesem Laptop tatsächlich gemessen wurden, nicht mit Beispielzahlen.

35 Wo die Zeit hingeht

Derselbe Stack, zweimal hochgefahren – einmal alle auf einmal, einmal nacheinander. Alle Zahlen aus crc-monitor-*.csv und rollout-*.csv.

Bereit: gleichzeitiger Start gegen gestaffelten Rollout
Versuch 1 – alle neun gleichzeitig Lastmittel der VM (vm_load1), 98 Messpunkte ueber 2 h 29, alle rund 90 s 0 40 90 150 Maximum 157,24 6 vCPU – darueber wartet etwas 22:54 00:20 01:15 Was in dieser Zeit nicht ging Der API-Server lief auf derselben Maschine mit – also lief auch oc in Zeitueberschreitungen. Ein Lastmittel von 100 auf 6 vCPU heisst: rund 94 Vorgaenge warten dauerhaft auf einen Kern. Nach zweieinhalb Stunden war der Stack nicht vollstaendig oben. Versuch 2 – einer nach dem anderen gemessene Dauer bis ready, aus rollout-20260902-012327.csv config-server 121 s · Last danach 2,2 catalog-service 144 s · 2,8 member-service 116 s · 1,1 lending-service 630 s reservation-service 560 s · 3,6 fine-service 97 s · 2,0 notification-service 117 s · 2,3 api-gateway 116 s · 1,5 library-frontend 11 s · kein JVM-Start 0200 s 400 s600 s Das Ergebnis Summe: 1912 s = 32 Minuten, alle bereit. Hoechste Last dabei: 5,25 – und die stand schon vor dem ersten Dienst an. Dieselbe Maschine, dieselben Images, dieselben Charts. Nur eine andere Reihenfolge. Zwei Dienste brauchen 20 der 32 Minuten – dort faengt an, wer die Gesamtzeit senken will, nicht bei den sieben mit je zwei Minuten. Die 630 s waren die Zeitueberschreitung des Skripts – der Dienst wurde trotzdem bereit. Warum Nacheinander schneller ist als Gleichzeitig: ein JVM-Start ist ueber Minuten CPU-gebunden. Acht davon auf sechs Kernen teilen sich nicht die Arbeit – sie verdraengen einander gegenseitig aus dem Zwischenspeicher und warten. Die Summe der Einzelzeiten ist dann kleiner als die Zeit, die alle zusammen brauchen. Genau das misst der Unterschied zwischen den beiden Haelften dieser Zeichnung. Uebertragbar: bei CPU-gebundener Arbeit hilft Parallelitaet nur bis zur Anzahl der Kerne. Darueber hinaus kostet sie. Bei I/O-gebundener Arbeit – Netz, Platte – gilt das Gegenteil, und dort lohnt sich das Parallele.
Dieselbe Arbeit, ein Fünftel der Zeit. Der gestaffelte Rollout ist nicht vorsichtiger, er ist schneller – 32 Minuten gegen zweieinhalb Stunden ohne vollständiges Ergebnis, also rund 21 %. Und er zeigt zugleich, wo man ansetzen müsste: zwei der neun Dienste verbrauchen knapp zwei Drittel der Gesamtzeit (1190 von 1912 s, 62 %).

36 Was überlebt was

Die Frage, die in sechs anderen Zeichnungen nebenbei vorkommt – hier einmal vollständig.

Bereit: Was überlebt einen Container-Neustart?
Containerneu gestartet Pod neuerstellt Nodeneu gestartet Cluster neuaufgebaut Servergeloescht Image-Schicht liegt in der Registry schreibbare Schicht alles, was der Container schreibt Docker-Volume auf dem Server, ausserhalb des Stapels emptyDir gehoert dem Pod PersistentVolumeClaim gehoert dem Cluster Snapshot des Servers liegt bei Hetzner restic-Repository liegt auf der Storage Box ××××× ×× ×××× ×× hier verschwanden die sieben Datenbanken Die eine Zeile, die man sich merken muss: emptyDir ueberlebt einen Container-Neustart, aber keinen Pod-Wechsel. Und ein Pod wird nicht nur beim Deployen neu erstellt, sondern auch, wenn der Node ihn verdraengt – etwa unter DiskPressure. Genau so kam es dazu. Drei Zeilen ueberstehen alles: die beiden untersten und, ganz oben, die Image-Schicht – alle drei liegen ausserhalb der Maschine. Was dazwischen steht, haengt an etwas, das man loeschen kann. Das Image bringt aber nur den Code zurueck, nie die Daten – dafuer bleibt allein das Backup.
Fünf Ereignisse, sieben Aufbewahrungsorte – und drei überstehen alles. Der rot umrandete Kasten ist der Fall, der in dieser Sammlung tatsächlich eingetreten ist: emptyDir gegen „Pod neu erstellt“. Kein Fehler im Betrieb, sondern eine bewusst gesetzte Einstellung, deren Reichweite unterschaetzt wurde.

37 Fehlerbild → wo suchen

Nicht erfunden: jede Meldung hier ist in dieser Sammlung mindestens einmal tatsächlich aufgetreten.

Bereit: HTTP-Antwort, Pod-Zustand, Build-/Push-Meldung
Der Aufruf kommt zurueck – aber falsch 401 / 403 issuer-uri gegen den iss-Claim im Token vergleichen http gegen https – zeichengenau. Zeichnung 14 und 31. Sonst: Uhr des Servers pruefen – ein abgelaufenes exp sieht genauso aus wie ein falscher Schluessel. 404 Route vorhanden? Zeigt sie auf den richtigen Service? oc get route · der Hostname muss exakt stimmen 503 zwei Ursachen Kein bereiter Pod im Endpoint oc get endpoints – leer? Dann Readiness pruefen. Zeichnung 23. Circuit Breaker offen – obwohl der Dienst laeuft Actuator-Endpunkt abfragen. Zeichnung 15: 422 als Fehler gezaehlt. Der Unterschied ist entscheidend Oben ist die Infrastruktur schuld, unten der Anwendungscode. Beide melden dasselbe. Wer nur die Route und die Pods ansieht, sucht im zweiten Fall stundenlang am falschen Ende. 504 Der Dienst antwortet, nur zu langsam Last ansehen, dann den Verbindungspool – nicht die Route Der Pod kommt nicht hoch Pending Der Scheduler findet keinen Platz oc describe node – Taints wie DiskPressure? PVC ungebunden? ImagePullBackOff Name, Tag oder Zugangsdaten Innen die svc-Adresse, aussen die Route – Zeichnung 09 und 19 CrashLoopBackOff Erst die Logs des vorigen Laufs lesen oc logs --previous · sonst: livenessProbe zu knapp? Running, aber 0/1 Laeuft, ist aber nicht bereit – kein Verkehr Der haeufigste Fall hinter einem 503. Zeichnung 23. Die Reihenfolge, die am schnellsten zum Ziel fuehrt 1 · oc get pods – Spalte READY, nicht STATUS 2 · oc describe pod – die Events ganz unten 3 · oc logs --previous – der Lauf, der gescheitert ist 4 · oc get endpoints – steht der Pod ueberhaupt drin? Erst wenn oc selbst in Zeitueberschreitungen laeuft, ist die Steuerungsebene das Problem – dann crictl auf dem Node. Die Events aus Schritt 2 sind fast immer schon die Antwort. Beim Bauen und Schieben manifest unknown Bitnami-Paywall – auf bitnamilegacy umbiegen short-name did not resolve Podman will den vollen Namen – Zeichnung 20 alte Fassung laeuft weiter Derselbe Tag neu geschoben – der Tag muss sich aendern Was fast immer stimmt Die Meldung nennt das Symptom, nicht die Ursache. 503 zeigt auf die Infrastruktur, obwohl zweimal der Code schuld war. Deshalb lohnt die Frage vor jeder Suche: Was hat sich zuletzt geaendert? – das schlaegt jede Systematik.
Vom Symptom zur Stelle, an der man nachsieht. Die drei Bänder sind nach dem sortiert, was man zuerst sieht: eine HTTP-Antwort, ein Pod-Zustand, eine Meldung beim Bauen. Der wichtigste Eintrag ist der doppelte 503 – dieselbe Meldung, zwei völlig verschiedene Ursachen, und die zweite kostet Stunden, wenn man sie nicht kennt.

38 Wo ein Passwort überall liegt

Ein Wert, acht Stationen – sechs sichtbare und zwei, an die man nicht denkt – und an jeder eine andere Art, ihn zu verlieren.

Bereit: vom Rechner bis in den Prozess
dein Rechner Umgebungsvariable GitHub-Secret verschluesselt gelagert Runner im Klartext im Speicher helm --set als Argument K8s-Secret Base64 in etcd Prozess env des Containers Leckstelle Shell-Historie .env versehentlich committet Gegenmittel: .gitignore, und nie als Argument tippen Hier ist er sicher. Nicht lesbar, auch nicht fuer dich. Aber: jeder mit Schreibrecht am Repo kann ihn ausgeben lassen. Protokollausgabe GitHub ersetzt ihn durch Sterne – aber nur den Wert selbst, nicht Teile davon. Deshalb nie umformen oder zerschneiden. ps aux Argumente sind fuer jeden auf der Maschine sichtbar. Besser: --set-file oder ein Secret, das schon im Cluster liegt. Base64 ist keine Verschluesselung Wer das Secret lesen darf, liest den Wert. RBAC begrenzen, etcd verschluesseln, oder externer Tresor. /proc/<pid>/environ Absturzberichte nehmen die Umgebung gern mit auf. Als Datei einhaengen statt als Variable – dann steht er nirgends in der Umgebung. Die zwei Stationen, die man nicht sieht Die Image-Schicht Ein Passwort, das beim Bauen in eine Schicht geraet, bleibt darin – auch wenn eine spaetere Schicht die Datei loescht. Schichten werden gestapelt, nie ueberschrieben. Wer das Image hat, hat den Wert. Gegenmittel: mehrstufiger Build, oder BuildKit-Secrets. Der Helm-Release-Verlauf Helm legt die verwendeten Werte je Revision als Secret ab – siehe Zeichnung 26. Ein einmal per --set uebergebenes Passwort steht damit im Verlauf, auch nachdem man es laengst geaendert hat. Gegenmittel: auf ein bestehendes Secret verweisen statt Werte zu uebergeben. Der rote Faden: ein Passwort ist an jeder Station so sicher wie der Zugriff auf diese Station – und es hinterlaesst an fast jeder eine Spur, die laenger lebt als es selbst. Deshalb ist „Passwort geaendert“ nicht dasselbe wie „altes Passwort weg“. Was in dieser Sammlung deshalb ueberall auftaucht: --password-stdin statt -p, einfache Anfuehrungszeichen um Variablen, die erst im Container aufgeloest werden sollen, und Umgebungsvariablen statt fest eingetragener Werte. Drei Gewohnheiten, die je eine Station absichern.
Acht Stationen, und nur eine davon ist wirklich sicher. Interessanter als der Weg selbst sind die beiden Stationen unten: Image-Schicht und Helm-Verlauf halten einen Wert fest, nachdem man ihn geändert hat. Beide sieht man nicht, wenn man nur den Weg von links nach rechts betrachtet.