GraalVM & Native Images als AWS Lambda – Unsere Umsetzung mit Quarkus

von Thomas Engel und Jonas Holtkamp | 8. November 2022 | Allgemein, Cloud, Deutsch

Thomas Engel

Senior Developer

Jonas Holtkamp

Lead Developer

In Kürze

  • Quarkus bietet eine sehr gute Tool-Unterstützung für das Deployment von AWS Lambdas
  • Mit AWS SAM kann auch mit wenig Vorkenntnissen ein natives AWS Lambda deployed werden
  • Antwortzeiten für native AWS Lambdas vor allem im Kaltstart deutlich schneller im Vergleich zur JVM

Warum AWS Lambdas? Und warum mit Quarkus?

Betrachtet man die aktuellen Trends in der Softwareentwicklung, so ist an Cloud Computing und Serverless-Applikationen kein Vorbeikommen. Das hat viele gute Gründe. Angefangen damit, dass sich Anwender keine Gedanken über Hardware und die physischen Server machen müssen, bis hin zur einfachen Umsetzung von Skalierungsanforderungen an die Anwendung. AWS Lambda ist als Teil der Amazon Web Services eine Plattform, auf der eventbasierte Serverless-Applikationen erstellt werden können. Die Plattform übernimmt dabei das komplette Management der Ressourcen und führt deployte Funktionen – AWS Lambdas – auf Basis von Events aus. Insbesondere wird die Funktion nur bei Bedarf aktiviert und der Kunde zahlt nur die tatsächlich genutzte Rechenleistung. Das führt allerdings zu dem Problem, dass zusätzlich zur Verarbeitung des Events die Funktion gegebenenfalls vorab initialisiert werden muss, was die Antwortzeit verlängert. Die Herausforderung ist es also, das Erstellen der Funktion zu minimieren. Zum einen aus Sicht des Aufrufers, der möglichst schnell die Antwort haben möchte, aber auch weil diese Zeit in Rechnung gestellt wird. Das ist auch einer der Hauptgründe, warum für die Implementierung der AWS Lambdas bisher keine JVM-basierten Programmiersprachen verwendet werden. Diese sind insbesondere bei der Initialisierung – um es nett zu formulieren – nicht gerade schnell. GraalVM verspricht dieses Problem mit den Native Images zu lösen und das wollten wir uns anschauen.

Als Framework zur Umsetzung der AWS Lambdas haben wir uns für Quarkus entschieden, da dieses Framework auf uns den besten Eindruck in Bezug auf GraalVM gemacht hat. Insbesondere die Tool-Unterstützung war anderen Frameworks wie beispielsweise Spring Boot und Micronaut voraus und die Unterstützung der Native Images bereitete mit Quarkus die geringsten Schwierigkeiten.
Zum detaillierten Nachvollziehen des Codes haben wir die nachfolgend beschriebenen Schritte auch zu unserem Repository hinzugefügt.

Vorbereitung

Für Quarkus ist keine lokale Installation von GraalVM notwendig, da das Kompilieren zu einem Native Image auch über die Kommandozeile in einem Docker Container durchgeführt werden kann. In diesem Fall muss lediglich Docker auf dem Betriebssystem laufen. Für den Fall, dass doch eine lokale GraalVM Installation gewünscht ist, kann die Anleitung aus der offiziellen Dokumentation verwendet werden.

Für das Deployment der AWS Lambdas haben wir uns für das Tool AWS SAM CLI entschieden, da dies eine einfache und für uns ausreichende Möglichkeit zum deployen von AWS Lambdas bietet. Dafür wird zuerst ein AWS-Account benötigt und das Tool muss auf dem Betriebssystem installiert werden, was hier genauer beschrieben ist. Anschließend müssen nur noch die AWS Credentials gesetzt und gegebenenfalls vorab ein Access Key erstellt werden, was in der Anleitung nochmals genauer beschrieben ist. Zum Testen der AWS-Credentials kann der Befehl “aws sts get-caller-identity” verwendet werden.

Erstellen des AWS Lambda

