← Ü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 · öffentlich lesbar Laptop · Windows hinter dem eigenen Anschluss Podman in WSL2 baut die Images CRC-Registry verlässt 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 könnten. Hetzner · Rechenzentrum öffentliche 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 öffentlich Tailnet SSH 22 + kubectl 6443 im WireGuard-Tunnel, UDP, ohne offenen Port geplant: auslagern selten gerufen, latenztolerant restic SFTP · täglich 03:15, dedupliziert Postgres und Redis bleiben, wo die Anwendung ist: jede Ausleihe macht mehrere Abfragen nacheinander, über WAN vervielfacht sich das pro Query. Ein Cache am anderen Ende einer Leitung ist keiner mehr. Voraussetzung für 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-Berührungspunkte: 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 läuft getrennt. Deshalb kann ein Bauteil überhaupt umziehen, ohne dass der Rest davon erfährt – 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 Prioritäten 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 öffnen WireGuard über 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 grüne Weg ist der interessante: das Tailnet baut die Verbindung von innen nach außen auf, also braucht es dafür 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 veröffentlicht – und was das gemeinsame Netz damit zu tun hat.

von außen: 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 über Dienstnamen – Dockers interner Resolver (127.0.0.11) unverschlüsselt – 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 für echte Netzdienste: wordpress steckt in zwei Netzen – erreichbar vom Proxy, die MariaDB für niemanden sonst. vaultwarden braucht kein zweites Netz, weil seine Datenbank kein Dienst ist, sondern eine SQLite-Datei, die der Container einhängt.
Nur der Proxy veröffentlicht Ports. Alle anderen Container sind ausschließlich über ihren Dienstnamen im gemeinsamen Netz erreichbar – Docker hält 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 über 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 verlässt 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 wäre bequemer und würde genau diese Zuordnung zerstören.

05 Server-Lebenszyklus

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

Der Zyklus, mit dem ein Lernserver nur dann Geld kostet, wenn er läuft.

läuft ~0,0116 EUR/h Dienste gestoppt + sync Snapshot läuft 1–4 Minuten Exit-Code = 0? gelöscht 0 EUR/h down preDown-Hook · SSH 22 je Server sync Puffer auf die Platte schreiben create-image Hetzner-API · HTTPS prüfen ja → server delete nein Server bleibt stehen und wird NICHT gelöscht up: neuer Server aus dem neuesten Snapshot – Konfiguration identisch bleibt über 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 trägt den ganzen Zyklus. Erst wenn der Snapshot nachweislich gelungen ist, wird der Server gelöscht – scheitert er, bleibt alles stehen. Ohne diese Prüfung wäre 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 entlangläuft.

Postgres im Container läuft weiter MariaDB im Container --single-transaction SQLite (Vaultwarden) .backup, nicht kopieren Dateien & Volumes /opt, /etc/docker Dump über 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, verschlüsselt --keep-daily 7 · --keep-weekly 5 --keep-monthly 12 · --prune Regeln wirken zusammen, nicht nacheinander SFTP über SSH wer prüft den Waechter? montags: check --read-data-subset=5% täglich: Totmann-Schalter meldet den Lauf 12:00: zweiter Timer prüft das Alter Der dritte Punkt rechts ist der wichtigste: der Totmann-Schalter meldet, wenn ein Backup läuft. Ein Backup, das gar nicht erst startet, schweigt auch beim Melden – genau diese Lücke schließt die Altersprufung.
Datenbanken werden gedumpt, nie als Datei kopiert. Eine laufende Datenbank hat Dateien in einem Zustand, aus dem sich nichts zuverlässig 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 für die Zertifikatsprüfung HTTPS 443 · eine TLS-Strecke HTTPS 443 · zweite TLS-Strecke Die orange Wolke verbirgt die Server-IP und fängt 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. Für blog und www ist er sinnvoll, für 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, Schlüssel Port 22, offen nur für /32 Tailnet von innen aufgebaut, kein Port WireGuard, ausgehend aufgebaut Hetzner-Konsole im Browser hängt am Bildschirm, nicht am Netz VNC am virtuellen Bildschirm – ohne Netz des Servers fällt aus, sobald der Anschluss eine neue Adresse bekommt (Zwangstrennung) fällt aus, wenn der Tailscale-Dienst auf dem Server nicht läuft 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 dafür, den dritten Weg zu kennen, bevor man ihn braucht. Die Hetzner-Konsole hängt am virtuellen Bildschirm der Maschine und ist von Firewall, Netz und SSH-Konfiguration völlig unabhängig.

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 für 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 außen Zwei Adressen für 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 über 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 ändern × 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 grüne Weg überhaupt 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 Gerät. Die CRC-VM, der Windows-Host und die WSL-Maschine sind füreinander entfernte Rechner – localhost bedeutet in jeder etwas anderes. Der grüne Weg funktioniert nur, weil CRC den Port 443 nicht auf Loopback beschränkt.

