← Übersicht  ·  Skripte & Dateien  ·  cloud-host · OpenShift · Legacy

Legacy-Apps auf OpenShift modernisieren

Das Modell steht in den Atlas-Diagrammen H1–H6. Referenz ist Novaris, eine fiktive alte Anwendung auf WebSphere Application Server traditional – der Atlas beschreibt sie konkret genug, um die sechs typischen Bruchstellen einer WAS-zu-OpenShift-Migration real durchzuspielen, auch ohne eine echte WAS-Installation: root und SCC, ein schreibgeschütztes Root-Dateisystem, Sticky Sessions, ein EJB-Timer als CronJob, eine Datenquelle ohne Rebuild, und am Ende das Vorher/Nachher als Tafel. EX280-relevant.

Stand: 4. September 2026 OKD 4.x / OCP 4.x ns library

Voraussetzung: Zugang zum Namespace library. Statt einer echten Novaris/WAS-Installation stehen generische Red-Hat-UBI-Images stellvertretend für „die alte Legacy-App“ – die Kubernetes-Mechanik dahinter ist real und identisch.

1 Root und SCC live scheitern lassen

Die alte Novaris-Annahme: läuft als root, chownt Verzeichnisse. Unter restricted-v2 (A7) scheitert das.

Die alte Novaris-App ist als root gebaut, chownt Verzeichnisse beim Start und schreibt nach festen Pfaden. Unter der Vorgabe-SCC restricted-v2 (A7) bekommt der Pod eine zufällige hohe UID – und alles davon schlägt fehl. Das ist der häufigste Blocker beim Migrieren. Der Fix ist das Image, nicht die SCC.

Die alte App will Root – restricted-v2 gibt ihr eine zufällige UID. die alte Novaris-Annahme läuft als root (UID 0), USER nicht gesetzt chown -R auf Verzeichnisse beim Start schreibt nach festen Pfaden (/opt/novaris/logs) bindet Port 80 erwartet ein beschreibbares Root-Dateisystem auf WAS traditional lief das jahrelang problemlos der Container ist als root gebaut, weil es „schon immer“ so war nichts davon geht unter der Vorgabe-SCC unter restricted-v2 (A7) zufällige hohe UID (z. B. 1000700003), nicht 0 chownOperation not permitted Schreiben nach /opt/novaris/logsPermission denied Port 80 → nicht erlaubt (nur ab 1024) Symptome: CrashLoopBackOff container has runAsNonRoot and image will run as root welche SCC der Pod bekam: Annotation openshift.io/scc Fix in dieser Reihenfolge 1 Image UID-agnostisch bauen Dateien Gruppe root, chmod -R g=u, kein chown USER 1001, Port 8080, readOnlyRootFilesystem (H2) 2 Brücke: anyuid an genau die eine SA läuft dann als die UID aus dem Image (auch 0) nicht am Namespace, nicht an einer Gruppe 3 nie privileged als Reflex nonroot-v2 reicht, wenn das Image nur einen festen Nicht-Null-UID will Ziel: zurück auf restricted-v2, sobald das Image sitzt die Brücke einbauen (Schritt 2) oc create sa novaris-legacy -n novaris oc adm policy add-scc-to-user anyuid -z novaris-legacy -n novaris dann serviceAccountName: novaris-legacy in der Deployment-Vorlage prüfen mit oc get pod <p> -o jsonpath auf die scc-Annotation Grundlagen: A7 ↗ die SCC zählt die der ServiceAccount, nicht die des Users darum ist die SA der richtige Ort für die Ausnahme der häufigste Legacy-Blocker, und der erste beim Migrieren Novaris: die 3 Legacy-Module bekommen je eine eigene SA Merksatz: nicht die SCC lockern und vergessen – das Image reparieren, anyuid nur als Brücke an einer SA Reihenfolge der Fehler: erst runAsNonRoot-Abweisung, dann chown, dann Schreibrechte, dann der kleine Port OCP-Unterschied: identisch – SCC ist Kern-OpenShift. Auf reinem Kubernetes bremst stattdessen die Pod Security Admission (baseline / restricted).
Root ist nicht mehr selbstverständlich. Die alte Novaris-App läuft als root, chownt beim Start und bindet Port 80 – unter restricted-v2 (A7) bekommt der Pod eine zufällige hohe UID, und jeder dieser Schritte scheitert mit Operation not permitted oder Permission denied. Der richtige Fix ist das Image: Dateien der Gruppe root, chmod g=u, kein chown, Port 8080. anyuid an genau die eine ServiceAccount ist nur die Brücke, bis das Image sitzt – nie privileged.
  • die alte App läuft als root, chownt Verzeichnisse, schreibt nach festen Pfaden und bindet Port 80 – auf WAS traditional lief das jahrelang
  • unter restricted-v2 (A7) bekommt der Pod eine zufällige hohe UID, nicht 0 – und damit scheitert alles davon
  • chown gibt Operation not permitted, Schreiben gibt Permission denied, Port 80 ist nicht erlaubt
  • Symptome: CrashLoopBackOff, container has runAsNonRoot and image will run as root, EACCES in den Logs
  • welche SCC der Pod bekam, steht in der Pod-Annotation openshift.io/scc
  • Fix 1 (der richtige): Image UID-agnostisch bauen – Dateien Gruppe root, chmod -R g=u, kein chown, USER 1001, Port 8080
  • Fix 2 (die Brücke): anyuid an genau die eine ServiceAccount – nicht am Namespace, nicht an einer Gruppe
  • Fix 3: nie privileged als Reflex – nonroot-v2 reicht, wenn das Image nur einen festen Nicht-Null-UID braucht
  • Brücke einbauen: oc create sa novaris-legacy -n novaris, oc adm policy add-scc-to-user anyuid -z novaris-legacy -n novaris
  • dann serviceAccountName: novaris-legacy in die Deployment-Vorlage – die SCC zählt die der SA, nicht die des Users (A7)
  • Novaris: die drei Legacy-Module bekommen je eine eigene SA, damit die Ausnahme so klein wie möglich bleibt
  • Ziel bleibt restricted-v2: sobald das Image UID-agnostisch ist, die anyuid-Ausnahme wieder entfernen – die Brücke ist temporär
  • OCP-Unterschied: identisch – auf reinem Kubernetes bremst stattdessen die Pod Security Admission (baseline / restricted)
Laptop · Bash
oc create deployment demo-legacy -n library \
  --image=registry.access.redhat.com/ubi9/ubi-minimal:latest \
  -- sh -c "id; chown -R 1:1 /etc/passwd 2>&1; sleep 3600"

oc get pods -n library -l app=demo-legacy
oc logs -n library -l app=demo-legacy
# id -> uid=1000700003(...) statt 0
# chown -> "Operation not permitted"

oc get pod -n library -l app=demo-legacy -o jsonpath='{.items[0].metadata.annotations.openshift\.io/scc}{"\n"}'
# restricted-v2 - genau die Vorgabe-SCC aus A7

Genau das Symptom aus dem Atlas: eine zufällige hohe UID statt 0, chown gibt Operation not permitted. Die Brücke, bis ein Image UID-agnostisch gebaut ist – anyuid an genau eine ServiceAccount, nie an den Namespace:

Laptop · Bash
oc create sa novaris-legacy -n library
oc adm policy add-scc-to-user anyuid -z novaris-legacy -n library

oc patch deployment demo-legacy -n library --type=merge -p \
  '{"spec":{"template":{"spec":{"serviceAccountName":"novaris-legacy"}}}}'

oc get pod -n library -l app=demo-legacy -o jsonpath='{.items[0].metadata.annotations.openshift\.io/scc}{"\n"}'
# anyuid - die Bruecke wirkt
oc logs -n library -l app=demo-legacy
# id -> uid=0(root) - chown auf /etc/passwd (im Image root:root) gelingt jetzt

Der richtige Fix ist das Image anyuid ist eine Brücke, kein Ziel. Der saubere Weg: Dateien der Gruppe root, chmod -R g=u, kein chown, USER 1001 – dann läuft die App wieder unter restricted-v2, ohne Ausnahme.

2 Root-Dateisystem sperren, emptyDir mounten

readOnlyRootFilesystem: true sperrt das Image, ein emptyDir gibt gezielt beschreibbare Inseln.

Legacy-Apps schreiben mitten ins Image: nach /tmp, in Log-Verzeichnisse, in Arbeits- und Temp-Ordner des App-Servers. Die saubere Lösung: das Root-Dateisystem schreibgeschützt stellen (readOnlyRootFilesystem: true) und an jeden Pfad, der beschreibbar sein muss, ein emptyDir mounten. Logs gehen sowieso nach stdout.

Das Image ist read-only – beschreibbare Inseln kommen als emptyDir. Container – Root-FS schreibgeschützt securityContext.readOnlyRootFilesystem: true nichts in den Image-Layern ist veränderbar – Best Practice beschreibbare Mounts (je ein emptyDir): /tmp (medium: Memory = tmpfs) /opt/IBM/WebSphere/AppServer/profiles/.../logs /opt/IBM/WebSphere/AppServer/profiles/.../temp /wstemp /opt/novaris/work alles andere: ein Schreibversuch → Read-only file system so fällt beim ersten Lauf auf, was die App wirklich braucht Reihenfolge: erst ohne Mounts starten, die Fehler sammeln, dann Mounts ergänzen oc set volume deploy/novaris --add --type=emptyDir --mount-path=/tmp emptyDir – die Eigenschaften pro Pod, leer beim Start, weg beim Neustart liegt auf der Node-Disk (oder RAM bei medium: Memory) sizeLimit deckelt den Verbrauch passt für Temp, Cache, Logs – alles Flüchtige nicht für Daten, die überleben müssen Logs statt Log-Dateien der App-Server auf stdout/stderr umstellen Liberty: <logging consoleLogLevel="INFO"/> dann sammelt Vector die Logs (G2), kein Log-Volume nötig das Log-emptyDir bleibt nur für das, was der Server erzwingt oc logs -f deploy/novaris statt in den Pod steigen welches Volume wofür emptyDir Temp, Cache, Scratch – darf weg sein emptyDir Memory schnelles /tmp, zählt gegen das RAM-Limit PVC Daten, die einen Neustart überleben (G3) ConfigMap/Secret Konfiguration, nicht Daten (B8, H5) Regel: teilen zwei Pods dieselben Bytes → PVC (RWX) oder S3 Falle: Upload-Ordner als emptyDir → Datei landet auf Pod A, der Download geht an Pod B → 404 Uploads gehören in Objekt-Storage (S3) oder ein RWX-Volume Novaris: Policen-Dokumente wandern nach S3, nicht in den Pod readOnlyRootFilesystem + allowPrivilegeEscalation: false gehören zusammen in den securityContext passt zur SCC-Vorgabe restricted-v2 (A7) und macht ein kompromittiertes Image weniger nützlich Merksatz: Image read-only, beschreibbare Pfade als emptyDir, Logs nach stdout, echte Daten in eine PVC Vorgehen: ohne Mounts starten, die Read-only file system-Fehler sammeln, genau diese Pfade als emptyDir nachrüsten Novaris: WAS schreibt an vier feste Pfade – vier emptyDir-Mounts, Policen-Dokumente in Objekt-Storage OCP-Unterschied: keiner – readOnlyRootFilesystem und emptyDir sind reines Kubernetes.
Das Image bleibt unangetastet. readOnlyRootFilesystem: true sperrt jede Schreiboperation in den Image-Layern – und an jeden Pfad, den die App wirklich beschreiben muss, mountest du ein emptyDir. Bei Novaris sind das die vier festen WAS-Verzeichnisse (logs, temp, wstemp, /tmp). Logs stellst du auf stdout um, dann sammelt sie der Cluster (G2). Alles, was einen Neustart überleben muss, gehört in eine PVC (G3) oder einen externen Store – nie in ein emptyDir.
  • Legacy-Apps schreiben mitten ins Image: /tmp, Log-Verzeichnisse, App-Server-Arbeits- und Temp-Ordner, Upload-Ordner
  • Best Practice: securityContext.readOnlyRootFilesystem: true – nichts in den Image-Layern ist mehr veränderbar
  • an jeden Pfad, der beschreibbar sein muss, ein emptyDir-Volume mounten
  • Novaris (WAS): vier feste Pfade – .../logs, .../temp, wstemp, /tmp – also vier emptyDir-Mounts
  • emptyDir ist pro Pod und weg beim Neustart – für Temp und Logs in Ordnung, weil Logs sowieso nach stdout gehen
  • emptyDir.medium: Memory gibt ein tmpfs-/tmp – schnell, aber es zählt gegen das Memory-Limit
  • sizeLimit auf dem emptyDir deckelt den Verbrauch, damit ein volllaufender Ordner nicht die Nodes-Disk killt
  • Vorgehen: erst ohne Mounts starten, die Read-only file system-Fehler sammeln, genau diese Pfade nachrüsten
  • Falle: Upload-Ordner als emptyDir – die Datei landet auf Pod A, der Download geht an Pod B
  • Uploads gehören in Objekt-Storage (S3) oder ein RWX-Volume (G3) – Novaris: Policen-Dokumente wandern nach S3
  • Logs statt Log-Dateien: den App-Server auf stdout/stderr umstellen, dann sammelt Vector sie (G2)
  • readOnlyRootFilesystem gehört zusammen mit allowPrivilegeEscalation: false in den securityContext – passt zu restricted-v2 (A7)
  • OCP-Unterschied: keinerreadOnlyRootFilesystem und emptyDir sind reines Kubernetes
Laptop · Bash
oc delete deployment demo-legacy -n library --ignore-not-found

cat <<'EOF' | oc apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-legacy
  namespace: library
spec:
  replicas: 1
  selector: { matchLabels: { app: demo-legacy } }
  template:
    metadata: { labels: { app: demo-legacy } }
    spec:
      serviceAccountName: novaris-legacy
      containers:
        - name: novaris
          image: registry.access.redhat.com/ubi9/ubi-minimal:latest
          command: ["sh", "-c", "echo start > /opt/novaris/logs/app.log 2>&1; sleep 3600"]
          securityContext:
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
EOF

oc logs -n library -l app=demo-legacy
# "Read-only file system" - genau der erwartete erste Fehler

oc set volume deployment/demo-legacy -n library --add \
  --type=emptyDir --mount-path=/opt/novaris/logs

oc rollout status deployment/demo-legacy -n library
oc exec -n library deploy/demo-legacy -- cat /opt/novaris/logs/app.log

Das Vorgehen aus dem Atlas eins zu eins: erst ohne Mounts starten, den Read-only file system-Fehler sammeln, dann genau diesen Pfad als emptyDir nachrüsten – nicht pauschal jeden denkbaren Pfad.

3 Sticky Sessions am Router

Mit mehreren Replicas landet der zweite Request bei einem anderen Pod – die Route-Annotation pinnt den Client per Cookie.

Die alte Novaris-App hält den Warenkorb und den Login im JVM-Heap. Mit einer Replica ging das gut. Mit drei Replicas landet der zweite Request bei einem anderen Pod, der die Session nicht kennt – der Nutzer ist plötzlich ausgeloggt. Zwei Auswege: Sticky Sessions am Router (schnell, aber ohne echte Ausfallsicherheit) oder den Zustand raus aus dem Heap in Redis.

Session im JVM-Heap + mehrere Replicas = ausgeloggt beim nächsten Klick der kaputte Zustand – In-Heap-Session Pod A Pod B Pod C Request 1 (Login) legt Session an Request 2 (Warenkorb) Round-Robin → Pod B kennt die Session nicht Ergebnis: HTTP 302 auf die Login-Seite, Warenkorb leer Workaround: Sticky Sessions am Router (B4) metadata.annotations: haproxy.router.openshift.io/balance: source haproxy.router.openshift.io/disable-cookies: "false" der Router pinnt einen Client per Cookie oder Quell-IP an denselben Pod stirbt der Pod → Session weg. Rollout → alle Nutzer wandern um kein echtes HA, keine gleichmäßige Last, kein Skalieren nach unten ohne Schmerz legitim als Zwischenschritt beim Lift-and-Shift – nicht als Ziel bei WebSockets / langen Verbindungen ist Sticky ohnehin fast Pflicht der echte Fix: Zustand raus aus dem Heap Redis externer Session-Store TTL = Session- Timeout Pod A Pod B Pod C Spring: spring-session-data-redis – eine Dependency, kein App-Code WAS: Session-Replikation auf externen Store umstellen, jeder Pod austauschbar für reine APIs: gar keine Session der Client schickt ein signiertes JWT im Authorization: Bearer-Header der Server prüft die Signatur und hält nichts – komplett zustandslos skaliert linear, kein Store, kein Sticky – dafür: Token-Widerruf ist schwer kurze Laufzeit + Refresh-Token, sensible Daten nie in die Payload passt für die Novaris-REST-API, nicht für das server-gerenderte Web-Frontend OAuth2 / OIDC (Keycloak) liefert die Tokens, der Router macht nur TLS Merksatz: ein Pod darf nichts wissen, was ein anderer Pod nicht auch herausfinden kann Novaris: Web-Frontend → Spring Session nach Redis, REST-API → JWT, Sticky nur als Brücke während der Migration OCP-Unterschied: die Router-Annotation ist OpenShift-spezifisch – auf Ingress-NGINX heißt sie nginx.ingress.kubernetes.io/affinity
In-Heap-Sessions und Skalierung schließen sich aus. Sobald mehr als eine Replica läuft, trifft der nächste Request statistisch einen anderen Pod. haproxy.router.openshift.io/balance: source pinnt einen Client per Cookie oder Quell-IP an denselben Pod – ein gültiger Zwischenschritt, aber ohne Ausfallsicherheit. Der echte Fix ist ein externer Store: spring-session-data-redis fürs Web-Frontend, ein signiertes JWT für die REST-API. Danach ist jeder Pod austauschbar und ein Neustart kostet keine Session.
  • Die alte Novaris-App hält Login und Warenkorb im JVM-Heap – mit einer Replica unauffällig
  • mit mehreren Replicas geht der zweite Request per Round-Robin an einen anderen Pod, der die Session nicht kennt
  • Symptom: der Nutzer wird mitten im Ablauf ausgeloggt, der Warenkorb ist leer, HTTP 302 auf die Login-Seite
  • Workaround Sticky Sessions: Route-Annotation haproxy.router.openshift.io/balance: source
  • der Router pinnt dann einen Client per Cookie oder Quell-IP an denselben Pod
  • Sticky hat keine Ausfallsicherheit: stirbt der Pod, ist die Session weg – und beim Rollout wandern alle Nutzer um
  • Sticky verhindert außerdem gleichmäßige Last und sauberes Herunterskalieren – nur als Brücke, nicht als Ziel
  • echter Fix: Zustand raus aus dem Heap in einen externen Store – spring-session-data-redis, eine Dependency ohne App-Code
  • bei WAS: die Session-Replikation auf einen externen Store umstellen, danach ist jeder Pod austauschbar
  • die Redis-TTL entspricht dem Session-Timeout – abgelaufene Sessions räumt Redis selbst weg
  • für reine APIs: gar keine Session – ein signiertes JWT im Authorization-Header, der Server hält nichts
  • JWT-Preis: Widerruf ist schwer – kurze Laufzeit plus Refresh-Token, keine sensiblen Daten in der Payload
  • Novaris-Entscheidung: Web-Frontend nach Redis, REST-API auf JWT, Sticky nur während der Migration (H6)
  • OCP-Unterschied: die balance-Annotation ist OpenShift-Router-spezifisch – auf Ingress-NGINX ist es affinity
Laptop · Bash
oc create deployment demo-session -n library \
  --image=registry.k8s.io/e2e-test-images/agnhost:2.39 --replicas=3 \
  -- /agnhost netexec --http-port=8080
oc expose deployment/demo-session -n library --port=8080
oc expose service/demo-session -n library

oc annotate route/demo-session -n library \
  haproxy.router.openshift.io/balance=source
oc get route demo-session -n library -o jsonpath='{.spec.host}{"\n"}'

# ohne Sticky-Cookie: jeder Request kann an einen anderen Pod gehen
for i in 1 2 3; do curl -s "http://$(oc get route demo-session -n library -o jsonpath='{.spec.host}')/hostname"; echo; done

# mit Cookie-Jar: derselbe Client landet wiederholt beim selben Pod
c=$(mktemp)
for i in 1 2 3; do curl -s -c "$c" -b "$c" "http://$(oc get route demo-session -n library -o jsonpath='{.spec.host}')/hostname"; echo; done

agnhost netexec ist ein Standard-Testimage aus dem Kubernetes-Projekt, das unter anderem seinen eigenen Hostnamen zurückgibt – genug, um Pinning sichtbar zu machen, ohne eine echte Session-App zu bauen. Die drei Aufrufe ohne Cookie streuen über die Pods, die drei mit Cookie landen wiederholt beim selben.

Grenze von StickyStirbt der gepinnte Pod, ist die Session weg – und bei einem Rollout wandern alle Nutzer gleichzeitig um. Der echte Fix bleibt, den Zustand aus dem Heap in Redis oder ein JWT auszulagern.

4 Den EJB-Timer als CronJob nachbauen

Ein @Schedule-Timer feuert in jeder JVM – mit drei Replicas dreimal. Der CronJob löst das sauber.

Die alte Novaris-App hat einen EJB-Timer, der jede Nacht die Prämien neu berechnet, und einen @Singleton-Bean als Cache. Beides ging davon aus, dass es genau eine JVM gibt. Mit drei Replicas feuert der Timer dreimal und jeder Pod hat seinen eigenen, abweichenden Cache. Die saubere Lösung ist fast immer: den Timer aus der App holen und zum CronJob machen.

Ein Timer in der App + N Replicas = der Job läuft N-mal das Problem @Schedule(hour="2") in einem EJB – in jeder JVM aktiv Pod A · Timer feuert 02:00 Pod B · Timer feuert 02:00 Pod C · Timer feuert 02:00 Prämienlauf 3× doppelte Buchungen und der @Singleton-Cache "Singleton" gilt nur pro JVM – jeder Pod hat seinen eigenen Pod A kennt den neuen Tarif, Pod B noch den alten welche Antwort du bekommst, hängt am Zufall des Load-Balancers dasselbe gilt für @Startup-Init, In-Memory-Locks, lokale Zähler Faustregel: alles, was "genau einmal" sein soll, gehört nach außen vier Lösungen – von sauber nach pragmatisch 1 · CronJob – der empfohlene Weg der Timer wird ein eigenes CronJob-Objekt (F4): schedule: "0 2 * * *", concurrencyPolicy: Forbid gleiches Image, anderer Entrypoint – läuft genau einmal, unabhängig von den Web-Replicas 2 · Leader-Election die Replicas wählen über die Lease-API einen Leader, nur der führt den Timer aus gut, wenn der Job Zustand aus der laufenden App braucht – mehr Code, echtes Failover 3 · Deployment auf eine Replica replicas: 1 + strategy.type: Recreate – nie zwei JVMs gleichzeitig der einfachste Schnitt, aber kein HA für genau diesen Dienst 4 · verteilter Lock Redis SET key NX PX oder DB SELECT ... FOR UPDATE – der erste Pod nimmt den Lock, die anderen überspringen Timer darf ruhig N-mal wecken, die Arbeit passiert nur einmal – Lock-Expiry nicht vergessen Singleton-Cache: raus nach Redis – dann teilen sich alle Pods denselben Zustand (H3) Merksatz: ein Timer in der App feuert pro JVM – also pro Replica. Was genau einmal passieren soll, gehört aus der App heraus Novaris: nächtliche Prämienberechnung → CronJob, @Singleton-Tarif-Cache → Redis Zwischenschritt bei Zeitdruck: das Batch-Modul auf replicas: 1 setzen, Web-Modul frei skalieren OCP-Unterschied: keiner – CronJob, Lease und Recreate sind Standard-Kubernetes. OpenShift gibt dir nur die Konsole dazu
"Genau einmal" ist keine App-Eigenschaft mehr. Ein @Schedule-EJB läuft in jeder JVM – also in jeder Replica – und ein @Singleton ist nur pro JVM einer. Die sauberste Antwort ist der CronJob: der Timer wird ein eigenes Objekt mit concurrencyPolicy: Forbid, unabhängig von den Web-Pods. Reicht das nicht (der Job braucht App-Zustand), dann Leader-Election über die Lease-API oder ein verteilter Lock. Der Singleton-Cache wandert nach Redis. Bei Novaris wird die nächtliche Prämienberechnung ein CronJob, der Tarif-Cache geht nach Redis.
  • Novaris hat einen EJB-Timer für die nächtliche Prämienberechnung und einen @Singleton-Cache – beide gingen von einer JVM aus
  • ein @Schedule-Timer ist in jeder JVM aktiv – mit drei Replicas feuert er dreimal, es gibt doppelte Buchungen
  • ein @Singleton gilt nur pro JVM – jeder Pod hat seinen eigenen Cache, sie driften auseinander
  • dasselbe Muster trifft @Startup-Init, In-Memory-Locks und lokale Zähler
  • Lösung 1 – CronJob (empfohlen): der Timer wird ein eigenes Objekt, schedule: "0 2 * * *", concurrencyPolicy: Forbid
  • der CronJob nutzt dasselbe Image mit anderem Entrypoint und läuft unabhängig von den Web-Replicas (F4)
  • Lösung 2 – Leader-Election: die Replicas wählen über die Lease-API einen Leader, nur der führt den Timer aus
  • Leader-Election lohnt, wenn der Job Zustand aus der laufenden App braucht – mehr Code, dafür echtes Failover
  • Lösung 3 – eine Replica: replicas: 1 plus strategy.type: Recreate, einfach, aber kein HA für diesen Dienst
  • Lösung 4 – verteilter Lock: Redis SET key NX PX oder DB SELECT ... FOR UPDATE, der erste Pod nimmt ihn, die anderen überspringen
  • beim Lock die Expiry setzen – sonst blockiert ein abgestürzter Pod den Job dauerhaft
  • Singleton-Cache: raus nach Redis, dann teilen sich alle Pods denselben Zustand (H3)
  • Novaris-Zuordnung: Prämienberechnung wird ein CronJob, der @Singleton-Tarif-Cache wandert nach Redis
  • OCP-Unterschied: keinerCronJob, Lease und Recreate sind Standard-Kubernetes
Laptop · Bash
cat <<'EOF' | oc apply -f -
apiVersion: batch/v1
kind: CronJob
metadata:
  name: praemien-berechnung
  namespace: library
spec:
  schedule: "0 2 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: berechnung
              image: registry.access.redhat.com/ubi9/ubi-minimal:latest
              command: ["sh", "-c", "echo \"Praemien berechnet um $(date -Iseconds)\""]
EOF

# nicht bis 2 Uhr nachts warten - von Hand anstossen:
oc create job --from=cronjob/praemien-berechnung praemien-manuell-1 -n library
oc logs -n library job/praemien-manuell-1 -f
oc get cronjob praemien-berechnung -n library

concurrencyPolicy: Forbid verhindert, dass ein zweiter Lauf startet, solange der erste noch arbeitet – genau das, was ein @Singleton-Timer in der JVM implizit hatte. Das CronJob-Image kann dasselbe sein wie das der Web-Replicas, nur mit anderem Entrypoint.

5 Eine Datenquelle ohne Rebuild umkonfigurieren

Die alte Welt: URL und Passwort im Image. Die neue: die Plattform liefert beides, das Image bleibt gleich.

In der alten Welt legt ein Admin die DataSource NovarisDS in der server.xml oder über die WAS-Konsole an – URL, Nutzer und Passwort stehen im Image oder im Profil. Auf OpenShift gehört die Verbindung der Plattform, nicht dem Image: URL als Env aus einer ConfigMap, Passwort aus einem Secret, in Liberty zusammengeführt über ein configDropins/overrides-Fragment.

Die DB-Verbindung gehört der Plattform, nicht dem Image alte Welt: DataSource im Image Admin legt NovarisDS an: • in der server.xml von Hand, oder • per WAS-Admin-Konsole / wsadmin (I4) <dataSource jndiName="jdbc/NovarisDS"> url=jdbc:db2://prod-db:50000/NOV user=novaris password=S3cr3t! (im Layer) </dataSource> jede Umgebung braucht ihr eigenes Image Passwort im Klartext im Registry-Layer Rotation = neuer Build Config über die Konsole ist nicht reproduzierbar (I4) und geht bei jedem Pod-Neustart verloren der db2jcc.jar-Treiber dagegen ist Code – der darf ins Image OpenShift-Form: Verbindung von außen ConfigMap db-config DB_URL: jdbc:db2://... DB_USER: novaris HIKARI_MAX: "20" nicht-geheime Werte, pro Umgebung anders Secret db-credentials DB_PASSWORD: **** als Env oder als Datei gemountet (B8) Rotation ohne Rebuild, Datei-Mount aktualisiert live Liberty configDropins-Fragment .../configDropins/overrides/datasource.xml <dataSource jndiName="jdbc/NovarisDS"> <properties url="${env.DB_URL}" user="${env.DB_USER}" password="${env.DB_PASSWORD}"/> kommt selbst aus einer ConfigMap, Liberty liest es beim Start kein Neubau des Images, kein Eingriff in die Basis-server.xml das Image (umgebungs-agnostisch) kennt nur jdbc/NovarisDS + den JDBC-Treiber-Jar. Sonst nichts über die DB App-Code ändert sich nicht – lookup("jdbc/NovarisDS") bleibt, wie es war Spring-Boot-Variante: SPRING_DATASOURCE_URL / _PASSWORD als Env – dieselbe Idee ohne XML HikariCP-Pool-Größen (max, timeout) sind auch Konfiguration – ab in die ConfigMap DB-TLS-Zertifikat als weiteres Secret, per Datei gemountet und in der URL referenziert Merksatz: der Treiber ist Code und darf ins Image – URL, Nutzer und Passwort sind Konfiguration und gehören nach außen Novaris: NovarisDS raus aus der server.xml, db-credentials als Secret, Pool-Werte als ConfigMap – ein Image für Dev, Test und Prod Datei-Mount statt Env fürs Passwort: es taucht nicht in oc set env --list oder Crash-Dumps auf (B8) OCP-Unterschied: keiner – ConfigMap, Secret und configDropins sind Kubernetes plus Liberty-Standard
Der Treiber ist Code, die Verbindung ist Konfiguration. Der db2jcc.jar darf im Image bleiben. URL, Nutzer und Passwort gehören nach außen: die URL in eine ConfigMap, das Passwort in ein Secret, in Liberty zusammengeführt über ein configDropins/overrides/datasource.xml-Fragment mit ${env.DB_URL}. Der App-Code ruft weiterhin lookup("jdbc/NovarisDS") – er merkt nichts. Ergebnis: ein Image für alle Umgebungen, Passwort-Rotation ohne Rebuild.
  • alte Welt: ein Admin legt NovarisDS in der server.xml oder über die WAS-Konsole an – URL, Nutzer, Passwort im Image oder Profil
  • Folgen: jede Umgebung braucht ein eigenes Image, das Passwort liegt im Klartext im Registry-Layer, Rotation heißt Rebuild
  • Konfiguration über die Konsole ist nicht reproduzierbar und geht bei jedem Pod-Neustart verloren (I4)
  • OpenShift-Form: das Image ist umgebungs-agnostisch und kennt nur den JNDI-Namen jdbc/NovarisDS plus den Treiber-Jar
  • die URL und der Nutzer kommen aus einer ConfigMap db-config als Environment-Variablen (B8)
  • das Passwort kommt aus einem Secret db-credentials – als Env oder besser als Datei gemountet
  • in Liberty legt ein configDropins/overrides/datasource.xml-Fragment die DataSource an und referenziert ${env.DB_URL} und ${env.DB_PASSWORD}
  • das Fragment kommt selbst aus einer ConfigMap und wird nach .../configDropins/overrides gemountet – Liberty liest es beim Start automatisch
  • der App-Code ändert sich nichtlookup("jdbc/NovarisDS") bleibt unverändert
  • Spring-Boot-Variante: SPRING_DATASOURCE_URL und _PASSWORD als Env – dieselbe Idee ohne XML
  • die HikariCP-Pool-Größen (max, timeout) sind auch Konfiguration und gehören in die ConfigMap
  • Datei-Mount statt Env fürs Passwort: es taucht nicht in oc set env --list oder Crash-Dumps auf (B8)
  • Novaris: NovarisDS raus aus der server.xml, db-credentials als Secret, Pool-Werte als ConfigMap – ein Image für Dev, Test und Prod
  • OCP-Unterschied: keiner – ConfigMap, Secret und configDropins sind Kubernetes plus Liberty-Standard
Laptop · Bash
oc create configmap db-config -n library \
  --from-literal=DB_URL=jdbc:postgresql://catalog-db:5432/novaris

oc create secret generic db-credentials -n library \
  --from-literal=DB_PASSWORD=ein-echtes-passwort

oc set env deployment/demo-legacy -n library --from=configmap/db-config
oc set env deployment/demo-legacy -n library --from=secret/db-credentials

oc exec -n library deploy/demo-legacy -- env | grep DB_
# DB_URL sichtbar, DB_PASSWORD ebenfalls als Env - fuer Produktion lieber gemountet

# dieselbe Datenquelle in Test statt Prod: nur die ConfigMap aendern, kein neues Image
oc set data configmap/db-config -n library \
  --from-literal=DB_URL=jdbc:postgresql://catalog-db-test:5432/novaris
oc rollout restart deployment/demo-legacy -n library

Der App-Code ändert nichts – ein Liberty-configDropins/overrides- Fragment oder SPRING_DATASOURCE_URL liest einfach die Umgebungsvariablen. Wer das Passwort nicht in oc set env --list sehen will, mountet das Secret stattdessen als Datei (secrets-in-openshift.html).

6 Vorher/Nachher als Tafel

Die Zusammenfassung von Block H als eine Tafel: links die alte Novaris-Landschaft auf WebSphere Application Server traditional, rechts dieselbe Anwendung auf OpenShift mit Open Liberty. Jede Zeile ist eine Design-Entscheidung, die sich mit der Plattform ändert – und am Ende drei Dinge, die auch nach der Migration schwer bleiben.

Dieselbe Anwendung, zwei Plattformen – Zeile für Zeile legacy-parent · WAS traditional modern-parent · Open Liberty auf OpenShift Topologie DMgr + Node Agents + n App Server in einer Zelle, von Hand aufgesetzt ein Deployment, N austauschbare Pods, vom Scheduler platziert Skalierung vertikal – mehr Heap und CPU für denselben JVM-Prozess horizontal – mehr Replicas, HPA nach CPU oder Requests/s (F2) Konfiguration Admin-Konsole und wsadmin gegen den laufenden Server (I4) Deployment + ConfigMap + Secret in Git, per GitOps ausgerollt (E3) Artefakt eine .ear wird in die laufende Zelle deployt ein Container-Image, das komplett ersetzt wird (C1) Session WAS-Session-Replikation zwischen den App-Servern zustandslose Pods + Redis oder JWT (H3) Timer @Schedule-EJB im App-Server CronJob oder Leader-Election (H4) Datenquelle JNDI in der server.xml oder Konsole, Passwort im Profil Env aus ConfigMap + Secret, configDropins (H5) Dateisystem schreibt frei ins Profil und nach /tmp readOnlyRootFilesystem + emptyDir (H2) Identität läuft als root oder als fester Betriebsnutzer zufällige hohe UID unter restricted-v2 (A7, H1) Routing Web-Server-Plugin + IBM HTTP Server davor Route + Router, TLS am Edge (B4) Neustart Server-Neustart in Minuten, Zustand im Heap Pod-Restart in Sekunden, Zustand außen Image-Größe Installation im GB-Bereich, selten neu gebaut Liberty-Image 200–400 MB, bei jedem Commit neu (I3) was auch nach der Migration schwer bleibt Verteilte XA-Transaktionen – Zwei-Phasen-Commit über mehrere Ressourcen. In Pods ist der Transaction-Recovery-Log schwer sicher abzulegen Echte stateful Singletons – Logik, die genau eine Instanz mit strikter Reihenfolge braucht. Leader-Election hilft, löst es aber nicht völlig Langlebige In-Memory-JVM-Caches – die auf einen warmen Heap angewiesen sind. Ein Pod-Neustall ist billig, das Neu-Füllen des Caches nicht Merksatz: die Migration macht das Deployment einfach – die schweren Fälle sind Architektur-Entscheidungen, keine Plattform-Themen So liest man die Tafel: jede Zeile links ist eine Annahme der alten Welt, jede Zeile rechts die OpenShift-Antwort aus Block H Reihenfolge der Migration: erst Identität und Dateisystem (H1, H2), dann Session und Timer (H3, H4), dann Konfiguration (H5), zuletzt GitOps (E3) OCP-Unterschied: die rechte Spalte ist zu 90% reines Kubernetes – OpenShift liefert Route, SCC, die Konsole und den integrierten Build (C1)
Block H auf einer Tafel. Links legacy-parent auf WAS traditional – Deployment Manager, Node Agents, vertikale Skalierung, Konsolen-Konfiguration, EAR-Deploy, Session-Replikation. Rechts modern-parent auf Liberty/OpenShift – Deployment, horizontale Skalierung mit HPA, GitOps, Image-Replace, zustandslose Pods mit Redis. Jede Zeile verweist auf das Diagramm in Block H, das sie im Detail behandelt. Der Kasten unten nennt drei Fälle, die Architektur bleiben und sich nicht wegmigrieren lassen: verteilte XA-Transaktionen, echte stateful Singletons, warme JVM-Caches.
  • Topologie: DMgr + Node Agents + n App Server in einer Zelle → ein Deployment mit N austauschbaren Pods
  • Skalierung: vertikal (mehr Heap/CPU pro JVM) → horizontal (mehr Replicas, HPA nach CPU oder Requests/s)
  • Konfiguration: Admin-Konsole und wsadmin gegen den laufenden Server (I4) → Deployment + ConfigMap + Secret in Git, per GitOps (E3)
  • Artefakt: eine .ear ins laufende System deployen → ein Container-Image komplett ersetzen (C1)
  • Session: WAS-Session-Replikation zwischen App-Servern → zustandslose Pods plus Redis oder JWT (H3)
  • Timer: @Schedule-EJB im App-Server → CronJob oder Leader-Election (H4)
  • Datenquelle: JNDI in server.xml oder Konsole → Env aus ConfigMap und Secret plus configDropins (H5)
  • Dateisystem: schreibt frei ins Profil → readOnlyRootFilesystem plus emptyDir (H2)
  • Identität: läuft als root oder festem Betriebsnutzer → zufällige hohe UID unter restricted-v2 (A7, H1)
  • Routing: Web-Server-Plugin plus IBM HTTP Server → Route und Router mit TLS am Edge (B4)
  • Neustart: Server-Neustart in Minuten mit Zustand im Heap → Pod-Restart in Sekunden mit Zustand außen
  • was schwer bleibt: verteilte XA-/Zwei-Phasen-Transaktionen – der Recovery-Log ist in Pods schwer sicher abzulegen
  • was schwer bleibt: echte stateful Singletons mit strikter Ordnung – Leader-Election hilft, löst es aber nicht völlig
  • was schwer bleibt: langlebige In-Memory-JVM-Caches – der Pod-Neustart ist billig, das Neu-Füllen des Caches nicht
  • Migrationsreihenfolge: erst Identität und Dateisystem, dann Session und Timer, dann Konfiguration, zuletzt GitOps
AspektWAS traditionalOpenShift + Open Liberty
TopologieDMgr + Node Agents + App-Server in einer Zelleein Deployment mit N austauschbaren Pods
Skalierungvertikal, mehr Heap pro JVMhorizontal, mehr Replicas, HPA
KonfigurationAdmin-Konsole, wsadminDeployment + ConfigMap + Secret in Git, GitOps
Artefakteine .ear ins laufende Systemein Container-Image komplett ersetzen
SessionWAS-Session-Replikationzustandslose Pods plus Redis oder JWT
Timer@Schedule-EJB im App-ServerCronJob oder Leader-Election
DatenquelleJNDI in server.xml oder KonsoleEnv aus ConfigMap/Secret plus configDropins
Dateisystemschreibt frei ins ProfilreadOnlyRootFilesystem plus emptyDir
Identitätroot oder fester Betriebsnutzerzufällige hohe UID unter restricted-v2

Was auch nach der Migration schwer bleibt: verteilte XA-Transaktionen (der Recovery-Log ist in Pods schwer sicher abzulegen), echte stateful Singletons mit strikter Ordnung (Leader-Election hilft, löst es nicht völlig), und langlebige In-Memory-Caches (der Pod-Neustart ist billig, das Neu-Füllen des Caches nicht). Migrationsreihenfolge: erst Identität und Dateisystem, dann Session und Timer, dann Konfiguration, zuletzt GitOps.

7 Typische Fallen

Symptommeist
CrashLoopBackOff nach dem Deploycontainer has runAsNonRoot and image will run as root – Image erwartet UID 0
Upload-Datei nach kurzer Zeit wegin einem emptyDir gelandet statt in S3 oder einer PVC – landet auf Pod A, Download geht an Pod B
Nutzer mitten im Ablauf ausgeloggtmehrere Replicas ohne Sticky Sessions und ohne externen Session-Store
doppelte Buchungen bei der Nachtberechnungein @Schedule-Timer lief noch im App-Code statt als CronJob, dreimal parallel
Passwort taucht in oc set env --list aufSecret als Env statt als Datei gemountet

+ Die Kurzfassung

Identitätder Fix ist das Image (UID-agnostisch), anyuid an einer SA nur die Brücke
Dateisystemread-only plus gezielte emptyDir-Inseln, echte Daten in eine PVC oder S3
Session/TimerZustand raus aus dem Heap – Redis/JWT statt Sticky, CronJob statt @Schedule
KonfigurationConfigMap/Secret statt server.xml – ein Image für alle Umgebungen
⌂ Cockpit