Ein paar graue Haare, aber …

Cloud Native Serverless Java mit Quarkus und GraalVM auf AWS Lambda

Niko Köbler

© Minur/Shutterstock.com
© Vik Y/Shutterstock.com
© Gravvi/Shutterstock.com

Wer jetzt noch nicht „Bingo“ gerufen hat, ist selbst schuld. Wie kann es gelingen, nahezu alle oben aufgezählten Bleeding-Edge-Technologien, Frameworks und Plattformen in einem Real-World-Projekt abseits der grünen Wiese und Hello-World-Demos erfolgreich und miteinander zu verwenden? Ein Erfahrungsbericht.

Mit diesem Artikel möchte ich bewusst keine Anleitung oder Beschreibung eines Frameworks geben, die erklärt, wie man es auf welche Weise und für welchen Anwendungszweck einsetzt. Da ich gerne mit neuen Technologien arbeite, stellte sich für mich in den letzten Monaten die folgende Herausforderung: Ist es möglich, viele der neuen und aktuellen Frameworks so miteinander zu verbinden, dass die Anwendung letztendlich betrieben werden kann, sich der programmatische und operative Aufwand aber in zu bewältigenden Grenzen hält? Aufbauend auf dieser Fragestellung möchte ich hier von meinem Vorhaben berichten. Details und Anleitungen zu den verwendeten Komponenten können auf den jeweiligen Webseiten und in Artikeln unterschiedlicher Autoren in vergangenen Ausgaben des Java Magazins nachgelesen werden.

Als Anwendung aus der realen Welt, die sich in Produktion befindet, habe ich die Anmelde- /Registrierungs-App der Java User Group Darmstadt ausgewählt, für die ich in der Organisation tätig bin. Diese Applikation ist bereits vor drei Jahren im Rahmen meines Serverless-Buchprojekts entstanden und läuft seitdem ohne die Hilfe eines Web-Frameworks wie Spring, Java EE o. ä. auf Basis von Java 8 in AWS Lambda. Mittlerweile hat sich die Anwendung wie so viele Projekte weiterentwickelt und der Code ist, wie man so schön sagt, historisch gewachsen.

Die Anwendung ist lokal nur bedingt lauffähig, Änderungen werden aufwendiger und führen teilweise zu recht fragilem Code. Höchste Zeit also, ein komplettes Review der Codebasis durchzuführen und diese zu aktualisieren.

Fachlich ist die Anwendung recht übersichtlich aufgebaut: Zu einer beliebigen Veranstaltung der JUG DA sollen sich interessierte Teilnehmer einfach und unverbindlich über ein Webformular registrieren können, sodass wir im Vorfeld der Veranstaltung bereits einen guten Überblick über die Teilnehmerzahlen bekommen und gegebenenfalls in Raum- und Cateringorganisation eingreifen können. Ist ein Teilnehmer erfolgreich registriert, verschicken wir eine Bestätigungsmail. Dieser Bereich ist öffentlich verfügbar, kommt also ohne eine Anmeldung des Anwenders aus. Auf Benutzerkonten haben wir aus verschiedenen Gründen bewusst verzichtet. Der Zugriff auf die bereits getätigten Anmeldungen für uns Organisatoren erfolgt über einen abgesicherten Bereich der Anwendung. Hierfür ist eine Anmeldung eines Orga-Mitglieds nötig.

Von Ist nach Soll

Die Anwendung besteht also aus einem Browser-Frontend, Authentifizierung/Autorisierung, Datenverarbeitung, Datenspeicherung und E-Mail-Versand. Für das Rendering des Frontends habe ich auf eine serverseitige Lösung mittels der Handlebars Templating Engine zurückgegriffen. Den Einsatz einer JavaScript-basierten Single-Page Application habe ich bewusst nicht gewählt, um möglichst wenig Einzelkomponenten zu haben. Ich wollte letztendlich nur ein oder maximal zwei Deployment-Komponenten verwalten. Das gesamte HTTP(S)-Handling wird vom Amazon API Gateway übernommen, in dem auch konfiguriert wird, welche Pfade der Anwendung mit einem sogenannten Authorizer versehen werden, um diese nur autorisierten Anwendern zugänglich zu machen. Innerhalb der Anwendung habe ich mir meine Service-Klassen über Singletons selbst erzeugt und verwaltet. Singletons klingen erstmal altbacken und anfällig, haben aber in Java-basierten AWS-Lambda-Funktionen durchaus eine Berechtigung, da es zur Laufzeit zu keinerlei konkurrierenden Threads kommen kann: Jeder Thread wird in einer eigenen Lambda-Instanz bearbeitet. Die Daten werden in einer Amazon DynamoDB NoSQL DB gespeichert und die E-Mails über den Amazon Simple E-Mail Service versendet. Für beide Dienste stellt AWS ein Java-API bereit. Letztendlich hatte ich damit vier Lambda-Funktionen (Registrierung, Löschung, Admin und Authorizer), ein API Gateway-Mapping und eine DB-Tabelle, die ich mit Hilfe des Serverless Frameworks deployt habe; mehrere Einzelkomponenten zwar, aber durch das Serverless Framework sehr einfach als eine Einheit in einem Projekt zu verwalten. Der Legacy-Code ist im GitHub Repository unter dem Tag legacy zu finden.

