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).
okd-deploy. Read-only Deploy-Key.
podman build → 10 Images. podman push → OKD-Registry-Route. helm → OKD-API :6443.
library, 8 Services + Frontend + Infra. Routes: library-frontend…, keycloak…, library….
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) aufapi.sno.<okd-ip>.nip.io:6443,kubeadmin-kubeconfig auf dem Laptop. - Größe des OKD-Node:
sno.ps1nimmt jetztcpx52(12 vCPU / 24 GB, ~0,14 €/h) – passt für OKD + diesen Stack. Details unten. Anpassen inscripts/sno.ps1($Type) oder.\scripts\sno.ps1 up -Type …nach einemdown(Resize im laufenden Betrieb geht nur größer). - Jenkins-Server – ein zweiter Hetzner-Server (
cpx42reicht: 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):
| Ressource | Ist | von | Kommentar |
|---|---|---|---|
| RAM (Random Access Memory) belegt | ~15,6 GB (Gigabyte) | 32 GB | kein Swap, MemoryPressure=False |
| davon OKD-Plattform | ~11 GB | kube-apiserver ~1,8 G, Prometheus ~1,8 G, etcd/OVN/Operatoren | |
davon Namespace library | ~3,7 GB | keycloak 630 M, kafka 610 M, 7 Services je ~320 M, postgres 190 M | |
| CPU (Central Processing Unit) | ~1,1 Kerne | 16 | ~7 % – im Steady State nie der Engpass |
RAM ist die einzige Grenze. Damit:
| Server | RAM | €/h | Urteil |
|---|---|---|---|
cpx62 | 32 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 / ccx23 | 16 GB | ~0,10–0,12 | 15,6 von 16 GB belegt – Redline. Bei einem frischen Deploy (8 Spring-JVMs + Keycloak kalt) OOM-Gefahr. Nur mit Trimmen |
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/helmauf dem Laptop für die einmalige OKD-Vorbereitung (Abschnitt 2/3). Danach macht alles Jenkins. hcloudCLI (Command-Line Interface) undghCLI (für den Deploy-Key).
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.
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.
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
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:
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.
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):
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.
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.
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.
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
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
Vier Installationen, 45 Schalter – die meisten wiederholen sich. Nach Zweck sortiert:
| Schalter | gilt für | bewirkt |
|---|---|---|
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.
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'
Zwei Schleifen im Pod, und dazwischen die vermutlich unleserlichste Zeile der ganzen Anleitung. Von aussen nach innen aufgedröselt:
| Konstrukt | Stelle | bewirkt |
|---|---|---|
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. |
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.
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).
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.
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.
.\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:
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/
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
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.
| Schalter | Zeile | bewirkt |
|---|---|---|
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. |
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.
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.
# 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'
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
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) | Typ | Inhalt | wofür |
|---|---|---|---|
KUBECONFIG_PROD | Secret file | /tmp/okd.kubeconfig aus 2C | helm upgrade --install gegen die API |
okd-registry | Username/Password | User pipeline, Passwort = SA-Token aus 2C | podman 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
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:
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()
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(Credokd-registry), dann pro Imagepodman tag+podman push. deployOrDryRun(…, imageRepo)– istimageRepogesetzt, hängt es--set image.repository=$imageRepo/$service --set image.pullPolicy=Alwaysan (das service-eigenelibrary/<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-serverzuerst.
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:
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
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
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)
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
podman build -v mag keine named Volumes
-v library-m2:/root/.m2 →
invalid 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 jetztwindow.__envstatt festerlocalhost-URLs.infra/helm/values/library-frontend-values.yaml(neu) – Chart-Overlay fürs nginx-Image.env/okd-values.yaml–ISSUER_URIauf die Keycloak-Route (Split-Horizon, Falle D).infra/keycloak/realm-library.json–library-frontend-Client: OKD-Redirect-URIs.Jenkinsfile– Frontend-Image mitbauen + pushen,helm upgrade library-frontend,oc exposefür die drei Routes.
07 Build starten & prüfen
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-Registry
→ Deploy 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).
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
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.
| # | Symptom | Ursache | Fix |
|---|---|---|---|
| 1 | Registry-Route fehlt, oc get route -n openshift-image-registry leer | Interne Registry auf SNO per Default Removed (kein Storage) | managementState: Managed + storage.emptyDir + defaultRoute: true |
| 2 | Nach sno.ps1 up: Registry-Operator hängt, Attempting to acquire leader lease… | Leader-Lease openshift-master-controllers nach Node-Reboot stale | oc delete lease -n openshift-image-registry openshift-master-controllers + Operator-Pod neu |
| 3 | Helm gegen API: i/o timeout vom Jenkins-Server | okd-sno-fw lässt 6443 nur von Laptop-IP | hcloud firewall add-rule … --port 6443 --source-ips <jenkins-ip>/32 |
| 4 | Bitnami-Pods: manifest unknown | Bitnami-Paywall – getaggte Images aus docker.io/bitnami entfernt | bitnamilegacy/* + global.security.allowInsecureImages=true |
| 5 | Bitnami-Pods: fsGroup…forbidden / SCC-Fehler | OpenShift restricted-v2 vs. fixe UID/fsGroup im Chart | global.compatibility.openshift.adaptSecurityContext=auto |
| 6 | kcadm.sh: Failed to create config file /.keycloak/kcadm.config | Zufällige OKD-UID, kein $HOME | export HOME=/tmp + --config /tmp/kc.cfg überall |
| 7 | podman build im Jenkins-Container: 'overlay' is not supported over overlayfs | overlay-on-overlay (verschachteltes Podman) | CONTAINER_HOST/DOCKER_HOST → Host-Podman-Socket, Socket mounten |
| 8 | podman build: short-name "jenkins/jenkins" did not resolve | Keine unqualified-search-registries | Zeile in registries.conf + FROM docker.io/… per sed |
| 9 | Git-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 …' |
| 10 | podman build -v library-m2:/root/.m2: invalid host path | podman build -v kann nur Bind-Mounts | Host-Verzeichnis /opt/m2cache (chmod 777) + -v /opt/m2cache:/root/.m2 |
| 11 | Crumb-403 bei --data-urlencode script | Crumb ohne passende Session | Cookie-Jar (-c/-b) mit dem Crumb aus /crumbIssuer |
| 12 | buildWithParameters: HTTP 400 „not parameterized“ | Parameter erst nach erstem Jenkinsfile-Parse bekannt | Plain-/build zuerst – oder ParametersDefinitionProperty per Groovy |
| 13 | helm … --create-namespace: namespaces is forbidden | admin-Rolle gilt nur im Namespace, nicht clusterweit | Namespace vorher anlegen – oder schmale ClusterRole für namespaces |
| 14 | Pods: CreateContainerConfigError | <svc>-db-credentials-Secrets fehlen | Prod-Stufe legt sie per kubectl create secret … --dry-run | apply an |
| 15 | config-server: You need to configure a uri for the git repository | Chart brennt SPRING_PROFILES_ACTIVE=k8s ein, Helm dedupt env nach name | Template: springProfilesActive | default "k8s" + Schlüssel im Loop skippen, native setzen |
| 16 | Services: Started … in 58s → sofort SIGTERM → CrashLoop | Liveness-Probe initialDelaySeconds: 30 tötet den Kaltstart | okd-values.yaml: liveness 150 s / readiness 90 s |
| 17 | Build lässt sich nicht stoppen (/stop, /term wirkungslos) | Hängender --wait-Schritt | POST …/<n>/kill (harter Abbruch, Ergebnis ABORTED) |
| 18 | local-path-Helper-Pod forbidden: hostPath volumes are not allowed | restricted-v2-SCC | oc adm policy add-scc-to-user hostmount-anyuid (nicht privileged) |
| 19 | Helper-Pod läuft, aber mkdir: Permission denied – auch als root | SCOS-SELinux: container_t darf nicht in var_t schreiben | Basisdir einmalig chcon -Rt container_file_t (via oc debug node) |
| 20 | helm upgrade --set persistence.enabled=true: updates to statefulset spec … forbidden | volumeClaimTemplates sind unveränderlich | dump → helm uninstall/install mit PVC (PersistentVolumeClaim) → restore |
| 21 | Registry-PVC bleibt Pending: NodePath only supports ReadWriteOnce | Operator 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.
.\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
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=always
→ podman-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.
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).
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.
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.
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
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.
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"'\"]" '
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.
| Konstrukt | Stelle | bewirkt |
|---|---|---|
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).
oc apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.30/deploy/local-path-storage.yaml
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):
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
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:
oc debug nodeoc 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.
oc patch storageclass local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
DB Postgres & Kafka umziehen
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 uninstall → helm install mit
Persistenz → restoren.
# 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
Sichern, wegwerfen, neu bauen, zurückspielen – jeder der vier Schritte hat eine Stelle, an der es schiefgeht, wenn man sie übergeht:
| Schalter | Schritt | bewirkt |
|---|---|---|
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
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:
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.
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=Redirect→httpantwortet mit302aufhttps.- Keycloak (dev-Modus,
proxyHeaders=xforwarded) liestX-Forwarded-Proto: httpsvom Router → deriss-Claim wird automatischhttps://keycloak.apps…. Kein Keycloak-Restart nötig. - Backends: in
env/okd-values.yamldieISSUER_URIaufhttps://(dieJWK_SET_URIbleibt clusterinternhttp://keycloak:8080). - Frontend:
KEYCLOAK_ISSUERinlibrary-frontend-values.yamlaufhttps://– das ist eine Container-Env,docker-env.shzieht sie beim Start, kein Image-Neubau. - Realm-Client
library-frontend: Redirect-URIs / Web-Origins aufhttps://library-frontend.apps…. auth.config.ts:requireHttps: 'remoteOnly'(lokalhttpok, remotehttpsPflicht).
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-homeperjenkins.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.
Bibliothek-Enterprise auf k3s
– dasselbe Projekt, aber per deploy.ps1 direkt vom Laptop auf k3s statt
über Jenkins auf OKD. Leichter, ohne CI-Schicht.