Nachhaltige Softwareentwicklung in Java – Native Builds und serverlose Deployments auf Kubernetes

Marius Stein and Vishal Shanbhag

Rechenzentrums-Migration – Im IT-Umfeld, insbesondere in großen Unternehmen, ist der Begriff Rechenzentrums-Migration (Data Center Migration) weit verbreitet. In vielen Fällen liegen bereits praktische Erfahrungen mit der Migration in die Cloud vor. Solche Projekte wurden schon lange realisiert, noch bevor der Begriff Cloud-Rechenzentrum überhaupt geprägt wurde.

Kosteneinsparungen sind für die meisten Entscheidungsträger das Hauptmotiv. Doch es gibt noch einen weiteren entscheidenden Faktor — Nachhaltigkeit. Rechenzentren verbrauchen enorme Mengen an Strom. Im Jahr 2022 wurde ihr weltweiter Energieverbrauch auf 415 TWh geschätzt — nahezu so viel wie der gesamte Stromverbrauch Frankreichs (426 TWh). Und das war noch vor dem durch KI-Tools wie ChatGPT ausgelösten Boom. Der Verbrauch wird in Zukunft voraussichtlich weiter steigen.

Daher sind alle technologischen Ansätze, die den Energieverbrauch von Anwendungen reduzieren können, von großem Interesse. Gleichzeitig tragen solche Maßnahmen zur Senkung der Betriebskosten von Anwendungen bei.

Dieser Artikel untersucht, wie sich drei grundlegende Technologien — Java, Virtualisierung und Containerisierung — weiterentwickelt haben. Wir beginnen mit dem frühen Java und der Virtualisierung und nähern uns modernen Ansätzen wie GraalVM-Native-Builds und serverlosen Anwendungen auf Kubernetes. Wir zeigen, wie eine unternehmensreife Java-Anwendung von einem einfachen Kubernetes-Deployment zu einem nativen ausführbaren Programm als serverloser Container mit Knative überführt werden kann — optimiert für Kaltstarts unter einer Sekunde — und wie dieser Wandel zur Nachhaltigkeit beiträgt.

Geschichte – Die 2000er

Anfang der 2000er wurde Java als Sprache gefeiert, mit der sich portable Anwendungen erstellen ließen. Man konnte einmal Code schreiben und ihn in eine Java-Bytecode-Repräsentation kompilieren. Dieser Bytecode (auch bekannt als .class-Dateien) konnte in ein komprimiertes Dateiformat (.jar) gepackt und überall ausgeführt werden, sofern auf dem Zielsystem eine Java-Laufzeitumgebung (JRE) verfügbar war.

Dies bedeutete, dass Anwendungen unter Windows oder macOS entwickelt und gebaut werden konnten – Betriebssysteme, die bei Endnutzern größere Verbreitung fanden – während dasselbe Paket dennoch auf Linux- oder Unix-Systemen ausgeführt werden konnte. Voraussetzung dafür war lediglich, dass das Zielsystem über eine kompatible JRE verfügte.

Zusammen mit anderen Faktoren wie der Verfügbarkeit zahlreicher Bibliotheken sowie Sprachfunktionen im Bereich objektorientierter Programmierung und Speicherverwaltung konnte Java schnell weite Verbreitung finden. Entwickler konnten nun Datenmodelle in einer an die reale Welt angelehnten Terminologie beschreiben – dank objektorientierter Features. Komplexe Programme ließen sich schreiben, ohne dass man sich um jedes einzelne Byte an Speicher kümmern musste. Solange man sich an bewährte Speicherverwaltungspraktiken hielt, kümmerte sich das System um die Zuweisung und Freigabe von Speicher.

Obwohl Java Plattformunabhängigkeit bot, wurden Unternehmensanwendungen damals häufig auf physischen Servern nach dem „eine Anwendung pro Maschine“-Prinzip betrieben. Die Vorteile dieses Ansatzes liegen in der starken Isolation zwischen Anwendungen auf verschiedenen Servern sowie in der Möglichkeit, unterschiedliche Betriebssystem-Abhängigkeiten in separaten Anwendungen zu verwenden. Die offensichtlichen Nachteile waren mangelnde Skalierbarkeit und Ressourcenverschwendung. Wenn nur eine Anwendung auf einem physischen Server lief, konnten alle verfügbaren Ressourcen ausschließlich von dieser Anwendung genutzt werden. Ressourcen-Sharing war in diesem Szenario nicht einfach möglich.

Virtuelle Maschinen – JRE und Hypervisoren

Aber wie erreichte Java seine Plattformunabhängigkeit? Einfach gesagt: Eine Java-Anwendung interagiert nicht direkt mit dem Betriebssystem. Jede Interaktion mit dem OS wird durch die Java Virtual Machine (JVM) abstrahiert. Für Entwickler fühlt sich die JVM an wie eine echte Maschine. Sie bietet Zugriff auf Systemressourcen wie Dateisystem, CPU und Speicher über die Java-Bibliotheken. Betriebssystemspezifische Kenntnisse sind nicht nötig, solange man mit den Java-Systembibliotheken umgehen kann.