Die Nutzung der Java Runtime in AWS Lambda hat jedoch den Nachteil, dass die Start-up-Zeiten von Java-basierten Funktionen verhältnismäßig hoch sind. In asynchronen, Event-getriebenen Datenverarbeitungs-Pipelines ist das kein Problem, kann in einem Kontext mit Benutzerinteraktion (also beispielsweise einer Website) jedoch schnell zu unerwünscht hohen Latenzen und damit zu unzufriedenen Anwendern führen. Einziger Workaround war bislang, den provisionierten Speicher für eine Lambda-Funktion zu erhöhen, sodass dieser damit auch mehr CPU-Leistung und Netzwerkbandbreite zugewiesen wird. Auch wenn man den eigentlichen Speicher nicht benötigt, kann das zu reduzierten Kosten führen, da die Ausführungszeit sinkt. Zusätzlich kann man eine Instanz der Lambda-Funktion für einige Zeit „warm“ halten, indem man sie mit Cron Events aus AWS CloudWatch regelmäßig aufruft. Klingt schräg, war aber lange Zeit wirklich die einzige Möglichkeit hierfür. Mittlerweile bietet AWS die Option der „Reserved Concurrency“ an, mit der man angeben kann, wie viele Instanzen vorgewärmt (initialisiert) zur Verfügung stehen sollen, und die damit schnellere Antwortzeiten liefern. Die weitere Nutzung von AWS Lambda war gesetzt, da ich zum einen ein Serverless Fanboy bin und zum anderen die Kosten für unseren Anwendungsfall auf einem sehr überschaubaren Level von null Euro gehalten werden können, da das kostenlose AWS-Nutzungskontingent nicht komplett ausgeschöpft wird.

An dieser Stelle kommen nun diverse neue Technologien, Frameworks und Plattformoptionen ins Spiel. Mit der GraalVM ist es zwar möglich, eine Java-Anwendung in OS-nativen Code zu kompilieren und somit performanter und auch speicherschonender auszuführen, jedoch sind der Aufwand und die Einstiegshürde nicht unerheblich, wenn man damit noch nicht gearbeitet hat. AWS Lambda bietet außerdem keine vorkonfigurierte Laufzeitumgebung an, um native Binaries auszuführen. Das wurde erst mit dem sogenannten Custom Runtime API möglich, mit dem man beliebige eigene Runtimes erzeugen, hochladen und nutzen kann.

Im Frühjahr 2019 wurde dann von Red Hat das Quarkus Framework vorgestellt, das durch schnelle Start-up-Zeiten, komfortable Hot-Reload-Möglichkeiten während der Entwicklung und der Option der nativen Kompilierung mittels GraalVM per einfachen Kommandozeilenparametern glänzen möchte. Für die Entwicklung von Microservices konzipiert, die später in Containern ausgeführt werden, spielt es im gleichen Lager wie etwa Micronaut und Helidon. Quarkus unterstützt jedoch auch die Entwicklung von AWS-Lambda-Funktionen. Diese Features machten Quarkus initial für mich interessant und brachten mich dazu, das Framework hinsichtlich der Überarbeitung der JUG-DA-Registrierung zu untersuchen und auch zu verwenden.

Eine Meinung haben

Quarkus gilt als „opinionated“ Framework. Es macht also Dinge auf seine eigene Weise und nach eigener Meinung. Und genau das sollte auch nicht vergessen werden. Das, was Quarkus kann und macht, macht es gut, aber auf seine Weise und unter eigenen Bedingungen. Damit kann das Framework für bestimmte Anwendungsfälle hervorragend geeignet sein, für andere wiederum überhaupt nicht.

Ich habe versucht, mich unvoreingenommen an die Arbeit zu machen, und habe zunächst nach einem Plug-in für meine IntelliJ-Entwicklungsumgebung Ausschau gehalten. Es gibt zwar bereits ein Plug-in, das sich jedoch in einer sehr frühen Phase (Version 0.0.3) befindet und noch nicht viele Features bietet: lediglich einen Wizard, um neue Projekte oder Module auf Basis des Generators von https://code.quarkus.io zu erzeugen, und eine Autovervollständigung auf Basis des Language-Servers für die Quarkus Properties in der application.properties-Datei. Eine Debug-Möglichkeit ist im Plug-in (noch) nicht enthalten. Dafür wird, wenn eine Quarkus-App im Dev Mode gestartet wird, automatisch ein Debug-Port geöffnet, sodass man sich mit einem Remote-Debugger aus der IDE damit verbinden kann. Wenigstens etwas. So ist das halt, wenn man mit jungen Frameworks arbeitet, für die das Ökosystem und Tooling noch im Entstehungsprozess begriffen sind.

