Entwicklung, Ausführung und Optimierung von Quarkus Web-Anwendung auf AWS Lambda

Vadym Kazulkin

Was werden wir in Diesem Artikel erforschen und lernen?

In diesem Artikel werden wir einige Möglichkeiten zur Entwicklung, Bereitstellung und Betrieb von Anwendungen auf AWS Lambda mithilfe von Quarkus Framework erforschen. Natürlich werden wir Performanz (die Kalt- und Warmstartzeiten) der Lambda-Funktion messen. Außerdem zeigen wir, wie wir die Performance der Lambda-Funktionen mit Lambda SnapStart und GraalVM Native Image optimieren können, das als AWS Lambda Custom Runtime bereitgestellt wird. Codebeispiele für die gesamte Serie finden Sie in meinem GitHub Account.

Beispielanwendung mit dem Quarkus-Framework auf AWS Lambda

Zur Erläuterung verwenden wir eine simple Beispielanwendung nutzen, dessen Architektur unten abgebildet ist.

Architektur der Beispielanwendung

In dieser Anwendung werden wir Produkte erstellen und sie nach deren ID abrufen, sowie Amazon DynamoDB als NoSQL-Datenbank für die Persistenzschicht verwenden. Wir nutzen Amazon API Gateway der das Erstellen, Veröffentlichen, Warten, Überwachen und Sichern von APIs für Entwickler vereinfacht und AWS Lambda zur Ausführung von Code, ohne dass Server bereitgestellt oder verwaltet werden müssen. Außerdem AWS SAM, der eine Kurzsyntax anbietet, die für die Definition von Infrastruktur als Code (nachfolgend IaC) für serverlose Anwendungen optimiert ist. Ich setze für dieses Artikel das grundsätzliche Verständnis der genannten AWS Services, serverlosen Architekturen in AWS, Quarkus-Framework und GraalVM inkl. dem Nativen Image voraus.

Um die Beispielanwendung bauen und deployen zu können, brauchen wir lokal folgende Installationen: Java 21, Maven, AWS CLI und SAM CLI. Für das GraalVM Beispiel zusätzlich noch GraalVM und Native Image.

Jetzt schauen wir uns relevante Quellcodefragmente an und beginnen mit der Beispielanwendung, die wir direkt auf der verwalteten Java 21 Laufzeit auf AWS Lambda ausführen werden. AWS Lambda unterstützt nur verwaltete Java LTS Versionen, somit ist die Version 21 z.Z. die Aktuellste.

Zuerst sehen wir uns Quellcode der Lambda-Funktion GetProductByIdHandler an. Diese Lambda Funktion ermittelt das Produkt anhand dessen ID und liefert es zurück.

@Named("getProductById")
public class GetProductByIdHandler implements RequestHandler {

@Inject
private ObjectMapper objectMapper;

@Inject
private DynamoProductDao productDao;

@Override
public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent requestEvent, Context context) {
    String id = requestEvent.getPathParameters().get("id");
    Optional<Product> optionalProduct = productDao.getProduct(id);

    try {
        if (optionalProduct.isEmpty()) {
            context.getLogger().log(" product with id " + id + " not found ");
            return new APIGatewayProxyResponseEvent().withStatusCode(HttpStatusCode.NOT_FOUND)
                    .withBody("Product with id = " + id + " not found");
        }
        context.getLogger().log(" product " + optionalProduct.get() + " found ");
        return new APIGatewayProxyResponseEvent().withStatusCode(HttpStatusCode.OK)
                .withBody(objectMapper.writeValueAsString(optionalProduct.get()));
    } catch (Exception je) {
        je.printStackTrace();
        return new APIGatewayProxyResponseEvent().withStatusCode(HttpStatusCode.INTERNAL_SERVER_ERROR)
                .withBody("Internal Server Error :: " + je.getMessage());
    }
  }

}

Die Methode handleRequest erhält ein Objekt vom Typ APIGatewayProxyRequestEvent als Eingabe, da APIGatewayRequest die Lambda-Funktion aufruft. Daraus lesen wir die Produkt-ID über requestEvent.getPathParameters().get(“id”) aus und fragen mit productDao.getProduct(id) unsere DynamoProductDao, ob ein Produkt mit dieser ID in der DynamoDB vorhanden ist. Je nachdem, ob das Produkt existiert oder nicht, verpacken wir die mittels Jackson serialisierte Antwort in ein Objekt vom Typ APIGatewayProxyResponseEvent und schicken es an Amazon API Gateway als Antwort zurück. Der Quellcode der Lambda-Funktion CreateProductHandler, die wir für die Erstellung und Persistierung von Produkten nutzen, sieht ähnlich aus.

Der Quellcode der Entität Product sieht sehr simpel aus:

public record Product(String id, String name, BigDecimal price) {}

Die Implementierung der Persistenzschicht DynamoProductDao nutzt AWS SDK for Java 2.0, um in die DynamoDB zu schreiben oder daraus zu lesen. Hier beispielhaft der Quellcode der getProductById Methode, die wir in der beschriebenen Lambda-Funktion GetProductByIdHandler genutzt haben:

  public Optional<Product> getProduct(String id) {
    GetItemResponse getItemResponse= dynamoDbClient.getItem(GetItemRequest.builder()
      .key(Map.of("PK", AttributeValue.builder().s(id).build()))
      .tableName(PRODUCT_TABLE_NAME)
      .build());
    if (getItemResponse.hasItem()) {
      return Optional.of(ProductMapper.productFromDynamoDB(getItemResponse.item()));
    } else {
      return Optional.empty();
    }
  }

Hier verwenden wir eine Instanz des DynamoDbClient, um ein GetItemRequest-Objekt zu erstellen. Damit fragen wir die DynamoDB-Tabelle ab, deren Namen wir über die Umgebungsvariable PRODUCT_TABLE_NAME mit System.getenv(“PRODUCT_TABLE_NAME”) aus dem AWS SAM Template beziehen – und zwar nach dem Produkt mit der entsprechenden ID. Falls das Produkt gefunden wird, nutzen wir den eigens geschriebenen ProductMapper, um den DynamoDB Item auf die Attribute der Product Entität zu mappen.

Bis auf die Annotation haben wir bisher keine Abhängigkeiten zum Quarkus-Framework gesehen. Wie alle Komponenten zusammenspielen, zeigt sich in der pom.xml. Neben den Abhängigkeiten zum Quarkus-Framework (wir verwenden Version 3.18.3 – ein Upgrade auf eine neuere Version ist möglich, in der Regel funktioniert das meiste weiterhin problemlos), dem AWS SDK für Java sowie weiteren AWS-Artefakten findet sich dort unter anderem folgende Abhängigkeit:

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

Diese Bibliothek bildet die Brücke zwischen AWS Lambda und dem Quarkus-Framework.

Im nächsten Schritt werfen wir einen Blick auf das letzte fehlende Element: die Infrastrukturdefinition mit AWS SAM, die in der Datei template.yaml beschrieben ist. Dort definieren wir Amazon API Gateway (inklusive UsagePlan und API Key), die Lambda-Funktionen sowie die DynamoDB-Tabelle:

 GetProductByIdFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: GetProductByIdWithWithQuarkus318
      AutoPublishAlias: liveVersion
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref ProductsTable
      Environment:
        Variables:
          QUARKUS_LAMBDA_HANDLER: getProductById
      Events:
        GetRequestById:
          Type: Api
          Properties:
            RestApiId: !Ref MyApi
            Path: /products/{id}
            Method: get     
            

Wir sehen, dass diese Lambda-Funktion mit dem HTTP Get Aufruf und dem Pfad /products/{id} des API Gateway verknüpft ist. Aber wie wird die GetProductByIdHandler Lambda Implementierung aufgelöst? Wir sehen die Umgebungsvariable QUARKUS_LAMBDA_HANDLER, dessen Wert getProductById mit dem Wert der Named Annotation (@Named(“getProductById“)) an der Klasse GetProductByIdHandler übereinstimmt. Die Auflösung selbst übernimmt der generische io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler Lambda-Handler, der im template.yaml im Globals-Abschnitt der Lambda-Funktionen wie folgt definiert ist:

Globals:
  Function:
    Handler: io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest
    CodeUri: target/function.zip
    Runtime: java21
    ....
    Environment:
      Variables:
        ...
        PRODUCT_TABLE_NAME: !Ref ProductsTable
    ....

Dort sind auch weitere Parameter definiert, die für alle Lambda-Funktionen gelten – etwa die Java-Laufzeitumgebung Java 21 und CodeURI. Außerdem haben wir DynamoDB Tabellenamen als Umgebungsvariable gesetzt, welches in der DynamoProductDao Klasse verwendet wird.

Jetzt müssen wir die Anwendung mit mvn clean package bauen (es wird function.zip erstellt und im Verzeichnis namens target abgelegt) und mit sam deploy -g deployen. Als Rückgabe sehen wir unsere individuelle Amazon API Gateway URL. Wir können sie nutzen, um Produkte zu erstellen und diese nach ID abzurufen. Die Schnittstelle ist mit dem API Key abgesichert. Als HTTP Header müssen wir folgendes mitschicken:  “X-API-Key: a6ZbcDefQW12BN56WEV318” – siehe MyApiKey Definition in template.yaml.

