Wir bringen eine Java-Anwendung in die Cloud - Teil 6

Migration nach AWS: Optimierung bei Containern

Sascha Möllering

©Shutterstock / Andrii Yalanskyi

Nicht alle Runtimes sind gleich. Eine wichtige Frage für Entwicklerteams ist: Besitzt die Anwendung eine Coldstart-Phase, die einen ersten Resource Burst verursacht? Es existieren einige Java Frameworks mit Dependency Injection und Annotation Scanning, die eine sehr intensive Coldstart-Phase verursachen. Aufgrund des dynamischen Wirings werden gerade in der Coldstart-Phase viele Ressourcen benötigt, die aber erst einmal komplett hochgefahren werden müssen.

In den vorigen fünf Teilen dieser Serie haben wir uns mit der schrittweisen Optimierung einer Three-Tier-Java-Anwendung hin zu einer Serverless-Architektur und der Verschlüsselung von Daten beschäftigt. In diesem Teil der Serie werfen wir einen Blick auf mögliche Optimierungen für Container mit dem Ziel, deren Startzeit und Größe zu verringern.

Artikelserie

Gerade für Microservices ist es sinnvoll, die Paketgröße so klein wie möglich zu halten, damit in einer etwaigen Coldstart-Phase kein Resource Burst verursacht wird. Einige AWS SDKs wie das Java SDK in der Version 2.0 [1] sind modular aufgebaut. Damit können genau die Komponenten ausgewählt werden, die für die Anwendung benötigt werden, wie beispielsweise Module für Amazon DynamoDB und Amazon Kinesis Data Streams. Dies ermöglicht kleinere Paketgrößen und beschleunigt somit die Coldstart-Phase.

Optimierung von Containern

Ein weiterer Weg, um die Größe der Container zu reduzieren, besteht in der Nutzung von Multistage Builds und jlink. Mehrstufige Builds sind eine relativ neue Funktion, die Docker 17.05 oder höher erfordern. Dieses Pattern ist nützlich, um Dockerfiles zu optimieren und sie gleichzeitig leicht lesbar und pflegbar zu halten. Bei mehrstufigen Builds können sich mehrere FROM-Anweisungen im Dockerfile befinden und jede FROM-Anweisung kann ein anderes Basis-Image verwenden. Jede dieser Anweisungen beginnt eine neue Phase des Builds. Artefakte des Builds können selektiv zwischen Stufen kopiert werden, damit die Anzahl der zu kopierenden Dateien begrenzt werden kann.

Vor der Einführung von Multistage Builds war die Situation deutlich komplizierter. Eine der anspruchsvollsten Aufgaben beim Bauen von Docker Images ist es, sie so klein wie möglich zu halten, denn jede Instruktion im Dockerfile fügt einen neuen Layer hinzu. Ein übliches Pattern war es, ein Dockerfile für die Test-Stage zu nutzen und für die Produktionsumgebung ein reduziertes, das nur die Anwendung enthielt. Dieses Pattern wird das Docker Builder Pattern genannt. Das Problem bei diesem Pattern ist offensichtlich: Zwei Dockerfiles zu pflegen ist nicht ideal, zudem gibt es Unterschiede zwischen den Stages, was den initialen Ansatz, in jeder Stage das identische Docker Image zu verwenden, ad absurdum führt.

Schauen wir uns in Listing 1 ein ganz konkretes Beispiel an. In der ersten Stage des Builds erzeugen wir zunächst ein Builder Image für eine sehr kleine JDK-Distribution mit einem sehr limitierten Set an Modulen und Dependencies. Unser Builder Image basiert auf debian:9-slim. In den ersten RUN-Statements aktualisieren wir alle Debian-Pakete, laden die OpenJDK-Distribution Amazon Corretto herunter und entpacken das Paket. In einem der folgenden RUN-Statements nutzen wir jlink, um eine JDK-Distribution mit einem limitierten Set an Modulen zu bauen. jlink ist ein Kommandozeilenwerkzeug, das es erlaubt, Module und deren transitive Abhängigkeiten zu verbinden, um ein Laufzeit-Image zu bauen. Der vollständige Build-Prozess ist self-contained aufgesetzt, findet also in einem Docker-Container statt, und es werden keine externen Abhängigkeiten zu Maven oder einer Java Runtime auf dem Build-Server benötigt.