Wenn alle Vorbereitungen abgeschlossen sind, kann ein neues Quarkus-Projekt erstellt werden. Dazu verwenden wir den Befehl aus Bild 1, der auf Basis des angegebenen Templates ein neues Projekt erstellt. Im Verlauf des Skripts wird nach weiteren Angaben, wie zum Beispiel der GroupId oder ArtifactId, gefragt, die entsprechend angegeben werden müssen. Das neu angelegte Projekt beinhaltet bereits ein paar Java Klassen, die gerne als Beispiel angeschaut werden können, aber für uns nicht relevant sind und daher entfernt werden. Dies gilt ebenso wie die mitgenerierten Konfigurationsdateien für Gradle.

mvn archetype:generate \
        -DarchetypeGroupId=io.quarkus \
        -DarchetypeArtifactId=quarkus-amazon-lambda-archetype \
        -DarchetypeVersion=1.11.3.Final

Bild 1: Erstellen eines Quarkus-Projekts

Als nächster Schritt müssen weitere AWS-Abhängigkeiten hinzugefügt werden (Bild 2) und der HTTP-URL-Handler muss für das Kompilieren als Native-Image im entsprechenden Profil konfiguriert werden (Bild 3).

<dependencies>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-amazon-lambda</artifactId>
    </dependency>

    <dependency>
        <groupId>com.amazonaws.serverless</groupId>
        <artifactId>aws-serverless-java-container-core</artifactId>
    </dependency>

    <dependency>
        <groupId>javax.ws.rs</groupId>
        <artifactId>javax.ws.rs-api</artifactId>
        <version>2.0</version>
    </dependency>
    <!-- ... -->
<dependencies>

Bild 2: Hinzufügen von AWS-Abhängigkeiten in der pom.xml

<profiles>
    <profile>
        <id>native</id>
        <activation>
            <property>
                <name>native</name>
            </property>
        </activation>
        <build>
            <plugins>
                <plugin>
                    <groupId>io.quarkus</groupId>
                    <artifactId>quarkus-maven-plugin</artifactId>
                    <version>${quarkus-plugin.version}</version>
                    <executions>
                        <execution>
                            <goals>
                                <goal>native-image</goal>
                            </goals>
                            <configuration>
                                <enableHttpUrlHandler>true</enableHttpUrlHandler>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
        <properties>
            <quarkus.package.type>native</quarkus.package.type>
        </properties>
    </profile>
</profiles>

Bild 3: Aktivieren des HTTP-URL-Handlers im Profil “native”

Da beim Kompilieren als Native Image einiges an Arbeitsspeicher benötigt wird, muss in den Docker-Einstellungen genügend Speicher zur Verfügung gestellt werden und das Kompilieren dauert zudem deutlich länger als das Kompilieren zu einer Jar-Datei. Über die Angabe des Profils kann allerdings jederzeit zwischen beiden Varianten gewechselt werden.
Nachdem alle Abhängigkeiten hinzugefügt wurden und das Profil zum nativen Kompilieren konfiguriert ist, kann mit der Implementierung des Request-Handlers begonnen werden. Dieser nimmt das Event in Empfang und liefert eine entsprechende Antwort darauf zurück. Dazu muss lediglich das Interface RequestHandler implementiert und die Klasse per Annotation mit einem Namen versehen werden, über den der Request-Handler in der Konfigurationsdatei angegeben wird. Da wir in unserem Beispiel das AWS Lambda über ein API-Gateway aufrufen wollen, muss der Request-Handler ein Event des Typs AwsProxyRequest entgegennehmen und eine AwsProxyResponse als Antwort liefern (siehe Bild 4).

@Slf4j
@Named("interest-rate")
public class InterestRateLambda implements 
    RequestHandler<AwsProxyRequest, AwsProxyResponse> {

    @Inject ProcessingService service;
    @Inject ObjectMapper mapper;

    @SneakyThrows
    @Override
    public AwsProxyResponse handleRequest(
        AwsProxyRequest event, 
        Context context) 
    {
        log.info("Received event: " + mapper.writeValueAsString(event));

        InterestRateRequest request = mapper.readValue(
            event.getBody(), 
            InterestRateRequest.class
        );
        InterestRateResponse response = service.process(request);
        response.setRequestId(context.getAwsRequestId());
        response.setRequest(request);

        String responseJson = mapper.writeValueAsString(response);
        log.info("Return response: " + responseJson);

        return new AwsProxyResponse(
                response.getStatus().getStatusCode(),
                null,
                responseJson);
    }
}