Um das Produkt mit ID=1 zu erzeugen, können wir folgende Curl-Abfrage nutzen:

curl -m PUT -d '{ "id": 1, "name": "Print 10x13", "price": 0.15 }' -H "X-API-Key: a6ZbcDefQW12BN56WEV318" https://{$API_GATEWAY_URL}/prod/products

Um z.B. das bereits existierende Produkt mit ID=1 abzufragen, können wir folgende Curl-Abfrage nutzen:

curl -H "X-API-Key: a6ZbcDefQW12BN56WEV318" https://{$API_GATEWAY_URL}/prod/products/1

In beiden Fällen müssen wir das {$API_GATEWAY_URL} durch das individuelle Amazon API Gateway URL ersetzten, welches Rückgabe des sam deploy -g Befehls ist. Diese URL kann auch in der AWS-Konsole im Amazon API Gateway Service eingesehen werden, indem man zu dem von uns bereitgestellten API navigiert.

Messungen von Kalt- und WarmStartzeiten unserer ApplikatioN

Im Folgenden werden wir die Performanz unserer GetProductByIdFunction Lambda-Funktion messen, die wir durch den Aufruf curl -H “X-API-Key: a6ZbcDefQW12BN56WEV318” https://{$API_GATEWAY_URL}/prod/products/1 triggern werden. Bei der Performanz sind uns zwei Aspekte wichtig: Kalt- und Warmstartzeiten. Es ist bekannt, dass v.a. Java Anwendungen eine recht hohe Kaltstartzeit haben. Einen guten Überblick über das Thema gibt der Artikel Understanding the Lambda execution environment lifecycle. Außerdem werden wir verschiedene Performanzoptimierungsmöglichkeiten vorstellen, so dass wir fünf unterschiedliche Messungen machen werden:

  1. Beispielanwendung, wie wir sie oben vorgestellt haben
  2. Beispielanwendung mit dem aktivierten AWS Lambda SnapStart ohne Anwendung von Priming
  3. Beispielanwendung mit aktiviertem AWS Lambda SnapStart und Priming der DynamoDB-Anfrage
  4. Beispielanwendung mit aktiviertem AWS Lambda SnapStart und Priming des API-Gateway-Request-Events
  5. Eine angepasste Beispielanwendung, die wir als GraalVM Native Image bauen und als Lambda Custom Runtime bereitstellen

Jetzt gehen wir alle fünf Performance-Messmethoden durch und zeigen anschließend, wie wir die Messungen durchgeführt haben. Am Ende stellen wir die Ergebnisse für alle fünf Methoden zusammen.

1. Beispielanwendung, wie wir sie oben vorgestellt haben

Um die Performanzmessung durchzuführen, müssen wir sicherstellen, dass AWS Lambda SnapStart, mit # deaktiviert ist. Das geschieht in template.yaml folgendermaßen:

Globals:
  Function:
    Handler: io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest
    CodeUri: target/function.zip
    Runtime: java21
    #SnapStart:
     #ApplyOn: PublishedVersions    
....

Daher ist es notwendig, alle vier Messungen an ein und derselben Anwendung durchzuführen. Je nach Messung müssen bestimmte Komponenten aktiviert oder deaktiviert werden. Eine elegantere Lösung wäre es, mehrere AWS SAM Templates zu definieren – jeweils mit und ohne SnapStart.

2. Beispielanwendung mit dem aktivierten AWS Lambda SnapStart ohne Anwendung von Priming

Wie wir sehen werden, zeigen die Performance-Messungen in Methode 1 ohne jegliche Optimierungen vor allem bei den Kaltstartzeiten deutlich höhere Werte. Aus diesem Grund stellen wir in den Methoden 2 bis 5 verschiedene Optimierungsansätze vor – einer davon ist AWS Lambda SnapStart.

Lambda SnapStart kann eine Startzeit einer Lambda-Funktion von weniger als einer Sekunde bieten. SnapStart vereinfacht die Entwicklung von reaktionsschnellen und skalierbaren Anwendungen ohne Bereitstellung von Ressourcen oder Implementierung komplexer Leistungsoptimierungen.

Der größte Anteil an der Startlatenz (oft als Kaltstartzeit bezeichnet) ist die Zeit, die Lambda mit der Initialisierung der Funktion verbringt, was das Laden des Funktionscodes, das Starten der Laufzeit und die Initialisierung des Funktionscodes umfasst. Mit SnapStart initialisiert Lambda unsere Funktion, wenn wir eine Funktionsversion veröffentlichen. Lambda macht einen Firecracker microVM Snapshot des Speicher- und Festplattenzustands der initialisierten Ausführungsumgebung, verschlüsselt den Snapshot und legt ihn intelligent im Cache ab, um die Abruflatenz zu optimieren.