Im ersten Schritt habe ich die benötigten Quarkus-Bibliotheken als Abhängigkeiten in die Maven pom.xml eingefügt. Ich habe mich für eine Lösung mit RESTEasy als JAX-RS-Implementierung und der AWS Lambda HTTP Extension für Quarkus entschieden. Die AWS-Lambda-Extensions sind noch im „Preview“-Status, API und Properties können sich also im Laufe der Entwicklung noch ändern. Wenn man „Living on the (tech) edge“ betreibt, ist man das ja gewohnt.

Danach machte ich mich daran, meine Lambda-Funktionsklassen, die das HTTP Event Handling abdeckten, in JAX-RS-annotierte Klassen umzuschreiben und die eigene Service-Klassen-Verwaltung von statischen Singletons in CDI zu ändern. Das war im Prinzip straight-forward, die APIs von JAX-RS und CDI sind mir geläufig, und den bestehenden Code kenne ich auch gut genug, um hier erst mal nicht auf Probleme zu stoßen.

Quarkus lebt das Highlander-Prinzip – es kann nur eine(n) geben!

Nachdem ich mit den ersten Änderungen wieder eine kompilierende Codebasis hatte, war ich neugierig darauf, wie sich diese verhält, wenn die Anwendung gestartet wird. Aber „Peng!“ – die Anwendung startet nicht. Das Quarkus-Maven-Plug-in sagt mir, dass mehrere Handler-Klassen gefunden wurden und dass es neben dem QuarkusStreamHandler aus der quarkus-lambda-http-Extension noch einen weiteren Custom Handler gäbe. Das ist tatsächlich richtig, das ist meine Authorizer-Funktion, die später vom API Gateway separat aufgerufen wird und damit auch separat deployt werden muss. Bislang war es problemlos möglich, mehrere Handler-Klassen in einem Projekt zu verwalten und auf verschiedene API Gateway-Pfade zu deployen. Mit Quarkus ist das nun nicht mehr so. Hier ist sie also, die erste, strenge Meinung von Quarkus: Es darf keine andere (Handler-Klasse) geben, außer mir! Das Highlander-Prinzip einmal anders.

Mit Quarkus darf es in einem Projekt (oder einem Modul) also nur einen möglichen Einstiegspunkt in den Code geben. Das kann sinnvoll sein, ist es im Kontext einer Serverless-Anwendung mit mehreren zusammenhängenden Lambda-Funktionen aber nicht immer. Zu diesem Zeitpunkt wollte ich mich aber damit noch nicht auseinandersetzen und das Thema Security erst später behandeln. Also flugs die Authorizer-Klasse gelöscht und die Anwendung erneut gestartet. Das ging dann, ohne die eigene Handler-Klasse, erstaunlich schnell und fehlerlos. Ein erster HelloWorld-Request war auch sofort erfolgreich. Sollte es also doch so einfach sein? Ich wäre erstaunt gewesen.

Noch eine Templating Engine?

Nun soll man den Tag bekanntlich nicht vor dem Abend loben: Ich habe versucht, das Registrierungsformular aufzurufen. Leider hat das nicht funktioniert, da die Handlebars Template Engine in Quarkus so ihre Probleme hat. Welche Ursache genau dahintersteckte, habe ich nicht herausgefunden. Ich schätze aber mal, dass es eventuell Class-Loader-Probleme gewesen sein könnten, da ein Aufruf aus Quarkus zur Handlebars Engine eine FileNotFoundException zutage brachte, die Dateien aber genau dort lagen, wo der Code sie suchen wollte. Ich habe mehrere Dinge ausprobiert, bin aber zu keinem Ergebnis gekommen. Letztendlich würde eine normale Templating Engine in einem nativen Image auch nicht so ohne Weiteres funktionieren, weil die meisten Engines sehr stark und umfänglich auf Reflection setzen. Natives Kompilieren heißt aber vereinfacht ausgedrückt, dass der Code zur Compile Time statisch auf alle möglichen Zweige untersucht wird und nur diese Ressourcen mit in das native Artefakt gepackt werden. Code, der zur Laufzeit dynamisch per Reflection genutzt wird, kann zum Kompilierungszeitpunkt nicht analysiert werden und ist letztendlich auch nicht im erzeugten Artefakt enthalten. Möchte man per Reflection genutzten Code in einem nativen GraalVM Build enthalten haben, muss dieser beim Kompiliervorgang in einer Config-Datei angegeben werden, damit er berücksichtigt werden kann. Templating bedeutet für viele Frameworks, viel Code per Reflection aufzulösen, was in einer schier unüberblickbaren GraalVM-Konfiguration enden würde.

