← Übersicht  ·  Skripte & Dateien  ·  Komplett-Deploy · CI (Continuous Integration)/CD (Continuous Delivery) auf echtem OpenShift

Bibliothek-Enterprise per Jenkins auf OKD deployen

Das ganze Projekt – 8 Spring-Services (mit Config-Server), dazu Postgres, Redis, Kafka und Keycloak – landet über eine echte Jenkins-Pipeline auf dem OKD-Single-Node. Jenkins baut die Container-Images, schiebt sie in die clusterinterne Registry von OKD (OpenShift Kubernetes Distribution) und rollt sie per Helm aus. Diese Seite dokumentiert jeden Schritt und jede Falle – es waren viele.

Stand: 1. September 2026 Ende-zu-Ende getestet (TLS, PVC, Frontend) OKD 4.22 · Jenkins LTS (Long-Term Support) · Hetzner Cloud

Platzhalter einsetzen – werden in allen Befehlen unten ersetzt, nur im Browser, nichts wird gesendet

Das Prinzip

Zwei Hetzner-Server: einer trägt das OKD (das schon aus der SNO-Anleitung steht), der andere ein Jenkins. Jenkins klont den Projekt-Branch, baut mit podman zehn Images (8 Spring-Services + discovery-server + das Angular- Frontend), meldet sich an OKDs interner Registry an (über deren externe Route), pusht die Images dorthin und ruft dann helm upgrade --install gegen die OKD-API auf. Die Pods ziehen ihr Image danach clusterintern – kein Docker Hub, kein ghcr.io, kein PAT (Personal Access Token).

GitHub privates Repo, Branch okd-deploy. Read-only Deploy-Key.
Jenkins-Server podman build → 10 Images. podman push → OKD-Registry-Route. helm → OKD-API :6443.
OKD-Node interne Registry, Namespace library, 8 Services + Frontend + Infra. Routes: library-frontend…, keycloak…, library….
Warum überhaupt Jenkins und nicht helm vom Laptop?

Weil das Projekt eine vierstufige Pipeline mitbringt (Build/Test → Test → QS → Prod, siehe Jenkinsfile im Repo-Root) und „Prod“ genau dieser OKD-Node sein soll. Der Lernwert liegt im Zusammenspiel: Image-Registry-Auth, ServiceAccount-Rechte, Helm-Overlays, Secrets, Probe-Timing – alles Dinge, die man bei helm install von Hand nie sauber durchdenkt.

01 Voraussetzungen

Ein Teil steht schon, ein Teil kommt hier dazu.

  • OKD-Single-Node läuft – per scripts/sno.ps1 install. Feste IP (Internet Protocol) <okd-ip>, API (Application Programming Interface) auf api.sno.<okd-ip>.nip.io:6443, kubeadmin-kubeconfig auf dem Laptop.
  • Größe des OKD-Node: sno.ps1 nimmt jetzt cpx52 (12 vCPU / 24 GB, ~0,14 €/h) – passt für OKD + diesen Stack. Details unten. Anpassen in scripts/sno.ps1 ($Type) oder .\scripts\sno.ps1 up -Type … nach einem down (Resize im laufenden Betrieb geht nur größer).
  • Jenkins-Server – ein zweiter Hetzner-Server (cpx42 reicht: Maven-Reactor + 9 Image-Builds sind CPU/RAM-hungrig). IP <jenkins-ip>. Aufsetzen: Abschnitt 4.

Last Wie ausgelastet ist der Node?

Gemessen mit allem oben (OKD-Control-Plane + Monitoring + die 8 Services + Postgres/ Redis/Kafka/Keycloak):

RessourceIstvonKommentar
RAM (Random Access Memory) belegt~15,6 GB (Gigabyte)32 GBkein Swap, MemoryPressure=False
  davon OKD-Plattform~11 GBkube-apiserver ~1,8 G, Prometheus ~1,8 G, etcd/OVN/Operatoren
  davon Namespace library~3,7 GBkeycloak 630 M, kafka 610 M, 7 Services je ~320 M, postgres 190 M
CPU (Central Processing Unit)~1,1 Kerne16~7 % – im Steady State nie der Engpass

RAM ist die einzige Grenze. Damit:

ServerRAM€/hUrteil
cpx6232 GB~0,18≈ 2× überdimensioniert im Steady State
cpx52 (Vorgabe)24 GB~0,14~8 GB Luft, kein Basteln. Der Jenkinsfile deployt per --wait nacheinander – kein gleichzeitiger Kaltstart-Spike
cpx42 / ccx2316 GB~0,10–0,1215,6 von 16 GB belegt – Redline. Bei einem frischen Deploy (8 Spring-JVMs + Keycloak kalt) OOM-Gefahr. Nur mit Trimmen
Auf 16 GB bringen

Monitoring verschlanken (Prometheus-Retention runter, Alertmanager/Grafana aus → ~2 GB), JVM--Xmx je Service senken (aktuell ~320 M, Limit 640 Mi in okd-values.yaml), keycloak/kafka-Heap kappen. Dann Plattform ~9 GB + Library ~4 GB ≈ 13 GB. Fuer ~0,04 €/h Ersparnis meist nicht den Aufwand wert.

  • WSL2 (Windows Subsystem for Linux 2) + oc/helm auf dem Laptop für die einmalige OKD-Vorbereitung (Abschnitt 2/3). Danach macht alles Jenkins.
  • hcloud CLI (Command-Line Interface) und gh CLI (für den Deploy-Key).
Kurz-Check

oc --kubeconfig ~/.kube/okd-sno-hetzner.yaml get co – alle Cluster-Operatoren Available=True? Dann kann es losgehen.

02 OKD vorbereiten

Namespace, interne Registry nach außen öffnen, ein ServiceAccount mit Push- und Deploy-Rechten, ein langlebiges Token. Alles einmalig vom Laptop.

A Namespace + interne Registry

Die interne Registry ist auf Single-Node per Default abgeschaltet (managementState: Removed) – sie braucht Storage, den SNO (Single-Node OpenShift) nicht automatisch stellt. Wir schalten sie auf Managed, geben ihr emptyDir (Lab: reicht, überlebt keinen Registry-Neustart) und eine externe Route.

Laptop · bash (→ OKD)
oc create namespace library

oc patch configs.imageregistry.operator.openshift.io/cluster --type merge -p '{
  "spec": {
    "managementState": "Managed",
    "storage": { "emptyDir": {} },
    "defaultRoute": true
  }
}'

oc get route -n openshift-image-registry
# -> default-route-openshift-image-registry.apps.sno.<okd-ip>.nip.io

B Pipeline-ServiceAccount + Rechte

Jenkins bekommt keinen kubeadmin. Stattdessen ein SA (ServiceAccount) pipeline im Namespace, mit genau drei Rollen: Images bauen/pushen, Registry-Inhalte editieren, im Namespace deployen.

Laptop · bash (→ OKD)
oc create sa pipeline -n library
oc policy add-role-to-user system:image-builder system:serviceaccount:library:pipeline -n library
oc policy add-role-to-user registry-editor      system:serviceaccount:library:pipeline -n library
oc policy add-role-to-user admin                system:serviceaccount:library:pipeline -n library
Falle: helm --create-namespace darf der SA nicht

Die admin-Rolle gilt im Namespace, nicht clusterweit – namespaces is forbidden. Entweder den Namespace (wie oben) vorher anlegen oder dem SA eine schmale Cluster-Rolle nur für namespaces geben:

Laptop · bash
oc create clusterrole library-ns-admin --verb=get,list,create,update,patch --resource=namespaces
oc adm policy add-cluster-role-to-user library-ns-admin -z pipeline -n library

C Langlebiges Token

Ein per oc create token geholtes Token läuft nach 1 h ab – unbrauchbar für einen Dienst. Ein SA-Token-Secret gibt ein Token ohne Ablauf. Dasselbe Token dient zweimal: als Passwort für podman login und als Bearer-Token in der kubeconfig für helm.

Laptop · bash (→ OKD)
cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: Secret
metadata:
  name: pipeline-token
  namespace: library
  annotations:
    kubernetes.io/service-account.name: pipeline
type: kubernetes.io/service-account-token
EOF

TOKEN=$(oc get secret pipeline-token -n library -o jsonpath='{.data.token}' | base64 -d)

Daraus eine eigenständige kubeconfig für den SA bauen (die geht später als Jenkins-Credential KUBECONFIG_PROD rein):

Laptop · bash
API=https://api.sno.<okd-ip>.nip.io:6443
KC=/tmp/okd.kubeconfig
oc --kubeconfig $KC config set-cluster sno --server=$API --insecure-skip-tls-verify=true
oc --kubeconfig $KC config set-credentials pipeline --token=$TOKEN
oc --kubeconfig $KC config set-context sno --cluster=sno --user=pipeline --namespace=library
oc --kubeconfig $KC config use-context sno
printf '%s' "$TOKEN" > /tmp/okd.token

D Firewall: OKD-API für den Jenkins-Server öffnen

Die Firewall okd-sno-fw lässt 6443 nur von der Laptop-IP zu. Der Jenkins-Server muss dazu – sonst hängt jeder helm-Aufruf im i/o timeout.

Laptop · PowerShell
hcloud firewall add-rule okd-sno-fw --direction in --protocol tcp --port 6443 `
  --source-ips <jenkins-ip>/32

Die Registry-Route (443) und die App-Routes (80/443) sind ohnehin für alle offen – nur der API-Port ist eng.

03 Bitnami-Infra + Keycloak

Postgres, Redis, Kafka, Keycloak – als Helm-Charts in denselben Namespace. Hier warten zwei handfeste Fallen.

Falle 1: OpenShift restricted-v2 vs. Bitnami

OKD verbietet fixe UID (User Identifier)/fsGroup/Capabilities. Bitnami-Charts setzen die. --set global.compatibility.openshift.adaptSecurityContext=auto lässt das Chart die securityContext-Blöcke der SCC (Security Context Constraints) anpassen.

Falle 2: Bitnami-Paywall (seit 28. August 2025)

Bitnami hat die versionsgetaggten Images aus docker.io/bitnami/* entfernt – nur noch :latest ist frei. Der Chart-Pin löst ins Leere (manifest unknown). Lösung: auf das eingefrorene docker.io/bitnamilegacy/* umbiegen und dem Chart erlauben, das „fremde“ Image zu akzeptieren:

--set global.security.allowInsecureImages=true \
--set image.registry=docker.io --set image.repository=bitnamilegacy/kafka
Laptop · bash (→ OKD)
helm repo add bitnami https://charts.bitnami.com/bitnami && helm repo update
NS=library; COMPAT='--set global.compatibility.openshift.adaptSecurityContext=auto'

# Postgres - die Fachservices legen ihre eigenen DBs darin an
helm upgrade --install postgres bitnami/postgresql -n $NS $COMPAT \
  --set fullnameOverride=postgres \
  --set auth.username=library --set auth.password=library --set auth.database=library \
  --set auth.postgresPassword=library --set primary.persistence.enabled=false --wait

# Redis - Service heisst dann "redis-master" (wichtig fuers Overlay)
helm upgrade --install redis bitnami/redis -n $NS $COMPAT \
  --set fullnameOverride=redis --set auth.enabled=false --set architecture=standalone \
  --set master.persistence.enabled=false --wait

# Kafka (KRaft, ein Controller) - horcht auf kafka:9092
helm upgrade --install kafka bitnami/kafka -n $NS $COMPAT \
  --set global.security.allowInsecureImages=true \
  --set image.registry=docker.io --set image.repository=bitnamilegacy/kafka \
  --set fullnameOverride=kafka --set controller.replicaCount=1 \
  --set listeners.client.protocol=PLAINTEXT --set listeners.controller.protocol=PLAINTEXT \
  --set controller.persistence.enabled=false --timeout 5m

# Keycloak - eigene DB in Postgres, dev-Modus
helm upgrade --install keycloak bitnami/keycloak -n $NS $COMPAT \
  --set global.security.allowInsecureImages=true \
  --set image.registry=docker.io --set image.repository=bitnamilegacy/keycloak \
  --set fullnameOverride=keycloak --set service.ports.http=8080 \
  --set auth.adminUser=admin --set auth.adminPassword=admin \
  --set postgresql.enabled=false --set externalDatabase.host=postgres \
  --set externalDatabase.user=library --set externalDatabase.password=library \
  --set externalDatabase.database=keycloak_db \
  --set production=false --set proxyHeaders=xforwarded --timeout 5m
Was die Schalter bedeuten

Vier Installationen, 45 Schalter – die meisten wiederholen sich. Nach Zweck sortiert:

Schaltergilt fürbewirkt
helm repo add bitnami …
helm repo update
Vorbereitung Ein Chart-Repository ist ein Verzeichnis fertiger Installationspakete – wie eine Paketquelle für apt, nur für Kubernetes. add trägt die Adresse unter dem Kurznamen bitnami ein, update holt die Liste der verfügbaren Versionen. Erst danach versteht Helm die Schreibweise bitnami/postgresql. Beides ist einmalig pro Rechner nötig, nicht pro Cluster – und schadet nicht, wenn man es wiederholt.
NS=library; COMPAT='--set global.compatibility…' Vorbereitung Eine gewöhnliche Shell-Variable, kein Helm-Begriff. Sie steht hier nur, damit der Namensraum und die lange Wiederholung darunter einmal dastehen statt viermal – wer einen anderen Namensraum nutzt, ändert genau diese Zeile. Beim Einfügen in die eigene Konsole muss sie mitkopiert werden, sonst sind die Variablen darunter leer.
-n $NS alle vier Der Namespace – die Schublade im Cluster, in der diese Anwendung liegt. Ohne die Angabe landet alles in default. Er muss vorher existieren; Helm legt ihn nicht selbst an.
global.compatibility.openshift.adaptSecurityContext=auto
(steckt in $COMPAT)
alle vier OpenShift verbietet unter restricted-v2 feste UID und fsGroup – genau die setzen die Bitnami-Charts. Der Schalter lässt das Chart seine securityContext-Blöcke an die zugewiesene SCC anpassen. Ohne ihn startet kein einziger Pod. Er steht in der Variablen, weil er sonst viermal identisch dastehen würde.
helm upgrade --install alle vier Installiert beim ersten Lauf, aktualisiert bei jedem weiteren. helm install allein bricht beim zweiten Mal mit cannot re-use a name ab – deshalb steht der Block so hier und kann gefahrlos wiederholt werden.
--set fullnameOverride=postgres usw. alle vier Erzwingt den Service-Namen exakt so. Helm hängt sonst den Release-Namen davor (postgres-postgresql). Die Anwendung erwartet aber postgres, kafka, redis-master und keycloak – so stehen die Adressen in den values-Dateien.
--set primary.persistence.enabled=false
--set master.persistence.enabled=false
--set controller.persistence.enabled=false
Postgres, Redis, Kafka Kein PersistentVolume; die Daten liegen im emptyDir des Pods. Der Pfad heisst je Chart anders, weil jedes Chart seine Hauptrolle anders nennt. Spart Platz und Zeit – kostet aber alle Daten, sobald der Pod neu erstellt wird.
--wait / --timeout 5m alle vier --wait lässt Helm blockieren, bis die Pods bereit sind, statt sofort zurückzukehren – erst dadurch stimmt die Reihenfolge, denn Keycloak braucht die laufende Postgres. Ohne Zeitangabe bricht das Warten nach 5 Minuten ab; Kafka und Keycloak bekommen die Angabe deshalb explizit.
--set global.security.allowInsecureImages=true
--set image.registry=docker.io
--set image.repository=bitnamilegacy/…
Kafka, Keycloak Die drei gehören zusammen und sind die Umgehung der Bitnami-Paywall von oben: die beiden image.*-Schalter biegen das Image auf das eingefrorene bitnamilegacy um, der erste bringt das Chart dazu, ein solches „fremdes“ Image überhaupt zu akzeptieren. Einzeln wirkt keiner von ihnen.
--set auth.enabled=false
--set architecture=standalone
Redis Kein Passwort und kein Replica-Set. Redis dient hier nur als Cache im Cluster-Netz; ein Replikat würde einen zweiten Pod kosten, ohne dass etwas davon abhängt.
--set controller.replicaCount=1
--set listeners.*.protocol=PLAINTEXT
Kafka Ein einzelner KRaft-Controller statt drei – seit KRaft braucht Kafka kein ZooKeeper mehr, und einer genügt ohne Ausfallsicherheit. PLAINTEXT schaltet TLS und SASL innerhalb des Clusters ab: für einen Lern-Cluster angemessen, in Produktion nicht.
--set postgresql.enabled=false
--set externalDatabase.host=postgres
Keycloak Das Keycloak-Chart würde sonst eine zweite Postgres-Instanz mitinstallieren. Der erste Schalter schaltet die ab, die vier externalDatabase.* zeigen stattdessen auf die vorhandene, mit eigener Datenbank keycloak_db.
--set production=false
--set proxyHeaders=xforwarded
Keycloak production=false startet Keycloak im Entwicklungsmodus: kein TLS-Zwang, kein erzwungener Hostname. proxyHeaders lässt es den X-Forwarded-*-Kopfzeilen des OpenShift-Routers vertrauen – ohne das erzeugt es Weiterleitungen auf http://keycloak:8080 statt auf die externe Adresse.

DBs Fachdatenbanken + Realm

Jeder Fachservice will seine eigene DB. Und Keycloak braucht den library-Realm.

Laptop · bash (→ OKD)
oc exec -n library postgres-0 -- bash -c '
  export PGPASSWORD=library
  for db in catalog_db member_db lending_db reservation_db fine_db notification_db keycloak_db; do
    psql -U library -h 127.0.0.1 -tc "SELECT 1 FROM pg_database WHERE datname='"'"'$db'"'"'" \
      | grep -q 1 || psql -U library -h 127.0.0.1 -c "CREATE DATABASE $db"
  done'

# Realm importieren (liegt im Projekt unter infra/keycloak/realm-library.json)
oc cp infra/keycloak/realm-library.json library/keycloak-0:/tmp/realm.json
oc exec -n library keycloak-0 -- bash -c '
  cd /opt/bitnami/keycloak/bin
  export HOME=/tmp
  ./kcadm.sh config credentials --config /tmp/kc.cfg --server http://localhost:8080 \
    --realm master --user admin --password admin
  ./kcadm.sh create realms --config /tmp/kc.cfg -f /tmp/realm.json'
Was hier passiert

Zwei Schleifen im Pod, und dazwischen die vermutlich unleserlichste Zeile der ganzen Anleitung. Von aussen nach innen aufgedröselt:

KonstruktStellebewirkt
for db in catalog_db member_db …; do … done DBs Eine gewöhnliche Shell-Schleife über sieben Namen. Sie läuft im Pod, nicht auf dem Laptop – alles zwischen den einfachen Anführungszeichen wird als ein Stück an die bash im Container übergeben. Jeder Dienst bekommt seine eigene Datenbank in einer Postgres-Instanz; das ist der Mittelweg zwischen einer Instanz je Dienst (teuer) und einer gemeinsamen Datenbank (die Dienste wären nicht mehr unabhängig).
oc exec … -- bash -c '…' beide Blöcke Das -- trennt die Argumente von oc von denen des Befehls im Pod – alles danach gehört dem Container. Das bash -c ist nötig, weil oc exec sonst genau ein Programm startet: ohne Shell gäbe es keine Schleife, keine Variablen und keine Pipe.
export PGPASSWORD=library DBs Die einzige Art, psql ein Passwort mitzugeben, ohne dass es interaktiv nachfragt – und damit der Grund, warum die Schleife ohne Nachfrage durchläuft. export statt einfacher Zuweisung, weil der Wert an jeden der vielen psql-Aufrufe darunter vererbt werden muss.
-h 127.0.0.1 DBs Erzwingt eine TCP-Verbindung statt des Unix-Sockets. Innerhalb desselben Pods wirkt das umständlich, ist aber nötig: über den Socket greift die peer-Authentifizierung, die den Linux-Benutzernamen mit dem Datenbankbenutzer vergleicht – und der stimmt im Container nicht überein.
psql -tc "SELECT 1 …" | grep -q 1 || psql -c "CREATE DATABASE …" DBs Das Muster, das den ganzen Block wiederholbar macht. -t lässt Kopfzeile und Zeilenzähler weg, sodass wirklich nur das Ergebnis ankommt, -c führt eine einzelne Anweisung aus. grep -q gibt nichts aus und setzt nur den Exit-Code; || führt den zweiten Befehl also genau dann aus, wenn der erste nichts gefunden hat. Ohne diese Prüfung bräche der zweite Lauf mit database already exists ab.
datname='"'"'$db'"'"' DBs Sechs Zeichen, die nur ein einziges einfaches Anführungszeichen erzeugen. Der ganze Befehl steht bereits in einfachen Anführungszeichen, und die kennen keine Maskierung – ein \' darin funktioniert nicht. Der Ausweg, von links gelesen: ' schliesst die laufende Zeichenkette, "'" ist ein einfaches Anführungszeichen in doppelten, ' öffnet wieder. Die Shell klebt die drei Teile ohne Leerzeichen zusammen. Nötig ist es, weil SQL den Datenbanknamen in einfachen Anführungszeichen erwartet, die Variable $db aber vorher von der Shell ersetzt werden muss.
oc cp … library/keycloak-0:/tmp/realm.json Realm Kopiert die Datei in den Pod; die Syntax ist namespace/pod:pfad. Unter Git Bash wandelt die Umgebung Argumente, die wie POSIX-Pfade aussehen, in Windows-Pfade um – aus keycloak-0:/tmp/realm.json wird keycloak-0;C:\…, und oc findet den Pod nicht. Abhilfe ist ein doppelter Schrägstrich: keycloak-0://tmp/realm.json. Unter PowerShell und Linux tritt das Problem nicht auf.
export HOME=/tmp Realm kcadm.sh legt Zustandsdateien im Heimatverzeichnis ab. Im Container gehört das dem Bitnami-Benutzer, der Pod läuft unter OpenShift aber mit einer zufälligen UID ohne Schreibrecht darauf. Ohne diese Zeile scheitert schon der erste Aufruf an einem Rechtefehler.
--config /tmp/kc.cfg Realm Muss bei jedem kcadm.sh-Aufruf dabeistehen. Der erste Aufruf legt die Anmeldung darin ab, der zweite liest sie wieder. Fällt es beim zweiten weg, sucht er an der Vorgabestelle, findet nichts und meldet, man sei nicht angemeldet.
--server http://localhost:8080 Realm Von innen gegen den eigenen Port, nicht über die Route. Das umgeht den Router vollständig – und damit auch die Hostnamen-Frage, die Keycloak im dev-Modus sonst so leicht durcheinanderbringt.
create realms -f /tmp/realm.json Realm Legt den Realm aus der Datei an. Anders als die Schleife darüber ist dieser Aufruf nicht wiederholbar: existiert der Realm schon, endet er mit Conflict detected. Beim erneuten Aufsetzen entweder vorher delete realms/library oder die Meldung bewusst übergehen.
Falle 3: kcadm hat kein $HOME

OKD gibt dem Pod eine zufällige UID ohne Home-Verzeichnis – kcadm.sh will aber ~/.keycloak/kcadm.config schreiben: Failed to create config file. Fix: export HOME=/tmp und --config /tmp/kc.cfg bei jedem kcadm-Aufruf.

04 Jenkins aufsetzen

Zwei Wege – beide dokumentiert. Variante 1 ist der Normalfall zum Lernen (läuft lokal), Variante 2 ist der hier verwendete Hetzner-Server.

V1 Lokal aus shared-infra

Das Nachbar-Repo shared-infra hat ein fertiges Jenkins: eigenes Image (Maven 3.9.9, Node 20, Helm, kubectl, Podman, Chromium vorinstalliert), JCasC-Config, Job-DSL, der die Pipeline-Jobs beim Start selbst anlegt.

Laptop · im Repo shared-infra
podman compose --profile jenkins up -d jenkins
# -> http://localhost:8080  (admin / ${JENKINS_ADMIN_PASSWORD:-admin})

Der Job library-platform-pipeline zeigt dort per Default auf */master. Für den OKD-Deploy auf */okd-deploy umstellen (UI: Configure → Pipeline → Branch, oder die Groovy aus Abschnitt 5). Die KUBECONFIG_PROD- und okd-registry-Credentials sind bewusst nicht in casc.yaml (Geheimnisse gehören nicht in ein versioniertes File) – die legt man von Hand an (Abschnitt 5).

Grenze von Variante 1

Der lokale Jenkins muss die OKD-API (:6443) und die Registry-Route erreichen. Von zu Hause heißt das: die eigene öffentliche IP in okd-sno-fw eintragen (macht die SNO-Anleitung schon für den Laptop). Wechselt die IP, klemmt der Deploy. Deshalb der eigene Server in Variante 2.

V2 Auf einem Hetzner-Server

Ein cpx42 mit Ubuntu, podman statt Docker. Das Jenkins läuft als Container, gebaut aus demselben shared-infra/jenkins/Dockerfile.

Skript-Weg: scripts/jenkins.ps1

.\scripts\jenkins.ps1 install macht die ganze V2-Prozedur unten in einem Rutsch (Server, podman, Image bauen, Container starten) und trägt die Server-IP gleich in okd-sno-fw:6443 ein. Danach: jenkins.ps1 down = Container stoppen → Snapshot → Server löschen, jenkins.ps1 up = aus dem Snapshot zurück – analog zu sno.ps1. Die Handschritte unten sind zum Verstehen / Anpassen.

Laptop · PowerShell
.\scripts\jenkins.ps1 install     # Server + podman + Image + Container (~12-18 min)
.\scripts\jenkins.ps1 down        # Container stoppen -> Snapshot -> Server loeschen
.\scripts\jenkins.ps1 up          # aus dem Snapshot zurueck, IP-Regel in okd-sno-fw neu
.\scripts\jenkins.ps1 status      # laeuft er? seit wann? ~Kosten? Web-UI offen?

… oder von Hand:

Laptop · PowerShell
hcloud server create --name jenkins --type cpx42 --image ubuntu-24.04 `
  --ssh-key laptop --location hel1
# dann: Dockerfile + casc.yaml + plugins.txt aus shared-infra/jenkins/ hochkopieren
scp -r ../shared-infra/jenkins/* root@<jenkins-ip>:/root/jenkins-src/
Jenkins-Server · bash (root)
apt-get update && apt-get install -y podman

# unqualifizierte Image-Namen aufloesen (sonst: "short-name did not resolve")
printf '\nunqualified-search-registries = ["docker.io"]\n' >> /etc/containers/registries.conf
sed -i 's#^FROM jenkins/jenkins#FROM docker.io/jenkins/jenkins#' /root/jenkins-src/Dockerfile

podman build -t jenkins-lib:latest /root/jenkins-src

systemctl enable --now podman.socket        # /run/podman/podman.sock
systemctl enable podman-restart.service     # Container nach Reboot (= nach 'up') autostart
mkdir -p /opt/jenkins-home /opt/m2cache && chmod 777 /opt/m2cache
cp /root/jenkins-src/casc.yaml /opt/jenkins-home/casc.yaml
chown -R 1000:1000 /opt/jenkins-home

podman run -d --name jenkins --restart=always -p 8080:8080 -p 50000:50000 \
  -e JENKINS_ADMIN_USER=admin -e JENKINS_ADMIN_PASSWORD='<jenkins-pw>' \
  -e CASC_JENKINS_CONFIG=/var/jenkins_home/casc.yaml \
  -e CONTAINER_HOST=unix:///run/podman/podman.sock \
  -e DOCKER_HOST=unix:///run/podman/podman.sock \
  -v /opt/jenkins-home:/var/jenkins_home:Z \
  -v /run/podman/podman.sock:/run/podman/podman.sock \
  localhost/jenkins-lib:latest
Was die Schalter bedeuten

Der Block macht drei Dinge auf einmal: Podman einrichten, das Image bauen, Jenkins starten. Die Hälfte der Zeilen existiert nur, weil Jenkins selbst Container bauen soll – und dafür an die Container-Engine des Hosts kommen muss.

SchalterZeilebewirkt
apt-get update && apt-get install -y podman zuerst update holt die Paketlisten, install installiert – ohne das update davor kennt apt nur den Stand des Images und findet die aktuelle Version nicht. Das -y beantwortet die Rückfrage „fortfahren?“ im Voraus mit Ja; ohne das bliebe der Befehl in einem Skript stehen und wartete auf eine Eingabe, die nie kommt.
podman build -t jenkins-lib:latest <pfad> Image bauen -t ist der Tag, also der Name, unter dem das fertige Image lokal abgelegt wird – ohne ihn bekommt es nur eine Prüfsumme und ist in der letzten Zeile nicht ansprechbar. Der Pfad am Ende ist der Build-Kontext: das Verzeichnis, das an den Bauvorgang übergeben wird und in dem das Dockerfile gesucht wird.
podman run -d --name jenkins starten -d (detached) lässt den Container im Hintergrund laufen und gibt die Konsole sofort zurück – ohne das hängt das Terminal an Jenkins, und ein Schliessen beendet ihn. --name vergibt einen festen Namen, damit spätere Aufrufe wie podman logs jenkins ohne Nachschlagen der Kennung funktionieren.
unqualified-search-registries = ["docker.io"] registries.conf Podman verweigert anders als Docker unvollständige Image-Namen: jenkins/jenkins allein ergibt short-name did not resolve to an alias. Der Eintrag sagt, wo gesucht werden soll. Das \n davor im printf ist kein Schmuck – endet die Datei ohne Zeilenumbruch, klebte der Eintrag sonst an der letzten Zeile.
sed -i 's#^FROM …#…#' Dockerfile Dieselbe Sache im Dockerfile, wo die Registry-Einstellung nicht greift. Die Rauten sind hier nur das Trennzeichen – frei wählbar, und praktisch, weil der Ersatztext Schrägstriche enthält, die man sonst alle maskieren müsste. Das ^ begrenzt den Treffer auf den Zeilenanfang.
systemctl enable --now podman.socket Podman --now ist „aktivieren und sofort starten“ in einem Aufruf. Der Socket ist Podmans Docker-kompatible Schnittstelle – ohne ihn hat der Jenkins-Container nichts, womit er Images bauen könnte.
systemctl enable podman-restart.service Podman Der Unterschied zu Docker: --restart=always allein bringt Container nach einem Neustart des Hosts nicht zurück, weil kein Podman-Daemon läuft, der sie starten könnte. Dieser Dienst übernimmt das. In dieser Anleitung ist er entscheidend, weil der Server per Snapshot regelmässig neu entsteht.
chmod 777 /opt/m2cache Verzeichnisse Grob, aber hier gewollt: in den Maven-Cache schreiben sowohl Jenkins (UID 1000) als auch die Build-Container, die mit wechselnden Kennungen laufen. Ein enger gesetztes Recht führt zu Builds, die mal durchlaufen und mal an Permission denied scheitern. Auf einem Server, den mehrere Leute nutzen, wäre stattdessen eine gemeinsame Gruppe richtig.
chown -R 1000:1000 /opt/jenkins-home Verzeichnisse Das offizielle Jenkins-Image läuft als UID 1000. Der Ordner gehört zunächst root; ohne diese Zeile startet Jenkins und scheitert sofort daran, seine eigene Konfiguration zu schreiben.
-e CONTAINER_HOST=…
-e DOCKER_HOST=…
podman run Zwei Variablen mit demselben Wert, weil zwei verschiedene Leser sie brauchen: Podman-eigene Werkzeuge lesen CONTAINER_HOST, die Jenkins-Plugins und alles Docker-kompatible lesen DOCKER_HOST. Fehlt eine, funktioniert ein Teil der Builds und der andere nicht.
-v /run/podman/podman.sock:/run/podman/podman.sock podman run Reicht die Container-Engine des Hosts in den Container hinein – so baut Jenkins Images, ohne selbst eine Engine mitzubringen. Das ist gleichbedeutend mit Root-Rechten auf dem Host: wer im Jenkins-Container Befehle ausführen kann, kann darueber einen privilegierten Container starten. Vertretbar auf einem Server, auf dem nur eigene Pipelines laufen – auf einem geteilten Jenkins nicht.
:Z am Ende des ersten -v podman run Setzt auf SELinux-Systemen die Sicherheitskennzeichnung des Verzeichnisses auf diesen Container um – sonst darf er trotz richtiger Datei-Rechte nicht hinein. Grosses Z heisst „exklusiv für diesen Container“, kleines z wäre „von mehreren geteilt“. Auf Ubuntu ohne SELinux ist es wirkungslos, schadet aber nicht. Am Socket steht es bewusst nicht: dessen Kennzeichnung darf nicht umgeschrieben werden.
-p 50000:50000 podman run Der Agent-Port. Für diesen Aufbau – alle Builds laufen auf dem Controller – wird er nicht gebraucht; er ist offen, falls später ein zweiter Build-Node dazukommt. Wer das nicht vorhat, lässt die Zeile weg.
-e CASC_JENKINS_CONFIG=… podman run Zeigt auf die YAML-Datei mit der gesamten Jenkins-Konfiguration (Configuration as Code). Sie wird bei jedem Start neu angewandt – deshalb überlebt der Aufbau das Löschen und Neuanlegen des Servers, und deshalb sind Klicks in der Oberfläche nicht dauerhaft.
localhost/jenkins-lib:latest podman run Das Präfix localhost/ ist Pflicht: es benennt das eben lokal gebaute Image. Ohne das Präfix suchte Podman nach dem oben eingetragenen docker.io – und fände dort nichts.
Falle 4: verschachteltes podman build scheitert

podman build im Jenkins-Container: 'overlay' is not supported over overlayfs, a mount_program is required – overlay-on-overlay geht nicht. Lösung ist oben schon eingebaut: CONTAINER_HOST + DOCKER_HOST zeigen auf den Podman-Socket des Hosts. podman build/push im Container reden dann mit dem Host-Podman (der hat echtes overlay), die Images landen in dessen Storage.

Falle 5: registries.conf auch im Container

Die unqualified-search-registries-Zeile muss auch in der registries.conf gelten, die der Build benutzt – da die Builds auf dem Host laufen (Falle 4), reicht die Host-Datei. Läuft ein Build doch mal im Container, dort dieselbe Zeile ergänzen. Der sed auf FROM ist der robuste Fallback.

05 Job, Credentials, Deploy-Key

Der Job muss auf den richtigen Branch zeigen, das private Repo klonen können und zwei Credentials für OKD haben.

A Read-only Deploy-Key

Das Repo ist privat. Statt einen Account-Token auf den Server zu legen: ein Deploy-Key – ein SSH-Schlüsselpaar, dessen öffentlicher Teil nur für dieses eine Repo als read-only hinterlegt wird.

Jenkins-Server · bash (root)
# Keypair im jenkins-home, damit es Neustarts ueberlebt
ssh-keygen -t ed25519 -N "" -C jenkins-okd -f /opt/jenkins-home/gh_deploy
chown 1000:1000 /opt/jenkins-home/gh_deploy*

# known_hosts + Key in den Container
podman exec -u root jenkins bash -c '
  mkdir -p /var/jenkins_home/.ssh
  ssh-keyscan github.com > /var/jenkins_home/.ssh/known_hosts 2>/dev/null
  chown -R 1000:1000 /var/jenkins_home/.ssh'
Laptop · bash (gh CLI)
PUB=$(ssh root@<jenkins-ip> cat /opt/jenkins-home/gh_deploy.pub)
gh api -X POST repos/<user>/LAB-WIP-MAVEN-Bibliothek-Enterprise/keys \
  -f title="jenkins-okd" -f key="$PUB" -F read_only=true
Falle 6: GIT_SSH_COMMAND ohne -i

Manuell ssh -i .../gh_deploy git@github.com geht, aber der Git-Plugin-Klon sagt Permission denied (publickey) – im Build ist $HOME ein anderes, der Key wird nicht gefunden. Fix: GIT_SSH_COMMAND global setzen, mit explizitem Key und IdentitiesOnly=yes:

GIT_SSH_COMMAND = ssh -i /var/jenkins_home/gh_deploy \
  -o StrictHostKeyChecking=accept-new \
  -o UserKnownHostsFile=/var/jenkins_home/.ssh/known_hosts \
  -o IdentitiesOnly=yes

Setzen per Manage Jenkins → System → Global properties → Environment variables oder per init.groovy.d-Hook.

B Die zwei OKD-Credentials

ID (Bezeichner)TypInhaltwofür
KUBECONFIG_PRODSecret file/tmp/okd.kubeconfig aus 2Chelm upgrade --install gegen die API
okd-registryUsername/PasswordUser pipeline, Passwort = SA-Token aus 2Cpodman login an der Registry-Route

Per UI (Manage Jenkins → Credentials → System → Global) oder per init.groovy.d. Fehlt eines, fällt die Prod-Stufe automatisch auf helm template (Dry-Run) zurück – sie schlägt nicht fehl, deployt aber auch nichts.

C Branch + Parameter

Falle 7: buildWithParameters → HTTP (Hypertext Transfer Protocol) 400 „not parameterized“

Ein frischer Pipeline-Job kennt seine parameters { } erst, nachdem ein erster Build den Jenkinsfile geparst hat. Vorher liefert buildWithParameters?DEPLOY_ONLY=true nur 400. Zwei Auswege: einen Plain-Build /build auslösen und abbrechen – oder den Parameter direkt am Job hinterlegen:

Datei · init.groovy.d / Script-Console
def job = Jenkins.instance.getItemByFullName('library-platform-pipeline')

// SCM auf den okd-deploy-Branch (nur wenn noetig - sonst gehen bei jedem
// Jenkins-Neustart die vom letzten Build entdeckten Parameter verloren)
// ... GitSCM mit URL git@github.com:<user>/...-Enterprise.git, BranchSpec "*/okd-deploy",
//     credentialsId "github-library-deploykey" ...

// DEPLOY_ONLY fest am Job -> buildWithParameters geht sofort
if (job.getProperty(ParametersDefinitionProperty) == null) {
  job.addProperty(new ParametersDefinitionProperty(
    new BooleanParameterDefinition('DEPLOY_ONLY', false, 'Tests ueberspringen, direkt deployen')))
}
job.save()
Falle 8: Job-Redefinition beim Neustart

JCasC (Jenkins Configuration as Code)/Job-DSL legt den Job bei jedem Jenkins-Start neu an – das setzt den SCM-Branch und die entdeckten Parameter zurück. Deshalb im Groovy-Hook prüfen, ob SCM (Source Code Management)/Parameter schon stimmen, und nur dann anfassen (idempotent).

06 Was am Projekt geändert wurde

Branch okd-deploy. Der Kern: die Prod-Stufe des Jenkinsfile deployt jetzt echt, dazu ein OKD-Overlay und zwei Chart-Korrekturen.

1 Jenkinsfile – Prod deployt auf OKD

  • Konstante OKD_INTERNAL_REGISTRY = 'image-registry.openshift-image-registry.svc:5000/library' – der clusterinterne Name, aus dem die Pods ziehen.
  • Env OKD_REGISTRY_ROUTE = die externe Route – dahin pusht Jenkins. Leer lassen → Prod-Stufe = Dry-Run.
  • Neue Stage „Push nach OKD-Registry“: podman login --tls-verify=false (Cred okd-registry), dann pro Image podman tag + podman push.
  • deployOrDryRun(…, imageRepo) – ist imageRepo gesetzt, hängt es --set image.repository=$imageRepo/$service --set image.pullPolicy=Always an (das service-eigene library/<svc> zeigt in OKD ins Leere).
  • DEPLOY_ONLY-Parameter: überspringt Unit-/Integration-/Frontend-Tests und Helm-Lint – fürs schnelle Iterieren am Deploy.
  • Prod-Stufe legt vor dem Deploy die DB-Secrets an (s. u.) und deployt config-server zuerst.

2 infra/helm/values/env/okd-values.yaml (neu)

Zweites -f hinter der Service-Datei – gilt für alle Services. Gleicht die Unterschiede zur Compose-/kind-Welt aus:

Datei · infra/helm/values/env/okd-values.yaml
replicaCount: 1
autoscaling: { enabled: false }

resources:                 # 32-GB-Node, alles drauf - bewusst knapp
  requests: { cpu: 50m,  memory: 320Mi }
  limits:   { cpu: 500m, memory: 640Mi }

probes:                    # JVM-Kaltstart auf geteiltem Node ~60-90 s
  liveness:  { initialDelaySeconds: 150, periodSeconds: 15, failureThreshold: 6 }
  readiness: { initialDelaySeconds: 90,  periodSeconds: 10, failureThreshold: 12 }

env:
  SPRING_KAFKA_BOOTSTRAP_SERVERS: "kafka:9092"          # nicht :29092 wie Compose
  SPRING_DATA_REDIS_HOST: "redis-master"                # nicht "redis"
  SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:  "http://keycloak:8080/realms/library"
  SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "http://keycloak:8080/realms/library/protocol/openid-connect/certs"

ingress:
  host: library.apps.sno.<okd-ip>.nip.io
Falle 9: CrashLoopBackOff nach erfolgreichem Start

Log: Started …Application in 58s – dann SIGTERM (Signal Terminate). Die Liveness-Probe (initialDelaySeconds: 30 aus dem Chart-Default) tötet den Pod, bevor der langsame JVM-Kaltstart auf dem geteilten Node durch ist. Daher die großen Startfenster oben.

3 config-server: native statt k8s

Falle 10: You need to configure a uri for the git repository

Das Chart brennt SPRING_PROFILES_ACTIVE=k8s fest ein – damit will der config-server sein Git-Backend. Er nutzt aber das native (Classpath-)Backend. Ein zweiter env-Eintrag über .Values.env half nicht: Helms Strategic-Merge dedupliziert env nach name und behält den ersten. Lösung: das Chart-Template auf {{ .Values.springProfilesActive | default "k8s" }} umstellen und im .Values.env-Loop den Schlüssel SPRING_PROFILES_ACTIVE überspringen. Dann config-server-values.yaml: springProfilesActive: "native".

Ergebnis: Started ConfigApplication in 25.994 seconds.

4 DB-Secrets (Jenkinsfile, Prod-Stufe)

Falle 11: CreateContainerConfigError

Das Chart erwartet je Fachservice ein Secret <service>-db-credentials (values.yaml → secretEnvFrom). Fehlt es, startet der Pod nicht. Die Prod-Stufe legt die sechs Secrets jetzt selbst an:

kubectl create secret generic ${svc}-db-credentials -n library \
  --from-literal=SPRING_DATASOURCE_USERNAME=library \
  --from-literal=SPRING_DATASOURCE_PASSWORD=library \
  --dry-run=client -o yaml | kubectl apply -f -

5 Maven-Cache als Bind-Mount

Falle 12: podman build -v mag keine named Volumes

-v library-m2:/root/.m2invalid host path, must be an absolute path. podman build -v kann nur Bind-Mounts (absolute Pfade). Fix: auf dem Podman-Host (wegen Falle 4) einmalig mkdir -p /opt/m2cache && chmod 777 /opt/m2cache, dann im Jenkinsfile -v ${MVN_CACHE}:/root/.m2 mit MVN_CACHE=/opt/m2cache. Sonst lädt jeder der 9 Builds alle Abhängigkeiten neu.

6 Frontend

  • library-frontend/Dockerfile + nginx.conf + docker-env.sh + public/env.js (neu) – Zwei-Stufen-Image, Laufzeit-Konfig. Details in Abschnitt Das Frontend.
  • src/…/api-config.ts / auth.config.ts / auth.interceptor.ts / index.html – lesen jetzt window.__env statt fester localhost-URLs.
  • infra/helm/values/library-frontend-values.yaml (neu) – Chart-Overlay fürs nginx-Image.
  • env/okd-values.yamlISSUER_URI auf die Keycloak-Route (Split-Horizon, Falle D).
  • infra/keycloak/realm-library.jsonlibrary-frontend-Client: OKD-Redirect-URIs.
  • Jenkinsfile – Frontend-Image mitbauen + pushen, helm upgrade library-frontend, oc expose für die drei Routes.

07 Build starten & prüfen

Laptop · bash (Jenkins-API)
J=http://<jenkins-ip>:8080; A='admin:<jenkins-pw>'
JAR=$(mktemp)
C=$(curl -s -c $JAR -b $JAR -u "$A" "$J/crumbIssuer/api/json" | sed -n 's/.*"crumb":"\([^"]*\)".*/\1/p')
curl -s -b $JAR -u "$A" -H "Jenkins-Crumb: $C" -X POST \
  "$J/job/library-platform-pipeline/buildWithParameters?DEPLOY_ONLY=true"

Der Lauf (DEPLOY_ONLY): Container-Images bauen (10× – 8 Services + discovery-server + Frontend, ~15 min beim ersten Mal) → Push nach OKD-RegistryDeploy nach Prod (DB-Secrets, dann config-server, dann die 7 anderen, dann library-frontend, je --wait --timeout 3m, zum Schluss oc create route edge für die drei Routes).

Laptop · bash (→ OKD)
oc get pods -n library
# alle 1/1 Running: api-gateway, catalog/member/lending/reservation/fine/notification-service,
#                  config-server, library-frontend + postgres/redis/kafka/keycloak

oc get route -n library
# https://library-frontend.apps.sno.<okd-ip>.nip.io   <- das SPA (Edge-TLS)
# https://keycloak.apps.sno.<okd-ip>.nip.io           <- OIDC-Login (Browser)
# library.apps.sno.<okd-ip>.nip.io            <- api-gateway direkt (curl/Swagger)

curl http://library.apps.sno.<okd-ip>.nip.io/actuator/health   # -> {"status":"UP"}
curl -sI http://library-frontend.apps.sno.<okd-ip>.nip.io/            # -> 200, das SPA
Ergebnis (31. August 2026)

Alle 8 Spring-Services + library-frontend 1/1 Running. SPA (Single-Page Application) unter library-frontend.apps…, Login gegen die Keycloak-Route, Test: Token von der Keycloak-Route holen und /api/books durch das Frontend-nginx aufrufen → HTTP 200 (die Backends akzeptieren den Issuer der Route). Der Weg dahin: die 21 Fallen unten (+ die 4 fürs Frontend in Abschnitt Das Frontend).

+ Stolpersteine – alle, mit Diagnose

Chronologisch, wie sie auftraten. Die meisten sind OKD-/Podman-spezifisch und begegnen einem bei jedem ähnlichen Setup wieder.

#SymptomUrsacheFix
1Registry-Route fehlt, oc get route -n openshift-image-registry leerInterne Registry auf SNO per Default Removed (kein Storage)managementState: Managed + storage.emptyDir + defaultRoute: true
2Nach sno.ps1 up: Registry-Operator hängt, Attempting to acquire leader lease…Leader-Lease openshift-master-controllers nach Node-Reboot staleoc delete lease -n openshift-image-registry openshift-master-controllers + Operator-Pod neu
3Helm gegen API: i/o timeout vom Jenkins-Serverokd-sno-fw lässt 6443 nur von Laptop-IPhcloud firewall add-rule … --port 6443 --source-ips <jenkins-ip>/32
4Bitnami-Pods: manifest unknownBitnami-Paywall – getaggte Images aus docker.io/bitnami entferntbitnamilegacy/* + global.security.allowInsecureImages=true
5Bitnami-Pods: fsGroup…forbidden / SCC-FehlerOpenShift restricted-v2 vs. fixe UID/fsGroup im Chartglobal.compatibility.openshift.adaptSecurityContext=auto
6kcadm.sh: Failed to create config file /.keycloak/kcadm.configZufällige OKD-UID, kein $HOMEexport HOME=/tmp + --config /tmp/kc.cfg überall
7podman build im Jenkins-Container: 'overlay' is not supported over overlayfsoverlay-on-overlay (verschachteltes Podman)CONTAINER_HOST/DOCKER_HOST → Host-Podman-Socket, Socket mounten
8podman build: short-name "jenkins/jenkins" did not resolveKeine unqualified-search-registriesZeile in registries.conf + FROM docker.io/… per sed
9Git-Klon: Permission denied (publickey) (manuell klappt's)Git-Plugin findet den Key nicht ($HOME im Build anders)Global GIT_SSH_COMMAND='ssh -i …/gh_deploy -o IdentitiesOnly=yes …'
10podman build -v library-m2:/root/.m2: invalid host pathpodman build -v kann nur Bind-MountsHost-Verzeichnis /opt/m2cache (chmod 777) + -v /opt/m2cache:/root/.m2
11Crumb-403 bei --data-urlencode scriptCrumb ohne passende SessionCookie-Jar (-c/-b) mit dem Crumb aus /crumbIssuer
12buildWithParameters: HTTP 400 „not parameterized“Parameter erst nach erstem Jenkinsfile-Parse bekanntPlain-/build zuerst – oder ParametersDefinitionProperty per Groovy
13helm … --create-namespace: namespaces is forbiddenadmin-Rolle gilt nur im Namespace, nicht clusterweitNamespace vorher anlegen – oder schmale ClusterRole für namespaces
14Pods: CreateContainerConfigError<svc>-db-credentials-Secrets fehlenProd-Stufe legt sie per kubectl create secret … --dry-run | apply an
15config-server: You need to configure a uri for the git repositoryChart brennt SPRING_PROFILES_ACTIVE=k8s ein, Helm dedupt env nach nameTemplate: springProfilesActive | default "k8s" + Schlüssel im Loop skippen, native setzen
16Services: Started … in 58s → sofort SIGTERM → CrashLoopLiveness-Probe initialDelaySeconds: 30 tötet den Kaltstartokd-values.yaml: liveness 150 s / readiness 90 s
17Build lässt sich nicht stoppen (/stop, /term wirkungslos)Hängender --wait-SchrittPOST …/<n>/kill (harter Abbruch, Ergebnis ABORTED)
18local-path-Helper-Pod forbidden: hostPath volumes are not allowedrestricted-v2-SCCoc adm policy add-scc-to-user hostmount-anyuid (nicht privileged)
19Helper-Pod läuft, aber mkdir: Permission denied – auch als rootSCOS-SELinux: container_t darf nicht in var_t schreibenBasisdir einmalig chcon -Rt container_file_t (via oc debug node)
20helm upgrade --set persistence.enabled=true: updates to statefulset spec … forbiddenvolumeClaimTemplates sind unveränderlichdump → helm uninstall/install mit PVC (PersistentVolumeClaim) → restore
21Registry-PVC bleibt Pending: NodePath only supports ReadWriteOnceOperator legt die PVC als RWX (ReadWriteMany) an, local-path kann nur RWO (ReadWriteOnce)PVC image-registry-storage vorher selbst mit RWO anlegen

+ Kosten: beide Server abbauen

Hetzner rechnet stundenweise ab, solange ein Server existiert. OKD-Node (cpx52, ~0,14 €/h) + Jenkins (cpx42, ~0,10 €/h) = ~0,24 €/h ≈ 5,70 €/Tag. Beide werden nur beim Deployen gebraucht – der Jenkins-Server kann nach dem Build sofort weg, der OKD-Node solange er die App halten soll.

Laptop · PowerShell
.\scripts\sno.ps1 down                 # OKD -> Snapshot, Server weg (feste IP + Snapshot bleiben)
.\scripts\jenkins.ps1 down             # Jenkins-Container stoppen -> Snapshot -> Server weg
# zurueck:
.\scripts\sno.ps1 up                   # OKD aus dem Snapshot, gleiche IP
.\scripts\jenkins.ps1 up               # Jenkins aus dem Snapshot, IP-Regel in okd-sno-fw neu
Nach sno.ps1 up: Registry aufwecken

Der Node-Reboot macht die Leader-Lease des Image-Registry-Operators stale (Stolperstein 2). Einmalig:

oc delete lease -n openshift-image-registry openshift-master-controllers
oc delete pod -n openshift-image-registry -l name=cluster-image-registry-operator
jenkins.ps1 up / down

Der Snapshot hält das gebaute Image, die Container-Definition (--restart=alwayspodman-restart.service startet ihn nach dem Reboot) und /opt/jenkins-home mit Deploy-Key, Credentials und Build-History. down stoppt den Container sauber (Jenkins fährt geordnet herunter), macht den Snapshot, löscht den Server und entfernt die jenkins-auto-Regel aus okd-sno-fw. up baut den Server daraus neu, startet den Container und trägt die neue IP wieder in okd-sno-fw:6443 ein – die IP wechselt bei jedem up (keine reservierte IP wie beim OKD-Node, Jenkins braucht keine feste).

Kill-Switch für beide Server: .\scripts\install-autodown.ps1 -For sno und .\scripts\install-autodown.ps1 -For jenkins – löschen beide jede Nacht um 03:00 (Windows-Aufgabenplanung). Desktop-Icons: .\scripts\create-shortcuts.ps1 -For jenkins. Muster: Server-Snapshot & Restore.

+ Das Frontend

Angular 22 (SPA). Bis hierher war es nur ng serve/ng build – kein Image, kein Chart, nirgends deployt. Jetzt mit dabei, mit drei eigenen Fallen.

1 Zwei-Stufen-Image

library-frontend/Dockerfile: Stufe 1 node:22-alpine baut das Bundle, Stufe 2 nginxinc/nginx-unprivileged serviert es.

Falle A: Node 22 vs. Agent-Image (Node 20)

Angular 22 will Node ≥ 20.19 / 22, das Jenkins-Agent-Image bringt Node 20 mit. Lösung: der Auslieferungs-Build läuft nicht mehr auf dem Agent, sondern in Stufe 1 des Dockerfiles mit der richtigen Node-Version. Die Stage „Frontend Build & Test“ bleibt für Lint / Unit-Tests (Compile-Check reicht dort mit Node 20).

Falle B: OKD restricted-v2 → nginx als zufällige UID

Der Standard-nginx will Port 80 (root) und schreibt nach /var/cache/nginx. OKD fährt den Pod als zufällige Nicht-Root-UID. nginx-unprivileged lauscht auf :8080 und kommt ohne root aus. Dafuer braucht /etc/nginx/conf.d zusätzlich chmod g+w, damit der Entrypoint die Config anpassen darf.

Falle C: proxy_pass löst den Hostnamen beim Start auf

proxy_pass http://api-gateway:8080; → nginx startet nicht (host not found in upstream), wenn api-gateway gerade nicht auflöst. Fix: den Hostnamen in eine nginx-Variable stecken + resolver setzen – dann löst nginx pro Request auf, und das Frontend startet auch, wenn das Gateway noch fehlt. Den resolver (Cluster-DNS aus /etc/resolv.conf) und den Gateway-FQDN setzt der Entrypoint docker-env.sh per sed in die Config.

2 Laufzeit-Konfiguration statt Build pro Umgebung

Das SPA hatte API_BASE_URL und den Keycloak-Issuer hart im Code (localhost). Statt pro Umgebung neu zu bauen: index.html lädt vor dem Angular-Bundle ein env.js, das window.__env setzt. api-config.ts / auth.config.ts lesen daraus (Fallback = localhost). Lokal liefert public/env.js die Defaults, im Container schreibt docker-env.sh /tmp/env.js aus den Env-Vars API_BASE_URL / KEYCLOAK_ISSUER. Dasselbe Image läuft überall.

Datei · infra/helm/values/library-frontend-values.yaml
image: { repository: library/library-frontend, pullPolicy: Always }
containerPort: 8080
probes:                       # nginx ist in ~1 s oben - KEIN env/okd-values.yaml (150-s-Delays)
  liveness:  { path: /, initialDelaySeconds: 5 }
  readiness: { path: /, initialDelaySeconds: 3 }
env:
  API_BASE_URL: ""            # leer -> das nginx proxyt /api/ ans Gateway (same-origin)
  KEYCLOAK_ISSUER: "http://keycloak.apps.sno.<okd-ip>.nip.io/realms/library"

Das generische library-service-Chart wird wiederverwendet – nginx ist daraus „ein Container auf :8080 mit HTTP-Health auf /“. Deployt wird ohne env/okd-values.yaml (dessen JVM-Probe-Delays passen nicht).

3 Keycloak muss nach außen – Split-Horizon

Falle D: Issuer-Mismatch

Der Login läuft im Browser direkt gegen Keycloak – also braucht Keycloak eine Route (oc expose svc/keycloak). Keycloak (dev-Modus) leitet den iss-Claim vom Host-Header ab → Tokens haben dann iss = http://keycloak.apps…nip.io/realms/library. Die Backend-Services validierten aber gegen http://keycloak:8080/… (intern) → jeder API-Call nach dem Login wäre 401.

Fix in env/okd-values.yaml: ISSUER_URI auf die Route setzen (nur String-Vergleich, kein Netz), JWK_SET_URI clusterintern lassen (Schlüssel holt der Service selbst – kein externer Hop, keine .well-known-Abfrage beim Start). Genau die Trennung, die der api-gateway schon im Code vorsieht. Und im Realm dem library-frontend-Client die OKD-Redirect-URIs / Web-Origins geben.

Laptop · bash (→ OKD) – Realm-Client nachziehen
FE=library-frontend.apps.sno.<okd-ip>.nip.io
oc exec -n library keycloak-0 -- bash -c '
  cd /opt/bitnami/keycloak/bin; export HOME=/tmp
  ./kcadm.sh config credentials --config /tmp/kc.cfg --server http://localhost:8080 --realm master --user admin --password admin
  CID=$(./kcadm.sh get clients --config /tmp/kc.cfg -r library -q clientId=library-frontend --fields id --format csv --noquotes | tail -1)
  ./kcadm.sh update clients/$CID --config /tmp/kc.cfg -r library \
    -s "redirectUris=[\"http://localhost:4200/*\",\"http://'"$FE"'/*\"]" \
    -s "webOrigins=[\"http://localhost:4200\",\"http://'"$FE"'\"]" '
Was hier passiert

Der Realm ist schon importiert – hier wird nur ein Feld daran nachgezogen: die Adressen, auf die Keycloak nach der Anmeldung zurückleiten darf. Ohne sie bleibt der Anmeldevorgang mit Invalid parameter: redirect_uri stehen.

KonstruktStellebewirkt
FE=library-frontend.apps.….nip.io Zeile 1 Eine Shell-Variable auf dem Laptop, nicht im Pod. Sie hält den öffentlichen Hostnamen des Frontends fest, damit er unten nicht dreimal ausgeschrieben werden muss. nip.io löst jede Subdomain der Form <irgendwas>.<ip>.nip.io auf genau diese IP auf – ein DNS-Ersatz für Umgebungen ohne eigene Domain.
./kcadm.sh config credentials … Anmeldung kcadm.sh ist Keycloaks Kommandozeilenwerkzeug. Der erste Aufruf meldet sich an und legt das Ergebnis in /tmp/kc.cfg ab; jeder weitere Aufruf muss --config mit demselben Pfad wiederholen, sonst sucht er an der Vorgabestelle und meldet, man sei nicht angemeldet. --realm master ist der Verwaltungs-Realm, aus dem heraus andere Realms bearbeitet werden – nicht der, den wir gerade ändern.
get clients -q clientId=library-frontend ID suchen Keycloak unterscheidet zwei Kennungen: die sprechende clientId (die man selbst vergibt) und die interne id (eine UUID). Zum Ändern braucht man die interne – und die bekommt man nur, indem man über die sprechende sucht. -q ist der Suchfilter, -r library der Realm.
--fields id --format csv --noquotes | tail -1 ID suchen Vier Schritte, um aus einer JSON-Antwort einen nackten Wert zu machen: --fields id beschränkt auf das eine Feld, --format csv lässt die geschweiften Klammern weg, --noquotes die Anführungszeichen, und tail -1 nimmt die letzte Zeile – denn kcadm.sh stellt gern eine Hinweiszeile voran. Ohne einen dieser vier landet Beiwerk in $CID, und der nächste Aufruf läuft auf eine Adresse, die es nicht gibt.
'"$FE"' redirectUris Dieselbe Klammer-Akrobatik wie beim Anlegen der Datenbanken. Der ganze Befehl steht in einfachen Anführungszeichen, damit die Shell des Laptops ihn unangetastet an den Pod weiterreicht – darin wird aber nichts ersetzt. Um $FE doch aufzulösen, wird die Zeichenkette kurz geschlossen ('), die Variable in doppelten Anführungszeichen eingesetzt ("$FE") und wieder geöffnet ('). Ergebnis: $FE wird auf dem Laptop ersetzt, alles andere im Pod ausgewertet.
-s "redirectUris=[\"…\"]" update -s setzt ein einzelnes Feld („set“). Der Wert ist eine JSON-Liste, deshalb die maskierten Anführungszeichen darin. Die Angabe ersetzt die bisherige Liste vollständig – sie hängt nichts an. Deshalb steht localhost:4200 hier noch einmal mit dabei, obwohl es sich nicht geändert hat.
/* am Ende der redirectUris update Der Platzhalter erlaubt jeden Pfad unterhalb der Adresse – nötig, weil die Anwendung nach der Anmeldung auf eine Unterseite zurückkehrt. Bei webOrigins steht er bewusst nicht: dort zählt nur die Herkunft (Schema, Host, Port), und ein Pfad wäre dort ungültig.
http:// statt https:// beide Felder Muss genau zu der Adresse passen, unter der die Anwendung tatsächlich aufgerufen wird – Keycloak vergleicht die Zeichenketten. Wer später auf eine TLS-Route umstellt, muss diese Zeilen mitändern, sonst bricht die Anmeldung genau hier ab.

(realm-library.json ist ebenfalls ergänzt – für einen frischen Cluster, wo der Realm neu importiert wird. Auf einem laufenden Keycloak wird der Import übersprungen, daher der kcadm-Weg.)

+ Persistenter Storage

Ohne CSI-Treiber (Hetzner-Cloud-Volumes gibt es auf SNO nicht out of the box) liefen Postgres, Kafka und die interne Registry auf emptyDir – ein oc delete pod löscht die Daten. Lösung: der local-path-provisioner (Rancher) legt PVs (PersistentVolumes) in einem Verzeichnis des Node an. Vier Fallen auf SCOS (CentOS Stream CoreOS).

Laptop · bash (→ OKD)
oc apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.30/deploy/local-path-storage.yaml
Falle E: die Helper-Pods dürfen kein hostPath

Der Provisioner startet für jedes PV (PersistentVolume) einen kurzen Helper-Pod, der per hostPath ein Verzeichnis anlegt – das verbietet die restricted-v2-SCC. Die proportionale Rechteerweiterung ist hostmount-anyuid (nicht privileged):

Laptop · bash
oc adm policy add-scc-to-user hostmount-anyuid -z local-path-provisioner-service-account -n local-path-storage
oc adm policy add-scc-to-user hostmount-anyuid -z default -n local-path-storage
Falle F: SELinux (Security-Enhanced Linux) verbietet den Schreibzugriff – auch als root

SCOS läuft mit erzwungenem SELinux. Ein Container (Typ container_t) darf nicht in ein Node-Verzeichnis vom Typ var_t schreiben, egal welche UID. privileged würde helfen (Typ spc_t), ist aber zu viel. Statt dessen das Basisverzeichnis einmalig auf container_file_t umlabeln:

OKD-Node · via oc debug node
oc debug node/<node> -- chroot /host sh -c '
  mkdir -p /var/local-path-provisioner
  chcon -Rt container_file_t /var/local-path-provisioner
  chmod 777 /var/local-path-provisioner'

Dann den Pfad in der ConfigMap local-path-config (config.json) von /opt/local-path-provisioner auf /var/local-path-provisioner setzen und im helperPod.yaml securityContext.runAsUser: 0 ergänzen.

Laptop · bash
oc patch storageclass local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

DB Postgres & Kafka umziehen

Falle G: volumeClaimTemplates eines StatefulSet sind unveränderlich

helm upgrade --set persistence.enabled=true auf ein laufendes Bitnami-Postgres → Forbidden: updates to statefulset spec … are forbidden. Also: dumpen → helm uninstallhelm install mit Persistenz → restoren.

Laptop · bash (→ OKD)
# 1. sichern (alle 7 DBs auf einmal)
oc exec -n library postgres-0 -- bash -c \
  'PGPASSWORD=library pg_dumpall -U library -h 127.0.0.1 --no-role-passwords' > dball.sql

# 2. neu, mit PVC
helm uninstall postgres -n library
oc delete pvc -n library -l app.kubernetes.io/name=postgresql
helm install postgres bitnami/postgresql -n library \
  --set global.compatibility.openshift.adaptSecurityContext=auto \
  --set fullnameOverride=postgres --set auth.username=library --set auth.password=library \
  --set auth.database=library --set auth.postgresPassword=library \
  --set primary.persistence.enabled=true --set primary.persistence.storageClass=local-path \
  --set primary.persistence.size=5Gi --wait

# 3. zurueckspielen
oc exec -i -n library postgres-0 -- bash -c \
  'PGPASSWORD=library psql -U library -h 127.0.0.1 -d postgres' < dball.sql

# Kafka genauso: uninstall/install --set controller.persistence.enabled=true
Was die Schalter bedeuten

Sichern, wegwerfen, neu bauen, zurückspielen – jeder der vier Schritte hat eine Stelle, an der es schiefgeht, wenn man sie übergeht:

SchalterSchrittbewirkt
helm uninstall postgres -n library 2 Entfernt alles, was Helm für diesen Release angelegt hat – Pods, Services, ConfigMaps. Nicht die PersistentVolumeClaims: die lässt Helm bewusst stehen, damit ein versehentliches Deinstallieren keine Daten vernichtet. Genau deshalb folgt die nächste Zeile.
oc exec … -- bash -c '…' 1 Das -- trennt die Argumente von oc von denen des Befehls im Pod; alles danach gehört dem Container. Ohne das bash -c drumherum liefe pg_dumpall ohne Shell – und die Zuweisung PGPASSWORD=… davor wäre keine Variable, sondern ein unbekannter Programmname.
PGPASSWORD=library 1 und 3 Die einzige Art, psql und pg_dumpall ein Passwort mitzugeben, ohne dass sie interaktiv danach fragen – und der Grund, warum die Zeile ohne Nachfrage durchläuft. Die Zuweisung gilt nur für diesen einen Aufruf.
-h 127.0.0.1 1 und 3 Erzwingt eine TCP-Verbindung statt des Unix-Sockets. Klingt umständlich innerhalb desselben Pods, ist aber nötig: über den Socket greift die peer-Authentifizierung, die den Linux-Benutzernamen mit dem Datenbankbenutzer vergleicht – und der stimmt im Container nicht überein.
--no-role-passwords 1 pg_dumpall will die Passwort-Hashes aller Rollen mitlesen und braucht dafuer Superuser-Rechte. Der Benutzer library hat sie nicht – ohne diesen Schalter bricht der Dump mit einem Rechtefehler ab, und zwar erst nachdem schon etwas in die Datei geschrieben wurde. Die Rollen entstehen ohnehin gleich neu aus den Helm-Werten.
> dball.sql 1 Die Umleitung wirkt auf dem Laptop, nicht im Pod – oc exec reicht die Ausgabe des Containers durch. Die Sicherung liegt damit ausserhalb des Clusters, was der ganze Punkt der Übung ist. Vor Schritt 2 unbedingt die Dateigrösse prüfen: eine leere Datei bedeutet, dass es kein Backup gibt.
oc delete pvc -l app.kubernetes.io/name=postgresql 2 helm uninstall lässt PersistentVolumeClaims absichtlich stehen – sonst könnte ein versehentliches Deinstallieren Daten vernichten. Genau deshalb muss man sie hier von Hand entfernen: bleibt der alte Claim liegen, greift die neue Installation ihn wieder auf und die neuen Storage-Einstellungen verpuffen wirkungslos. -l wählt über das Label aus, weil der Name je nach Chart-Version abweicht.
--set primary.persistence.enabled=true
--set ….storageClass=local-path
--set ….size=5Gi
2 Der eigentliche Zweck des ganzen Umzugs: statt emptyDir jetzt ein echtes Volume. storageClass muss zu dem passen, was im Cluster bereitsteht – ein falscher Name führt zu einem Claim, der dauerhaft in Pending hängt, ohne Fehlermeldung am Pod. Die Grösse lässt sich später nicht ohne Weiteres ändern.
--wait 2 Hält Helm an, bis der Pod bereit ist. Ohne das liefe Schritt 3 sofort los und träfe auf eine Datenbank, die noch startet.
oc exec -i … < dball.sql 3 Das -i ist der Unterschied zwischen Erfolg und stiller Wirkungslosigkeit: nur damit wird die Standardeingabe in den Pod durchgereicht. Ohne -i läuft der Befehl durch, meldet keinen Fehler – und die Datenbank bleibt leer. Ein -t gehört hier ausdrücklich nicht dazu; ein Terminal würde die eingelesene Datei verfälschen.
psql … -d postgres 3 Die Verbindung geht gegen die Verwaltungsdatenbank postgres, nicht gegen eine der Fachdatenbanken. Der Dump aus pg_dumpall enthält die CREATE DATABASE-Anweisungen selbst – er braucht einen Einstiegspunkt, der nicht zu den Datenbanken gehört, die er gerade anlegt.

Danach die DB-Consumer einmal oc rollout restart (frische Connection-Pools). Die 12 Bücher / 3 Mitglieder / Ausleihen der Demo überstehen den Dump/Restore.

Reg Interne Registry auf PVC

Falle H: der Registry-Operator will ReadWriteMany

oc patch configs.imageregistry… storage: pvc lässt den Operator eine RWX-PVC anlegen – local-path kann nur RWO (auf SNO reicht das, es gibt nur einen Node). Also die PVC vorher selbst mit RWO anlegen, der Operator übernimmt sie:

Laptop · bash
oc patch configs.imageregistry.operator.openshift.io/cluster --type json \
  -p '[{"op":"remove","path":"/spec/storage/emptyDir"}]'
oc patch configs.imageregistry.operator.openshift.io/cluster --type merge \
  -p '{"spec":{"rolloutStrategy":"Recreate","storage":{"pvc":{"claim":""}}}}'

cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: image-registry-storage, namespace: openshift-image-registry }
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: local-path
  resources: { requests: { storage: 20Gi } }
EOF

Die neue PVC ist leer – alle library/*-Images sind weg. Einmal die Pipeline (DEPLOY_ONLY) laufen lassen, dann sind sie wieder da.

+ TLS

Die Routes waren http. OpenShift bringt einen Router mit selbstsigniertem Wildcard-Zertifikat (*.apps.sno.…) mit – damit sind Edge-Routes (TLS am Router, http intern) ein Einzeiler.

Laptop · bash (→ OKD)
for r in "api-gateway:library" "keycloak:keycloak" "library-frontend:library-frontend"; do
  svc=${r%%:*}; host=${r##*:}
  oc delete route $svc -n library 2>/dev/null
  oc create route edge $svc -n library --service=$svc \
    --hostname=$host.apps.sno.<okd-ip>.nip.io --insecure-policy=Redirect
done
  • --insecure-policy=Redirecthttp antwortet mit 302 auf https.
  • Keycloak (dev-Modus, proxyHeaders=xforwarded) liest X-Forwarded-Proto: https vom Router → der iss-Claim wird automatisch https://keycloak.apps…. Kein Keycloak-Restart nötig.
  • Backends: in env/okd-values.yaml die ISSUER_URI auf https:// (die JWK_SET_URI bleibt clusterintern http://keycloak:8080).
  • Frontend: KEYCLOAK_ISSUER in library-frontend-values.yaml auf https:// – das ist eine Container-Env, docker-env.sh zieht sie beim Start, kein Image-Neubau.
  • Realm-Client library-frontend: Redirect-URIs / Web-Origins auf https://library-frontend.apps….
  • auth.config.ts: requireHttps: 'remoteOnly' (lokal http ok, remote https Pflicht).
Zertifikat

Der Router liefert sein selbstsigniertes Wildcard-Zertifikat – der Browser warnt einmal. Ein echtes Zertifikat: cert-manager + Let's Encrypt per DNS-01 (HTTP-01 geht mit nip.io schlecht) oder das Router-Zertifikat gegen ein eigenes tauschen (oc -n openshift-ingress create secret tls … + IngressController-Patch).

+ Offene Punkte

  • Echtes TLS-Zertifikat statt des selbstsignierten Router-Defaults (siehe oben).
  • Storage auf dem Node statt in einem CSI-Backend – ein Platten-Defekt = Datenverlust. Für echt: Hetzner-Cloud-Volume an den Node + der LVM-Storage-Operator, oder ein externer DB-Dienst.
  • Ein Jenkins-Server ohne HA (Hochverfügbarkeit)/Backup. jenkins-home per jenkins.ps1 down-Snapshot sichern (JCasC + Job-DSL machen den Rest reproduzierbar).
  • Kafka/Redis-Persistenz: Kafka hat jetzt ein PVC, Redis bleibt als Cache bewusst flüchtig.
Verwandt

Bibliothek-Enterprise auf k3s – dasselbe Projekt, aber per deploy.ps1 direkt vom Laptop auf k3s statt über Jenkins auf OKD. Leichter, ohne CI-Schicht.