Um die Ausfallsicherheit zu gewährleisten, verwaltet Lambda mehrere Kopien jedes Snapshots. Lambda patcht Snapshots und ihre Kopien automatisch mit den neuesten Laufzeit- und Sicherheitsupdates. Wenn wir die Funktionsversion zum ersten Mal aufrufen und wenn die Aufrufe zunehmen, setzt Lambda neue Ausführungsumgebungen aus dem zwischengespeicherten Snapshot fort, anstatt sie von Grund auf zu initialisieren, was die Startlatenz verbessert. Mehr Informationen finden Sie im Artikel Reducing Java cold starts on AWS Lambda functions with SnapStart. Ich habe online die ganze Serie über Lambda SnapStart für Java Anwendungen veröffentlicht.

Um Lambda SnapStart zu aktivieren, müssen wir in template.yaml bei der Lambda-Funktion folgendes durchführen:

Globals:
  Function:
    Handler: io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest
    CodeUri: target/function.zip
    Runtime: java21
    SnapStart:
     ApplyOn: PublishedVersions    
....

SnapStart kann entweder im Globals-Abschnitt des AWS SAM Templates aktiviert werden – in diesem Fall gilt es automatisch für alle definierten Lambda-Funktionen.
Alternativ kann SnapStart auch gezielt bei einzelnen Funktionen aktiviert werden, indem man folgende Zeilen in die jeweilige Funktionsdefinition einfügt:

SnapStart:
 ApplyOn: PublishedVersions    

Um die Performance-Messung ohne Priming-Techniken durchzuführen (wie in den Methoden 3 und 4 beschrieben), sollte in den Klassen AmazonDynamoDBPrimingResource und AmazonAPIGatewayPrimingResource die Annotation @Startup entweder auskommentieren oder entfernen.

3. Beispielanwendung mit aktiviertem AWS Lambda SnapStart und Priming der DynamoDB-Anfrage

In der Methode 2 haben wir bereits Lambda SnapStart kennengelernt. Für diese Methode ist das Aktivieren von Lambda SnapStart ebenfalls die Voraussetzung. Wie wir später sehen werden, reduzieren sich Kaltstartzeit allein durch die Aktivierung von SnapStart ohne Änderung an unserem Quellcode deutlich. Jedoch gibt es weitere Techniken, diese zu reduzieren, die wir Priming nennen und die Änderungen am Quellcode voraussetzen.

SnapStart und Runtime Hooks bieten Ihnen neue Möglichkeiten, Ihre Lambda-Funktionen für eine geringe Startlatenz zu erstellen. Mit dem Pre-Snapshot-Hook können wir unsere Java-Anwendung so weit wie möglich auf den ersten Aufruf vorbereiten. Wir laden und initialisieren so viel wie möglich, was unsere Lambda-Funktion benötigt, bevor der Snapshot erstellt wird. Diese Technik wird als Priming bezeichnet.

In dieser Methode stelle ich euch das Priming der DynamoDB-Anfrage vor, das in der Klasse AmazonDynamoDBPrimingResource umgesetzt ist.

@Startup
@ApplicationScoped
public class AmazonDynamoDBPrimingResource implements Resource {
	
    @Inject
	private ObjectMapper objectMapper;
	
	@Inject
	private DynamoProductDao productDao;	
	
    @PostConstruct
	public void init () {
		Core.getGlobalContext().register(this);
	}

	@Override
	public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
		 productDao.getProduct("0");
	}

	@Override
	public void afterRestore(org.crac.Context<? extends Resource> context) throws Exception {
	}
	
} 

Wir nutzen hier CRaC Runtime-Hooks. Dafür müssen wir in pom.xml folgende Abhängigkeit deklarieren:

<dependency>
	<groupId>io.github.crac</groupId>
	<artifactId>org-crac</artifactId>
</dependency>

AmazonDynamoDBPrimingResource Klasse ist mit @Startup Annotation annotiert (damit diese Klasse/Bean direkt beim Starten der Anwendung initialisiert wird) und implementiert org.crac.Resource Interface. Die Klasse registriert sich selber als CRaC-Ressource im Konstruktor. Das Priming selbst geschieht in der Methode, wo wir DynamoDB nach den Produkt mit der ID gleich 0 fragen. beforeCheckpoint Methode ist ein CRaC Runtime-Hook, der vor dem Erstellen des microVM Snapshots aufgerufen wird. Dabei sind wir am Ergebnis des Aufrufs productDao.getProduct(“0”) gar nicht interessiert. Vielmehr sorgt dieser Aufruf dafür, dass alle dafür benötigten Klassen instanziiert werden und die einmalige, ressourcenintensive Initialisierung des HTTP-Clients (standardmäßig Apache HTTP Client) sowie der Jackson-Marshaller zur Umwandlung von Java-Objekten in JSON und zurück erfolgt. Da dies bei aktiviertem SnapStart bereits während der Bereitstellungsphase (Deployment) der Lambda-Funktion geschieht – also bevor der Snapshot erstellt wird – enthält der Snapshot anschließend all diese initialisierten Komponenten. Nach der schnellen Snapshot-Widerherstellung während des Lambda-Aufrufs werden wir durch Priming dieser Art viel an Performanz im Falle des Kaltstarts gewinnen (siehe untenstehenden Messungen). Somit nehmen wir ein Priming der DynamoDB-Anfrage vor.