Bild 4: Implementierung des Request-Handlers

Für das InnoLab haben wir das AWS Lambda verwendet, um den Zinssatz für unsere fiktive Kredit-Antragsstrecke zu berechnen. Der Zinssatz ist dabei ausschließlich von den Eingabeparametern abhängig, sodass die Berechnung eine reine Funktion darstellt und sich deswegen hervorragend als Lambda-Funktion eignet. Als Eingabeparameter dienen Kundenstammdaten sowie Angaben über den gewünschten Kredit, wie zum Beispiel die Kredithöhe, Laufzeit und die monatliche Rate. Die komplette Berechnungslogik ist in einem eigenen Service gekapselt, der im Request-Handler aufgerufen wird, und damit unabhängig von AWS Lambda ist. Das heißt, sobald das Setup für AWS Lambdas vorgenommen wurde, gibt es keinen Unterschied zur normalen Entwicklung mit GraalVM, Native Images und Quarkus mehr.
Hervorzuheben ist allerdings der hervorragende Tool-Support von Quarkus. Wenn das Projekt gebaut wird, wird neben der Jar-Datei eine Zip-Datei mit der Funktion erstellt, die das implementierte AWS Lambda enthält und so wie sie ist für das Deployment mit den AWS-Services verwendet werden kann. Außerdem werden eine Reihe an nützlichen Skripten und Templates erstellt, die gerade für Beginner im AWS-Umfeld eine Menge Arbeit erleichtern. Zum einen wird ein Skript zum direkten Deployen und Aufrufen des Lambdas erstellt, das nach der Angabe einer Rolle per Kommandozeile oder Umgebungsvariable sofort verwendet werden kann, und zum anderen werden Templates für das Deployment mit AWS SAM CLI generiert.

Deployment mit AWS SAM CLI

Damit ein AWS Lambda mit SAM CLI deployed werden kann, muss zuerst eine entsprechende Konfigurationsdatei angelegt werden. Dabei geht es vor allem darum den Namen des Stacks und ein S3-Bucket inklusive Prefix anzugeben, in das die Zip-Datei mit der AWS-Lambda-Funktion hochgeladen wird, um von dort aus deployed werden zu können (siehe Bild 5). Falls kein bestehendes S3-Bucket verwendet wird, muss zuvor ein Neues erstellt werden.

version = 0.1
[default]
[default.deploy]
[default.deploy.parameters]
stack_name = "innolab-graalvm-interest-rate-lambda-demo-native"
s3_bucket = "innolab-graalvm-bucket"
s3_prefix = "interest-rate-lambda-native-demo"
region = "eu-central-1"
capabilities = "CAPABILITY_IAM"

Bild 5: Konfigurationsdatei für das Deployment eines nativen AWS Lambda mit SAM CLI

Anschließend wird ein SAM Template benötigt, in dem unter anderem alle Ressourcen angegeben werden, die für das Deployment des AWS Lambdas benötigt werden (siehe Bild 6). Interessant ist vor allem die Ressource für die AWS-Lambda-Funktion (Zeile 11). Hier wird unter anderem der Ort der Zip-Datei, die die Funktion enthält (Zeile 14), und das Event des API-Gateways (Zeile 23) angegeben. Außerdem können weitere Einstellungen, wie zum Beispiel der gewünschte Speicher oder Timeouts, konfiguriert werden. Zuletzt werden die Outputs definiert und das Template ist fertig. An dieser Stelle sei erwähnt, dass mit GraalVM auch JVM-basierte AWS Lambdas erstellt und deployed werden können. Dafür muss das Template lediglich leicht angepasst werden, indem ein bestimmter Handler und eine bestimmte Runtime angegeben werden. Ein genaues Beispiel, um beide Templates zu vergleichen, ist in unserem Demo-Repository zu finden.

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
 Innolab GraalVM und Native Images: Interest Rate Lambda as Native Image

Globals:
 Function:
   Timeout: 3

Resources:
 InterestRateNativeFunction:
   Type: AWS::Serverless::Function
   Properties:
     CodeUri: ../../../../target/function.zip
     Handler: not.used.in.provided.runtime
     Runtime: provided
     MemorySize: 128
     Timeout: 15
     Environment:
       Variables:
         DISABLE_SIGNAL_HANDLERS: true
     Events:
       InterestRate:
         Type: Api
         Properties:
           Path: /interest-rate
           Method: post