Im Quarkus-Ökosystem war zu diesem Zeitpunkt noch kein Templating-Mechanismus ersichtlich, es gab nur GitHub Issues, die einen entsprechenden Wunsch äußerten, aber noch ohne zu erkennende Timeline. Aus diesem Grund machte ich mich weiter auf die Suche nach einem geeigneten Templating Framework und wurde mit Rocker zunächst fündig. Rocker ist eine Templating Engine, die komplett ohne Reflection auskommt und zur Compile-Zeit puren Java-Code generiert, der letztlich die Templates mit Near-Zero Copy Rendering erzeugt. Ein sehr interessantes Konzept, das ich später weiter evaluieren und verwenden wollte. In meinen Augen wäre Rocker ein gutes Framework gewesen, um es in Quarkus zu integrieren. Schließlich brauchen wir nicht noch eine Templating Engine am Markt, es gibt bereits (zu) viele gute, und eine gute Lösung weiterzuverwenden, ist schließlich sinnvoll.

Wie das Entwicklerleben nun mal so spielt, konnte ich die Anwendung für ein paar Tage aus zeitlichen Gründen nicht weiterentwickeln und ließ sie liegen. Zeitgleich las ich auf Twitter die ersten Tweets über Qute – die Templating Engine für Quarkus. Ganz ehrlich, mein erster Gedanke war: Wow, neben Quarkus hatten sie wohl noch einen sehr seltsamen Namen übrig, von dem sie nicht wussten, was sie mit ihm anstellen sollen. Ja, naming things is hard. Doch zurück zum Thema.

Eine neue Templating Engine also, die bislang noch das „Experimental“-Schild trägt, was so viel heißt wie „Wir probieren das gerade mal aus und vielleicht werfen wir es auch wieder weg“. Egal, living on the edge! Hauptsache, es funktioniert mit Quarkus, und so highly sophisticated sind meine Templates nicht.

Die Anwendung bzw. Integration in den Code und die Syntax der Templatefunktionen und Platzhalter sind natürlich wieder anders als in anderen Templating-Lösungen, aber das hatte ich bereits erwartet. Das Umschreiben der Templates und des Java-Codes war jedoch nur einfaches Abarbeiten der Änderungen, nichts Kompliziertes. Da Qute noch recht jung ist, sind naturgemäß auch noch nicht viele Funktionen enthalten, sodass fast nur einfaches Ersetzen von Properties, If-Bedingungen und Schleifen funktionieren. Dafür gibt es bereits zum jetzigen Zeitpunkt eine @TemplateExtension-Annotation, mit der eigene Templateerweiterungen implementiert werden können, falls Qute das gewünschte Verhalten noch nicht zur Verfügung stellt.

So habe ich an einer Stelle in einem Template auf das Vorhandensein eines Schlüssels in einer Map reagieren wollen:

<div class="{#if myMap.containsKey('name')}has-error{/if}">...</div>

Leider wird containsKey() auf Maps noch nicht unterstützt. Letztendlich konnte ich mit einer eigenen Erweiterung (Listing 1) aber genau dieses Verhalten erzeugen: Wenn im Templatecode auf einer Map (erster Parameter der @TemplateExtension-Methode) die Funktion containsKey() aufgerufen wird (Name der @TemplateExtension-Methode), dann wird diese Methode ausgeführt. Weitere Parameter können aus dem Template übergeben werden, wie hier z. B. der Key. Es ist aber davon auszugehen, dass in späteren Versionen solch eine Funktionalität direkt in Qute implementiert sein wird.

public class QuteExtension {
  @TemplateExtension
  static boolean containsKey(Map<?, ?> map, Object key) {
    return map.containsKey(key);
  }
}

Security

Nachdem die Anwendung auch mit Frontend und erfolgreich gerenderten Templates lief, war es dann doch an der Zeit, sich an das Thema Sicherheit in Sachen Authentifizierung und Autorisierung zu setzen. Zur Erinnerung: Im Kontext von AWS Lambda und API Gateway kennt eine Lambda-Funktion keinen HTTP-Stack, sondern lediglich ein Event mit Attributen aus einem HTTP Request. Die eigentliche HTTP-Kommunikation mit den anfragenden Clients übernimmt das API Gateway und leitet dann die Daten in Form eines APIGatewayProxyRequestEvents an die Lambda-Funktion weiter. Sollen Anfragen für einen bestimmten Pfad, z. B. /admin, nur für autorisierte Requests weitergeleitet werden, muss dies im API Gateway konfiguriert und dieser Pfad mit einem Autorisierer (IAM, Cognito oder eine eigene Lambda-Funktion) versehen werden. Damit hat man im API Gateway mindestens zwei verschiedene Einstiegspunkte, nämlich z. B. /registration für die öffentlich erreichbare Registrierung zu einem Event und /admin für die Verwaltung. Beide Pfade können aber auf die gleiche Lambda-Funktion deuten, wenn diese korrekt implementiert ist und ebenfalls die Pfade auswertet. Das ist beim Einsatz mit Quarkus, RESTEasy und der Lambda HTTP Extension der Fall.

Da das bereits genannte Quarkus-Highlander-Prinzip keine anderen Lambda-Funktionen im selben Projekt bzw. im selben Laufzeitklassenpfadkontext zulässt, habe ich zunächst versucht, das Thema Authentifizierung und Autorisierung direkt in der Quarkus-Applikation zu behandeln. Das Framework stellt hierfür ebenfalls bereits einige Extensions bereit. Ich nutze eine einfache Basic Authentication in der Anwendung, und so habe ich für einen ersten Test die Properties File Based Authentication implementiert.