Java-Programme werden in eine plattformunabhängige, niedrigstufige Repräsentation – den Bytecode – kompiliert. Dieser Bytecode kann nicht direkt auf einem System ausgeführt werden. Stattdessen startet die JRE zur Laufzeit eine JVM, die weiß, wie sie die Bytecode-Instruktionen interpretieren muss.

An dieser Stelle kommt Just-In-Time-Compilation (JIT) ins Spiel. Nicht alle für eine Anwendung benötigten Bibliotheken sind im Bytecode enthalten. Eine Bibliothek wird erst beim ersten Aufruf dynamisch geladen und in den Speicher eingebunden.

Das bedeutet, dass ein Java-Paket eine lockere Zusammenstellung von Bytecode-Komponenten ist, die zur Laufzeit vom JIT-Compiler zusammengeführt und optimiert werden.

In Zeiten, in denen die Beschaffung von Hardware mehrere Monate dauern konnte, war diese Portabilität von Java hilfreich – Anwendungen konnten entwickelt werden, ohne sich auf eine bestimmte Hardware festzulegen. Solange das Rechenzentrum über freie Ressourcen verfügte, konnte die Anwendung dort bereitgestellt werden. Dadurch erlebte Java zwischen 2000 und 2010 eine rasante Verbreitung.

Eine weitere Technologie, die in diesem Zeitraum große Verbreitung fand, waren Hypervisoren. Hypervisoren basieren – genau wie Java – auf dem Prinzip der Virtualisierung, wenden es jedoch nicht auf Anwendungsebene, sondern auf Betriebssystem- und Kernel-Ebene an. Durch Virtualisierung konnte ein physischer Server in mehrere virtuelle Maschinen aufgeteilt werden, die sich CPU- und Speicherressourcen teilen – und so eine effizientere Ressourcennutzung ermöglichen.

Die Virtualisierung funktioniert, indem die Hardware-Schnittstelle eines physischen Servers durch eine Softwarekomponente – den Hypervisor – simuliert wird. Dieser stellt mehreren virtuellen Maschinen eine virtualisierte Hardwareumgebung bereit. Jede virtuelle Maschine besitzt ihren eigenen Kernel zur Interaktion mit dem virtualisierten Betriebssystem. Dadurch werden Prozesse auf verschiedenen virtuellen Maschinen streng voneinander getrennt, bei gleichzeitig effizienter Ressourcennutzung – mit einem Sicherheits- und Isolationsniveau, das dem von physischen Servern nahekommt. Dennoch bringt Virtualisierung einen gewissen Overhead mit sich, da jede VM ihr eigenes Betriebssystem und ihren eigenen Kernel betreibt, was Ressourcen kostet.

Auch auf Anwendungsebene verursacht Virtualisierung Overhead. Alles, was innerhalb einer JVM läuft, hat:

  • einen höheren Ressourcenverbrauch als ein nativ-kompiliertes Programm, da die JVM selbst Speicher benötigt.
  • langsamere Startzeiten, da auch die JVM erst initialisiert werden muss.
  • bei der ersten Ausführung einer Funktion meist schlechtere Performance als bei der zweiten, wegen der dynamischen JIT-Kompilierung.

Um diese JIT-bedingten Probleme zu umgehen, wurden Bibliotheken wie Enterprise Java Beans oder Spring eingeführt. Sie ermöglichten das Vorladen benötigter Bibliotheken über sogenannte „Beans“. Jede häufig genutzte Bibliothek wurde beim Start einmal in den Speicher geladen und war dann jederzeit verfügbar.

Das verbesserte die Ausführungszeiten nach dem Start, hatte aber auch Nachteile. Hauptsächlich führte es zu längeren Startzeiten, da zusätzlich zur JVM nun auch eine „Aufwärmphase“ nötig war, in der die Beans geladen wurden. Die Laufzeitperformance war jedoch überlegen, da kein zusätzlicher JIT-Overhead mehr anfiel. Alle zur Laufzeit benötigten Klassen waren bereits geladen.

Container, Serverless und GraalVM Native Images

Im Jahr 2013 popularisierte Docker eine Technologie namens Containerisierung, die seither die Art und Weise, wie Anwendungen entwickelt, ausgeliefert und bereitgestellt werden, revolutioniert hat. Anders als virtuelle Maschinen simulieren Container keine vollständige Hardwareumgebung. Stattdessen teilen sie sich den Kernel des Host-Betriebssystems, was eine leichtgewichtige und effiziente Isolation von Prozessen ermöglicht. Jeder Container enthält die Anwendung samt all ihrer Abhängigkeiten und Bibliotheken, was sicherstellt, dass sie in verschiedenen Umgebungen – von der Entwicklung bis zur Produktion – konsistent läuft.