Outputs:
 InterestRateNativeApi:
   Description: "API Gateway endpoint URL for Interest Rate function"
   Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/interest-rate"
 InterestRateNativeFunction:
   Description: "Interest Rate Lambda Function ARN"
   Value: !GetAtt InterestRateNativeFunction.Arn
 InterestRateNativeFunctionIamRole:
   Description: "Implicit IAM Role created for Interest Rate function"
   Value: !GetAtt InterestRateNativeFunctionRole.Arn

Bild 6: SAM-CLI-Template zum Deployen eines nativen AWS Lambdas

Nachdem sowohl die Konfigurationsdatei als auch das Template erstellt wurden, kann das AWS Lambda mit dem Profil “native” kompiliert und anschließend mit dem Befehl “sam deploy” deployed werden. Ein Aufruf der Funktion über das API-Gateway ist mit einem REST-Aufruf möglich, der im Payload das fachliche Event enthält (siehe Bild 7).

curl -v \
 -X POST \
 -H "Content-Type: application/json" \
 --data '{
       "customerDetails": {
         "name": "Max Mustermann",
         "dateOfBirth": "1990-01-01",
         "income": 2250
       },
       "creditDetails": {
         "amount": 20000,
         "start": "2021-04-01",
         "end": "2025-03-31"
       }
     }' \
 https://<aws-lambda-id>.execute-api.eu-central-1.amazonaws.com/Prod/interest-rate

Bild 7: Aufruf des AWS Lambda

Dabei mussten wir allerdings feststellen, dass das native AWS Lambda zur Laufzeit einen Fehler geworfen hat, weil nicht alle Klassen im Native Image vorhanden waren (Bild 8).

ERROR [io.qua.ama.lam.run.AbstractLambdaPollLoop] (Lambda Thread) Failed to run lambda: \
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: \
 Cannot construct instance of `com.amazonaws.serverless.proxy.model.Headers` \
 (no Creators, like default constructor, exist): no default constructor found

Bild 8: Fehlermeldung zur Laufzeit beim Ausführen des nativen AWS Lambda

Lösung ist die Angabe einer ReflectionConfig (siehe Bild 9), in der alle zusätzlich benötigten Klassen angegeben werden, sodass diese ebenfalls ins Native Image gepackt werden können. Da zur Laufzeit jeweils nur der erste Fehler angezeigt wird, kann dieser Prozess mehrere Iterationen brauchen, bis wirklich alle Klassen gefunden und hinzugefügt wurden. Erfahrung im Umgang mit Native Images hilft dabei, diesen Prozess zu verkürzen.

@RegisterForReflection(targets = {
        AlbContext.class,
        ApiGatewayAuthorizerContext.class,
        ApiGatewayRequestIdentity.class,
        AwsProxyResponse.class,
        AwsProxyRequest.class,
        AwsProxyRequestContext.class,
        CognitoAuthorizerClaims.class,
        Headers.class
})
public class ReflectionConfig {
}

Bild 9: ReflectionConfig zum vollständigen Erstellen des nativen AWS Lambda

Vergleich natives vs. JVM-basiertes AWS Lambda – Was macht der Kaltstart?