Grundsätzlich hat das auch funktioniert, auch wenn die Erweiterung die Abhängigkeiten eines gefühlt halben Undertow-HTTP-Servers mit in das Projekt zog, die ich ja dank API Gateway gar nicht benötige (und auch gar nicht in meinem Projekt haben möchte). „Grundsätzlich“ deshalb, da die Quarkus-Anwendung zwar bei einem Request ohne Authorization-Header einen WWW-Authenicate-Basic-Header in die Response schreibt, was den Client darüber informieren soll, dass entsprechende Credentials fehlen. Dieser Header wird vom API Gateway jedoch in einen x-amzn-Remapped-WWW-Authenticate-Header umgeschrieben. Somit erkennt der Client nicht mehr, dass Autorisierungsinformationen fehlen. Dieses Mapping ist nicht änderbar und das ist auch völlig korrekt, denn für die eigentliche Stelle, die das HTTP-Handling vornimmt – das API Gateway –, ist zunächst keine Autorisierung des Requests konfiguriert. Also darf das Gateway den Header nicht ungemappt zurückliefern, da ein Request mit einem korrekten Header dann ja auch vom API Gateway geprüft und autorisiert werden müsste. Das API Gateway ist in diesem Fall aber eben nicht nur ein Proxy, der den HTTP Request an andere HTTP-Server weiterleitet, sondern übernimmt weitere Aufgaben im Gesamtkontext, darunter auch die Autorisierung von Requests. Und die Lambda-Funktion ist eben auch kein HTTP-Server; ich nutze dort mit Quarkus lediglich ein Framework, das zufällig auch HTTP sprechen könnte.

Damit bleibt uns nichts anderes übrig, als die Autorisierung der Requests im API Gateway durchzuführen und dort eine Authorizer-Funktion für alle /admin Requests zu verwenden. Eine Java-Klasse im selben Projekt funktioniert aus o. a. Gründen nicht. Ein Maven-Multi-Module-Projekt, nur einer einzigen Klasse wegen, wollte ich aber auch, wenn möglich, vermeiden. Polyglotter Programmierung und der möglichen Node. js-Laufzeitumgebung in AWS Lambda sei Dank, konnte ich die Authorizer-Funktion letztendlich in JavaScript implementieren. Das stört nicht im Java Classpath der Quarkus-Anwendung, und ich kann die Funktion im selben Projekt verwalten. Beim Deployment muss die Funktion natürlich separat paketiert und hochgeladen werden, was aber mit dem Serverless Framework problemlos möglich ist. Die Konfigurationsdatei für das Deyploment mit dem Serverless Framework ist in vereinfachter Form in Listing 2 zu sehen.

service: jugda-registration
 
provider:
  name: aws
  runtime: java8
  stage: prod
  region: eu-central-1
  memorySize: 2048
 
package:
  individually: true
 
functions:
  admin:
    handler: io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler
    events:
      - http:
        path: /admin/{proxy+}
        method: any
        authorizer:
          name: basicAuthorizr
          type: token
          identitySource: method.request.header.Authorization
    package:
      artifact: target/jugda-registration-runner.jar
  public:
    handler: io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler
    events:
      - http:
        path: /{proxy+}
        method: any
    package:
      artifact: target/jugda-registration-runner.jar
  basicAuthorizr:
    handler: js/basicAuthorizr.handler
    runtime: nodejs12.x
    memorySize: 128

Going Native

Die Applikation läuft nun in AWS Lambda unter der Java 8 Runtime, ich kann sie auch lokal starten und damit komfortabel entwickeln, debuggen und testen. Fehlt nur noch, dass ich die Quarkus-Anwendung nun per GraalVM auch nativ kompilieren, deployen und ausführen kann. Schließlich will ich von einer besseren (Start-up-)Performance bei einem gleichzeitig geringeren Speicherverbrauch profitieren. Laut Dokumentation soll das ganz einfach mit $ mvn package -Pnative funktionieren. Das entsprechende Maven-Profil in der pom.xm. vorausgesetzt, referenziert es alle notwendigen Einstellungen aus der Quarkus pom.xml, die dort für den Entwickler bereits vorkonfiguriert sind. Das ist sehr komfortabel und erspart mir für den Anfang eine tiefe und lange Einarbeitung in die Welt der GraalVM-Konfiguration.

Ein erster Compile-Vorgang brachte aber leider eine ganze Handvoll Fehler hervor. Fehlermeldungen, die für einen Java-Entwickler erstmal sehr merkwürdig und auf den ersten Blick unverständlich klingen. Ich bewege mich ja nicht mehr in der Java-Welt mit den gewohnten Exceptions o. ä., sondern in der nativen Welt, wo die Fehlermeldungen erstmal anders aussehen.

