Modernisierung von Java-Anwendungen mit Amazon EKS: Ein Cloud-nativer Ansatz

Sascha Möllering

Cloud-native Entwicklung ist das Fundament der modernen Anwendungsentwicklung, wobei die Containerisierung die Strategie für die Bereitstellung von Anwendungen revolutioniert. Für Java-Entwickler und Unternehmen mit umfangreichen Java-Investitionen bietet der Übergang zur Cloud-nativen Architektur sowohl Chancen als auch Herausforderungen. Dieser Artikel beleuchtet, wie Amazon EKS in Kombination mit innovativen Technologien wie Coordinated Restore at Checkpoint (CRaC) und Quarkus das Deployment von Java-Anwendungen in der Cloud transformiert.

Einführung in Amazon Elastic Kubernetes Service

Amazon Elastic Kubernetes Service (Amazon EKS) ist ein vollständig verwalteter Kubernetes-Dienst, der es Kunden ermöglicht, Kubernetes sowohl in der AWS-Cloud als auch in On-Premises-Rechenzentren nahtlos zu betreiben. In der Cloud automatisiert Amazon EKS das Management der Kubernetes-Cluster-Infrastruktur. Dies ist essenziell für das Scheduling von Containern, die Verwaltung der Anwendungsverfügbarkeit, die dynamische Skalierung von Ressourcen, die Optimierung der Rechenleistung, die Speicherung von Clusterdaten und weitere kritische Funktionen.
Mit Amazon EKS profitieren Kunden von der hohen Leistung, Skalierbarkeit, Zuverlässigkeit und Verfügbarkeit der AWS-Infrastruktur und können nahtlos in AWS-Netzwerk-, Sicherheits- und Speicherdienste integrieren. Zur Vereinfachung des Kubernetes-Betriebs in On-Premises-Umgebungen können Kunden dieselben Amazon EKS-Cluster, -Funktionen und -Tools verwenden, um Knoten auf AWS Outposts oder ihrer eigenen Infrastruktur auszuführen. Alternativ steht Amazon EKS Anywhere für isolierte, eigenständige Umgebungen zur Verfügung.

EKS Auto Mode: Die nächste Entwicklungsstufe

Mit dem Amazon EKS Auto Mode können Kunden das Cluster-Management automatisieren, ohne tiefgehende Kubernetes-Kenntnisse zu benötigen. Dieser Modus wählt automatisch optimale Compute-Instanzen, skaliert Ressourcen dynamisch, optimiert kontinuierlich die Kosten, verwaltet Core-Add-ons, aktualisiert Betriebssysteme und integriert AWS-Sicherheitsdienste.
AWS erweitert seine operative Verantwortung im EKS Auto Mode im Vergleich zur kundenverwalteten Infrastruktur in ihren EKS-Clustern. Neben der Verwaltung der EKS-Steuerebene konfiguriert, verwaltet und sichert AWS die AWS-Infrastruktur, die Kundenanwendungen in EKS-Clustern benötigen.

Amazon EKS-Cluster-Architektur mit Auto Mode
Amazon EKS-Cluster-Architektur mit Auto Mode

Kunden können jetzt schnell loslegen, die Leistung verbessern und den Verwaltungsaufwand reduzieren, wodurch sie sich auf die Entwicklung innovativer Anwendungen konzentrieren können, anstatt sich mit Cluster-Management-Aufgaben zu beschäftigen. EKS Auto Mode reduziert außerdem den Aufwand für die Beschaffung und den Betrieb kosteneffizienter GPU-beschleunigter Instanzen, damit generative KI-Workloads die benötigte Kapazität zum richtigen Zeitpunkt zur Verfügung haben. Es startet automatisch EC2-Instanzen basierend auf Bottlerocket OS und AWS Elastic Load Balancing (ELB) und stellt Amazon Elastic Block Store (Amazon EBS) Volumes innerhalb der AWS-Kundenkonten und kundenspezifischen VPCs bereit, wenn Kunden ihre Anwendungen deployen. EKS Auto Mode startet und verwaltet den Lebenszyklus dieser EC2-Instanzen, skaliert und optimiert die Datenebene entsprechend sich ändernder Anwendungsanforderungen zur Laufzeit und ersetzt automatisch nicht funktionsfähige Nodes.

Herausforderungen von Java in Containern

Der Betrieb von Java-Anwendungen in Containern stellt uns vor komplexe Herausforderungen, die sorgfältige Überlegung und Konfiguration erfordern. Das Memory-Management ist dabei wohl die kritischste Komponente – vor Java 10 konnte die JVM Container-Speicherlimits nicht korrekt erkennen, was zu potenziellen Out-of-Memory-Fehlern führte, da Berechnungen auf Host-Ressourcen statt auf Container-Beschränkungen basierten. Diese Komplexität wird durch den Native-Memory-Overhead von Thread-Stacks, Direct Buffers und JVM-internen Strukturen noch verstärkt, die neben der Heap-Zuweisung berücksichtigt werden müssen.
Das Management von Off-Heap-Speicher fügt eine weitere Komplexitätsebene hinzu, da Tools wie Netty oder Memory-Mapped Files Heap-Limits umgehen können, aber dennoch auf die Container-Speicherlimits angerechnet werden. Die Startzeit stellt eine weitere erhebliche Herausforderung dar, besonders in dynamischen Container-Umgebungen, die schnelle Skalierung erfordern. Der zeitaufwändige Prozess des Class-Loadings, besonders bei großen Anwendungen mit vielen Abhängigkeiten, kann zu langsamer Container-Initialisierung führen. Dies wird durch initiale Heap-Sizing-Entscheidungen und JIT-Kompilierungs-Overhead noch verstärkt, wobei der Kompromiss zwischen Startgeschwindigkeit und Laufzeitleistung entscheidend wird. Auf diese spezifische Herausforderung werden wir im Verlauf des Artikels näher eingehen.

Die Ressourcennutzung bringt ihre eigenen Herausforderungen mit sich – CPU-Drosselung in Container-Umgebungen kann Javas Fähigkeit zur Code-Optimierung beeinträchtigen, während Memory-Swapping die Leistung erheblich beeinträchtigen kann, wenn es nicht richtig verwaltet oder deaktiviert wird. I/O-Beschränkungen können besonders problematisch für Java-Anwendungen werden, die stark auf Dateioperationen oder Netzwerkkommunikation angewiesen sind, da Container-Limitierungen für die Anwendung nicht sofort erkennbar sein können. Der traditionelle “Fat JAR”-Ansatz, der häufig in Java-Anwendungen verwendet wird, führt zu größeren Container-Images, was die Deployment-Zeiten und den Ressourcenverbrauch erhöht. Container-Images werden unter Verwendung von Layers erstellt. Layered JAR-Dateien trennen die Anwendung und ihre Abhängigkeiten, sodass jeder Teil in einem dedizierten Container-Image-Layer gespeichert werden kann. Dies hat den Vorteil, dass die gecacheten Layer während des Builds der Anwendung wiederverwendet werden können, was den Rebuild des Container-Images erheblich beschleunigt. Dies kann auch Auswirkungen auf die Startzeit haben, wenn Technologien wie Seekable OCI (SOCI) verwendet werden. SOCI ist eine von AWS als Open Source bereitgestellte Technologie, die es Containern ermöglicht, durch verzögertes Laden des Container-Images schneller zu starten. SOCI funktioniert durch Erstellung eines Index (SOCI Index) der Dateien innerhalb eines existierenden Container-Images. Dieser Index ist ein wichtiger Enabler für schnelleres Container-Starten und bietet die Möglichkeit, eine einzelne Datei aus einem Container-Image zu extrahieren, bevor das gesamte Archiv heruntergeladen wird.