Die Motivation für den Einsatz von GraalVM und Native Images für AWS Lambdas war es, auch JVM-basierte Programmiersprachen für solche AWS Lambdas verwenden zu können. Das größte Problem war dabei die lange Ausführungsdauer für den ersten Aufruf – auch Kaltstart genannt – bei dem die Funktion neu erstellt werden muss und dieses neu erstellen bei JVM-basierten Sprachen vergleichsweise lange dauert. GraalVM verspricht dieses Problem mit Native Images zu beheben, indem sie quasi sofort starten. Es stellt sich jetzt die Frage, was das genau für AWS Lambdas bedeutet. Deshalb haben wir sowohl die native Variante des vorher beschriebenen AWS Lambdas, als auch eine JVM-basierte deployed und miteinander verglichen.
Der erste Unterschied ist bereits in den SAM-Templates zu sehen. Da das Native Image und damit auch das native AWS Lambda zur Ausführungszeit deutlich weniger Speicher braucht, haben wir das bereits beim Deployment berücksichtigt und dem nativen AWS Lambda die Hälfte weniger (128 MB statt 256 MB) zugeordnet. Bei den Aufrufen wurde dieser Unterschied bestätigt, als bei einem nativen AWS Lambda im Schnitt ca. 45 MB verwendet wurden, während das JVM-basierte bei etwas über 110 MB lag. Nachdem – neben der Ausführungszeit – der Speicherverbrauch mit ausschlaggebend für die Kosten ist, kann das einen entscheidenden Unterschied ausmachen.
Auch bei den Antwortzeiten gab es insbesondere für den Kaltstart erhebliche Unterschiede. Das JVM-basierte AWS Lambda hat wie erwartet sehr lange gebraucht und lag zwischen 2,5–3,0 Sekunden für einen Aufruf. Das native AWS Lambda hat hingegen lediglich zwischen 0,7–1,0 Sekunde gebraucht. Hier sieht man sehr schön, dass die Verwendung von JVM-basierten AWS Lambdas gerade für Anwendungsfälle, bei denen häufige Kaltstarts vorkommen, überhaupt nicht geeignet ist, wohingegen native AWS Lambdas durchaus verwendet werden können. Für Aufrufe nach dem Kaltstart gab es keinen so großen Unterschied, allerdings waren auch hier Aufrufe des nativen AWS Lambdas im Schnitt um 10–20 % schneller als das JVM-basierte AWS Lambda. Was auf den ersten Blick vielleicht nicht allzu interessant erscheint, wird aber auf den zweiten Blick sehr wohl relevant, wenn man auf die Abrechnungszeiten der AWS Lambdas schaut. Hier gab es einen sehr großen Unterschied. Das native AWS Lambda hat (abgesehen vom Kaltstart) eine relativ konstante Abrechnungszeit von 2 ms gehabt, während das JVM-basierte AWS Lambda um ein Vielfaches höher lag (12 ms) und auch deutlich mehr Ausreißer nach oben (bis zu 75 ms) hatte. Beim Aufruf über das API-Gateway inklusive Netzwerklatenzen machen sich diese paar Millisekunden nicht so sehr bemerkbar, aber was die Kosten betrifft, macht es sehr wohl einen Unterschied und muss berücksichtigt werden.

Fazit

GraalVM kann mit seinen nativen AWS Lambdas das Problem der langen Ausführungszeiten beim Kaltstart lösen und bietet die Möglichkeit, AWS Lambdas mit einer JVM-basierten Programmiersprache zu entwickeln. Außerdem wird dabei zur Laufzeit wesentlich weniger Speicher benötigt und auch die Ausführungszeiten sind schneller, abgesehen vom Kaltstart, als es bei JVM-basierten AWS Lambda der Fall ist, was insgesamt zu einer Verringerung der Laufzeit-Kosten führt. Quarkus bietet dabei als Framework einen sehr guten Tool-Support um native AWS Lambdas zu erstellen und macht es auch für Anfänger leicht. Allerdings kommt man um die bereits bekannten Herausforderungen von Native Images nicht herum. Der Build-Prozess dauert deutlich länger und benötigt mehr Speicher, dazu fallen viele Fehler erst zur Laufzeit auf und benötigen manuelle Hilfe mittels zusätzlicher Konfiguration, damit das Native Image mit allen notwendigen Klassen erstellt wird. Quarkus bietet auch hier eine Unterstützung, womit Integrationstests für das Native Image automatisch in den Build-Prozess integriert werden können, um Fehler frühzeitig zu erkennen. Während bei anderen Anwendungen, wie zum Beispiel Web-Applikationen, sehr leicht zurück auf die JVM-basierte Variante gewechselt werden kann, ist das für AWS Lambdas zwar technisch problemlos möglich, allerdings aufgrund der langen Ausführungszeiten beim Kaltstart nicht zu empfehlen. Das heißt, man sollte sich den Einsatz vorab gut überlegen. Da AWS Lambdas in sich abgeschlossen sind, bieten sie jedoch eine hervorragende Möglichkeit, einzelne Funktionen mit GraalVM und Native Images umzusetzen und erste Erfahrungen damit zu machen.