FROM debian:9-slim AS builder
LABEL maintainer="Sascha Möllering <smoell@amazon.de>"

# First step: build java runtime module

RUN apt-get update 
RUN apt-get install wget -y 
RUN mkdir -p /usr/share/man/man1
RUN apt-get install openjdk-8-jdk-headless -y

RUN wget http://mirrors.koehn.com/apache/maven/maven-3/3.6.3/binaries/apache-maven-3.6.3-bin.tar.gz -P /tmp
RUN tar xf /tmp/apache-maven-3.6.3-bin.tar.gz -C /opt
RUN ln -s /opt/apache-maven-3.6.3 /opt/maven

ENV M2_HOME=/opt/maven
ENV MAVEN_HOME=/opt/maven
ENV PATH=${M2_HOME}/bin:${PATH}

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

ENV MAVEN_OPTS='-Xmx6g'

RUN mvn -Dmaven.test.skip=true clean package

RUN set -ex && \
  apt-get update && apt-get install -y wget unzip && \
  wget https://d3pxv6yz143wms.cloudfront.net/11.0.3.7.1/amazon-corretto-11.0.3.7.1-linux-x64.tar.gz -nv && \
  mkdir -p /opt/jdk && \
  tar zxvf amazon-corretto-11.0.3.7.1-linux-x64.tar.gz -C /opt/jdk --strip-components=1 && \
  rm amazon-corretto-11.0.3.7.1-linux-x64.tar.gz && \
  rm /opt/jdk/lib/src.zip

RUN /opt/jdk/bin/jlink \
  --module-path /opt/jdk/jmods \
  --verbose \
  --add-modules java.base,java.logging,java.naming,java.net.http,java.se,java.security.jgss,java.security.sasl,jdk.aot,jdk.attach,jdk.compiler,jdk.crypto.cryptoki,jdk.crypto.ec,jdk.internal.ed,jdk.internal.le,jdk.internal.opt,jdk.naming.dns,jdk.net,jdk.security.auth,jdk.security.jgss,jdk.unsupported,jdk.zipfs \
  --output /opt/jdk-minimal \
  --compress 2 \
  --no-header-files

Dies ist die zweite Phase des Builds und wir kopieren die frisch erstellte JDK-Distribution vom Build Image in das eigentliche Target Image. In diesem Schritt verwenden wir auch debian:9-slim als Basis-Image. Nachdem wir das minimale JDK kopiert haben, kopieren wir unsere Java-Anwendung nach /opt, fügen Docker Health Checks hinzu und starten den Java-Prozess (Listing 2).

# Second step: generate Docker run image
FROM debian:9-slim
LABEL maintainer="Sascha Möllering <smoell@amazon.de>"

RUN mkdir /opt/app && apt-get update &amp;amp;&amp;amp; apt-get install curl -y

COPY --from=builder /opt/jdk-minimal /opt/jdk-minimal
COPY --from=builder target/reactive-vertx-1.6-fat.jar /opt/app/reactive-vertx-1.6-fat.jar

ENV JAVA_HOME=/opt/jdk-minimal
ENV PATH="$PATH:$JAVA_HOME/bin"

HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
  CMD curl -f http://localhost:8080/health/check || exit 1

EXPOSE 8080

CMD ["java", "-server", "-XX:+DoEscapeAnalysis", "-XX:+UseStringDeduplication", \
    "-XX:+UseCompressedOops", "-XX:+UseG1GC", \
    "-jar", "opt/app/reactive-vertx-1.6-fat.jar"]

Nutzung von GraalVM