Damit nur dieses Priming greift, bitte in der folgenden Klasse AmazonAPIGatewayPrimingResource @Startup Annotation entweder auskommentieren oder entfernen.

4. Beispielanwendung mit aktiviertem AWS Lambda SnapStart und Priming des API-Gateway-Request-Events

Hier stelle ich Ihnen eine weitere experimentelle Priming-Technik vor, die das ganze Web Request (API Gateway Request Event) preinitialisiert. Es wird dadurch mehr preinitialisiert als in der Methode 3, erfordert aber auch deutlich mehr Code zu schreiben. Die Idee ist trotzdem vergleichbar. Für diese Methode ist das Aktivieren von Lambda SnapStart ebenfalls die Voraussetzung. Sehen wir uns die Umsetzung in der Klasse AmazonAPIGatewayPrimingResource an :

@Startup
@ApplicationScoped
public class AmazonAPIGatewayPrimingResource implements Resource {
		
    @PostConstruct
	public void init () {
		Core.getGlobalContext().register(this);
	}

	@Override
	public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
		logger.info("enter before checkpoint method");
		new QuarkusStreamHandler().handleRequest
		 (new ByteArrayInputStream(convertAwsProxRequestToJsonBytes()), 
				 new ByteArrayOutputStream(), new MockLambdaContext());
	}

	@Override
	public void afterRestore(org.crac.Context<? extends Resource> context) throws Exception {
	}
	
	private static byte[] convertAwsProxRequestToJsonBytes () throws JsonProcessingException {
		ObjectWriter ow = new ObjectMapper().writer().withDefaultPrettyPrinter();
		return ow.writeValueAsBytes(getAwsProxyRequest());
	}
	
    private static AwsProxyRequest getAwsProxyRequest () {
    	final AwsProxyRequest awsProxyRequest = new AwsProxyRequest ();
    	awsProxyRequest.setHttpMethod("GET");
    	awsProxyRequest.setPath("/products/0");
    	awsProxyRequest.setResource("/products/{id}");
    	awsProxyRequest.setPathParameters(Map.of("id","0"));
    	final AwsProxyRequestContext awsProxyRequestContext = new AwsProxyRequestContext();
    	final ApiGatewayRequestIdentity apiGatewayRequestIdentity= new ApiGatewayRequestIdentity();
    	apiGatewayRequestIdentity.setApiKey("blabla");
    	awsProxyRequestContext.setIdentity(apiGatewayRequestIdentity);
    	awsProxyRequest.setRequestContext(awsProxyRequestContext);
    	return awsProxyRequest;		
    }
}

Damit das Priming greift, stellen Sie bitte sicher, dass die @Startup-Annotation in der Klasse AmazonAPIGatewayPrimingResource vorhanden ist. Wie wir sehen, erstellen wir in der Methode getAwsProxyRequest Objekt vom Typ AwsProxyRequest, welches an den Pfad /products/{id} geschickt wird und ID =0 setzt. In der CRaC Runtime-Hook beforeCheckpoint Methode wird AwsProxyRequest in Byte-Array konvertiert und mittels Aufruf QuarkusStreamHandler().handleRequest verarbeitet. Im Grunde wird dadurch APIGatewayProxyRequestEvent gemockt und der Aufruf wird auf die Lambda-Funktion GetProductByIdHandler gemappt, dessen handleRequest Methode direkt aufgerufen wird. Das Priming wird in AWS lokal vorgenommen, es braucht also keinen Netzwerktrip.

Was wir dadurch bezwecken, ist das mit diesem Priming alle dafür benötigten Klassen instanziiert werden und die AWS Lambda Programmiermodell (und Aufruf) in Quarkus Programmiermodell übersetzt wird. Durch den preinitialisierten Aufruf von handleRequest Methode des GetProductByIdHandler wird das in der Methode Nummer 3 vorgestellte Priming vom DynamoDB Aufruf ebenfalls automatisch vorgenommen.

Damit nur dieses Priming greift, bitte in der folgenden Klasse AmazonDynamoDBPrimingResource die @Startup-Annotation entweder auskommentieren oder entfernen.