10 Was wo läuft

Bereit: Routen, Services und Infrastruktur

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

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, prüft 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 draußen. Die sechs Fachdienste und der config-server sind ausschließlich clusterintern erreichbar, über ihren Servicenamen; von den acht Spring-Diensten hat allein das api-gateway eine Route. Der grün 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

Fünf 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 · Zähler 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 schlägt das Speichern fehl, wird die Reservierung zurückgenommen (markCopyAvailable) Die Reihenfolge ist bewusst: erst prüfen, dann reservieren, dann speichern. Wäre das Reservieren zuerst, müsste 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 verändert, ist markCopyLoaned – und weil danach noch das lokale Speichern scheitern kann, gibt es dafür eine Ruecknahme. Diese eine Zeile ist der Grund, warum die Ausleihe ohne verteilte Transaktion auskommt.

12 Zustände: Exemplar und Ausleihe

Bereit: AVAILABLE, LOANED, LOST, ACTIVE und RETURNED

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

CopyStatus – catalog-service AVAILABLE LOANED LOST RESERVED · IN_REPAIR im Enum, aber kein Codepfad setzt sie je ausleihen zurückgeben auch von LOANED Real gibt es nur zwei Zustände im Betrieb: AVAILABLE und LOANED. markCopyLost prüft den Quellzustand nicht – LOANED kann direkt nach LOST, ohne AVAILABLE. LoanStatus – lending-service ACTIVE RETURNED zurückgeben Nur zwei Zustände – überfällig ist kein Zustand, sondern ein Datumsvergleich. Genau hier zählt 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 nächste Zeichnung zeigt, wie. Nebenbei sichtbar: RESERVED und IN_REPAIR sind im Enum vorgesehen, aber kein Pfad setzt sie – und markCopyLost prüft 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, Prüfen, Schreiben Anfrage A Anfrage B DB SELECT → AVAILABLE SELECT → AVAILABLE Prüfung besteht Prüfung 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 Lücke. Es wird kleiner, wenn die Maschine schnell ist – und verschwindet nie. Unter Last wird es groß. nachher – bedingtes UPDATE Anfrage A Anfrage B DB UPDATE … SET status = LOANED WHERE id = ? AND status = AVAILABLE 1 Zeile geändert dieselbe Anweisung 0 Zeilen geändert Die Bedingung steckt in der Anweisung selbst. Die Datenbank serialisiert die beiden – nicht der Code. A bekommt 201, B bekommt 422. „0 Zeilen“ heißt: inzwischen vergeben – und löst dieselbe Ausnahme aus wie zuvor die Statusprüfung. Nach außen ändert sich nichts. Kein Sperren, keine Transaktionsklammer über Dienste hinweg, keine zusätzliche Abfrage. Eine WHERE-Bedingung. Der Unterschied ist nicht, dass rechts geprüft wird und links nicht – links wird auch geprüft. 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 über die Route https://keycloak.apps-crc.testing edge-terminiert: TLS endet am Router Anmeldung HTTPS über 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 nötig 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 Schlüssel holt der Dienst clusterintern ab, dort genügt http. Wer beides gleichsetzt, bekommt 401.
Zwei Adressen für dieselbe Keycloak, und beide müssen unterschiedlich sein. Der iss-Claim entsteht aus dem Host-Header der Anmeldung, also mit https. Steht in der Konfiguration http, schlägt der Vergleich fehl – und die Fehlermeldung spricht von einem ungültigen 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 für einen Ausfall hielt.