Eine weitere Möglichkeit für die Reduktion der Applikationsgröße und Startzeit der Anwendung ist die Verwendung von GraalVM. GraalVM ist eine Erweiterung der Java Virtual Machine, um mehr Sprachen und Ausführungsmodi zu unterstützen. Das Graal-Projekt beinhaltet einen leistungsstarken Java-Compiler, der selbst Graal genannt wird, der in einer Just-in-Time-Konfiguration auf der HotSpot VM oder in einer Ahead-of-Time-Konfiguration auf der SubstrateVM verwendet werden kann. Schauen wir uns ein konkretes Beispiel an – einen Ausschnitt aus der Maven-pom.xml-Datei (Listing 3).

<plugin>
 <groupId>com.oracle.substratevm</groupId>
 <artifactId>native-image-maven-plugin</artifactId>
 <version>${graal.version}</version>
 <executions>
   <execution>
     <goals>
      <goal>native-image</goal>
      </goals>
      <phase>package</phase>
    </execution>
  </executions>
  <configuration>
   <skip>${skip.native}</skip>
    <imageName>${project.artifactId}</imageName>
   <mainClass>${vertx.verticle}</mainClass>
    <buildArgs>--enable-all-security-services --report-unsupported-elements-at-runtime --allow-incomplete-classpath --verbose</buildArgs>
 </configuration>
</plugin>

Substrate VM ist ein Framework von Graal, das es erlaubt, eine Java-Anwendung in self-contained Executables zu kompilieren. In unserer pom.xml-Datei ist dies als eigenes profile aufgesetzt und läuft in der Phase package. Damit können wir erreichen, die Anwendung zum einen als Uber JAR zu kompilieren und zum anderen den Native Image Build zu verwenden, je nachdem, was gerade benötigt wird. Das Kompilieren in ein Native Image benötigt signifikant mehr Zeit als das einfache Bauen eines Uber-JARs. Für schnelle Testzyklen bietet sich daher an, zunächst eine JAR-Datei zu bauen (Listing 4).

FROM oracle/graalvm-ce:19.1.0 AS build-aot

RUN yum clean all && yum update -y && yum install wget -y

RUN wget http://mirrors.koehn.com/apache/maven/maven-3/3.6.3/binaries/apache-maven-3.6.3-bin.tar.gz -P /tmp
RUN tar xf /tmp/apache-maven-3.6.3-bin.tar.gz -C /opt
RUN ln -s /opt/apache-maven-3.6.3 /opt/maven
RUN ln -s /opt/graalvm-ce-19.1.0 /opt/graalvm

ENV GRAALVM_HOME=/opt/graalvm
ENV JAVA_HOME=/opt/graalvm
ENV M2_HOME=/opt/maven
ENV MAVEN_HOME=/opt/maven
ENV PATH=${M2_HOME}/bin:${PATH}
ENV PATH=${GRAALVM_HOME}/bin:${PATH}

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

ENV MAVEN_OPTS='-Xmx6g'

RUN gu install native-image

RUN mvn -Dmaven.test.skip=true -Pnative-image-fargate clean package

FROM debian:9-slim
LABEL maintainer="Sascha Möllering <smoell@amazon.de>"

ENV javax.net.ssl.trustStore /cacerts
ENV javax.net.ssl.trustAnchors /cacerts

RUN apt-get update &amp;amp;&amp;amp; apt-get install curl -y
COPY --from=build-aot target/reactive-vertx /usr/bin/reactive-vertx
COPY --from=build-aot /opt/graalvm/jre/lib/amd64/libsunec.so /libsunec.so
COPY --from=build-aot /opt/graalvm/jre/lib/security/cacerts /cacerts

HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
  CMD curl -f http://localhost:8080/health/check || exit 1

EXPOSE 8080

CMD [ "/usr/bin/reactive-vertx" ]