Nach einigen Stacktrace-Untersuchungen und Recherchen konnte ich, sehr zu meinem Erstaunen, alle Fehlermeldungen auf die verwendeten AWS-Bibliotheken zurückführen. Der übrige Code war scheinbar frei von Compile-Fehlern (was Laufzeitfehler ja dennoch nicht verhindern würde). In der Anwendung nutze ich AWS APIs für DynamoDB und SES. Für DynamoDB gibt es glücklicherweise bereits eine Quarkus Extension, sodass ich diese hoffentlich einfach verwenden kann und nicht in die Tiefen der Implementierung und Anpassung auf Quarkus abtauchen muss. So der Plan. Die Anpassung von Drittbibliotheken kann für eine effiziente Nutzung in Quarkus notwendig, aber am Anfang auch zeitaufwendig sein, wenn man dies noch nie zuvor getan hat. Die Quarkus-DynamoDB-Extension verwendet die Version 2 des AWS Java API, das ein Rewrite der V1 APIs darstellt, den Code aber besser voneinander entkoppelt und das modularer nutzbar geworden ist. In meiner Anwendung nutz(t)e ich noch das V1 API, was nicht weiter schlimm ist, da es weiterhin von AWS unterstützt und auch weiterentwickelt wird. Da das AWS V2 API aber noch recht jung ist, kann es teilweise noch nicht mit dem Funktionsumfang des V1 API mithalten. Bei DynamoDB sind so z. B. das Document API und das Object Mapper API noch nicht in V2 implementiert. Gerade das Object Mapper API (ähnlich der JPA-Annotationen für POJOs und weitere High-Level-Funktionen) hatte ich in der Anwendung verwendet. Damit hieß es für mich zunächst, die gesamte DynamoDB-Implementierung in der Anwendung umzuschreiben. Wieder etwas, das nicht kompliziert ist, jedoch vor allem Zeit kostet, gerade wenn das neue API noch nicht so geläufig ist.

Zusätzlich zur DynamoDB ist noch die Abhängigkeit zum Simple Email Service in der Anwendung enthalten. Auch hier verwendete ich die V1 des AWS Java API. Allerdings gibt es für SES, zumindest bislang, keine Quarkus Extension. Die zwei verschiedenen AWS-Java-API-Versionen in einem Projekt gleichzeitig verwenden zu können, ist zwar machbar, erhöht aber die weiteren transitiven Abhängigkeiten; auch weil das V1 API eine harte Abhängigkeit auf den Apache HttpClient hat, dies beim V2 API aber modular gelöst ist und z. B. durch einen auf der im JDK enthaltenen java.net.URLConnection-Klasse basierenden Client ersetzt werden kann. Der Vorteil des URLConnection-basierten Clients ist, dass dieser schneller startet als der Apache HttpClient, doch dafür bietet er weniger Durchsatz. In meinem Anwendungsfall habe ich nicht mit hohen Durchsatzraten zu kämpfen und konnte so mit einer besseren Performance noch zusätzlich die Abhängigkeiten reduzieren. Die SES-Implementierung habe ich dann also auch noch auf das V2 API umgeschrieben. Ein erneuter nativer Kompiliervorgang hat danach funktioniert, und die Anwendung war für das Deployment bereit.

Nun ja, war sie nicht ganz, denn ich habe das native Image auf meinem MacBook kompiliert, was heißt, dass es unter macOS läuft, nicht aber unter Linux und damit nicht in AWS Lambda. Nativ heißt eben wirklich nativ. Glücklicherweise bietet Quarkus aber auch hier bereits eine Unterstützung an, die rein konfigurativ auskommt, vorausgesetzt, man hat z. B. Docker in seiner Umgebung zur Verfügung. Mit entsprechenden Properties startet Quarkus während des nativen Builds einen Docker-Container, in dem das native Image dann gebaut wird. Somit erhält man ein Linux-basiertes natives Binary, das dann auch in AWS Lambda ausgeführt werden kann:

quarkus.native.container-build=true
quarkus.native.container-runtime=docker

AWS Lambda hat zwar keine reine Linux-Laufzeitumgebung, bietet aber mit dem Custom Runtime API eine Möglichkeit, eigene Runtimes zu erstellen und zu nutzen. Eine der Voraussetzungen ist, dass der Einstiegspunkt in diese Custom Runtime mit dem Dateinamen bootstrap versehen ist. Die erzeugten Artefakte eines Quarkus-Lambda-Projekts, die mit den Quarkus Archetypes erstellt werden, liefern freundlicherweise bereits eine Maven-Assembly-Konfiguration mit, damit das generierte native Binary in bootstrap umbenannt und gleich in eine Zip-Datei verpackt wird, die zu AWS Lambda hochgeladen werden kann. Im Gegensatz zu einer vordefinierten Runtime muss dann beim Deployment aber auch kein definierter Handler für eine Funktion angegeben werden, das erledigt das bootstrap-Artefakt.

Deployment des nativen Binaries starten und Anwendung aufrufen. Läuft! Also fast. Die Anwendung kann noch nicht per HTTPS/TLS auf die AWS APIs zugreifen, da das native Image nicht weiß, wie es mit HTTPS umgehen soll und auch keine Zertifikate kennt. Hierfür braucht die Laufzeitumgebung die Sun-EC-Bibliothek aus einer Linux-JDK-Distribution (libsunec.so), damit der Sun-EC-Provider korrekt geladen werden kann, und die cacerts-Datei mit den Zertifikaten aus einem JDK, ggf. versehen mit eigenen, selbstsignierten Zertifikaten. Diese beiden Dateien müssen mit deployt und der Umgebung bekannt gemacht werden. In das native Image können diese nicht mit reinkompiliert werden, sie müssen als externe Dateien per Startparameter angegeben werden. Die Quarkus-Dokumentation beschreibt zwar, wie das für eine Docker-Umgebung möglich ist, verliert aber keinen Ton darüber, was in einer AWS-Lambda-Umgebung notwendig wird.

Lambda Layers bietet hier Abhilfe. Ein Layer ist eine Zip-Datei, die separat zu Lambda hochgeladen wird, statische (Binär-)Dateien bereitstellt und von einer oder mehreren Lambda-Funktionen genutzt werden kann. Beispielsweise könnten die Laufzeitbibliotheken für eine Groovy-Anwendung als Layer deployt werden. Eine Lambda-Funktion referenziert diesen Layer, kann die Bibliotheken einbinden, muss sie selbst aber nicht mit deployen. In meinem Fall deploye ich also die beiden oben genannten Dateien als einen Layer graalvm und referenziere diesen beim Initialisieren der nativen Funktion. Diese darf ich dann nicht mehr bootstrap nennen, sondern muss einen eigenen bootstrap-Wrapper schreiben, der die Layerdateien per System-Property-Parameter meiner nativen Funktion bekannt macht und diese aufruft (Listing 3).

#!/usr/bin/env bash
RUNNER=$( find . -maxdepth 1 -name '*-runner' )
export DISABLE_SIGNAL_HANDLERS=true
$RUNNER -Djavax.net.ssl.trustStore=/opt/graalvm/jre/lib/security/cacerts  -Djavax.net.ssl.trustAnchors=/opt/graalvm/jre/lib/security/cacerts -Djava.library.path=/opt/graalvm/jre/lib/amd64

Der bootstrap-Wrapper und das native Image werden zusammen in die zu deployende Zip-Datei gepackt. Die Deployment-Konfiguration für das Serverless Framework für das native Image inklusive des GraalVMSecurity-Layers ist in Listing 4 zu sehen. Unter dem beim Layer angegebenen Pfad liegen die Dateien in den benötigten Verzeichnissen und werden, so wie sie dort liegen, gezippt und als Layer zu Lambda hochgeladen.

service: jugda-registration
 
provider:
  name: aws
  runtime: provided
  stage: prod
  region: eu-central-1
  memorySize: 256
 
package:
  individually: true
 
functions:
  admin:
    handler: not.used.in.provided.runtime
    events:
      - http:
        path: /admin/{proxy+}
        method: any
        authorizer:
          name: basicAuthorizr
          type: token
          identitySource: method.request.header.Authorization
    package:
      artifact: target/function-admin.zip
    layers:
      - { Ref: GraalvmSecurityLambdaLayer }
  public:
    handler: not.used.in.provided.runtime
    events:
      - http:
        path: /{proxy+}
        method: any
    package:
      artifact: target/function-public.zip
    layers:
      - { Ref: GraalvmSecurityLambdaLayer }
  basicAuthorizr:
    handler: js/basicAuthorizr.handler
    runtime: nodejs12.x
    memorySize: 128
 
layers:
  GraalvmSecurity:
    path: lambda-layer

Läuft die Anwendung jetzt? Besser, aber noch nicht ganz.

Das Anmeldeformular erscheint und ich kann es auch absenden. Die E-Mail wird verschickt, dann allerdings läuft die Anwendung auf einen Fehler. Eine Loganalyse zeigt schnell, dass beim E-Mail-Versand eine Klasse nicht per Reflection aufgerufen werden kann. Reflection? Ja, der Fehler tritt beim Versuch auf, die API Response auf eine Instanz der Klasse XMLInputFactoryImpl zu mappen. Diese Klasse ist dem nativen Image eben nicht bekannt, da sie nicht per statischer Codeanalyse erkannt werden konnte. Hier gibt es mehrere Tipps und Hinweise, was man in solchen und ähnlichen Fällen tun muss. In meinem Fall hat es ausgereicht, diese einzelne Klasse in einer Datei namens reflection-config.json zu definieren

[{
  "name" : "com.sun.xml.internal.stream.XMLInputFactoryImpl",
  ...
}]

und diese in der application.properties als weiteren Build-Parameter anzugeben:

quarkus.native.additional-build-args=-H:ReflectionConfigurationFiles=reflection-config.json

Das wars. Die Anwendung läuft performant, stabil und mit ungefähr einem Achtel des ursprünglich provisionierten Speichers. Der aktuelle Stand der Anwendung ist im öffentlichen GitHub Repository zu finden.

