Skip to content
Alle Beiträge

AWS Lambda: Erste Schritte mit Java

Coding

Serverless-Angebote erfreuen sich zurzeit großer Beliebtheit. Amazon hat mit AWS ein breites Spektrum an Diensten, u.a. auch AWS Lambda. In diesem Artikel lernen wir, was die Vorteile bei der Verwendung von AWS Lambda sind und wie wir Java-Code in AWS-Lambda installieren und ausführen können. 

Wir verwenden bei der Implementierung Java 17 mit Gradle und Lombok. Den Beispielcode aus diesem Artikel haben wir in einem Git-Repository bereitgestellt. Weiterhin benötigen wir für die Installation und Ausführung einen AWS-Account

Warum AWS Lambda? 

Cloud Computing bietet mit IaaS/CaaS/PaaS/FaaS/SaaS ein breites Spektrum an Diensten. Allen gemeinsam ist die höhere Effizienz und Optimierung durch Auslagern der Verantwortlichkeiten für Elastizität, Skalierbarkeit, Sicherheit, Disaster Recovery und Vieles mehr. 

Eine weit verbreitete Nutzung besteht in der Implementierung von Microservices-Architekturen mit CaaS bzw. PaaS. Anwendungen werden dabei üblicherweise fachlich geschnitten (Domain Driven Design) und mithilfe von Container-Technologien in der Cloud betrieben. Die Verwendung von Containern ermöglicht eine automatisierte Skalierung, die Kommunikation mit standardisierten Protokollen außerdem auch Flexibilität bei der Auswahl der Technologien für die Implementierung. Jedoch laufen diese Container dauerhaft und erzeugen damit je nach Modell laufende Kosten unabhängig von deren Nutzung. Außerdem verbrauchen Cloud-Systeme mit laufenden Containern mehr Strom, was dem derzeitig aufkommenden Trend zur Minimierung des CO2-Fußabdrucks (Green IT) entgegensteht. 

Hier kommt AWS Lambda ins Spiel. Java-Entwickler kennen seit Java 8 bereits die gleichnamigen Lambda Expressions. Und die Namensgleichheit kommt nicht von ungefähr – stehen beide Technologien für eine Ausführung On-Demand. Jedoch betrachten wir bei AWS Lambda die Ausführung von Containern, die dann im Bauch wiederum beliebige Technologien enthalten können. Um dies zu ermöglichen, ist es wichtig, eine standardisierte Aufrufschnittstelle zu definieren und die Startzeiten für Container so performant wie möglich zu gestalten. 

AWS Lambda ist außerdem Teil einer Serverless-Familie. Zwar erlaubt es durchaus, eigens konfigurierte Container zu deployen, es bietet aber bereits vordefinierte Laufzeitumgebungen an, aktuell für Java, C#, node.js, Python, Ruby und Go. Damit entfällt das Server-Management, was die Entwicklung effizienter gestaltet und die Anwendungen von Natur aus skalierbar macht. Außerdem sind die Kosten für die Nutzung solcher Angebote im Sinne von „Pay-as-you-go“ geringer als sonst. 

Was kann mit AWS Lambda ausgeführt werden?

Wie so oft bietet AWS Lambda ein Werkzeug an, das unterschiedlich genutzt werden kann. Die Bandbreite ergibt sich hierbei durch die Einbettung in die Landschaft an Cloud-Diensten. So kann eine Lambda-Funktion beispielsweise im Rahmen eines Prozess-Flows mit AWS Step Functions aufgerufen werden. Solche Funktionen erledigen dann kleine, abgeschlossene Aufgaben, z.B. den Aufruf eines REST-Service oder einen kleinen Teil an Business-Logik. Bei Einbindung hinter einem API-Gateway ist es allerdings durchaus möglich, komplette Microservices als Lambda-Funktion zu betreiben. Somit können bestehende Anwendungen leicht auf On-Demand-Betrieb migriert werden (Lift and Shift). Spring Boot und Quarkus haben für diesen Anwendungsfall bereits Integrationen. Mittel- und langfristig ist es jedoch empfehlenswert, Lambdas kleinteiliger auf einzelne Funktionen zu schneiden. 

Bild1-2


Wie funktioniert ein Aufruf mit AWS Lambda?

JSON

AWS arbeitet mit sogenannten Event-Objekten, die in Form von JSON bei der Kommunikation zwischen den Bausteinen der Gesamtarchitektur übertragen werden. So auch bei Aufruf eines Lambdas – es wird ein JSON-Objekt übergeben und es wird mit einem JSON-Objekt geantwortet. Die Bearbeitung übernimmt dann ein RequestHandler, den wir mit der jeweiligen Logik implementieren. 

Während beim Aufruf über Step Functions das JSON-Objekt beliebig definiert werden kann, erhalten wir vom API-Gateway ein vordefiniertes Schema. Ein Beispiel dafür finden wir in der AWS-Dokumentation

Bild2

Kalt- vs. Warmstart 

Lambdas haben einen Lebenszyklus. Bei der ersten Anfrage wird der Code geladen (aus dem eigenen Speicher bzw. üblicherweise aus einem S3-Bucket) und die Laufzeitumgebung generiert (Kaltstart). Danach erfolgt dann die Ausführung des Codes. Nach der Ausführung wird die Laufzeitumgebung behalten, sodass bei erneuter Anfrage lediglich der Code ausgeführt werden muss (Warmstart). Erfolgt längere Zeit keine Anfrage, wird die Laufzeitumgebung freigegeben. Bei erneuter Anfrage erfolgt dann wieder ein Kaltstart. Details dazu finden wir in einem AWS-eigenen Blogeintrag

Für die Performance eines Lambdas ist also vor allem die Zeit zur Initialisierung entscheidend. Das gilt einerseits für den Container beim Kaltstart, als auch bei jeder Anfrage für unseren Code. Für die Optimierung des Codes sollten wir vor allem auf minimale Initialisierungen achten, z.B. Lazy Initialization konfigurieren oder unnötige Dependencies vermeiden. Native Builds mit GraalVM verlagern Initialisierungsvorgänge wie Classpath-Scanning, Reflection, Dependency-Injection-Analysen und JIT-Compiling nach vorn in die Bauzeit. In letzter Instanz kann AWS Lambda bereits initialisierte Anwendungen „warm“ halten, was jedoch zusätzliche Kosten verursacht (Provisioned Concurrency). 

Wie implementiere ich eine Funktion für AWS Lambda?

Grundsätzlich können wir Lambdas mit beliebigen Programmiersprachen oder als Shell-Skript implementieren. Für Java, C#, node.js, Python, Ruby und Go gibt es bereits fertige, optimierte Laufzeitumgebungen. Wir implementieren unser Beispiel mit Java und verzichten zur Vereinfachung bewusst auf Frameworks wie Spring oder Quarkus. 

Beispiel 

Als fachliches Beispiel implementieren wir eine Funktion zur Reservierung von Räumen für jeweils einen bestimmten Tag. Hierfür implementieren wir im Kern einen RoomReservationService mit folgender Aufrufschnittstelle:

code1

Einstiegspunkt 

Für die Aufrufschnittstelle kann jede öffentliche Funktion einer beliebigen Klasse definiert werden. Üblich ist jedoch die Implementierung eines in AWS bereitgestellten RequestHandler-Interfaces. Dieses wird vom AWS Lambda SDK bereitgestellt, das wir als Dependency ebenso wie Lombok in unserem Projekt einbinden:code2

Input

Für die Definition der Aufrufschnittstelle benötigen wir nun einen Datentyp, der von AWS Lambda automatisch auf das Event-Objekt gemappt wird. Der Aufruf für die Raumreservierung soll mit diesem Objekt erfolgen:

code3

Der zugehörige Datentyp sieht dann in etwa so aus:

code4

Output

Und auch für die Antwort des Lambdas benötigen wir einen Datentyp:code5

Damit antwortet unser Lambda im Erfolgsfall mit diesem JSON:

code6

Handler-Implementierung

Die Implementierung des Handlers verbindet dann alle Komponenten miteinander:

code7

JSON-Serialisierung mit Standard-Frameworks

Das Mapping der Objekte auf JSON erfolgt mit einer Eigenimplementierung von AWS. Für eine bessere, standardisierte Konfiguration kann es sinnvoll sein, auf Standard-Frameworks wie Jackson, GSON oder JSON-B zurückzugreifen. Für größer geschnittene Lambdas kann es außerdem erforderlich sein, flexibel auf unterschiedliche Eingangs-JSON reagieren zu können.

Wir möchten in unserem Beispiel auf Jackson umbauen. Dafür binden wir die entsprechenden Dependencies ein:

code8

Nun implementieren wir statt dem Request Handler einen RequestStreamHandler, der anstelle von fertigen Objekten mit In- und OutputStream arbeitet. In unserem Beispiel nutzen wir die o.g. Implementierung als Delegate. Damit trennen wir auch automatisch Verantwortlichkeiten:

code9

Jetzt haben wir die Möglichkeit, die Jackson-eigenen Konfigurationsmöglichkeiten zu nutzen. Unser Datentyp für das Event-Objekt könnte nun auch so ausschauen:

code10

Logging

AWS Lambda Logging in AWS CloudWatch erfolgt automatisch bei Nutzung der JRE-eigenen System-Klasse oder aber bei Nutzung des über den Context-Parameter mitgelieferten Logger, der allerdings keine Log-Levels unterscheiden kann:

4.4

Eine Integration von Log4j2 kann sinnvoll sein – z.B. um Ausgaben von Dependencies auf Basis von Log4j2 ebenfalls zu erhalten. Hierfür nehmen wir die entsprechende Dependency auf:

code11

Wir konfigurieren Log4j2 wie gewohnt per log4j2.xml. Hierbei ist zu beachten, dass das AWS Lambda SDK einen eigenen Appender bereitstellt, der die AWS Request ID automatisch mitloggen kann, was für Tracing notwendig ist:

code12

Nun können wir in unseren Anwendungsklassen Log4j2-Logger verwenden:

code13

AWS-spezifische Events

Wie bereits erwähnt, können Lambdas auch von anderen Komponenten der AWS-Infrastruktur mit standardisierten Event-Objekten aufgerufen werden. In diesem Fall wäre es mühsam, die entsprechenden Java-Datentypen selbst zu erstellen und zu verwalten. Selbstverständlich bietet AWS diese Datentypen bereits in einer Bibliothek an. Wir erweitern hierfür unser Projekt lediglich um eine weitere Dependency:

code14

Für einen Aufruf vom API Gateway z.B. können wir den Handler nun so implementieren:

code15

 

Wie installiere und starte ich die Lambda-Funktion?

Build

Neben dem klassischen JAR erlaubt AWS auch die Bereitstellung einer ZIP-Datei. Dies ermöglicht das Verpacken mitsamt der Abhängigkeiten, ohne sog. Shaded JARs bzw. Uber JARs erzeugen zu müssen. Hierzu erweitern wir unsere Gradle-Konfiguration wie folgt:

code16

Beim Bauen wird damit ein AWS-Lambda-konformes ZIP erstellt, das alle Abhängigkeiten enthält:

 Bild3

Layers

Mithilfe von Layers können Abhängigkeiten unserer Lambda-Funktion separat eingestellt werden. Dies kann gerade bei externen Abhängigkeiten sinnvoll sein, um die Größe der Deployment-Pakete zu minimieren – aber auch bei einer Trennung von Lambda unabhängiger Logik und der Aufrufschnittstelle. So ist es auch möglich, eine Business-Logik zu entwickeln und zentral zu deployen, die dann mit mehreren RequestHandler-Implementierungen und damit in mehreren Lambda-Funktionen ansprechbar ist.

 

Bild4

 

Deployment

Die ZIP-Datei können wir nun in AWS installieren und ausführen. Dazu haben wir mehrere Möglichkeiten.

AWS Management Console

Über die AWS Management Console erhalten wir eine Browser UI zur Verwaltung der Lambdas. Dort können wir manuell Lambdas erstellen, Anwendungscode hochladen oder direkt editieren sowie Einstellungen dazu vornehmen. Wir erstellen zuerst eine neue Funktion:

Bild5

Hier können wir der Funktion einen Namen geben (z.B. room-reservation) sowie einen der vorkonfigurierten Container auswählen (hier. Java 17). Bei Bedarf können auch Berechtigungen angepasst und Layer deklariert bzw. eingebunden werden.

Beim Bearbeiten der Funktion laden wir die gebaute ZIP-Datei hoch und geben den Namen des Handlers (Klassenname::Methodenname) an:

Bild6

Fertig! Über den Reiter „Test“ können wir die Funktion nun probeweise ausführen. Hierzu spezifizieren wir lediglich das Event-Objekt:

Bild7

Wir erhalten nun das Ergebnis:

Bild8

 

Kommandozeile

Die AWS CLI bietet auch für Lambdas entsprechende Funktionen an. Für eine CI/CD-Pipeline kann beispielsweise die Aktualisierung des Codes interessant sein. Nach dem Gradle-Build kann dies mit dem Befehl update-function-code geschehen:Kommandozeile

Als Funktionsname kann alternativ auch die in AWS zugewiesene ARN verwendet werden. Statt dem ZIP-File kann auch ein S3-Bucket angegeben werden.

Zu beachten ist, dass für diese Operation und den in der CLI verwendeten Benutzeraccount eine Berechtigung vergeben werden muss. Hierfür müssen wir in den Details zur Lambda-Funktion im Reiter „Konfiguration“ auf „Berechtigungen“ gehen und eine ressourcenbasierte Richtlinienanweisung vergeben:

Bild9

Ausführen lässt sich die Funktion ebenfalls über die CLI mit entsprechender lambda:InvokeFunction-Berechtigung:

code17

 Serverless Application Model (SAM)

SAM ist ein Toolkit für ein effizienteres Erstellen und Ausführen von Serverless-Anwendungen. Es ermöglicht, die Infrastruktur als YML-Datei zu deklarieren, ein entsprechendes Paket zu bauen und als S3-Bucket hochzuladen. Damit befassen wir uns in einem späteren Artikel.

Testen

Wir haben bereits gelernt, die Lambda-Funktion in der AWS Management Console sowie über die Kommandozeile zu starten. Für die Entwicklung sind aber auch weitere Unterstützungen sinnvoll.

Integration in Entwicklungsumgebungen

Für die gängigen Entwicklungsumgebungen gibt es das AWS Toolkit (z.B. IntelliJ, VS Code). Damit lassen sich u.a. Lambda-Funktionen ausführen – sowohl in AWS (remote) als auch lokal. Letzteres benötigt eine Docker Runtime sowie eine installierte SAM CLI.

Bild11

Bild12

Nach der Ausführung ist ein entsprechendes lokales Docker-Image vorhanden:

Bild13

 

Automatisierte Tests

In der AWS Lambda Dokumentation werden die unterschiedlichen Testmöglichkeiten erwähnt:

  • Modultests mit Mocks – unser Code sollte so strukturiert sein, dass wir unabhängig von einer Lambda-Umgebung testen können.
  • Integrationstests – Deployment in AWS Lambda und Ausführen etwa mit der CLI wie beschrieben.
  • Lokale Emulation – z.B. mit AWS SAM local.

Letztlich ist die lokale Emulation nur eingeschränkt möglich (z.B. wenn weitere AWS-Ressourcen benötigt werden), sodass ein gutes Testkonzept auf Modul- und Integrationstests aufbaut. Spezielle Dependencies braucht es dafür nicht unbedingt. Eine kleine AWS-Lambda-eigene JUnit-basierte Testbibliothek für das Laden von Events aus JSON-Testdaten gibt es dennoch.

 

FAZIT

AWS Lambda lässt dem Entwickler keine Wünsche offen. Die API ist minimal-invasiv – es ließen sich sogar komplett Lambda-unabhängige JARs deployen, was auch die Migration bestehender Anwendungen vereinfachen kann. Mit Browser-UI und CLI sowie Integrationen in Entwicklungsumgebungen bleiben keine Wünsche offen. Lediglich die JSON-basierte Aufrufschnittstelle und das bevorzugte kleinere Schneiden bedeutet ein wenig Umdenken. Allerdings lässt sich über die Layer auch hier eine entsprechende Abstraktion erreichen.

 

Weitere Beiträge