so ist er gedacht CLOSED OPEN HALF_OPEN Fehlerquote überschritten nach Wartezeit Probe erfolgreich OPEN heißt: Aufrufe gehen gar nicht erst raus, der Fallback antwortet sofort. so lief es tatsächlich catalog antwortet 422 – Exemplar inzwischen vergeben Feign macht daraus eine Ausnahme Resilience4j zählt sie als Fehler – Breaker öffnet Fallback wirft CatalogServiceUnavailableException Nutzer sieht: 503 „Dienst nicht erreichbar“ Der Dienst war die ganze Zeit erreichbar. Was er sagte, war eine völlig normale Fachregel – nur kam sie als Ausfall an, und je mehr Konflikte es gab, desto sicherer öffnete der Breaker und machte aus einem seltenen Konflikt einen flaechendeckenden Ausfall. Kurzfristige Abhilfe: ignoreExceptions. Richtig wäre, den Konflikt als fachliches Ergebnis zurückzugeben – dann entsteht gar keine Ausnahme, der Retry greift wieder nur bei echten Netzwerkfehlern, und der Breaker misst wieder das, wofür er gedacht ist. Noch offen.
Ein Circuit Breaker misst, was man ihm als Fehler hinstellt. Hier war das eine fachliche Ablehnung – und die Rückmeldung an den Nutzer wurde dadurch nicht nur falsch, sondern irreführend: sie zeigte auf die Infrastruktur, während 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 prüfen) nur hier kann ein Dienst den anderen mit herunterreißen 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 wäre bequemer – und die Dienste wären nicht mehr unabhängig.
Eine Instanz, sieben Eigentuemer. Die gemeinsame Postgres spart Speicher und Startzeit; die Trennung nach Datenbanken hält trotzdem durch, dass jeder Dienst nur seine eigenen Tabellen kennt. Informationen fließen über Kafka oder HTTP – und nur der rote Kasten ist der Weg, auf dem ein Dienst einen anderen mit herunterreißen 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 vollständiges 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-Lücke 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 eigenständige 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 entfällt die Zwischenschicht: dort läuft 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 ändert, wird eine Schicht. build Image – unveränderlicher Stapel 4 · ENTRYPOINT (nur Metadaten) 3 · app.jar – 40 MB 2 · /app angelegt – wenige Bytes 1 · Java-Laufzeit – 180 MB Nur Schicht 3 ändert sich beim nächsten Build – der Rest kommt aus dem Cache. run Container – laufender Prozess schreibbare Schicht weg, sobald der Container gelöscht wird dieselben vier Schichten – nur lesend geteilt mit jedem anderen Container aus dem Image Volume – außerhalb des Stapels überlebt Löschen und Neuerstellen Tag gegen Digest ghcr.io/nursude/app:latest ein Etikett – kann jederzeit auf ein anderes Image zeigen …@sha256:9f2c4a… der Inhalt selbst – ändert sich der Inhalt, ändert 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 läuft weiter, und man sucht den Fehler im Code. Der Tag muss sich ändern. 2. Ein Passwort in einer Schicht bleibt darin, auch wenn eine spätere Schicht es löscht. Schichten werden gestapelt, nie überschrieben. Warum der zweite Build so viel schneller ist: jede Schicht ist über ihren Inhalt adressiert. Ändert sich nur das jar, bleiben die 180 MB Java-Laufzeit unberührt – weder neu gebaut noch neu übertragen. Genau deshalb steht COPY app.jar im Dockerfile nach allem, was sich selten ändert. Und deshalb gibt es Volumes: alles, was einen Neustart des Containers überleben soll, darf nicht in der schreibbaren Schicht liegen. In Kubernetes heißt 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, ergänzt 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. Öffentliches Ziehen ohne. Öffentlich 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, läuft 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 Verlässt den Laptop nie. Für den lokalen Cluster reicht sie – für 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 außen – zum Schieben vom Laptop. Geht über 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 gehören zu diesem Namen) und Schichten (adressiert über ihre Prüfsumme). Sie führt nichts aus und weiß nichts von Containern. Für 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 müssten.
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) über 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 Überblick weg – und beim Systemstart bringt er die Container mit restart: always von selbst zurück. Podman – normaler CLI-Aufruf ohne Daemon podman run … (dein Befehl) startet den Container direkt als eigenes Kind rootless: als du selbst, über User-Namespaces Container A Container B Im rootless-CLI-Fall kein dauerhafter Root-Prozess und damit keine zentrale Stelle, an der alles hängt. Autostart: systemd oder Socket-Service konfigurieren. Was in dieser Sammlung daran hängt 1. systemctl enable podman-restart.service aus der Jenkins-Anleitung – ohne Daemon bringt --restart=always die Container nach einem Neustart des Servers nicht zurück. Bei einem Server, der per Snapshot regelmäßig neu entsteht, ist das entscheidend. 2. podman system service stellt trotzdem einen Docker-kompatiblen Socket bereit – für Werkzeuge, die einen Daemon erwarten. Genau den bekommt Jenkins über CONTAINER_HOST und DOCKER_HOST hineingereicht. 3. Podman verlangt vollständige Image-Namen: jenkins/jenkins allein ergibt short-name did not resolve. Docker ergänzt 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. Fällt der Rechner aus, fällt alles aus – und genau das ist oft völlig in Ordnung. Kubernetes – verteilt auf mehrere Objekte Deployment image, replicas, Neustartverhalten Service der Port – clusterintern erreichbar Ingress / Route erst hiermit von außen 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 prüft, hat davon nichts. Kubernetes verlangt stattdessen, dass jeder Dienst mit einem fehlenden Gegenüber umgehen kann: Pod stirbt, wird neu gestartet, versucht es erneut – und die Readiness-Probe hält 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 gelöst hätte.
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 prüft, 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, Prüfungen und Protokollierung greifen. Scheduler sucht für 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 erklärt, was einen anfangs verwirrt kubectl apply kommt sofort zurück Es hat nur den Wunsch hinterlegt. Ob er erfüllt wird, entscheidet sich danach – und vielleicht nie. Deshalb gibt es rollout status und --wait: sie warten, bis das Ist dem Soll entspricht. Ein gelöschter Pod kommt zurück Nicht aus Trotz: das ReplicaSet zählt weiter eins und sieht null. Also legt es einen an. Wirklich weg ist er erst, wenn das Soll sich ändert – also am Deployment. Handänderungen verschwinden Der Controller kennt nur sein Soll. Was du am Objekt änderst, gleicht er beim nächsten Durchlauf wieder aus. Dasselbe gilt für 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 Zeitüberschreitungen läuft. Es kann dafür 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 wäre.
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, wählt 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 zählt nach und gleicht ab Pod eine IP, ein oder mehrere Container, gemeinsames Netz Die drei Proben – und was sie steuern startupProbe solange sie läuft, pausieren die anderen beiden – genau das rettete Keycloak mit 5 min 34 Startzeit readinessProbe entscheidet allein über die Endpoints-Mitgliedschaft – schlägt sie fehl, bekommt der Pod keinen Verkehr mehr livenessProbe schlägt 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 läuft, 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 Schönheitsfehler, sondern die Voraussetzung dafür, dass die Anwendung ihre Gegenstellen überhaupt 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 für *.apps-crc.testing selbst – deshalb gibt es hier kein Secret und kein Let's Encrypt. Älter 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 – dafür verschachtelt. Das Zertifikat kommt aus einem Secret, das jemand fuellen muss. Läuft überall, 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 übersetzt. 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, für 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, Größenbegrenzungen – steht in Annotationen, und die heißen bei jedem Controller anders. Eine nginx.ingress.kubernetes.io/*-Angabe wird von Traefik stillschweigend ignoriert. Wieder ohne Fehler. Das gemeinsame Muster der beiden roten Kästen: ein Ingress ist nur ein Wunsch. Erfüllt wird er von einem Controller, der zufällig zuhoert – oder eben nicht. Kubernetes prüft dabei nur, ob das Objekt formal gültig 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 Verschlüsselung. 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 verschlüsselt ablegen – wenn      der Cluster dafür eingerichtet ist Sonst: gleiche Größengrenze, gleiche Handhabung, gleiche drei Wege in den Pod. Drei Wege in den Pod 1 · einzelne Variable env: [{name: PW, valueFrom: secretKeyRef}] Ein Schlüssel, ein Name im Prozess. Wird beim Start eingesetzt – und nie wieder. In diesem Projekt der Weg für die DB-Zugangsdaten. 2 · envFrom envFrom: [{secretRef: {name: …}}] Alle Schlüssel auf einmal als Variablen. Ebenfalls nur beim Start. Bequem – und man sieht nicht mehr, was ankommt. 3 · als Datei eingehängt volumeMounts: [{mountPath: /config}] Jeder Schlüssel wird eine Datei. Der einzige Weg, der Änderungen mitbekommt das kubelet gleicht sie nach etwa einer Minute ab. Außer bei subPath – dann wieder nicht. Die Falle, die jeden einmal erwischt Eine ConfigMap oder ein Secret zu ändern 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 läuft mit dem alten Wert weiter, und man sucht den Fehler im Code. Abhilfe: kubectl rollout restart deploy/<name> – oder im Chart eine Prüfsumme der Werte als Annotation am Pod, dann erledigt Helm es von selbst. Warum die Trennung trotzdem sinnvoll ist: nicht weil das Secret sicherer wäre – sondern weil man ihm getrennte Rechte geben kann. Wer die Konfiguration lesen darf, muss nicht auch das Passwort lesen dürfen. Diese eine Eigenschaft ist der ganze Gewinn. Was nicht hineingehört: alles über 1 MiB, alles Binaere in Größe, und alles, was nie in etcd stehen soll. Für Letzteres gibt es externe Tresore, die zur Laufzeit einspeisen – dann steht im Cluster nur noch ein Verweis. Zusammenhang mit Zeichnung 38: ein per --set übergebenes Passwort landet zusätzlich im Helm-Release-Verlauf – also an einer zweiten Stelle, an die man beim Ändern 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 genügt für einen zweiten Cluster eine weitere Datei auf Stufe 3 – und der Tag, der sich bei jedem Deployment ändert, steht auf Stufe 4. zusammensetzen (rendern) fertige Manifeste gewöhnliches 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 für 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 überhaupt. Was der Cluster sieht Ganz normale Objekte. Kubernetes weiß nichts von Helm, Charts oder Werten. Wer ein Objekt von Hand ändert, hat es beim nächsten 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 prüfen 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 enthält das Jenkinsfile Webhook HTTPS, eingehend oder Abfrage im Takt Jenkins-Container auf dem Server Controller – hier läuft auch jeder Build casc.yaml – Configuration as Code wird bei jedem Start eingelesen und angewandt Deshalb halten Klicks in der Oberfläche nicht: der nächste Start setzt alles auf den Stand der Datei zurück. Zugangsdaten-Speicher verschlüsselt abgelegt in den Logs durch Sterne ersetzt Arbeitsverzeichnis /var/jenkins_home/workspace/<job> je Job eines, bleibt zwischen Läufen Pipeline aus dem Jenkinsfile Build & Push Deploy post / failure Die Datei liegt im Repository – die Pipeline ändert sich also mit dem Code, nicht per Klick. Podman-Socket des Wirts hineingereicht als Volume Der Build läuft auf der Engine des Servers – nicht im Jenkins-Container. Wer hier Befehle ausführen kann, ist faktisch root auf dem Server. Unix-Socket, kein Netz Registry ghcr.io – Image mit Commit-Tag Anmeldung über --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 überlebt der Aufbau das Löschen und Neuanlegen des Servers – genau das, was der Snapshot-Zyklus aus Zeichnung 05 täglich 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 gehören, 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 Änderung. 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 löschen“ 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 rückwärts main crc-anleitungen nachher main Der Zettel wandert – mehr passiert nicht. Kein neuer Commit, keine Verschmelzung, nichts zu lösen. 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 Möglichkeiten: merge – ein neuer Commit mit zwei Vorgaengern rebase – die eigenen Commits neu aufbauen (neue Hashes!) Warum rebase nach dem Pushen weh tut Beim Neuaufbau ändert sich der Inhalt jedes Commits – also auch sein Hash. Für alle anderen sind das neue Commits, während 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 nächstes wächst nur hinten Die Reihenfolge gilt innerhalb einer Partition – sonst nirgends. Mehr Partitionen bringen mehr Durchsatz und kosten genau diese Garantie; der Schlüssel des Ereignisses entscheidet, in welche es fällt. 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 Empfänger muss das aushalten. Gruppe „fine-service“ gelesen bis Offset 1 völlig unabhängig 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 nötig – aber auch keine Ausfallsicherheit. Was daraus folgt 1. Der Producer ist sofort fertig. Eine langsame oder abgestuerzte Benachrichtigung verzögert keine einzige Ausleihe – anders als beim HTTP-Aufruf an catalog-service, wo lending wartet und mit hängt. 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. Fällt der eine Controller aus, steht der gesamte Ereignisfluss. Für einen Lern-Cluster in Ordnung – in Produktion wären 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 öffnen, kein Token vorhanden 2 · verifier wuerfeln, merken challenge = SHA-256(verifier) 3 · Umleitung auf /auth – client_id, redirect_uri, code_challenge Keycloak prüft die redirect_uri hier zeichengenau – passt sie nicht, erscheint statt der Maske: Invalid parameter: redirect_uri HTTP 302 · der Browser folgt, alles über HTTPS 4 · Anmeldemaske, Nutzer meldet sich an 5 · Umleitung zurück auf redirect_uri?code=… nur wenn die redirect_uri aus Schritt 3 gültig war – sonst kam hier nie ein Code an 6 · Browser lädt 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 geprüft HTTP GET keycloak:8080 · clusterintern Der Dienst fragt Keycloak nicht bei jeder Anfrage. Warum der Umweg über 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, könnte ihn also einloesen. Mit PKCE nützt der Code allein nichts: nur wer den zufälligen verifier kennt, bekommt Tokens. Der Code ist einmalig und lebt Sekunden; das access_token Minuten; erneuert wird über das refresh_token, ohne dass sich jemand neu anmeldet.
Das Passwort erreicht die Anwendung nie. Es geht ausschließlich 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-Blöcke, 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 Schlüssel geprüft 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 Verschlüsselung – das liest jeder mit. Nichts Geheimes hinein. 3 · Signatur Keycloak unterschreibt Teil 1 + 2 mit seinem privaten Schlüssel. Jeder Dienst prüft mit dem öffentlichen – frei abrufbar. Ein geändertes Byte in Teil 2 macht die Signatur ungültig. JWKS-Endpunkt /realms/library/protocol/openid-connect/certs liefert die öffentlichen Schlüssel, nach kid sortiert wird einmal geholt und zwischengespeichert HTTP GET · einmal, dann gemerkt Was der Dienst prüft 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 öffentlichen Schlüssel vor und rechnen selbst nach. Fällt Keycloak aus, können 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 zurückziehen.
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 weiß, 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 (.) „für .com frag die da“ 2 · TLD-Server (.com) „für 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 Änderung nicht wirkt Jede Antwort trägt eine Haltbarkeit. Bis die abläuft, antwortet jeder Resolver auf dem Weg aus dem Gedaechtnis – ohne zu fragen. Bei TTL 3600 sehen manche noch eine Stunde die alte Adresse, während andere schon die neue haben. Beides gleichzeitig. Deshalb: TTL vor einem Umzug senken, danach wieder anheben. Und beim Prüfen 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 löst jede Subdomain auf, ohne dass ein Record angelegt wird. Genau das braucht OpenShift, das api.<cluster> und *.apps.<cluster> auflösbar verlangt. Für ein Lab ideal – für Produktion nicht: die IP steht im Namen, und der Dienst ist fremd. Der lokale Sonderfall: apps-crc.testing löst kein DNS-Server der Welt auf. CRC trägt dafür 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 Auflösung, 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 unverschlüsselt SNI = keycloak.apps-crc.testing · sichtbar ohne ECH · Verfahren · halber Schlüssel Der Hostname geht im Klartext hinaus – nur so weiß der Router, welches Zertifikat er nehmen soll. ServerHello, dann Zertifikat + Signatur bei TLS 1.3 ist ab dem ServerHello schon verschlüsselt – Zertifikat und Signatur sieht ein Mithoerer nicht gewähltes Verfahren · anderer halber Schlüssel · Beweis, dass er den privaten Schlüssel hat beide rechnen denselben Schlüssel aus ab hier verschlüsselt – erst jetzt geht die eigentliche Anfrage hinaus TLS 1.3 braucht dafür eine Rundreise; das Aushandeln kostet also Zeit, aber nur beim Verbindungsaufbau. Warum der Browser glaubt Serverzertifikat keycloak.apps-crc.testing · gültig 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 für die Anwendung heißt edge – hier verwendet Router entschlüsselt, spricht dann unverschlüsselt mit dem Pod. Der Pod sieht http – und wuesste nichts von https, gäbe es nicht die X-Forwarded-*-Kopfzeilen. passthrough Router reicht durch, ohne zu lesen. Der Pod hält das Zertifikat selbst. Nötig für Client-Zertifikate – und der Router kann dann nicht mehr nach Pfad verteilen. reencrypt Router entschlüsselt und verschlüsselt zum Pod neu. Auch im Cluster nichts im Klartext – dafür 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 hätte gern ein Zertifikat für blog.seb4u.com“ HTTPS · ACME-API 2 · „Beweise, dass er dir gehört – hier ist ein Token“ 3 · legt ihn ab unter /.well-known/acme-challenge/<token> 4 · holt ihn ab – über Port 80, von mehreren Standorten der Welt Genau deshalb muss Port 80 aus dem Internet erreichbar sein. Eine Weiterleitung auf https ist erlaubt – der Prüfserver folgt ihr. Entscheidend ist nur, dass 80 nicht gefiltert wird. 5 · Zertifikatsanfrage (der private Schlüssel bleibt hier) HTTPS · ACME-API 6 · unterschriebenes Zertifikat, 90 Tage gültig 90 Tage sind Absicht. Eine kurze Laufzeit zwingt zur Automatik – und ein abhandengekommener Schlüssel ist schneller wertlos. Der Proxy erneuert ab Tag 60 selbsttätig; scheitert das unbemerkt, fällt es erst am 90. Tag auf. Ein Blick auf das Ablaufdatum gehört 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 dafür Schreibrechte auf die DNS-Zone – dafür 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 über 2 h 29, alle rund 90 s 0 40 90 150 Maximum 157,24 6 vCPU – darüber 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 Zeitüberschreitungen. Ein Lastmittel von 100 auf 6 vCPU heißt: rund 94 Vorgaenge warten dauerhaft auf einen Kern. Nach zweieinhalb Stunden war der Stack nicht vollständig 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 fängt an, wer die Gesamtzeit senken will, nicht bei den sieben mit je zwei Minuten. Die 630 s waren die Zeitüberschreitung des Skripts – der Dienst wurde trotzdem bereit. Warum Nacheinander schneller ist als Gleichzeitig: ein JVM-Start ist über 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. Übertragbar: bei CPU-gebundener Arbeit hilft Parallelität nur bis zur Anzahl der Kerne. Darüber 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 Servergelöscht Image-Schicht liegt in der Registry schreibbare Schicht alles, was der Container schreibt Docker-Volume auf dem Server, außerhalb des Stapels emptyDir gehört dem Pod PersistentVolumeClaim gehört 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 überlebt 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 überstehen alles: die beiden untersten und, ganz oben, die Image-Schicht – alle drei liegen außerhalb der Maschine. Was dazwischen steht, hängt an etwas, das man löschen kann. Das Image bringt aber nur den Code zurück, nie die Daten – dafür 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 unterschätzt 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 zurück – 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 prüfen – ein abgelaufenes exp sieht genauso aus wie ein falscher Schlüssel. 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 prüfen. Zeichnung 23. Circuit Breaker offen – obwohl der Dienst läuft 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, außen 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 Läuft, ist aber nicht bereit – kein Verkehr Der häufigste Fall hinter einem 503. Zeichnung 23. Die Reihenfolge, die am schnellsten zum Ziel führt 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 überhaupt drin? Erst wenn oc selbst in Zeitüberschreitungen läuft, 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 läuft weiter Derselbe Tag neu geschoben – der Tag muss sich ändern 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 geändert? – das schlägt 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 verschlüsselt 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 für 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 für jeden auf der Maschine sichtbar. Besser: --set-file oder ein Secret, das schon im Cluster liegt. Base64 ist keine Verschlüsselung Wer das Secret lesen darf, liest den Wert. RBAC begrenzen, etcd verschlüsseln, oder externer Tresor. /proc/<pid>/environ Absturzberichte nehmen die Umgebung gern mit auf. Als Datei einhängen 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 Gerät, bleibt darin – auch wenn eine spätere Schicht die Datei löscht. Schichten werden gestapelt, nie überschrieben. 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 übergebenes Passwort steht damit im Verlauf, auch nachdem man es längst geändert hat. Gegenmittel: auf ein bestehendes Secret verweisen statt Werte zu übergeben. 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 geändert“ nicht dasselbe wie „altes Passwort weg“. Was in dieser Sammlung deshalb überall auftaucht: --password-stdin statt -p, einfache Anführungszeichen um Variablen, die erst im Container aufgelöst 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.