Darüber hinaus kann das Garbage-Collection-Verhalten von Java unerwartete Pausen verursachen, die möglicherweise die Health-Checks der Container-Orchestratoren verletzen und zu unnötigen Pod-Neustarts führen. Diese Herausforderungen werden in Microservices-Architekturen noch ausgeprägter, wo mehrere Java-Container um Ressourcen auf demselben Host konkurrieren können, was ein sorgfältiges Tuning sowohl der JVM-Parameter als auch der Container-Ressourcenlimits erfordert, um optimale Leistung und Zuverlässigkeit zu erreichen.

Bessere Leistung mit CRaC und Warp

Coordinated Restore at Checkpoint (CRaC), ein innovatives OpenJDK-Projekt unter der Führung von Azul, stellt einen bedeutenden Durchbruch bei der Bewältigung der bekannten Startup-Zeit-Herausforderungen von Java dar. Durch das Erfassen des Zustands einer aufgewärmten Java-Anwendung und JVM zu einem beliebigen Zeitpunkt (‘Checkpoint’) ermöglicht CRaC Anwendungen, von diesem gespeicherten Zustand aus neu zu starten und damit den traditionellen zeitaufwändigen Initialisierungsprozess zu umgehen. Diese Checkpoint-Fähigkeit, die als zusätzlicher Schritt in Container-Image-Builds integriert werden kann, verbessert die Startup-Performance dramatisch, indem die Anwendung sofort in ihren optimierten Zustand zurückversetzt wird. Diese leistungsstarke Funktion bringt jedoch wichtige Überlegungen mit sich – Checkpoint-Dateien können sensible Daten enthalten, und zustandsbehaftete Elemente wie File-Handles und Netzwerkverbindungen erfordern eine sorgfältige Behandlung während der Wiederherstellung. Während Spring Boot native Unterstützung für CRaC bietet, können externe Bibliotheken zusätzliche Implementierungsarbeit erfordern. Beispielsweise müssen Anwendungen, die das AWS SDK für Java V2 verwenden, eigene Logik implementieren, um Verbindungen nach der Wiederherstellung neu aufzubauen. CRaC adressiert diese Herausforderungen durch sein Resource-Interface, das beforeCheckpoint() und afterRestore() Callbacks bereitstellt, wodurch Entwickler die Zustandsspeicherung und -wiederherstellung über ihre Anwendungskomponenten hinweg effektiv verwalten können.
AWS hat eine Demo-Anwendung entwickelt, um die Verwendung von CRaC in Kombination mit dem AWS SDK für Java zu demonstrieren. Die in dieser Implementierung verwendete Anwendung ‘UnicornStore‘ interagiert mit Amazon EventBridge durch das AWS SDK. Zunächst wird ein Client erstellt, der dann für Operationen auf EventBridge verwendet wird. Jeder Client verwaltet seinen eigenen HTTP-Verbindungspool. Um den Checkpoint zu erfassen, müssen die Verbindungen im Pool (Netzwerkverbindungen) geschlossen werden – dies wird erreicht, indem der Client in der beforeCheckpoint()-Methode geschlossen und in afterRestore() neu erstellt wird. Der folgende Code-Ausschnitt zeigt, wie die UnicornPublisher-Klasse angepasst wurde, um CRaC-Anforderungen für Netzwerkverbindungen durch das org.crac.Resource-Interface zu handhaben:

public class UnicornPublisher implements Resource {
    ...
    @PostConstruct
    public void init() {
        createClient();
        Core.getGlobalContext().register(this);
    }

    @Override
    public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
        logger.info("Executing beforeCheckpoint...");
        closeClient();
    }

    @Override
    public void afterRestore(Context<? extends Resource> context) throws Exception {
        logger.info("Executing afterRestore ...");
        createClient();
    }

    private void createClient() {
        logger.info("Creating EventBridgeAsyncClient");

        eventBridgeClient = EventBridgeAsyncClient
                .builder()
                .credentialsProvider(DefaultCredentialsProvider.create())
                .build();
    }

    public void closeClient() {
        logger.info("Closing EventBridgeAsyncClient");
        eventBridgeClient.close();
    }
    ...
}