Ich betrachte diese Priming-Technik als experimentell, denn sie führt zu sehr viel Extra-Code, der durch ein paar Utility-Methoden deutlich vereinfacht werden kann. Daher ist die Entscheidung über die Nutzung dieser Priming-Methode dem Leser selber überlassen.

5. Eine umgebaute Beispielanwendung, die wir als GraalVM Native Image bauen und diese als Lambda Custom Runtime bereitstellen

Dieser Artikel setzt Vorkenntnisse über GraalVM und dessen Native Image Fähigkeiten voraus. Einen kompakten Überblick darüber und wie man beides installiert, finden Sie in den folgenden Artikeln: Introduction to GraalVM, GraalVM Architecture und GraalVM Native Image.

Da es gewisse Unterschiede gibt, wie man eine Anwendung als GraalVM Native Image umsetzt, kompiliert und als Lambda Custom Runtime bereitstellt, habe ich ein extra GitHub Repository dafür erstellt.

Schauen wir uns die Unterschiede zur unseren vorherigen Beispielanwendung an. Was den Quellcode der Anwendung angeht, ist die Klasse ReflectionConfig dazugekommen. In dieser Klasse definieren wir mithilfe von @RegisterForReflection Annotation die Klassen, die nur zur Laufzeit geladen werden.

@RegisterForReflection(targets = {
		APIGatewayProxyRequestEvent.class,
		HashSet.class, 
		APIGatewayProxyRequestEvent.ProxyRequestContext.class, 
		APIGatewayProxyRequestEvent.RequestIdentity.class,
        DateTime.class,
        Product.class,
        Products.class,
})

Da GraalVM Native Image Ahead-of-Time-Kompilierung verwendet, müssen wir solche Klassen im Voraus mitgeben, sonst werden zur Laufzeit ClassNotFound Fehler geworfen. Dazu gehören eigene Entitäten-Klassen wie Product und Products, einige AWS Abhängigkeiten zu APIGateway Proxy Event Request (aus dem artifact id aws-lambda-java-events aus pom.xml), DateTime Klasse zur Konvertierung von Zeitstempel von JSON zu Java-Objekt und einige anderen Klassen. In der Regel sind mehrere Durchläufe der Anwendung erforderlich, um sämtliche für die Laufzeit benötigten Klassen zu erkennen.

Es gibt andere Wege, Klassen für Reflection zu registrieren, die in diesem Artikel Tips for writing native applications beschrieben sind.

In der pom.xml sind zusätzliche Deklarationen notwendig. Zuerst benötigen wir die Amazon DynamoDB Client Quarkus extension von Quarkiverse. Ohne diese kommt es zu Fehlern. Die Konfiguration erfolgt wie folgt:

 <dependency>
   <groupId>io.quarkiverse.amazonservices</groupId>
   <artifactId>quarkus-amazon-dynamodb</artifactId>
    <version>3.2.0</version>
 </dependency>

Diese Extension greift automaisch, wir müssen dafür den Quellcode der Anwendung nicht verändern. Außerdem müssen wir das native Profil wie folgt aktivieren:

<profile>
   <id>native</id>
   <activation>
      <property>
	      <name>native</name>
      </property>
   </activation>
   <properties>
	  <quarkus.native.additional-build-args>
		  --initialize-at-run-time=software.amazonaws.example.product.dao
	   </quarkus.native.additional-build-args>
	   <quarkus.native.enabled>true</quarkus.native.enabled>
   </properties>
 </profile>

Hier initialisieren wir alle Klassen im Package software.amazonaws.example.product.dao zur Laufzeit. Ansonsten verursachen wir einen Fehler bei der Erstellung vom GraalVM Native Image.

Um unsere Anwendung als GraalVM Native Image zu bauen, müssen wir das native Profil als Parameter mitgeben. Der Aufruf sieht dann wie folgt aus: mvn clean package -Dnative. Dadurch wird GraalVM Native Image als Datei namen boostrap gebaut in function.zip abgelegt.

Der letzte Teil der Änderungen betrifft AWS SAM template.yaml. Da es keine verwaltete Lambda GraalVM Laufzeitumgebung gibt, stellt sich die Frage, wie wir auf AWS Lambda unseren Nativen GraalVM Image deployen können. Das geht, wenn wir Lambda Custom Runtime als Laufzeitumgebung wählen (diese unterstützt z.Z. nur Linux) und die gebaute .zip Datei als Deploymentartefakt deployen. Mehr dazu erfahren sie im Artikel Building a custom runtime for AWS Lambda. Genau das definieren wir in template.yaml wie folgt:

Globals:
  Function:
    Handler: io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest
    CodeUri: target/function.zip
    Runtime: provided.al2023  
    ....

Mit Runtime provided.al2023 definieren wir die Lambda Laufzeitumgebung als Amazon Linux 2023 Custom Runtime. Mit CodeUri target/function.zip definieren wir den Pfad zum Deploymentartifakt, das im vorherigen Schritt mit Maven kompiliert wurde. Das Deployment erfolgt anschließend wie gewohnt mit sam deploy -g. Wie Sie Produkt erstellen und abfragen, können Sie oben aus der initialen Beispielanwendung entnehmen. Bitte beachten Sie, dass die GraalVM Native Image Anwendung einen anderen API Key nutzt, nämlich a6ZbcDefQW12BN56WES318, welcher in template.yaml definiert ist. Bitte nutzt diesen bei euren Curl-Aufrufen (Beispiele siehe oben).

Somit haben wir alle 5 Methoden beleuchtet und wollen jetzt die Perfomanz der Lambda-Funktion mit allen Methoden messen.

Hier ist noch einmal die Zusammenfassung der Methoden. Wir werden nachher die Ergebnisse in der Tabelle unten der entsprechenden Methodennummer zuweisen:

  1. Messung ohne Aktivierung von Lambda SnapStart
  2. Messung mit Aktivierung von Lambda SnapStart, aber ohne Anwendung der Priming-Methoden
  3. Messung mit Aktivierung von Lambda SnapStart und mit der Anwendung des Priming der DynamoDB Anfrage
  4. Messung mit Aktivierung von Lambda SnapStart und mit der Anwendung des Priming vom API Gateway Request Event
  5. Messung mit GraalVM Native Image

Die Ergebnisse des Experiments basieren auf der Reproduktion von mehr als 100 Kalt- und etwa 100.000 Warmstarts mit der Lambda-Funktion GetProductByIdFunction (wir fragen nach dem bereits existierenden Produkt mit ID=1 ) für die Dauer von ca. 1 Stunde. Wir geben Lambda-Funktion 1024 MB Speicher, was einen guten Trade-Off zwischen Performanz und Kosten darstellt. Außerdem nutzen wir (Default) x86 Lambda Architektur. Für die Lasts-Tests habe ich das Lasttest-Tool hey verwendet, der in der Anwendung sehr dem Curl ähnelt. Sie können aber jedes beliebige Tool verwenden, z.B. Serverless Artillery oder Postman.

Bevor ich die Performanzmessungen präsentiere, noch ein paar Hinweise zum Messumfang bei den Methoden 1 bis 4.

  1. Wir werden alle 4 Messungen mit tiered compilation (die ist Default in Java 21, wir müssen nichts gesondert setzen) und Kompilierungsoption XX:+TieredCompilation -XX:TieredStopAtLevel=1 messen. Um die letzte Option zu verwenden, müsen wir in template.yaml diese in JAVA_OPTIONS Umgebungsvariable wie folgt setzen:
Globals:
  Function:
    Handler: io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest
    ...
    Environment:
      Variables:
        JAVA_TOOL_OPTIONS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

Mit den beiden erreichen wir die beste Performanz mit den unterschiedlichen Trade-Offs. Dazu können sie auch den Artikel Optimizing AWS Lambda function performance for Java lesen. Diese Kompilierungsoption ist für den GraalVM Native Image logischerweise irrelevant.

2. Bitte beachte Sie auch den Effekt von AWS SnapStart Snapshot Tiered-Cache. Das heißt, dass wir im Falle der SnapStart Aktivierung v.a. die größten Kaltstarts bei den ersten Messungen bekommen. Durch das Tiered-Cache werden die nachfolgenden Kaltstarts niedrigere Werte haben. Für weitere Details über die technische Umsetzung von AWS SnapStart und dessen Tiered-Cache verweise ich euch auf den Vortrag Mike Danilov: “AWS Lambda Under the Hood”. Damit der Effekt von Snapshot Tiered Cache für euch sichtbar wird, werde ich die Performanzmessungen sowohl für alle ca. 100 Kaltstartzeiten (in der Tabelle als all gekennzeichnet), aber auch für die letzten ca. 70 (in der Tabelle als last 70 gekennzeichnet) präsentieren. Je nachdem, wie oft die jeweilige Lambda-Funktion aktualisiert wird und dadurch einige Schichten des Caches invalidiert werden, kann eine Lambda Funktion tausende oder zehntausende Kaltstarts während ihres Lebenszyklus erleben, so dass die ersten länger dauernden Kaltstarts nicht mehr stark ins Gewicht fallen.

Performanzmessergebnisse