Und hier sehen wir wieder das bekannte Bild: Ein Docker Multistage Build, der self-contained ist, in diesem Fall aber mit dem GraalVM Image als Basis. Dieses benötigen wir an dieser Stelle, da wir mit der Substrate VM in ein natives Binary kompilieren wollen, das TLS Encryption nutzt und daher Zertifikate und auch eine zusätzliche Shared Library (libsunec.so) benötigt, die Teil der Graal-VM-Distribution sind. Um in diesem Container die Anwendung bauen zu können, installieren wir zunächst Maven und starten dann einen Maven-Prozess mit den notwendigen Parametern, damit der Build mit der Substrate VM gestartet wird. Im zweiten Schritt des Builds aktualisieren wir das Debian Image und installieren curl (für die Docker Health Checks). Anschließend kopieren wir die notwendigen Dateien aus dem Image des ersten Build-Schritts. Damit TLS korrekt funktioniert, geben wir als ENV-Variable noch trust store und trust anchors mit.

Ein vollständiges Beispiel zu allen genannten Punkten ist unter [2] zu finden. Die Beispielanwendung implementiert einen Microservice basierend auf den Prinzipien des Reactive Manifesto. Als Haupt-Framework wird Vert.x genutzt, ein Toolkit für die Erstellung reaktiver Anwendungen auf der JVM. Vert.x ist ein ereignisgesteuertes, reaktives, nicht blockierendes und polyglottes Framework zur Implementierung von Microservices. Es läuft auf der Java VM unter Verwendung der Low-Level-IO-Bibliothek Netty (Listing 5).

GraalVM Native Image ist eine Optimierung, die den Speicherbedarf und die Startzeit einer Anwendung reduziert, was zur Folge hat, dass der gesamte Code zum Zeitpunkt des Bauens des Artefakts bekannt ist, d. h., dass zur Laufzeit kein neuer Code geladen wird. Wenn eine Anwendung nicht optimierbar ist, dann wird ein sogenanntes Fallback Image generiert, das die Java HotSpot VM startet, d. h. ein JDK zur Ausführung benötigt. Die Auflistung der Einschränkungen von GraalVM Native Image können unter [3] gefunden werden. Mit Hilfe der Datei native-image.properties können dem Build-Pozess bestimmte Parameter mitgegeben werden.

Args = --initialize-at-build-time=io.netty,io.vertx,com.fasterxml.jackson,org.slf4j,software.amazon.awssdk \
  --initialize-at-run-time=io.netty.handler.codec.http.HttpObjectEncoder,... \
  --enable-url-protocols=http,https\
  -H:+UseServiceLoaderFeature\
  -H:IncludeResources=(META-INF/|vertx-default-jul-logging).* \
  -H:ReflectionConfigurationFiles=classes/${.}/reflection.json

In der Konfigurationsdatei unseres Beispiels werden folgende Argumente verwendet:

  • –initialize-at-build-time: eine Liste von Klassen, die während der Erzeugung des Native Image initialisiert werden

  • –initialize-at-run-time: eine Liste von Klassen, die während der Laufzeit initialisiert werden müssen

  • –enable-url-protocols: eine Liste von zu aktivierenden Protokollen

Der Umgang von GraalVM mit Java Reflection verdient besondere Beachtung: Die Substrate VM hat teilweise Unterstützung für die Java Reflection, muss aber die Teile der Applikation kennen, die mit Reflection arbeiten. Zum Teil kann die Substrate VM die Nutzung über die Aufrufe an das Reflection API eigenständig erkennen. An einigen Stellen müssen jedoch die entsprechenden Calls manuell konfiguriert werden. Dafür existiert die Option -H:ReflectionConfigurationFiles, die auf eine JSON-Datei verweist.

Native Java mit Quarkus für Container

Im vorherigen Abschnitt haben wir gesehen, wie GraalVM genutzt werden kann und welche Konfigurationen dafür notwendig sind. Viele Entwickler möchten aber nicht einzelne Werkzeuge miteinander kombinieren und einen hohen Aufwand in die korrekte Konfiguration investieren. Quarkus ist in diesem Fall eine Alternative: Quarkus ist ein Kubernetes-native Java Stack, maßgeschneidert für GraalVM und OpenJDK HotSpot, basierend auf Hibernate, Netty, RESTEasy, Eclipse, MicroProfile, Eclipse Vert.x, Apache Camel und anderen bekannten Bibliotheken. Ein Beispiel für die Nutzung von Quarkus in Kombination mit Amazon ECS und AWS Fargate kann auf GitHub gefunden werden [4]. Die Anwendung kann sowohl in ein Uber JAR, das mit einem JRE ausgeführt werden kann, als auch in ein Native Image kompiliert werden, dafür existieren in der pom.xml-Datei zwei unterschiedliche Maven-Profile. Im genannten Projekt befindet sich die Datei Dockerfile.native, die einige GraalVM-spezifische Modifikationen beinhaltet, um TLS zu unterstützen, was notwendig für die Nutzung des AWS SDK für Java ist (Listing 6).