Spring verfügt seit Version 6.1 über eine integrierte CRaC-Unterstützung (und Spring Boot seit Version 3.2), was unter anderem bedeutet, dass CRaC in den Spring Lifecycle integriert ist (weitere Informationen können hier gefunden werden).
Mit diesem Ansatz ist es möglich, ausschließlich den Framework-Code zu snapshotten, nicht aber den Anwendungscode. Die Konsequenz dieses Ansatzes ist natürlich, dass keine Änderungen an der Anwendung notwendig sind, wenn nur Spring Boot-Funktionalitäten verwendet werden. Bei der automatischen Checkpointing-Methode haben wir keine vollständig aufgewärmte JVM, die gesnapshottet wird, was bedeutet, dass die Startzeit im Vergleich zum manuellen Snapshotting-Ansatz etwas langsamer ist. Das folgende Dockerfile zeigt einen kompakten Multi-Stage-Build-Ansatz zur Erstellung eines Container-Images, das einen CRaC-Snapshot verwendet.

FROM azul/zulu-openjdk:21-jdk-crac-latest AS builder
RUN apt-get -qq update && apt-get -qq install -y curl maven
ARG SPRING_DATASOURCE_URL
ENV SPRING_DATASOURCE_URL=$SPRING_DATASOURCE_URL
ARG SPRING_DATASOURCE_PASSWORD
ENV SPRING_DATASOURCE_PASSWORD=$SPRING_DATASOURCE_PASSWORD

COPY ./pom.xml ./pom.xml
COPY src ./src/

# Build the application
RUN mvn clean package -ntp && mv target/store-spring-1.0.0-exec.jar store-spring.jar

# Run the application and take a checkpoint
RUN <<END_OF_SCRIPT
#!/bin/bash
java -Dspring.context.checkpoint=onRefresh -Djdk.crac.collect-fd-stacktraces=true \
    -XX:CRaCEngine=warp -XX:CPUFeatures=generic -XX:CRaCCheckpointTo=/opt/crac-files -jar /store-spring.jar & PID=$!
wait $PID || true
END_OF_SCRIPT

FROM azul/zulu-openjdk:21-jdk-crac-latest AS runner
RUN apt-get -qq update && apt-get -qq install -y adduser
RUN addgroup --system --gid 1000 spring
RUN adduser --system --disabled-password --gecos "" --uid 1000 --gid 1000 spring

COPY --from=builder --chown=1000:1000 /opt/crac-files /opt/crac-files
COPY --from=builder --chown=1000:1000 /store-spring.jar /store-spring.jar

USER 1000:1000
EXPOSE 8080

# Restore the application from the checkpoint
CMD ["java", "-XX:CRaCEngine=warp", "-XX:CRaCRestoreFrom=/opt/crac-files"]

Der Containerisierungsprozess für CRaC-fähige Java-Anwendungen folgt einem mehrstufigen Build-Ansatz, der Azuls zulu-openjdk mit CRaC-Unterstützung als Grundlage sowohl für Builder- als auch Runner-Stages nutzt. In der ersten Builder-Stage kompiliert und verpackt der Prozess die UnicornStore-Anwendung in eine JAR-Datei und führt dann die Anwendung aus, um einen Checkpoint ihres eingelaufenen Zustands zu erstellen. Dieser entscheidende Schritt erfasst den optimierten Laufzeitzustand der JVM und Anwendung. Die zweite Stage etabliert eine saubere Laufzeitumgebung, in der sowohl die Checkpoint-Dateien als auch die Anwendungs-JAR aus der Builder-Stage kopiert und mit entsprechenden Berechtigungen für den unprivilegierten ‘spring‘-Benutzer konfiguriert werden, um Best Practices für die Sicherheit zu gewährleisten. Schließlich wird der Container so konfiguriert, dass er die Anwendung beim Start direkt aus den Checkpoint-Dateien wiederherstellt, was eine schnelle Initialisierung ermöglicht, indem die traditionelle JVM-Aufwärmphase übersprungen und eine nahezu sofortige Anwendungsbereitschaft erreicht wird.
Das Shell-Skript startet die Anwendung mit den JVM-Optionen -Dspring.context.checkpoint=onRefresh und -XX:CRaCCheckpointTo=/opt/crac-files/. Wie bereits erwähnt, wird der Checkpoint automatisch beim Start während der LifecycleProcessor.onRefresh-Phase erstellt. Mit dem Parameter -XX:CRaCEngine=warp haben wir eine spezifische Engine namens Warp festgelegt. Warp ist eine neue Engine, die in Azul Zulu Builds verfügbar ist und CRIU (Checkpoint/Restore In Userspace) vollständig ersetzen kann und keine zusätzlichen Berechtigungen benötigt. Das bedeutet, es ist nicht notwendig, zusätzliche Berechtigungen für das Kubernetes-Deployment hinzuzufügen, und ein zweiter Vorteil ist, dass Warp für die Demo-Anwendung schneller ist als die CRIU-basierte Engine. Mehr Informationen über Warp können in folgendem Blogpost gefunden werden.

