Migration nach AWS: Optimierung bei Containern

©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
Teil 2: Verwendung von Managed Services
Teil 3: Verwendung von Containern
Teil 5: Verschlüsselung für Java-Entwickler
Teil 6: Optimierung bei Containern
Teil 7: Optimierung bei Serverless-Anwendungen
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 <[email protected]>" # 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 <[email protected]>" RUN mkdir /opt/app && apt-get update &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 <[email protected]>" ENV javax.net.ssl.trustStore /cacerts ENV javax.net.ssl.trustAnchors /cacerts RUN apt-get update &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.
Hinterlasse einen Kommentar