Artikelreihe
Dies ist der zweite von vier Artikeln, die verschiedene Aspekte der Auslieferung von Hilla-Anwendungen in Produktion beschreiben:
- Teil 1: Production-Build
- Teil 2: Docker-Images
- Teil 3: CI/CD
- Teil 4: Serverless Deployment
Deployment als Docker-Container
Das Deployment von Anwendungen findet heutzutage üblicherweise auf Basis von Containern statt. Dies gilt gleichermaßen für Deployments in öffentliche Clouds, wie auch in On-Premise-Cloud-Umgebungen. Ein Production-Build in Form einer ausführbaren JAR-Datei oder in Form eines ausführbaren Native Image eignet sich sehr gut für ein solches Deployment als Docker-Container. Ausgangspunkt ist in beiden Fällen ein entsprechendes Dockerfile
.
Docker-Image für JAR-Datei
Liegt der Production-Build als ausführbare JAR-Datei vor, kann ein passendes Docker-Image mit folgendem Dockerfile
beschrieben werden. Das Dockerfile
sollte im Basis-Verzeichnis des Hilla-Projektes liegen und folgenden Inhalt haben:
# Base image with Java runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
# Copy production build JAR into Docker image
COPY target/*.jar app.jar
# Expose port of Hilla app
EXPOSE 8080
# Start Hilla app on container startup
ENTRYPOINT ["java", "-jar", "app.jar"]
Das Dockerfile
enthält die erforderliche Java-Laufzeitumgebung, den Production-Build als JAR-Datei, die in das Docker-Image kopiert wird, und die Konfiguration für den Port, über den die Anwendung Anfragen erhalten kann. Der ENTRYPOINT
zeigt auf die JAR-Datei im Docker-Image, die beim Start des Containers ausgeführt wird.
Das Docker-Image kann lokal mit Docker oder Podman erstellt werden:
docker|podman build --tag my-app .
Der Befehl wird im Basis-Verzeichnis des Hilla-Projektes ausgeführt. Der .
zeigt dabei auf das selbige Verzeichnis und das darin befindliche Dockerfile
. Das Docker-Image ist anschließend lokal verfügbar und verwendbar.
Auf Basis des erstellten Docker-Images kann ein Docker-Container mit dem Production-Build der Hilla-Anwendung gestartet werden:
docker|podman run -it --rm --publish 8080:8080 my-app
Mit Hilfe des erstellten Dockerfile
oder des Docker-Images kann der Production-Build der Hilla-Anwendung bei vielen Cloud-Anbietern oder in einem unternehmensinternen Kubernetes-Cluster deployed werden.
Optimierungen
Eine ausführbare JAR-Datei ist praktisch für das Deployment, bringt aber auch gewisse Nachteile mit sich. Das Laden aller erforderlichen Klassen aus einer verzweigten JAR-Datei kann beim Start der Anwendung etwas Zeit in Anspruch nehmen, insbesondere bei größeren JAR-Dateien. Dies lässt sich umgehen, wenn man die JAR-Datei entpackt. Das Laden von Klassen aus einer entpackten JAR-Datei ist schneller und wird daher auch für das Deployment in Produktion empfohlen. Das Entpacken der JAR-Datei kann separat oder als Build-Stage im Dockerfile
stattfinden:
# First stage
FROM eclipse-temurin:21-jre AS build
WORKDIR /build
# Copy production build JAR into Docker image
COPY target/*.jar app.jar
# Extract JAR
RUN java -Djarmode=tools -jar app.jar extract --destination extracted
# Second stage
FROM eclipse-temurin:21-jre
WORKDIR /app
# Copy extracted lib folder and app.jar from build stage
COPY --from=build /build/extracted/lib lib
COPY --from=build /build/extracted/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Im Rahmen eines Tests mit einer einfachen Hilla-Beispielanwendung konnte ich durch diese Optimierung eine Reduzierung der Startzeit um durchschnittlich ca. 1 Sekunde feststellen. Ohne diese Optimierung lag die durchschnittliche Startzeit bei ca. 4 Sekunden, mit Optimierung bei ca. 3 Sekunden.
Das Entpacken der JAR-Datei des Production-Builds hilft auch bei der Erstellung möglichst effizienter Docker-Images mit unterschiedlichen Layern. Man geht dabei davon aus, dass sich die verschiedenen Bestandteile der JAR-Datei unterschiedlich oft ändern. Während sich der Code der Anwendung wahrscheinlich häufig ändert, kann man davon ausgehen, dass sich einzelne Abhängigkeiten nicht so oft ändern. Bestandteile, die sich nicht so oft ändern, sollten in unteren Layern eines Docker-Images enthalten sein. Bestandteile, die sich häufiger ändern, sollten sich in oberen Layern eines Docker-Images befinden. Auf diese Art muss Docker beim Laden des Docker-Images nur die aktualisierten Layer laden. Die bekannte Anweisung zum Entpacken der JAR-Datei des Production-Builds der Hilla-Anwendung kann um die zusätzliche Option --layers
erweitert werden. Dabei wird die Datei BOOT-INF/layers.idx
verwendet. Diese Datei wurde von Spring Boot während der Erstellung des Production-Builds erzeugt:
- "dependencies":
- "BOOT-INF/lib/"
- "spring-boot-loader":
- "org/"
- "snapshot-dependencies":
- "application":
- "BOOT-INF/classes/"
- "BOOT-INF/classpath.idx"
- "BOOT-INF/layers.idx"
- "META-INF/"
Die enthaltenen Informationen werden verwendet, um die einzelnen Bestandteile der JAR-Datei so zu verteilen, dass sie in einem passenden Dockerfile zu möglichst effizienten Layern zusammengefasst werden können:
# First stage
FROM eclipse-temurin:21-jre AS build
WORKDIR /build
# Copy production build JAR into Docker image
COPY target/*.jar app.jar
# Extract JAR using additional --layers option
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# Second stage
FROM eclipse-temurin:21-jre
WORKDIR /app
# Copy the extracted jar content from the build stage
# Every COPY step creates a new docker layer
# This allows docker to only pull the changes it really needs
COPY --from=build /build/extracted/dependencies/ ./
COPY --from=build /build/extracted/spring-boot-loader/ ./
COPY --from=build /build/extracted/snapshot-dependencies/ ./
COPY --from=build /build/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Eine weitere Option zur Reduzierung der Startzeit und des Speicherverbrauchs bietet das so genannte Class Data Sharing (CDS). Hierfür wird die Hilla-Anwendung nach dem Entpacken der JAR-Datei zu Trainingszwecken einmalig ausgeführt. Das Ergebnis dieses Trainingslaufs ist die Datei application.jsa
. Diese Datei enthält einen Dump der internen Repräsentation der beim Start der Hilla-Anwendung geladenen Klassen. Diese Datei kann wie ein Cache bei nachfolgenden Starts der gleichen Hilla-Anwendung verwendet werden. Auch diese Optimierung kann separat oder innerhalb des Dockerfile
stattfinden:
# First stage
FROM eclipse-temurin:21-jre AS build
WORKDIR /build
# Copy production build JAR into Docker image
COPY target/*.jar app.jar
# Extract JAR
RUN java -Djarmode=tools -jar app.jar extract --destination extracted
WORKDIR /build/extracted
# Perform a training run and dump classes to app.jsa
RUN java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar
# Second stage
FROM eclipse-temurin:21-jre
WORKDIR /app
# Copy extracted lib folder, app.jar and app.jsa from build stage
COPY --from=build /build/extracted/lib lib
COPY --from=build /build/extracted/app.jar app.jar
COPY --from=build /build/extracted/app.jsa app.jsa
EXPOSE 8080
# Use cache with extra parameter
ENTRYPOINT ["java", "-XX:SharedArchiveFile=app.jsa", "-jar", "app.jar"]
Im Rahmen eines weiteren Tests konnte ich durch diese Optimierung mit CDS eine zusätzliche Reduzierung der Startzeit um durchschnittlich ca. 1 Sekunde feststellen. Mit Hilfe der gezeigten Optimierungen ließ sich die durchschnittliche Startzeit der einfachen Hilla-Anwendung von ca. 4 Sekunden auf ca. 2 Sekunden reduzieren.
Spring Boot unterstützt CDS seit Version 3.3. CDS ist ein alternativer Ansatz zur Optimierung, wenn der Einsatz von Native Images nicht möglich ist bzw. infrage kommt. Weitere Informationen zu CDS in Verbindung mit Spring Boot findet man unter anderem in diesem aktuellen Video: Efficient Containers with Spring Boot 3, Java 21 and CDS (SpringOne 2024).
Docker-Image für Native Image
Liegt der Production-Build als Native Image vor, kann ein passendes Docker-Image mit folgendem Dockerfile
beschrieben werden. Auch in diesem Fall sollte das Dockerfile
im Basis-Verzeichnis des Hilla-Projektes liegen und folgenden Inhalt haben:
# Lightweight base image
FROM debian:bookworm-slim
WORKDIR /app
# Copy production build native image into Docker image
COPY target/my-app /app/my-app
# Expose port of Hilla app
EXPOSE 8080
# Start Hilla app on container startup
CMD ["/app/my-app"]
Das Dockerfile
basiert auf einem abgespeckten Basis-Image, hier debian:bookworm-slim
. Der Production-Build der Hilla-Anwendung in Form des Native Image wird in das Docker-Image kopiert. Des Weiteren enthält das Dockerfile
die Konfiguration für den Port, über den die Anwendung Anfragen erhalten kann. Über CMD
wird festgelegt, dass das Native Image im Docker-Image beim Start des Containers ausgeführt werden soll.
Auch dieses Docker-Image kann lokal mit Docker oder Podman erstellt werden:
docker|podman build --tag my-app .
Der Befehl wird wieder im Basis-Verzeichnis des Hilla-Projektes ausgeführt. Der .
zeigt dabei auf das selbige Verzeichnis und das darin befindliche Dockerfile
. Das Docker-Image ist anschließend lokal verfügbar und verwendbar.
Auf Basis des erstellten Docker-Images kann ein Docker-Container mit dem Production-Build der Hilla-Anwendung gestartet werden:
docker|podman run -it --rm --publish 8080:8080 my-app
Das Bauen der Hilla-Anwendung als Native Image kann bei Bedarf auch Bestandteil des Dockerfile
sein. Dafür wird das Dockerfile
in zwei Stages aufgeteilt:
# First stage: Base image with GraalVM and native image support
FROM ghcr.io/graalvm/native-image-community:21.0.2 AS build
WORKDIR /usr/src/app
# Copy project files
COPY . .
# Create native image
RUN ./mvnw clean package -Pproduction -Pnative native:compile
# Second stage: Lightweight base image
FROM debian:bookworm-slim
WORKDIR /app
# Copy the native image from the build stage
COPY --from=build /usr/src/app/target/my-app /app/my-app
EXPOSE 8080
CMD ["/app/my-app"]
In der ersten Stage des Dockerfile
wird das Native Image erzeugt. Als Basis-Image wird ein Image mit Unterstützung für GraalVM verwendet. Die erforderlichen Dateien und Abhängigkeiten werden in das Docker-Image kopiert und anschließend der Production-Build der Hilla-Anwendung als Native Image gebaut. In der zweiten Stage wird das Docker-Image mit dem gebauten Native Image erstellt.
Auch in diesem Fall kann mit Hilfe des erstellten Dockerfile
oder des Docker-Images der Production-Build der Hilla-Anwendung bei vielen Cloud-Anbietern oder in einem unternehmensinternen Kubernetes-Cluster deployed werden.
Fazit
Die Erstellung von passenden und effizienten Docker-Images ist eine wichtige Grundlage für die Auslieferung einer produktionsbereiten Hilla-Anwendung in eine Container-Umgebung. Wurde der Production-Build der Hilla-Anwendung als ausführbare JAR-Datei paketiert, kann die Startzeit im Docker-Container durch gezieltes Entpacken und CDS optimiert werden. Dabei können relevante Arbeitsschritte als Build-Steps im Dockerfile
abgebildet werden.
Teil 3 der Artikelserie beschreibt, wie die Erstellung des Production-Build und die Erstellung und Veröffentlichung des passenden Docker-Images in einer CI/CD-Pipeline automatisiert werden kann.