Bevor wir die Messergebnisse präsentieren, ein paar Hinweise:

  • Wir werden die Ergebnisse in der Tabelle unten der entsprechenden Methodennummer 1 bis 5 zuweisen. Siehe die Beschreibung oben
  • C in der Spalte ist die Abkürzung von Kaltstart
  • W in der Spalte ist die Abkürzung von Warmstart
  • P in der Spalte ist die Abkürzung von Perzentil
  • Alle Messungen sind in Millisekunden (ms).

Messergebnisse für tiered compilation:

Messergebnisse für XX:+TieredCompilation -XX:TieredStopAtLevel=1 Kompilierung:

Messergebnisse für GraalVM Native Image:

Fazit

In diesem Artikel haben wir einige Möglichkeiten zur Entwicklung, Bereitstellung und Betrieb von Anwendungen auf AWS Lambda mithilfe von Quarkus Framework erforscht und Performanz (die Kalt- und Warmstartzeiten) der Lambda-Funktion gemessen. AAußerdem haben wir gezeigt, wie wir die Performance der Lambda-Funktionen optimieren können – sowohl mit Lambda SnapStart inklusive verschiedener Priming-Techniken als auch mit GraalVM Native Image, das als AWS Lambda Custom Runtime eingesetzt wird.

Die Warmstartzeit sind auch ohne aktiviertes SnapStart sehr akzeptabel, denn Java an sich ist eine sehr schnelle Programmiersprache. Mit GraalVM Native Image sind die Warmstartzeiten jedoch etwas höher als bei Verwendung der verwalteten Java 21 Version in AWS Lambda.

Wir haben gesehen, dass allein die Aktivierung von Lambda SnapStart die Kaltstartzeit deutlich reduziert und noch viel mehr, wenn wir das Priming der DynamoDB Anfrage (Methode 3) umsetzen, was etwas Umsetzungsaufwand bedeutet. Der experimentelle API Gateway Request Priming (Methode 4) reduziert die Kaltstartzeit noch weiter, erfordert aber das Schreiben von viel Code. Die Verwendung ist daher dem Leser überlassen. Der Effekt von SnapStart AWS SnapStart Snapshot Tiered-Cache ist jedoch deutlich sichtbar.

Die niedrigsten Kaltstartzeiten erreichen wir jedoch mit GraalVM Native Image.

Ich empfehle, mit Lambda SnapStart zu beginnen – insbesondere dann, wenn die Priming-Techniken wie in Methode 3 anwendbar sind. SnapStart ist komplett von AWS verwaltet, daher müssen wir uns nicht um die Erstellung, Signierung, fehlertolerante Aufbewahrung und Wiederherstellung eines Snapshots kümmern. Im Falle von GraalVM sind wir für CI/CD selbst zuständig. Beachten Sie, dass sowohl mit SnapStart in der Deploymentphase, als auch mit GraalVM Native Image bei der Erstellung des Native Image, zusätzlich Zeit in Anspruch genommen wird. Es handelt sich dabei um mehrere Minuten. Daher empfehle ich z.B. auf allen Umgebungen außer live/production ohne die Aktivierung von SnapStart zu arbeiten.

Es gibt bei weitem mehr, was wir zwecks Performanzoptimierung erforschen können, z.B.

  • Unterschiedlichen RAM der Lambda-Funktion zuweisen. Mit mehr Speicher wird Lambda teuer, aber hat CPU Leistungen und wird dadurch schneller. Da eine der Komponente vom AWS Lambda-Preise GB/Sekunde ist, lohnt es sich mit RAM-Einstellungen zu expirementeiren, um das beste Preis-Leistungsverhältnis zu ermitteln. Besonders Anwendungen, die auf AWS Lambda als GraalVM Native Image bereitgestellt werden, bleiben auch mit weniger Speicher sehr performant.
  • Unterschiedliche Implementierungen des HTTP Client bei der Erstellung von DynamoDbClient setzen. Der Default ist ApacheClient, aber es gibt noch UrlConnection und den von AWS bereitgestellten AWS CRT HTTP Client. Seit Kurzem unterstützt AWS CRT Client auch GraalVM Native Image.
  • Anstelle von Default x86 Lambda Architektur arm64 setzen. Das ist 25 % günstiger pro GB/s als x86 ist. (siehe AWS Lambda-Preise).

Mit all diesen Einstellungen ergeben sich Abweichungen in den Kalt- und Warmstartzeiten. Die Experimente sprengen jedoch den Rahmen dieses Artikels. Ich werde diese aber im Laufe der Zeit auf meiner persönlichen Blogseite veröffentlichen.

Total
0
Shares
Previous Post

JAVAPRO Magazin – Java 25 Special Edition – Call for Papers

Next Post

Modernisierung von Java-Anwendungen mit Amazon EKS: Ein Cloud-nativer Ansatz

Related Posts