kind-Cluster fuer die Jenkins-Test-Stufe

Zielgruppe: DevOps. Phase 11. Ergaenzt jenkins-pipeline.md - macht die Stufe "Deploy nach Test" zu einem ECHTEN Deployment statt eines Dry-Runs. Fuer einen produktionsnahen, dauerhaften Cluster (CRC/OKD, Cloud) bleibt kubernetes-guide.md zustaendig - beide Dokus bewusst getrennt, siehe dort fuer die Begruendung.

Warum kind und nicht CRC/OKD

kubernetes-guide.md dokumentiert einen konkreten, mehrstuendigen CRC-Versuch, der an abgelaufenen Bundle-Zertifikaten, einer zu alten CRC-Version und RAM-Konflikten mit der laufenden Compose-Infrastruktur scheiterte. kind ("Kubernetes IN Docker" - der Name; hier mit Podman als Provider betrieben, KIND_EXPERIMENTAL_PROVIDER=podman) umgeht diese Probleme strukturell: statt einer eigenen VM (Hyper-V) ist ein kind-"Node" schlicht ein normaler Podman-Container, der einen kompletten Kubernetes-Node simuliert. Kein separates VM-Bootstrapping, kein Zertifikatsproblem, deutlich geringerer Ressourcenbedarf - laeuft in der bereits vorhandenen Podman-Installation. Der Preis: kind ist bewusst ein Wegwerf-Cluster fuer Test-/CI-Zwecke, kein produktionsnahes Setup (siehe "Bewusst ausserhalb des Scopes" unten).

Installation

scoop install kind          # Windows, Paketmanager bereits fuer die JDK dieses Projekts genutzt
# Alternativen: choco install kind / go install sigs.k8s.io/kind@latest / siehe kind.sigs.k8s.io
kind version                # z. B. kind v0.32.0

# kind muss den Podman-Provider nutzen (dieses Projekt hat kein Docker). Die Env-Var bei JEDEM
# "kind ..."-Aufruf setzen - am besten dauerhaft ins Profil:
$env:KIND_EXPERIMENTAL_PROVIDER = "podman"     # PowerShell
# export KIND_EXPERIMENTAL_PROVIDER=podman     # bash

Voraussetzung: die Podman-Machine laeuft bereits (podman machine start, dieselbe, die auch infra/docker-compose.yml betreibt) - kind braucht keine zusaetzliche Virtualisierungsschicht. Rootless-Podman-Hinweis: fuer kind wird ggf. ein systemd-cgroup-v2-Delegate benoetigt - siehe kind.sigs.k8s.io/docs/user/rootless.

Cluster erzeugen

kind create cluster --config infra/kind/kind-config.yaml --name library-test
kubectl cluster-info --context kind-library-test

infra/kind/kind-config.yaml ist bewusst ein Einzel-Knoten-Cluster (Lernprojekt/Test-Stufe, keine Multi-Node-Redundanz noetig) ohne extraPortMappings - Zugriff erfolgt ueber kubectl/kubectl logs, nicht ueber den Browser (siehe "Bewusst ausserhalb des Scopes" unten). kind create cluster schreibt automatisch einen neuen Kontext (kind-library-test) in die lokale kubeconfig und aktiviert ihn - kubectl zeigt danach ohne weiteres Zutun auf den neuen Cluster.

Wie kind-Pods die bestehende Compose-Infrastruktur erreichen

Bewusste Entscheidung gegen einen Ausbau des Helm-Charts um Bitnami-Postgres/-Kafka/-Redis/ -Keycloak-Subcharts (das waere kubernetes-guide.mds "Vertiefungsaufgabe" auf einen Schlag umgesetzt - deutlich groesserer Scope als hier angefragt). Stattdessen bleibt die komplette Infrastruktur unveraendert in Podman Compose, und die acht App-Services laufen in kind als einzige neue Schicht. Erreichbarkeit ueber host.containers.internal (Podmans DNS-Adresse fuer "der Host, auf dem Podman laeuft"; Podman legt zusaetzlich host.docker.internal als Alias an). Dazu die bereits von docker-compose.yml exponierten Host-Ports:

Stolperstein Podman + kind: anders als Docker Desktop injiziert Podman host.containers.internal nur in den kind-Node-Container, nicht automatisch in die Pods darin. Loest ein Pod den Namen nicht auf, hilft eine der ueblichen Abhilfen: --add-host=host.containers.internal:host-gateway ist beim kind-Node nicht direkt setzbar - stattdessen entweder die feste Gateway-IP des Podman-Netzwerks (podman network inspect kind -> gateway) in env/kind-values.yaml eintragen, oder extraPortMappings + einen NodePort nutzen. Fuer dieses Lernprojekt bleibt der kind-Pfad bewusst "nur wenn KUBECONFIG_TEST wirklich auf einen kind-Cluster zeigt" (siehe unten) - die genaue Netz-Verdrahtung ist dann Teil des Setups auf der jeweiligen Maschine.

Infrastruktur Erreichbar unter Bereits vorhandener Host-Port?
PostgreSQL host.containers.internal:5432 Ja, unveraendert
Redis host.containers.internal:6379 Ja, unveraendert
RabbitMQ host.containers.internal:5672 Ja, unveraendert
Keycloak (JWKS-Abruf) host.containers.internal:8180 Ja, unveraendert
Kafka host.containers.internal:9094 Neu - dritter Listener, siehe unten

Gesetzt wird das ueber ein gemeinsames Overlay, infra/helm/values/env/kind-values.yaml, das als vierte -f-Datei hinter der Service- und der Test-Overlay-Datei uebergeben wird (siehe "Deploy-Kette" unten).

Stolperstein: Kafka braucht einen dritten Listener

Der bestehende PLAINTEXT_HOST-Listener in docker-compose.yml advertised sich als localhost:9092. Ein Kafka-Client verbindet sich zwar initial erfolgreich zu einer beliebigen erreichbaren Adresse, bekommt dann aber per Metadata-Response localhost:9092 als tatsaechliche Broker-Adresse genannt und versucht, dorthin ERNEUT zu verbinden (Kafkas Broker-Discovery-Protokoll). Aus einem kind-Pod heraus zeigt "localhost" auf den Pod selbst, nicht auf den Podman-Host - die zweite Verbindung schlaegt fehl, obwohl die erste erfolgreich war (ein tueckischer Fehler: er sieht wie ein zeitweiliges Netzwerkproblem aus, ist aber strukturell).

Loesung: ein DRITTER Kafka-Listener (KIND_HOST, Port 9094), zusaetzlich zu den bestehenden zwei, NICHT als Ersatz - der bestehende PLAINTEXT_HOST-Listener fuer lokale IDE-Prozesse bleibt unveraendert, um diesen bereits funktionierenden Weg nicht zu gefaehrden:

# infra/docker-compose.yml, kafka-Service (Auszug)
KAFKA_LISTENERS: PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,PLAINTEXT_HOST://0.0.0.0:9092,KIND_HOST://0.0.0.0:9094
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092,KIND_HOST://host.containers.internal:9094
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,KIND_HOST:PLAINTEXT

env/kind-values.yaml setzt entsprechend SPRING_KAFKA_BOOTSTRAP_SERVERS: host.containers.internal:9094 fuer alle Services, die Kafka nutzen.

Stolperstein: Helm-Values-Layering braucht Maps statt Listen

Ein gemeinsames kind-Overlay fuer env (wie bei test-values.yaml/qs-values.yaml) war mit der urspruenglichen Chart-Struktur nicht moeglich: env war eine LISTE ([{name: X, value: Y}, ...]), und Helm merged Listen zwischen mehreren -f-Dateien NICHT Element-fuer-Element - eine spaeter uebergebene Datei ERSETZT die komplette Liste. Ein env-Overlay haette also den kompletten, bereits vorhandenen env-Block jedes der 8 Services erneut vollstaendig duplizieren muessen.

Loesung: env von Liste auf Map umgestellt ({SPRING_X: "Y"} statt [{name: SPRING_X, value: "Y"}]). Maps werden zwischen -f-Dateien SCHLUESSELWEISE (und sogar verschachtelt, siehe unten) gemerged - eine spaetere Datei ueberschreibt nur passende Schluessel, der Rest bleibt erhalten.

# infra/helm/library-service/templates/deployment.yaml - vorher/nachher
# vorher (Liste):
{{- range .Values.env }}
- name: {{ .name }}
  value: {{ .value | quote }}
{{- end }}
# nachher (Map):
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
  value: {{ $value | quote }}
{{- end }}

probes und resources waren in diesem Chart schon immer verschachtelte Maps (nie Listen) - Helms Merge-Verhalten gilt genauso fuer sie, deshalb kann kind-values.yaml weiter unten auch einzelne Unter-Schluessel wie probes.liveness.initialDelaySeconds ueberschreiben, ohne Geschwister-Schluessel wie probes.liveness.periodSeconds anzutasten (siehe pattern-katalog.md fuer den Katalogeintrag zu diesem Muster).

SPRING_DATASOURCE_URL bleibt trotzdem je Service in einer eigenen, winzigen Datei (env/kind/<service>-values.yaml) - der Datenbankname unterscheidet sich pro Service in derselben JDBC-URL-Zeichenkette, ein einzelner Env-Var-Schluessel kann nicht TEILWEISE ueberschrieben werden.

Deploy-Kette

helm upgrade --install catalog-service infra/helm/library-service \
  -f infra/helm/values/catalog-service-values.yaml \
  -f infra/helm/values/env/test-values.yaml \
  -f infra/helm/values/env/kind-values.yaml \
  -f infra/helm/values/env/kind/catalog-service-values.yaml \
  -n library-test --create-namespace --set image.tag=<git-sha>

Reihenfolge ist bewusst: service-spezifisch → umgebungsweit (Test) → kind-weit → kind-spezifisch je Service - jede spaetere Datei ueberschreibt nur die Schluessel, die sie tatsaechlich setzt. api-gateway und config-server haben kein env/kind/<service>-values.yaml (kein SPRING_DATASOURCE_URL, siehe infra/helm/values/README.md) - das Jenkinsfile prueft das per fileExists(...), bevor es die vierte -f anhaengt.

Vorher noetig: die DB-Zugangsdaten als Kubernetes-Secret (wie in kubernetes-guide.md beschrieben, gilt identisch fuer den kind-Namespace):

kubectl create secret generic catalog-service-db-credentials -n library-test \
  --from-literal=SPRING_DATASOURCE_USERNAME=library \
  --from-literal=SPRING_DATASOURCE_PASSWORD=library

Image im Cluster verfuegbar machen - kind hat keine eigene Registry, kind load docker-image (das Subkommando heisst weiterhin so) kopiert mit KIND_EXPERIMENTAL_PROVIDER=podman ein lokal gebautes Image aus Podmans Storage direkt in den Node-Container:

podman build -f library-platform/Dockerfile --build-arg MODULE=catalog-service \
  --build-arg PORT=8080 -t library/catalog-service:<tag> library-platform
kind load docker-image library/catalog-service:<tag> --name library-test

Im Jenkinsfile uebernimmt das loadImageIntoKindIfClusterAvailable(...) automatisch vor jedem helm upgrade der Test-Stufe - nur wirksam, wenn das KUBECONFIG_TEST-Credential tatsaechlich auf einen kind-Cluster zeigt (siehe "Jenkins-Anbindung" unten), sonst greift ohnehin der Dry-Run-Fallback aus jenkins-pipeline.md.

Stolperstein: CPU-Drosselung macht Spring-Boot-Kaltstarts extrem langsam

Die urspruenglichen Test-Overlay-Werte (env/test-values.yaml: 300m CPU-Limit) waren fuer einen dedizierten Test-Cluster kalkuliert. Unter kind reicht das auf der Referenzmaschine nicht: kind selbst ist bereits eine Container-in-Container-Schicht (Podman-Machine/WSL2 → kind-Node- Container → Pod) - der zusaetzliche Virtualisierungs-Overhead macht das Standard-Limit spuerbar zu knapp. Gemessen am reinen Spring-Data-Repository-Scanning (ein einzelner, isoliert messbarer Startphasen-Schritt):

CPU-Limit Dauer fuer Repository-Scanning
300m (Standard-Test-Overlay) 16,9 Sekunden
1500m (kind-Overlay) 0,4-0,6 Sekunden

Ueber 25x langsamer unter dem Standard-Limit - und das ist nur EIN Startphasen-Schritt von vielen (DataSource, Flyway, JPA/Hibernate, Kafka-Client, JWKS-Abruf kommen alle noch dazu). In der Praxis bedeutete das: der Pod wurde von der Liveness-Probe (Standard-Delay 30s) staendig neu gestartet, BEVOR die Anwendung ueberhaupt fertig hochgefahren war - ein CrashLoopBackOff, der wie ein Anwendungsfehler aussieht, aber reine Ressourcen-Ursache hat.

Loesung: env/kind-values.yaml erhoeht CPU (500m/1500m statt 50m/300m) und Speicher (512Mi statt 384Mi - mehr CPU-Zuteilung bedeutet mehr parallele GC-/JIT-Compiler-Threads, was zusaetzlichen Speicher jenseits des Heaps braucht; ohne diese Erhoehung endete ein Pod mit Exit-Code 137) sowie grosszuegigere Probe-Delays (Liveness 240s, Readiness 90s). Diese Zahlen sind bewusst am beobachteten WORST CASE bemessen, nicht am Durchschnitt: auf derselben Referenzmaschine schwankte die Gesamt-Boot-Zeit zwischen ca. 22 Sekunden (Host im Leerlauf) und ueber 200 Sekunden (Host durch parallele Container-Builds/Image-Loads ausgelastet) - keine feste Zahl ist hier "richtig", nur eine mit ausreichend Sicherheitsmarge.

Stolperstein: ein zweiter, unabhaengiger Bug wurde dabei aufgedeckt

Der allererste echte Deploy-Versuch deckte einen bis dahin unbemerkten Schema-Bug in catalog-service auf: BookEntity.reservationCount (@Column(nullable = false)) hat keine entsprechende Spalte in der Flyway-Migration V1__init_catalog_schema.sql - Hibernates Schema-Validierung (ddl-auto=validate) lehnte den Start folgerichtig mit missing column [reservation_count] in table [books] ab.

Derselbe Bugtyp wie ein bereits dokumentierter Fund weiter oben in genau dieser Migrationsdatei (author_order, siehe Kommentar dort): eine Spalte, die eine JPA-Entity verlangt, aber die Migration nie angelegt hat - unbemerkt, weil die Testcontainers- Integrationstests, die das sonst gefangen haetten, auf dieser Maschine damals wegen der dokumentierten Docker-Desktop-npipe-Flakiness nie zuverlaessig durchliefen (inzwischen ueber Podman geloest, siehe docker-compose-guide.md). Erst der erste ECHTE kind-Deploy - die erste Gelegenheit, ddl-auto=validate gegen eine wirklich frische Postgres-Instanz laufen zu lassen - deckte es auf. Gefixt nach demselben, bereits etablierten Muster: Spalte direkt in V1 ergaenzt (nicht als separate V2 - V1 wurde nie gegen eine echte, dauerhafte Datenbank angewendet, siehe Kommentar in der Migrationsdatei selbst).

Die Lehre ist dieselbe wie beim ersten Fund, nur diesmal durch kind statt durch einen zufaelligen Testlauf bestaetigt: ein Cluster, der wirklich deployt, findet Bugs, die ein Dry-Run strukturell nicht finden kann - genau das Argument fuer diese ganze Phase 11.

Jenkins-Anbindung

kind create cluster erzeugt automatisch eine kubeconfig fuer den neuen Kontext. Um sie als Jenkins-Credential zu hinterlegen:

kind get kubeconfig --name library-test > kubeconfig-test.yaml

Unter "Manage Jenkins → Credentials" ein Credential vom Typ "Secret file" mit der ID KUBECONFIG_TEST anlegen (Datei: kubeconfig-test.yaml) - genau die ID, die loadImageIntoKindIfClusterAvailable(...) und deployOrDryRun(...) im Jenkinsfile erwarten. Bewusst NICHT in casc.yaml vorbelegt (ein Kubeconfig ist ein Geheimnis, siehe PR-Checkliste, Security - dieselbe Begruendung wie beim GitHub-Token in jenkins-pipeline.md).

Wichtiger Unterschied zu einem "echten" Cluster: eine kind-Kubeconfig zeigt per Default auf 127.0.0.1:<zufaelliger-Port> - erreichbar von der Maschine, auf der kind create cluster lief, aber NICHT unbedingt vom Jenkins-Container aus (der selbst als Container laeuft, siehe shared/enterprise-infrastructure/docker-compose.yml, jenkins-Service). Da der Jenkins-Container ueber den Podman-Socket-Mount ohnehin vollen Host-Podman-Zugriff hat (siehe "Vertiefungsaufgaben" in jenkins-pipeline.md), funktioniert kubectl/helm aus dem Container heraus trotzdem - die kind-Node-Container sind fuer Host-Podman sichtbar, das der Jenkins-Container mitbenutzt.

Manueller Test-Deploy (ohne Jenkins, zur Kontrolle)

# 1. Cluster erzeugen (falls noch nicht geschehen)
kind create cluster --config infra/kind/kind-config.yaml --name library-test
kubectl cluster-info --context kind-library-test

# 2. Infrastruktur muss laufen - kind ersetzt sie NICHT, siehe oben
cd infra && podman compose up -d postgres redis kafka rabbitmq keycloak

# 3. DB-Secret anlegen (einmalig pro Service/Namespace)
kubectl create secret generic catalog-service-db-credentials -n library-test \
  --from-literal=SPRING_DATASOURCE_USERNAME=library \
  --from-literal=SPRING_DATASOURCE_PASSWORD=library

# 4. Image bauen und laden (KIND_EXPERIMENTAL_PROVIDER=podman muss gesetzt sein)
podman build -f library-platform/Dockerfile --build-arg MODULE=catalog-service \
  --build-arg PORT=8080 -t library/catalog-service:kindtest library-platform
kind load docker-image library/catalog-service:kindtest --name library-test

# 5. Deployen (bewusst ohne --wait - siehe Stolperstein oben zur variablen Boot-Zeit;
#    --wait mit einem zu knappen --timeout schlaegt sonst faelschlich fehl, waehrend der Pod
#    im Hintergrund normal weiter hochfaehrt)
helm install catalog-service infra/helm/library-service \
  -f infra/helm/values/catalog-service-values.yaml \
  -f infra/helm/values/env/test-values.yaml \
  -f infra/helm/values/env/kind-values.yaml \
  -f infra/helm/values/env/kind/catalog-service-values.yaml \
  -n library-test --create-namespace --set image.tag=kindtest

# 6. Status/Logs pruefen (READY 1/1 nach realistisch 20-200s je nach Host-Auslastung)
kubectl get pods -n library-test -w
kubectl logs -n library-test -l app.kubernetes.io/instance=catalog-service -f

Ein erfolgreicher Start zeigt in den Logs u. a.:

o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v1
j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'

Aufraeumen

kind delete cluster --name library-test

Der Cluster selbst ist kein Repository-Artefakt und jederzeit aus infra/kind/kind-config.yaml neu erzeugbar - anders als die Compose-Infrastruktur (die persistente Volumes wie pgdata haelt) gibt es hier nichts, was ueber das Loeschen hinaus aufgeraeumt werden muesste.

Bewusst ausserhalb des Scopes (Vertiefungsaufgaben)

⌂ Cockpit