FROM quay.io/quarkus/ubi-quarkus-native-image:19.2.1 as nativebuilder
RUN mkdir -p /tmp/ssl-libs/lib \
  && cp /opt/graalvm/jre/lib/security/cacerts /tmp/ssl-libs \
  && cp /opt/graalvm/jre/lib/amd64/libsunec.so /tmp/ssl-libs/lib/

FROM registry.access.redhat.com/ubi8/ubi-minimal
WORKDIR /work/
COPY target/*-runner /work/application
COPY --from=nativebuilder /tmp/ssl-libs/ /work/
RUN chmod 775 /work
EXPOSE 8080
CMD ["./application", "-Dquarkus.http.host=0.0.0.0", "-Djava.library.path=/work/lib", "-Djavax.net.ssl.trustStore=/work/cacerts"]

Wie im Dockerfile zu sehen ist, werden die Dateien cacerts (ein Java Truststore) und libsunec.so für die TLS-Unterstützung aus dem Build Image in das Ziel-Image kopiert. Der SunEC-Provider implementiert die Kryptografie mit elliptischen Kurven (ECC). ECC bietet im Vergleich zu traditionellen Kryptosystemen wie RSA gleichwertige Sicherheit mit kleineren Schlüsselgrößen, was zu schnelleren Berechnungen, geringerem Stromverbrauch sowie Speicher- und Bandbreiteneinsparungen führt. Das Bauen der Anwendung zu einer Uber-JAR-Datei, die mit der JVM gestartet werden kann, ist sehr einfach:

$ ./mvnw package –DskipTests

Ähnlich funktioniert das Kompilieren der Anwendung in ein Native Image, es müssen lediglich einige Parameter hinzugefügt werden:

$ ./mvnw package -Pnative -Dquarkus.native.container-build=true -DskipTests

Wie an dieser Stelle zu erkennen ist, wird ein neues Maven-Profil mit dem Namen „native“ genutzt, genau wie im ersten Teil des Artikels, in dem lediglich die GraalVM genutzt wird. Nach dem erfolgreichen Bauen können wir ein Docker Image auf Basis des bereits angesprochenen Dockerfiles erstellen:

$ docker build -f src/main/docker/Dockerfile.native -t

Dieses Docker-Container-Image kann nun in eine Registry wie beispielsweise Amazon ECR [5] hochgeladen und in Container-Services wie beispielsweise Amazon ECS [6] oder Amazon EKS [7] genutzt werden.

Fazit

Im sechsten Teil unserer Artikelserie haben wir unterschiedliche Optimierungsmöglichkeiten für containerbasierte Applikationen betrachtet. Neben Multistage Builds und jlink ist GraalVM eine sehr junge Möglichkeit, Docker-Container-Images und die enthaltenen Anwendungen zu optimieren. Typische Optimierungsziele sind die Reduzierung der Startzeit und der Anwendungsgröße, was mit der Kompilierung zu einem Native Image erreicht werden kann. Quarkus bietet Anwendungsentwicklern eine einfach zu konfigurierende Plattform auf Basis von bekannten Frameworks an, die den Native Images Build von Haus aus mitbringt.

 

Geschrieben von
Sascha Möllering
Sascha Möllering
Sascha Möllering arbeitet als Solutions Architect bei der Amazon Web Services Germany GmbH. Seine Interessen liegen in den Bereichen Automation, Infrastructure as Code, Distributed Computing, Container und JVM.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: