Hilla-Anwendungen in Produktion ausliefern. Teil 2: Docker-Images

René Wilby | 23.09.2024 Min. Lesezeit

Artikelreihe

Dies ist der zweite von vier Artikeln, die verschiedene Aspekte der Auslieferung von Hilla-Anwendungen in Produktion beschreiben:

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.