Microservices Spring Boot sur OpenShift : Ce qui se passe vraiment

Microservices Spring Boot sur OpenShift : Ce qui se passe vraiment
par Zelkulon28 avril 20261 min de lecture

Nous avons migré nos backends Spring Boot de Railway vers OpenShift. Quatre erreurs en une session – et ce que chacune révèle sur l'interaction entre Hibernate, Liquibase, Spring Security et les sondes Kubernetes.

Die Ausgangslage

Im Artikel über Next.js auf OpenShift haben wir beschrieben, warum wir das Frontend auf der Developer Sandbox genutzt haben – und warum wir danach trotzdem bei Netlify geblieben sind. Die Antwort war pragmatisch: OpenShift ist schneller, aber die Sandbox läuft nach 30 Tagen aus. Und so ultra-schneller echter Cluster ist für eine kleine UG schlicht zu teuer.

Jetzt haben wir die Backends nachgezogen. Nicht weil sich die Kalkulation geändert hätte – sondern weil wir die Frage vollständig beantworten wollten: Was passiert, wenn man Zelkulon komplett auf OpenShift betreibt? Die gemeinsame PostgreSQL bleibt auf Railway. Das stand vorerst außerfrage.

Was folgte, waren vier Crashes in einer Session. Jeder mit einer anderen Ursache, keiner davon exotisch. Dieser Artikel ist kein Tutorial – er ist ein ehrlicher Bericht darüber, was uns aufgehalten hat.

GitHub Repo
    │
    ▼
OpenShift BuildConfig (Docker-Strategy)
    │  clont Repo, baut Dockerfile, ~5 Min
    ▼
ImageStream (internes Registry)
    │
    ▼
Deployment → Pod → Service → Route (HTTPS, edge TLS)
                              │
                              └── https://<service>-zelkulon-dev.apps.rm2.thpm.p1.openshiftapps.com

Das Dockerfile für OpenShift

OpenShift führt Container nicht als root aus. Denn nicht jeder Hotelbesucher soll in alle Räume dürfen. ([weiterführender artikel:](https://medium.com/@madeva.tuppad/why-are-non-root-containers-so-important-in-openshift-489ef97ab2a0)) Jeder Pod bekommt eine zufällige UID aus dem Namespace-Range. Die GID ist dabei immer 0 – root-Gruppe. Das erfordert eine spezifische Berechtigungsstrategie: Dateien müssen für die Gruppe schreibbar sein, nicht nur für den Besitzer.

FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -q
COPY src ./src
RUN ./mvnw package -DskipTests -q

FROM eclipse-temurin:21-jre-alpine AS runner
WORKDIR /app

# GID=0 ist in OpenShift immer gesetzt – chmod g=u erlaubt
# der zufälligen UID Lese-/Schreibzugriff über die Gruppe.
RUN addgroup --system --gid 1001 spring \
    && adduser --system --uid 1001 --ingroup spring spring \
    && chown -R 1001:0 /app \
    && chmod -R g=u /app

COPY --from=builder --chown=1001:0 /build/target/*.jar app.jar
RUN chmod g=u /app/app.jar

USER 1001
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]

Ein wichtiger Hinweis zur Build-Engine: OpenShift nutzt Buildah([opeshift docs](https://docs.redhat.com/en/documentation/openshift_container_platform/4.4/html/builds/custom-builds-buildah) und [Buildah-Webseite](https://buildah.io/blogs/2017/11/02/getting-started-with-buildah.html)), nicht Docker BuildKit. Das # syntax=docker/dockerfile:1 Pragma und --mount=type=cache gehören deshalb nicht ins Dockerfile – Buildah ignoriert sie nicht stillschweigend, es bricht daran ab.

Fehler 1: Hibernate validiert citext als varchar

Der Auth-Service startete, crashte sofort. Die Meldung war eindeutig – aber nur, wenn man weiß, was sie bedeutet.

Fehler 1

Schema-Validation schlägt fehl

Ursache: Liquibase hatte die E-Mail-Spalte korrekt als citext angelegt – dem PostgreSQL-Typ für case-insensitive Suche. Hibernate kennt citext nicht. Es erwartet varchar und bricht die Validierung ab.

Fehlermeldung: found [citext (Types#OTHER)], but expecting [varchar(255) (Types#VARCHAR)]

Fix: JPA_DDL_AUTO auf 'none' setzen. Wenn Liquibase das Schema verwaltet, hat Hibernate bei der Validierung nichts zu suchen.

# Im Deployment-Manifest stand fälschlicherweise:
#   JPA_DDL_AUTO: validate
# Fix via oc:
oc set env deployment/auth-service JPA_DDL_AUTO=none

# application-prod.yml – Default bereits auf none:
spring:
  jpa:
    hibernate:
      ddl-auto: ${JPA_DDL_AUTO:none}

Die Merkregel ist einfach: Entweder Liquibase oder Hibernate-DDL. Beide zusammen auf validate bedeutet, dass Hibernate PostgreSQL-spezifische Typen nicht kennt und die Validierung verweigert.

✓ Warum das lokal nie aufgefallen war

In H2 – der lokalen Dev-Datenbank – gibt es kein citext. Der Typ-Konflikt taucht erst gegen echtes PostgreSQL auf. Containerisierung macht Konfigurationslücken sichtbar, die in anderen Environments versteckt waren.

Fehler 2: Liquibase-Prüfsummenkollision bei geteilter Datenbank

Auth-Service lief. Blog-Service startete – und crashte beim Liquibase-Startup. Die Fehlermeldung war zunächst rätselhaft: Eine Prüfsumme hatte sich geändert, obwohl wir das Changeset nicht angefasst hatten.

Fehler 2

Liquibase ValidationFailedException beim Start

Ursache: Beide Services teilen eine PostgreSQL-Datenbank und nutzen denselben Changelog-Dateinamen. Liquibase identifiziert Changesets über ID + Author + Dateiname. Auth-Service hatte 000-enable-extensions mit pgcrypto + citext gespeichert; Blog-Service hatte nur pgcrypto – andere Prüfsumme, selbe ID.

Fehlermeldung: changesets check sum db/changelog/db.changelog-master.yaml::000-enable-extensions::zelkulon was: 9:5ed... but is now: 9:7ba...

Fix: Inhalt in beiden Changelogs angleichen. Liquibase überspringt das Changeset dann als bereits ausgeführt – der Hash stimmt wieder.

# blog-service: db.changelog-master.yaml – Inhalt angeglichen:
- changeSet:
    id: 000-enable-extensions
    author: zelkulon
    changes:
      - sql:
          sql: |
            CREATE EXTENSION IF NOT EXISTS pgcrypto;
            CREATE EXTENSION IF NOT EXISTS citext;   # ← hinzugefügt

Die eigentliche Lektion: Bei geteilten Datenbanken müssen identische Changeset-IDs auch identischen Inhalt haben. Besser noch: eindeutige Dateinamen pro Service – auth-changelog-master.yaml und blog-changelog-master.yaml. Dann gibt es keine Kollisionen, egal was die Services einzeln tun.

Fehler 3: Spring Security blockiert /actuator/health

Nach dem Changeset-Fix startete der Blog-Service – lief 45 Sekunden problemlos – und wurde dann von Kubernetes gekillt. 45 Sekunden: das ist kein Zufall, sondern das Ende des initialDelaySeconds-Fensters.

Fehler 3

Liveness-Probe schlägt fehl mit HTTP 403

Ursache: Die SecurityConfigProd endet mit .anyRequest().denyAll(). Der Kubernetes-Kubelet, der die Health-Probe sendet, hat keine Credentials – und bekommt 403. Nach drei fehlgeschlagenen Checks tötet Kubernetes den Pod.

Fehlermeldung: Liveness probe failed: HTTP probe failed with statuscode: 403

Fix: /actuator/health und /actuator/info explizit in der Security-Konfiguration freigeben. Das ist kein Sicherheitsrisiko.

@Configuration
@Profile("prod")
public class SecurityConfigProd {

    @Bean
    SecurityFilterChain security(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // Kubernetes-Kubelet hat keine Credentials – muss explizit freigegeben werden
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/blog/**").permitAll()
                .anyRequest().denyAll()
            )
            .build();
    }
}

/actuator/health gibt nur { "status": "UP" } zurück – keine internen Daten, keine Tokens. Den Health-Check zu sichern schützt vor gar nichts. Es verhindert nur das Deployment.

# openshift/blog-service.yaml – Probe-Konfiguration
livenessProbe:
  httpGet:
    path: /actuator/health
    port: 8080
  initialDelaySeconds: 60   # Puffer für Spring Boot + Liquibase
  periodSeconds: 30
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /actuator/health
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 5

✓ initialDelaySeconds: 60 – nicht weniger

Spring Boot mit Hibernate-Init und Liquibase-Migrationen braucht realistisch 40–45 Sekunden. 60 Sekunden ist der sichere Puffer. Wer das unterschätzt, sieht einen Pod, der startet, kurz läuft – und dann stirbt.

Fehler 4: Next.js bricht beim Static Export ab

Das war nicht am Backend. Der Frontend-Build auf OpenShift lief über eine Stunde – und schlug dann fehl. Das war der teuerste Fehler in Zeiteinheiten.

Fehler 4

Frontend-Build schlägt nach ~1 Stunde fehl

Ursache: Die /references-Seite lädt Link-Previews für externe Websites direkt beim Build. Next.js gibt jedem Seitenexport 60 Sekunden. Aus dem OpenShift-Build-Pod sind manche externen Seiten langsam oder geblockt – nach drei Versuchen bricht der Build ab.

Fehlermeldung: Failed to build /[locale]/references/page: /en/references after 3 attempts. Next.js build worker exited with code: 1

Fix: export const dynamic = 'force-dynamic' auf der Seite. Die Fetches laufen dann zur Request-Zeit, nicht beim Build.

// src/app/[locale]/references/page.tsx

// Externe Fetches laufen zur Request-Zeit, nicht beim Build
export const dynamic = 'force-dynamic';

export default function RefPage({ params }: Props) {
    // ...
}

Eine Zeile. Der Build dauerte danach wieder fünf Minuten statt über einer Stunde. Alle anderen Seiten bleiben statisch – nur die References-Seite wird server-side gerendert, was für dynamisch geladene Link-Previews ohnehin die sinnvollere Strategie ist.

Das Ergebnis – und warum wir trotzdem bleiben, wo wir sind

Nach der Session laufen beide Services stabil auf OpenShift Developer Sandbox:

Zelkulon Infrastruktur (nach Migration)
──────────────────────────────────────────────────────────────
  zelkulon.com (DNS)
       │
       ▼
  Netlify (Frontend · Next.js 15)
       │
       ├──► auth-service-zelkulon-dev.apps.rm2.thpm.p1.openshiftapps.com
       │         Spring Boot 3 · Java 21 · Port 8082
       │         JWT-Ausgabe · Liquibase-Migrationen
       │
       └──► blog-service-zelkulon-dev.apps.rm2.thpm.p1.openshiftapps.com
                 Spring Boot 3 · Java 21 · Port 8080
                 REST-API · Cloudinary-Integration

  Gemeinsame PostgreSQL auf Railway (geteilte DB, getrennte Tabellen)

OpenShift ist schneller. Das ist keine Theorie – dedizierte Ressourcen, kein Cold-Start, Kubernetes-Scheduling. Man merkt es. Die Services antworten schneller als auf Railway.

Trotzdem bleiben wir bei Netlify und Railway. Nicht weil OpenShift schlechter ist, sondern weil die Sandbox nach 30 Tagen ausläuft – und ein echter OpenShift-Cluster für eine kleine UG schlicht zu teuer ist. Der Performancevorteil rechtfertigt die Kosten aktuell nicht.

✓ Kontrolle kostet Zeit

Probes konfigurieren, Security Contexts anpassen, Buildah-Eigenheiten umschiffen – das ist Arbeit, die Netlify und Railway einem abnehmen. Für ein kleines Team ist diese Abstraktion kein Verlust, sondern ein Gewinn. OpenShift zu verstehen war es trotzdem wert.

  • citext + Hibernate: JPA_DDL_AUTO=none, wenn Liquibase das Schema verwaltet – nie beides zusammen auf validate
  • Shared DB + Liquibase: gleiche Changeset-IDs erfordern gleichen Inhalt – oder besser: eindeutige Dateinamen pro Service
  • Spring Security + Kubernetes: /actuator/health explizit freigeben – der Kubelet hat keine Credentials
  • Next.js Static Export: externe Fetches gehören nicht in den Build-Schritt – force-dynamic löst das mit einer Zeile
  • OpenShift Dockerfile: kein BuildKit-Cache, chmod g=u für GID=0 – der Container läuft nie als root

Was bleibt: Die Backends laufen auf OpenShift, solange die Sandbox aktiv ist. Netlify bleibt das Frontend. Railway bleibt die Datenbank. Eine pragmatische, hybride Entscheidung – und eine, die wir bewusst getroffen haben.

Microservices Spring Boot sur OpenShift : Ce qui se passe vraiment