Dieser Ansatz macht ein vollständiges Betriebssystem innerhalb jeder Instanz überflüssig, was zu schnelleren Startzeiten und geringerem Ressourcenverbrauch im Vergleich zu klassischen virtuellen Maschinen führt.

Container ermöglichen zudem eine höhere Skalierbarkeit und Flexibilität. Mit Orchestrierungstools wie Kubernetes können Unternehmen Tausende von Containern über Cluster hinweg verwalten und je nach Nachfrage dynamisch Ressourcen skalieren. Container-Technologie ist somit ideal für Microservice-Architekturen oder zellbasierte Architekturen, bei denen Anwendungen in kleinere, unabhängig deploybare Komponenten aufgeteilt werden. Darüber hinaus vereinfacht die Portabilität von Containern Entwicklungs-Workflows und ermöglicht es Entwicklern, sich auf das Schreiben von Code zu konzentrieren, ohne sich um Kompatibilitätsprobleme in unterschiedlichen Umgebungen sorgen zu müssen.

Eine weitere moderne Entwicklung in der Softwaretechnik ist das Aufkommen von Serverless Computing, einem Paradigma, das die Infrastrukturverwaltung abstrahiert und es Entwicklern erlaubt, sich ausschließlich auf das Schreiben und Bereitstellen von Code zu konzentrieren. Obwohl der Begriff “Serverless” je nach Kontext unterschiedlich verwendet wird, definieren wir ihn hier als Bereitstellungsmodell, das folgende Kriterien erfüllt:

  • Dynamische Skalierung: Eine Serverless-Anwendung skaliert automatisch entsprechend der Nachfrage und sorgt so für optimale Performance ohne manuelles Eingreifen.
  • Skalierung auf Null: Bei fehlender Nachfrage fährt sich die Serverless-Anwendung auf null herunter, wodurch keine Kosten für ungenutzte Ressourcen entstehen.
  • Abstraktion der Serververwaltung: Entwickler müssen sich nicht mehr um die zugrunde liegende Infrastruktur kümmern – also etwa um Provisionierung, Patch-Management oder das horizontale/vertikale Skalieren.

Durch dynamische Skalierung und das Herunterfahren auf Null lässt sich die Ressourcenauslastung signifikant verbessern. Anwendungen mit wenig oder keinem Traffic geben ihre zugewiesenen CPU- und Speicherressourcen frei, die dann für andere Workloads im Cluster zur Verfügung stehen. Bei steigender Last sorgt das Serverless-Modell durch horizontales Hochskalieren für Ausfallsicherheit – ohne dass Ressourcen überdimensioniert bereitgestellt werden müssen.

Während das Serverless-Paradigma ursprünglich durch Public-Cloud-Anbieter wie AWS Lambda, Azure Functions oder Google Cloud Functions geprägt wurde, hat es mittlerweile auch Einzug in On-Premises- und Hybrid-Umgebungen gehalten – durch Open-Source-Lösungen wie Knative oder OpenFaaS, die Serverless-Bereitstellungen auf Kubernetes-Clustern ermöglichen. Das erlaubt es Unternehmen, die bereits stark in Container-Orchestrierung investiert haben, Serverless-Prinzipien auch intern zu nutzen.

Allerdings bedeutet der Wechsel zum Serverless-Modell für Anwendungen, die im JIT-Paradigma entwickelt wurden, dass die Vorteile von JIT nicht mehr greifen – ja sogar kontraproduktiv werden können.

Zum einen erlaubt es die Containerisierung (z. B. mit Docker) dem Entwickler, die gesamte Anwendungsumgebung inklusive Betriebssystem zu kontrollieren – ohne sich um die zugrundeliegende Hardware kümmern zu müssen. Dadurch ist der JVM-Portabilitätsvorteil weitgehend irrelevant geworden, da man gezielt ein spezifisches Zielbetriebssystem innerhalb des Containers auswählen kann.

Zum anderen arbeitet Java immer noch innerhalb einer JVM – und somit bleibt die Startzeit der JVM ein realer Flaschenhals. Wenn man dann noch umfangreiche Bibliotheken wie Spring verwendet, die zur Laufzeit eine große Anzahl an Beans laden, werden Anwendungen zu schwergewichtig, um sie nach Bedarf starten und stoppen zu können. Startzeiten können je nach Komplexität mehrere Sekunden bis hin zu Minuten betragen.

Die Umstellung solcher monolithischer Anwendungen auf mehrere kleinere Microservices ist zwar längst Realität und wird von vielen Java-Entwickler:innen praktiziert – dennoch bleiben Startzeiten im Sub-Sekunden-Bereich eine Herausforderung.

Ganz so düster ist die Lage jedoch nicht – die Java-Community stellt sich der Herausforderung. Eine der Lösungen ist bereits historisch bewährt: In klassischen kompilierten Sprachen wie C oder C++ wird der Code direkt in native ausführbare Binärdateien für die Zielplattform übersetzt.

Im Gegensatz zur Java-Bytecode-Kompilierung erzeugen diese Sprachen direkt plattformabhängige Maschinencode-Binaries. Diese enthalten nicht nur den Programmcode selbst, sondern auch alle zugehörigen Bibliotheken, Systemfunktionen und Abhängigkeiten – alles in einer einzigen, optimierten Binärdatei verlinkt. Dieser Prozess wird als Ahead-of-Time (AOT) Compilation bezeichnet.

Bei AOT-Kompilierung sind alle für die Ausführung benötigten Bibliotheken bereits zur Kompilierzeit bekannt. Dadurch entfällt zur Laufzeit jegliche dynamische Klassennachladung oder Bytecode-Interpretation – was zu schnellerem Start und geringerem Ressourcenverbrauch führt.

Die Lösung für das Problem langer Start- und Aufwärmzeiten in Java-Anwendungen liegt also in der Rückbesinnung auf bewährte Prinzipien – kombiniert mit einer modernen Java-gerechten Umsetzung: und genau hier kommt GraalVM ins Spiel.

Ohne zu sehr in die technischen Details der AOT-Kompilierung einzutauchen, lässt sich sagen: GraalVM ist eine Technologieplattform, die genau das ermöglicht. GraalVM analysiert vorhandenen Java-Code statisch, erkennt alle genutzten Abhängigkeiten und generiert daraus eine native ausführbare Datei oder Bibliothek. Dabei werden alle Klassen aus dem Classpath und dem JVM-Laufzeitsystem aufgenommen, die tatsächlich benötigt werden. Alles andere wird weggelassen, was die resultierende Binärdatei deutlich schlanker und effizienter macht.

Eine so generierte native Binärdatei ist in vielen Fällen deutlich schneller startbereit als eine reguläre Java-Anwendung, die innerhalb der JVM ausgeführt wird. Denn es gibt keine JVM, die gestartet werden muss, keine Klassen, die dynamisch geladen werden, keine Just-in-Time-Kompilierung – alle Verknüpfungen sind bereits zur Compilezeit erfolgt, sodass die Anwendung beim Start direkt loslegen kann.

Natürlich hat auch dieser Ansatz gewisse Einschränkungen. Da GraalVM eine statische Analyse durchführt, kann es bei Programmen, die dynamische Funktionen wie Reflection, JNI (Java Native Interface) oder Serialisierung einsetzen, vorkommen, dass nicht alle Abhängigkeiten automatisch erkannt werden. In solchen Fällen müssen Entwickler konfigurationsseitig nachhelfen, um GraalVM mitzuteilen, welche weiteren Klassen oder Ressourcen zur Laufzeit verwendet werden sollen.

Die Dokumentation von GraalVM bietet hierfür detaillierte Werkzeuge und Hilfsmittel – diese technischen Feinheiten gehen jedoch über den Rahmen dieses Artikels hinaus.

Festzuhalten bleibt: Mit der Verfügbarkeit von Ahead-of-Time-Kompilierung durch GraalVM lassen sich Java-Anwendungen deutlich besser für die Cloud adaptieren – sei es zur Ausführung in Kubernetes-Umgebungen, in einem Knative-Setup oder sogar innerhalb von AWS Lambda.

Aspekt
JVM (z. B. HotSpot)
GraalVM Native Image
Startzeit
Langsamer (durch Klassenladen, JIT-Warm-up)
Sehr schnell (Ahead-of-Time kompiliert, nahezu sofortiger Start)
SpeicherverbrauchHöher (JIT-Overhead, Laufzeit-Metadaten)Geringer (minimale Laufzeit, aggressive Optimierungen)
Leistung (Spitzenwert)Höher (JIT-Optimierungen passen sich zur Laufzeit an)Gut, aber im Allgemeinen geringer als JVM bei lang laufenden Prozessen
Build-ZeitSchnell (Standard-Kompilierung)
Langsam (Erzeugung des Native Image ist komplex und zeitaufwändig)
AnwendungsgrößeKleiner (nur Bytecode und Abhängigkeiten)Größer (enthält statisch verlinkten nativen Code)
KompatibilitätBreit (unterstützt die meisten Java-Bibliotheken und -Funktionen)
Eingeschränkt (dynamische Features und Reflection benötigen Konfiguration)
Warm-up-VerhaltenErfordert Warm-up, um Spitzenleistung zu erreichenKein Warm-up erforderlich
Eignung für DeploymentIdeal für lang laufende Dienste
Ideal für kurzlebige/serverless oder „scale-to-zero“-Anwendungen

Im weiteren Verlauf dieses Artikels zeigen wir, wie eine unternehmensreife Spring Boot Java-Anwendung auf Kubernetes in eine serverlose Anwendung mit Knative überführt werden kann. Wir erklären, in welchen Szenarien eine solche Migration sinnvoll ist und welche Schritte notwendig sind. Zudem gehen wir auf häufige Stolpersteine ein und wie man sie umgeht. Die verwendete Demo-Anwendung ist auf GitHub verfügbar: https://github.com/stein-solutions/java-knative-demo

Die Demo-Anwendung

Unsere Demo-Anwendung ist eine Java Spring Boot-Anwendung mit einer einfachen REST-API, mit der Produkt- und Kategorie-Entitäten verwaltet werden können. Obwohl die Anwendung zustandslos ist (sie speichert keinen Zustand auf einem physischen Laufwerk), initialisiert sie eine H2-In-Memory-Datenbank mit Produkt- und Kategorietabellen über JPA. Dies simuliert realitätsnahe Startzeiten. In realen Anwendungen würde H2 durch persistente Datenbanken wie MySQL oder PostgreSQL ersetzt werden.

Darüber hinaus bietet die Anwendung Metriken im Prometheus-Format zur Integration in ein Cloud-natives Monitoring. Diese Metriken können regelmäßig durch Prometheus abgefragt werden. Die nötige Prometheus-Konfiguration wird mithilfe des Kubernetes Prometheus Operators und der ServiceMonitor Custom Resource automatisch angewendet.Die Anwendung wird als Deployment auf Kubernetes ausgerollt und ist über einen HTTP-Endpunkt erreichbar, der über eine Kubernetes Ingress-Resource konfiguriert wird. Eine automatische Skalierung ist für die Demo-Anwendung nicht aktiviert.

Erstellung eines nativen Builds

Nach Installation der erforderlichen GraalVM-Abhängigkeiten kann ein nativer Build einer Spring Boot-Anwendung einfach erstellt werden. Aktuelle Spring Boot-Versionen bringen bereits ein natives Maven-Profil und das Plugin native-maven-plugin mit. Mit folgendem Befehl erstellen wir ein GraalVM-Native-Binary unserer Anwendung:

mvn -Pnative native:compile

Das Ergebnis ist ein Ahead-of-Time (AOT) kompiliertes Binärprogramm, das direkt auf dem Host System lauffähig ist. Bibliotheken, die Reflections oder JNI nutzen, müssen diese Aufrufe registrieren. Falls das nicht geschieht (z. B. bei H2), kann das Community-Projekt graalvm-reachability-metadata (https://github.com/oracle/graalvm-reachability-metadata) helfen. Wo dies nicht verfügbar ist, müssen Entwickler selbst sogenannte Runtime-Hints angeben, etwa bei dynamischem Code wie bei Project Lombok.

Dockerisierte Builds und Ausführungsumgebungen

Als Nächstes dockerisieren wir das native Executable. Wir empfehlen die Verwendung eines Multistage-Builds, bei dem wir in einem ersten Schritt das native Executable erstellen und im zweiten Schritt das endgültige Container-Image haben, das nur das erstellte Executable und möglicherweise seine Abhängigkeiten enthält. Für den Build-Schritt empfehlen wir, ein von GraalVM bereitgestelltes Basis-Image zu verwenden. Dieses Basis-Image enthält bereits alle erforderlichen Abhängigkeiten, um erfolgreich ein natives Executable unserer Anwendung zu erstellen. Wir müssen den Container-Bauprozess nur anweisen, das native Executable zu erstellen, indem wir das oben erwähnte Maven-Ziel aufrufen. Der vollständige Build-Schritt sieht wie folgt aus:

FROM ghcr.io/graalvm/native-image-community:23 AS builder


WORKDIR /build


# Copy the source code into the image for building
COPY . /build


# Build
RUN ./mvnw --no-transfer-progress -e native:compile -Pnative


RUN chmod +x /build/target/demo

Der zweite Schritt unseres Container-Bauprozesses bereitet die Laufzeitumgebung für unser natives Executable vor. Wenn wir ein Basis-Image verwenden, das dieselben OS-Level-Abhängigkeiten wie das Basis-Image des Build-Schritts bietet, müssen wir nur das erstellte Executable kopieren und einen Entrypoint-Befehl definieren, der dem Container-Runtime mitteilt, was beim Starten des Containers ausgeführt werden soll. In diesem Beispiel verwenden wir „ubuntu:jammi“ als Basis-Image. Es ist außerdem eine gute Praxis, hier die Ports zu definieren, auf denen unsere Anwendung hört. Dies beeinflusst jedoch nicht direkt, wie der Container ausgeführt wird und dient hier nur beschreibenden Zwecken. Das vollständige Dockerfile sieht wie folgt aus:

FROM ubuntu:jammy


# Copy the native executable into the containers
COPY --from=builder /build/target/demo /usr/local/bin/app


EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/app"]

Das resultierende Container-Image ist etwa 220 MB groß.

Optimierung durch statische Verlinkung

Das native Executable enthält noch viele nicht benötigte Betriebssystem-Bibliotheken durch die Verwendung von ubuntu:jammy als Basis-Image. Wir können die Dateigröße weiter reduzieren, indem wir ein vollständig statisch verlinktes Executable unserer Anwendung erstellen. GraalVM unterstützt dies direkt, wir müssen nur die Parameter --static und --libc=musl zum Build-Befehl hinzufügen, und das resultierende Executable wird mit musl als Standard-C-Compiler-Bibliothek verlinkt.

Es empfiehlt sich, ein separates Maven-Profil für statisch verlinkte Builds zu erstellen. Der folgende Code zeigt, wie das geht:

   <profiles>
       <profile>
           <id>nativelinked</id>
           <build>
               <plugins>
                   <plugin>
                       <groupId>org.graalvm.buildtools</groupId>
                       <artifactId>native-maven-plugin</artifactId>
                       <configuration>
                           <buildArgs combine.children="append">
                               <buildArg>--verbose</buildArg>
                               <buildArg>--static</buildArg>
                               <buildArg>--libc=musl</buildArg>
                           </buildArgs>
                       </configuration>
                   </plugin>
               </plugins>
           </build>
       </profile>
   </profiles>

Dieser Code erweitert die Konfiguration des native-maven-plugin, um die erforderlichen Parameter hinzuzufügen. Um den Build auszuführen, führen wir mvn -Pnative,nativelinked native:compile aus. Es ist wichtig zu beachten, dass der obige Befehl auf ARM-Macs fehlschlagen wird. Das resultierende Executable enthält alle erforderlichen Abhängigkeiten, um auf einer bestimmten CPU-Architektur ausgeführt zu werden.

Mit allen OS-Level-Abhängigkeiten, die in das statische Executable eingebunden sind, können wir das Basis-Image für den Anwendungs-Container entfernen und stattdessen das Container-Image von Grund auf neu erstellen. Das resultierende Docker-Image enthält nur unser Executable und keine anderen Dateien oder Ordner. Damit der eingebettete Tomcat innerhalb von Spring Boot funktioniert, müssen wir allerdings noch ein /tmp-Verzeichnis erstellen. Da unser Scratch-Basis-Image jedoch keine Tools zum Erstellen von Verzeichnissen hat, kopieren wir einfach ein leeres Verzeichnis aus dem Build-Schritt.

FROM ghcr.io/graalvm/native-image-community:23-muslib AS builder


WORKDIR /build


# Copy the source code into the image for building
COPY . /build


# Build
RUN ./mvnw --no-transfer-progress -e native:compile -Pnative,nativelinked


RUN chmod +x /build/target/demo
RUN mkdir /custom-tmp-dir


# The deployment Image
FROM scratch


EXPOSE 8080


# Copy the native executable into the containers
COPY --from=builder /build/target/demo /usr/local/bin/app
# Spring embedded Tomcat fails to start if /tmp is not present
COPY --from=builder /custom-tmp-dir /tmp


ENTRYPOINT ["/usr/local/bin/app"]

Deployment auf Kubernetes mit Knative

Knative ist eine Open-Source-Lösung für Kubernetes, die die Bereitstellung von serverlosen Anwendungen ermöglicht, die auf Kubernetes auf Null skalieren können. Um eine Anwendung auf einem Knative-aktivierten Kubernetes-Cluster bereitzustellen, kann die folgende YAML-Konfiguration verwendet werden:

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: test-java-knative-demo
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/window: "6s"  # Set a custom stable window
    spec:
      automountServiceAccountToken: false
      containers:
        - image: "ghcr.io/stein-solutions/java-knative-demo:nativelinked-f5b208448747baf69e9afe06f0cac1d0b86a265e" 
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          resources:
            limits:
              cpu: 125m
              memory: 256Mi
            requests:
              cpu: 125m
              memory: 256Mi

Dies erstellt einen Knative-Dienst, der dynamisch basierend auf der Anzahl der HTTP-Anfragen skaliert, die an die Anwendung gesendet werden. Wenn keine HTTP-Anfragen an unsere Anwendung gerichtet sind, wird Knative die Anwendung auf Null Replikate skalieren, wodurch alle CPU- und Speicherkapazitäten freigegeben werden. In unserer Konfiguration ist der Zeitraum, nach dem der Knative skaliert, wenn keine Anfragen empfangen werden, auf 6 Sekunden festgelegt (der minimale mögliche Zeitraum). In Produktionsumgebungen ist es wahrscheinlich sinnvoll, einen längeren Zeitraum zu wählen, um Kaltstarts zu vermeiden.

Kaltstarts – das Übel der serverlosen Architektur und wie man es löst

Für serverlose Anwendungen sind akzeptable Kaltstartzeiten eine Notwendigkeit. Wenn man benutzerorientierte APIs betreibt, sind Antwortzeiten von mehreren zehn Sekunden inakzeptabel. Hier kommen native Builds ins Spiel.

„Normale“ (also nicht-native) Spring Boot-Anwendungen haben relativ langsame Startzeiten. Während wir unsere Demo-Anwendung auf einem M2 Pro MacBook ausführen, dauert der Start der Anwendung etwa 1,6 Sekunden, jedoch überschreitet die Startzeit in ressourcenbeschränkten Umgebungen leicht 10 Sekunden und mehr. Wenn wir die verfügbaren CPUs für unsere containerisierte Spring Boot-Anwendung auf 200 Milicores (20% eines CPU-Kerns) begrenzen, benötigt die Anwendung rund 30 Sekunden, bis sie mit Knative vollständig gestartet ist. Auch bei einer vollen CPU dauert es noch etwa 10 Sekunden, und wenn keine CPU-Nutzung eingeschränkt wird (auf einem Azure Standard_D2s_v3 Worker Node – 2 verfügbare CPUs), erreichen wir Startzeiten von rund 5 Sekunden.

Das Problem bei der Zuweisung einer vollständigen CPU oder mehr an unsere Demo-Anwendung ist, dass diese Ressourcen für den gesamten Lebenszyklus der Anwendung blockiert werden und nicht nur für den Start der Anwendung, wenn sie benötigt werden. Nach dem Start benötigt die Anwendung nur etwa 200 Milicores oder weniger, um HTTP-Anfragen zu beantworten, und während der Leerlaufzeit muss praktisch keine Ressource für unsere Anwendung reserviert werden. Allerdings blockiert unsere Anwendung weiterhin 100% des angeforderten CPU-Kerns.

Um das Problem der Ressourcenüberversorgung zu lösen, bietet Kubernetes die Möglichkeit, Ressourcenerfordernisse und -limits für Container zu konfigurieren. Ressourcenerfordernisse definieren die minimal garantierten CPU- und Speicherressourcen, auf die ein Container zugreifen kann, um Stabilität und Leistung zu gewährleisten. Limits hingegen setzen ein Maximum an Ressourcen, das ein Container verwenden kann, um zu verhindern, dass er mehr als seinen zugewiesenen Anteil verbraucht und andere Workloads beeinträchtigt.

Durch die Festlegung eines niedrigeren CPU-Anforderungswerts (z.B. 200 Millicores) und eines höheren CPU-Limits (z.B. 1 voller Kern) für unsere Spring Boot-Anwendung können wir ein Gleichgewicht finden. Diese Methode stellt sicher, dass die Anwendung während des Starts ausreichend Ressourcen erhält, während der Leerlaufressourcenverbrauch verringert wird. Die Anwendung kann bei Bedarf vorübergehend zusätzliche Ressourcen nutzen, ohne diese dauerhaft für andere Workloads zu blockieren. Ein wesentlicher Nachteil dieser Methode besteht darin, dass der Anwendung kein vollständiger CPU-Kern garantiert zur Verfügung steht, wenn nicht genügend CPU-Kapazität vorhanden ist; sie erhält in diesem Fall lediglich den zugesicherten Minimalwert von 200 Millicores. Zudem bleiben diese 200 Millicores auch während inaktiver Phasen für die Anwendung reserviert und stehen somit anderen Anwendungen nicht zur Verfügung.

Durch die Optimierung der Ressourcenzuweisung mit Kubernetes und die dynamische Anpassung von CPU-Anforderungs- und -Limitwerten können wir die Notwendigkeit der Überversorgung verringern, wodurch die Anzahl der in unserem Cluster benötigten CPUs begrenzt wird. Diese Methode unterstützt eine nachhaltigere IT-Infrastruktur, indem sie den Umwelteinfluss durch die Aufrechterhaltung überschüssiger Hardware minimiert.

Wir haben einige Tests mit unserer Demo-Anwendung durchgeführt, die über Knative auf einem Azure Kubernetes-Cluster bereitgestellt wurde. Als Worker-Nodes verwendeten wir Azure Standard_D2s_v3-Instanzen mit 2 Kernen und 6 GB RAM. Die folgende Tabelle zeigt Metriken zu der Request-Duration und App-Startzeiten bei Kaltstarts. Für jede Konfiguration, wie sie durch CPU und Speicherzuweisung definiert ist, haben wir 80 Kaltstarts gemessen. Alle Messungen hatten das Container-Image unserer Anwendung bereits auf dem jeweiligen Worker-Node heruntergeladen. Da nicht nur die Anwendung gestartet werden muss, sondern auch Knative die Anfrage zum gestarteten Pod weiterleiten muss, dauert die gesamte Anfrage länger als nur der einfache App-Start.

ConfigAvg. Req. DurationAvg. App StartMedian Req. Dur.Median App Start2 sigma Req. Dur.2 sigma App Start
50m / 128Mi3.989s2.765s2.961s2.758s3.291s2.908s
75m / 128Mi2.013s1.887s2.005s1.877s2.170s1.993s
125m / 256Mi1.177s1.021s1.163s1.019s1.32s1.115s
250m / 256Mi1.002s808.958ms1.006s798.205ms1.079s924.783m
750m / 512Mi988.207ms575.536ms1.003s554.031ms1.085s692.777ms
1000m / 512Mi990.191ms530.500ms1.005s524.964ms1.060s594.912ms

Wenn der Anwendung ein vollständiger CPU-Kern zugewiesen wird, dauert ein Kaltstart im Durchschnitt 990 ms. Allerdings benötigt unsere Anwendung nur etwa 530 ms, um zu starten und bereit zu sein, Anfragen zu verarbeiten. Die verbleibende Zeit wird von der Knative-Activator-Komponente beansprucht, bevor die Anfrage an den tatsächlichen Anwendungspod weitergeleitet wird.

Eine bemerkenswerte Entdeckung war, dass wir die CPU- und Speicherzuweisung auf etwa 125 Millicores (⅛ eines CPU-Kerns) und 256 MB RAM reduzieren können, bevor sich die Antwortzeit der Anwendung signifikant verschlechtert. Mit dieser Konfiguration erreichen wir immer noch eine durchschnittliche Anfragedauer von 1,177 Sekunden – nur etwa 180 ms langsamer als mit einem vollen CPU-Kern. Allerdings ist die Startzeit der Anwendung in dieser Konfiguration deutlich höher. Die App benötigt hier im Schnitt 1,021 Sekunden zum Starten – fast 500 ms mehr als in der ersten Konfiguration. Dies verschafft uns als Anwendungsentwickler einen gewissen Spielraum: Ein App-Start von etwa einer Sekunde führt zu ähnlichen Antwortzeiten wie ein App-Start von etwa 500 ms.

Bei Konfigurationen mit 75 Millicores (1/16 eines CPU-Kerns) nehmen die Anfragen deutlich mehr Zeit in Anspruch. Dieser Anstieg in der Anfragedauer ist hauptsächlich auf die längere Startzeit der Anwendung zurückzuführen – der Overhead durch das Knative-Routing beträgt lediglich rund 100 ms.

Einschränkungen

Die Architektur hinter Knative erlaubt lediglich Autoscaling auf Basis von HTTP-Anfragen, nicht jedoch auf Basis von TCP-Verbindungen. Aus diesem Grund können WebSocket-Verbindungen zwar initialisiert werden, werden aber bei Skalierungsentscheidungen von Knative nicht berücksichtigt.

Zudem ist es für akzeptable Kaltstartzeiten notwendig, dass die Anwendung zustandslos (stateless) ist. Obwohl es technisch möglich ist, persistente Volumes einzubinden, wird davon abgeraten. Der Mounting-Prozess solcher Volumes dauert üblicherweise mehrere Sekunden, was zu inakzeptablen Kaltstartzeiten führt. Selbst das Mounten des Kubernetes-Service-Account-Tokens zur Anwendung erhöht die durchschnittliche Anfragedauer eines Kaltstarts um etwa 200 ms.

Aus Nachhaltigkeitsperspektive ist es notwendig, mehrere Anwendungen auf unserer Infrastruktur zu betreiben, bevor sich eine spürbare Verbesserung ergibt. Laut offizieller Dokumentation benötigt Knative selbst mindestens 6 CPUs und 6 GiB RAM, um auf einem Kubernetes-Cluster betrieben zu werden. Damit unser Setup nachhaltig ist, müssen also eine kritische Anzahl von Knative-Services im Cluster laufen.

Darüber hinaus sollte das Lastmuster, das von unserer Anwendung verarbeitet wird, volatil sein, sodass längere Zeiträume ohne Anfragen tatsächlich vorkommen. Knative spielt seine Stärken bei selten angefragten Anwendungen aus – je konstanter die Last ist, desto weniger Ressourcen können durch dieses Modell eingespart werden.

Ausblick

In diesem Artikel haben wir gezeigt, dass es möglich ist, zustandslose, unternehmensfähige Java-Anwendungen als serverlose Container auf Kubernetes zu betreiben. Die Kaltstartzeiten dieser Container sind mit denen anderer serverloser Technologien wie AWS Lambda vergleichbar. Dieses Vorgehen kann in bestimmten Lastszenarien zu nachhaltigeren IT-Systemen führen. Anwendungen, die nur selten aufgerufen werden, aber dennoch schnelle Reaktionszeiten erfordern, würden von dieser Architektur profitieren.

Wie bei allen IT-Architekturen gilt jedoch: Diese Lösung ist kein Allheilmittel für alle Anwendungen und Anforderungen.

References:

https://www.linkedin.com/pulse/managing-forecasting-your-cloud-consumption-prakash-a/

https://www.cesarsotovalero.net/blog/aot-vs-jit-compilation-in-java.html

https://www.statista.com/chart/32689/estimated-electricity-consumption-of-data-centers-compared-to-selected-countries/

https://www.graalvm.org/jdk21/reference-manual/native-image/dynamic-features/Reflection/

Anmerkung:
Der Artikel wurde im Original auf englisch verfasst und hier veröffentlicht.

Die vorliegende deutsche Version basiert auf einer maschinellen Übersetzung des englischen Originaltextes.

Total
0
Shares
Previous Post

Kino, Code, Community: Die JCON EUROPE 2025 setzt neue Maßstäbe für Java-Events

Next Post

Erstellen einer einfachen Datei-Up/Download-Anwendung mit Vaadin Flow

Related Posts