Kubernetes-natives Java mit Quarkus

Quarkus, entwickelt als Kubernetes-natives Java-Framework, bietet eine leistungsstarke Grundlage für containerisierte Anwendungen auf Amazon EKS. Seine Container-First-Philosophie führt zu deutlich schnelleren Startzeiten und geringerem Speicherverbrauch im Vergleich zu traditionellen Java-Anwendungen. In Kombination mit Mandrel, Red Hats Downstream-Distribution von GraalVM, können Entwickler ihre Quarkus-Anwendungen zu nativen ausführbaren Dateien kompilieren, die in Millisekunden starten und minimalen Speicher verbrauchen – Eigenschaften, die besonders in containerisierten Umgebungen wertvoll sind. Auf Amazon EKS ermöglichen diese nativen Ausführungsdateien eine effizientere Ressourcennutzung, schnellere Skalierungsoperationen und reduzierte Kosten, da mehr Container auf jedem Node untergebracht werden können. Mandrels Kompatibilität mit Quarkus gewährleistet einen stabilen und unterstützten Weg zur nativen Kompilierung bei gleichzeitigem Zugriff auf wichtige AWS-Dienste durch Quarkus-Erweiterungen. Diese Kombination liefert einen produktionsreifen Stack für moderne, cloud-native Java-Anwendungen, die die Orchestrierungsfähigkeiten von Amazon EKS vollständig nutzen und dabei den traditionellen Overhead von Java in Containern minimieren.

Der folgende Abschnitt zeigt, wie ein Quarkus-Projekt mit quarkus-maven-plugin erstellt werden kann:

mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.amazon \
-DprojectArtifactId=quarkus-eks \
-DclassName="com.example.GreetingResource" \
-Dextensions="quarkus-container-image-jib,quarkus-kubernetes"

Die quarkus-kubernetes-Erweiterung hilft bei der automatischen Generierung von Kubernetes-Manifesten. Im nächsten Schritt können wir die Anwendung als JVM-basiertes Container-Image mit Jib paketieren:

mvn package -Dquarkus.container-image.build=true \ 
-Dquarkus.container-image.name=quarkus-eks \
-Dquarkus.container-image.tag=latest \
-Dquarkus.container-image.registry=<your-ecr-repo> \
-Dquarkus.container-image.push=true \
-Dquarkus.container-image.group=javapro

Nachdem wir das Image erstellt haben, können wir es in die Amazon Elastic Container Registry (ECR) hochladen, den Image-Pfad in der generierten Kubernetes-YAML-Datei ändern und durch Anwenden der Kubernetes-YAML-Dateien auf EKS deployen:

kubectl apply -f target/kubernetes/kubernetes.yml
kubectl rollout status deployment quarkus-eks

Durch die Kompilierung der Anwendung zu einer nativen ausführbaren Datei erreichen wir schnellere Kaltstarts und geringeren Speicherverbrauch, ideal für Autoscaling in Kubernetes. Zunächst müssen wir unsere Anwendung mit dem in pom.xml definierten Native-Profil bauen:

mvn package -Dnative

Jetzt können wir das native Image bauen und in ECR pushen:

docker build -f src/main/docker/Dockerfile.native -t <your-container-image>:latest .
docker push <your-container-image>:latest

Und schließlich das Kubernetes-Deployment-Manifest aktualisieren und anwenden.

Performance Resultate

Bei AWS haben wir einen umfassenden “Java on AWS”-Immersion Day entwickelt, der Entwicklern dabei helfen soll, sich in der vielfältigen Landschaft von Java-Deployments in Cloud-Umgebungen zurechtzufinden. Durch den hands-on Ansatz des Workshops konzentriert sich dieser besonders darauf, verschiedene Ansätze für den Betrieb von Java-Workloads auf AWS-Infrastruktur zu demonstrieren und dabei gängige Herausforderungen und moderne Lösungen zu behandeln. Eine wichtige Komponente des Programms befasst sich mit Container-Optimierungstechniken. Dieser Abschnitt untersucht verschiedene Strategien zur Verbesserung von Java-Anwendungen in containerisierten Umgebungen und hilft Entwicklern dabei, eine bessere Ressourcennutzung, schnellere Startzeiten und effizientere Deployments zu erreichen, während gleichzeitig die Anwendungsleistung und Zuverlässigkeit gewährleistet bleiben.
Unsere Optimierungsreise begann mit einem unmodifizierten Container-Image als Ausgangspunkt. Von dort aus haben wir schrittweise verschiedene Optimierungstechniken implementiert, um die Leistung zu verbessern. Wir nutzten Jlink und Jdeps zur Erstellung einer maßgeschneiderten Laufzeitumgebung, integrierten Jib für verbesserte Container-Builds, implementierten Class Data Sharing (CDS) durch Archiv-Erstellung, setzten CRaC für Container-Snapshots ein und integrierten schließlich die GraalVM Native-Kompilierung. Jeder dieser Schritte wurde gründlich auf Amazon EKS getestet, wobei die Messungen sich auf zwei kritische Metriken konzentrierten: die resultierende Image-Größe und die Anwendungsstartzeit.

VersionImage-GrößeStartzeit (p99)
Keine Optimierung351MB6.459s
Custom JRE221MB6.019s
Jib231MB5.71s
CDS633MB3.194s
GraalVM460MB0.581s
CRaC412MB0.085s
Vergleich der Optimierungen

Aus der Tabelle können wir sehr deutlich erkennen, dass verschiedene Optimierungen zu unterschiedlichen Ergebnissen führen. Custom JRE und Jib zeigen eine signifikante Reduzierung der Container-Image-Größe. Bei den Startzeiten stechen GraalVM und CRaC mit weniger als einer Sekunde aus bekannten Gründen deutlich hervor.

Zusammenfassung

Da die Containerisierung für die moderne Anwendungsentwicklung immer wichtiger wird, stehen Java-Entwickler vor besonderen Herausforderungen bei der Optimierung ihrer Anwendungen für Cloud-Umgebungen. Dieser umfassende Leitfaden zeigt, wie Amazon EKS in Kombination mit modernsten Technologien wie CRaC, Quarkus und GraalVM die Java-Bereitstellung in der Cloud verändert. Um diese Fortschritte in Ihren eigenen Anwendungen zu nutzen, beginnen Sie damit, Ihre aktuelle Containerisierungsstrategie anhand unserer Benchmark-Ergebnisse zu evaluieren, erkunden Sie den EKS Auto Mode für vereinfachtes Cluster-Management und erwägen Sie die Implementierung von CRaC oder Quarkus in Kombination mit GraalVM für Anwendungen, die schnelle Startzeiten erfordern.

Total
0
Shares
Previous Post

Entwicklung, Ausführung und Optimierung von Quarkus Web-Anwendung auf AWS Lambda

Next Post

Agile, Scrum, Kanban – und die Märchen, die wir uns über Wertschöpfung erzählen

Related Posts