Fazit

Wie wir gesehen haben, lässt sich die eingangs gestellte Frage, ob es möglich ist, verschiedene, teilweise noch sehr junge Frameworks, Plattformen und Technologien zu einer lauffähigen Anwendung in der realen Welt miteinander verbinden und betreiben zu können, mit „ja“ beantworten. Allerdings möchte ich nicht verschweigen, dass mich die eine oder andere Ecke ein paar graue Haare gekostet hat und meine Nachbarn mich bestimmt öfter als sonst haben fluchen hören. Mehr als einmal war ich während der Migration versucht, das Projekt einfach aufzugeben.

Zugegeben, Quarkus ist ein noch sehr junges Projekt. Zum jetzigen Zeitpunkt ist es noch kein Jahr alt. Dafür kann es auf der einen Seite schon recht viel, an anderen Stellen fehlt aber auch noch einiges. Es ist sicherlich nicht die eierlegende Wollmilchsau, die von vielen Personen immer wieder gerne durchs Dorf getrieben wird. Quarkus ist ein Framework mit einem eigenen Ansatz für bestimmte Szenarien. So kann es hinsichtlich einer containerisierten Welt punkten und ist damit für die Gegenwart gemacht. Da sich dieser Zustand aber in meinen Augen nicht allzu lange halten wird und die Infrastruktur sich immer mehr in Richtung Serverless Workloads bewegen wird, kann Quarkus hier noch nicht mithalten. Das „Quarkus-Highlander-Prinzip“ ist hier einfach zu restriktiv und für die Verwaltung von mehreren, inhaltlich zusammenhängenden Funktionen nicht geeignet. Quarkus ist in meinen Augen sehr „opinionated“ – und das sollte man nicht vergessen. Eine bestehende Anwendung einfach hin zu Quarkus zu migrieren, weil man es kann und Quarkus so „hip“ erscheint, ergibt keinen Sinn. Es sollten schon gute Gründe dafür sprechen. Existierende Alternativen im Ökosystem sollten auf jeden Fall immer mit in Betracht gezogen und gegen die wirklichen fachlichen Anforderungen des Projekts abgeglichen werden. Das sind, was wichtig ist, nicht die Befindlichkeiten und Wünsche von Entwicklern/Architekten oder die Meinungen von Evangelisten.

Die eigentliche Integration mit GraalVM und der damit verbundenen Möglichkeit, native Images zu erzeugen, ist gut gelöst, solange man sich mit überschaubaren Services beschäftigt. Nutzt man aber Drittbibliotheken, die nicht als Quarkus Extension zur Verfügung stehen, kann das zur Herausforderung werden. Speziell wenn diese Bibliotheken in einem umfangreichen Maß Reflection verwenden und man die Anwendung mit GraalVM nativ kompilieren möchte. Die dazu möglicherweise notwendig werdenden Konfigurationsdateien können das Projekt schnell wieder unübersichtlich werden lassen.

Für die existierenden Quarkus Extensions gibt es bereits umfangreiche Dokumentationen, mal ausführlicher, mal weniger detailliert. Allerdings wächst das Framework auch so rasant, dass die veröffentlichten Guides teilweise noch veraltete Informationen enthalten. Properties und APIs, die sich mittlerweile geändert haben. Vertrauen ist hier gut, Kontrolle noch besser.

Es gibt auch noch viele Unternehmen, für die Containerisierung und Startgeschwindigkeit kein Thema mit Top-Priorisierung ist. Für diese tun es auch etablierte Lösungen wie Spring (Boot). Auch hier kann durch Optimierung einiges performanter gestaltet werden. Ein wichtiger Faktor, der nicht vergessen werden darf, ist auch das Know-how der zur Verfügung stehenden Entwickler. Nicht jedes Team kann und möchte alle zwei Jahre auf einen neuen Technologiezug aufspringen. Der effektive und effiziente Einsatz von bestehendem Wissen und Technologien kann mitunter gewinnbringender sein, als 0,2 Sekunden schneller zu starten. Auch ist nicht jedes zweite Unternehmen Netflix oder Google, auch wenn viele der Meinung sind, sie hätten die gleichen Anforderungen.

Dennoch, der Ansatz von Quarkus ist toll, und es ist gut, dass sich in Bezug auf den Java-Einsatz in einer containerbasierten Welt etwas bewegt. Wie gesagt, das Framework ist noch jung, und die Zeit wird zeigen, wie es sich entwickelt. Nur einen Wunsch habe ich noch: Denkt euch bitte schönere Namen aus!

Geschrieben von
Niko Köbler
Niko Köbler
Niko Köbler ist freiberuflicher Software-Architekt, Developer & Trainer für Java & JavaScript-(Enterprise-)Lösungen, Integrationen und Webdevelopment. Er ist Co-Lead der JUG Darmstadt, schreibt Artikel für Fachmagazine und ist regelmäßig als Sprecher auf internationalen Fachkonferenzen anzutreffen. Niko twittert @dasniko.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: