SpringBoot in einer Lambda Runtime mit SnapStart

Wie ich in den Artikeln Java goes Cloud native und Java auf CRaC bereits beschrieben habe, ist eine der wichtigsten Voraussetzungen in der Cloud die schnelle Startup-Zeit von neuen Instanzen. Da Java und gerade SpringBoot Anwendungen eine lange Cold-Startphase haben, muss einiges getan werden, um Java Fit für die Cloud zu machen. Dafür gibt es aktuell diverse Ansätze wie AoT Optimizations, GraalVM Native Images, CDS und den erwähnten CRaC. In diesen Artikel werde ich daher den Einsatz von CRaC innerhalb einer SpringBoot Anwendung im Betrieb in einer AWS Lambda betrachten und dabei die AWS SnapStart Eigenschaft nutzen.
 

Vorbereitung der SpringBoot Anwendung

Da Lambdas Serverless functions sind, sind diese zunächst nicht direkt kompatibel zu einer SpringBoot Anwendung, die eine Web/Rest-Schnittstelle zur Verfügung stellt.
Es gibt mehrere Möglichkeiten, eine SpringBoot Anwendung innerhalb einer Lambda zu verwenden. Ich nutze in der Anwendung die AWS Servless Java Container. Das ist eine von der AWS zur Verfügung gestellte Library, die es für unterschiedliche Java Frameworks ermöglicht, eine bestehende Anwendung mit Rest-Schnittstelle in einer Lambda zum Laufen zu bringen.
 

Beispiel Anwendung

Die Anwendung besitzt eine Simple API mit unter anderen einen GET /posts/ Endpunkt um Posts abzufragen. 
				
					@RestController
@RequestMapping("posts")
@RequiredArgsConstructor
public class PostsController {

    private final PostsClient postsClient;

    @GetMapping
    public List<Post> getPosts() {
        return postsClient.getPostsService().getPosts();
    }
    ...
}
				
			
Ein erster Testaufruf in der Lokalen Entwicklungsumgebung kann SpringBoot typisch über folgendem Befehl erfolgen:
				
					$ mvn spring-boot:run

$ curl -X GET http://localhost:8080/posts -H "Content-Type: application/json"
				
			
Das Ergebnis sieht wie folgt aus:
				
					[
  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
  {
    "userId": 1,
    "id": 2,
    "title": "qui est esse",
    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
  }
]
				
			

Verpacken der SpringBoot Anwendung für eine Lambda Runtime via Maven

Um die Anwendung nun für die Lambda fit zu machen, müssen ein paar kurze Schritte unternommen werden.
1. Ergänzung einer Dependency in der pom.xml. Diese liefert die Funktionalität die für die Übersetzung der Lambda Runtime und deren Request/Response Struktur zu HTTP Anfragen.
				
							<dependency>
			<groupId>com.amazonaws.serverless</groupId>
			<artifactId>aws-serverless-java-container-springboot3</artifactId>
			<version>2.0.0-M2</version>
			<scope>compile</scope>
		</dependency>
				
			
2. Für die Lambda Runtime muss eine zip-Datei mit entsprechendem Layout erstellt werden. Dazu verwende ich das maven-assembly-plugin und maven-dependency-plugin. Details sind dem Projekt auf github zu entnehmen. Das “normale” SpringBoot gebaute jar-Datei hat kein passendes Layout.

 

3. Damit das Rest-Interface über die Lambda angesprochen werden kann, muss noch das Interface com.amazonaws.services.lambda.runtime.RequestStreamHandler implementiert werden.
				
					public class StreamLambdaHandler implements RequestStreamHandler {
    private static SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler;
    static {
        try {
            handler = new SpringBootProxyHandlerBuilder<AwsProxyRequest>()
                    .defaultHttpApiV2Proxy()
                    .springBootApplication(SpringWithCracApplication.class)
                    .servletApplication()
                    .buildAndInitialize();
                    // Platz für Aufrufe der Anwendung um weitere Initialisierungen zu veranlassen.
        } catch (ContainerInitializationException e) {
            throw new RuntimeException("Could not initialize Spring Boot application", e);
        }
    }

    public StreamLambdaHandler() {
        Timer.enable();
    }

    @Override
    public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context)
            throws IOException {
        handler.proxyStream(inputStream, outputStream, context);
    }
}
				
			
Der Handler ist relativ simple aufgebaut. Es wird ein SpringBootProxyHandlerBuilder initialisiert, in diesen Fall mit defaultHttpApiV2Proxy. Diese Einstellung ermöglicht es, über eine Lambda Function Url das Rest-Interface auch zu erreichen. Zusätzlich muss gewählt werden ob es sich um eine servletApplication oder reactiveApplication handelt. 
Zusätzlich kann noch eine asynchrone Initialisierung aktiviert werden. Diese ist im Falle der Verwendung von SnapStart (CRaC) auf keinen Fall zu aktivieren. Eine Lambda hat eine maximale Initialisierungsdauer von 10 Sekunden. Benötigt die Lambda länger, wird ein Timeout ausgelöst und die Lambda startet erneut. Wird kein SnapStart aktiviert, ist es bei länger startenden Anwendungen mit der RequestsHandler wichtig, die asynchrone Initialisierung zu wählen, damit die Anwendung überhaupt korrekt starten kann.
Bei aktiviertem SnapStart findet die Lambda Initialisierung ein wenig anders statt. Nach einen Deployment (siehe nächsten Abschnitt) wird zunächst die SpringBoot Anwendung gestartet, um das Speicherabbild zu erzeugen. Würde nun eine asynchrone Initialisierung aktiviert sein, so kann kein Vollständiges Speicherabbild erstellt werden. Die Initialisierungsdauer bei aktivierten SnapStart darf bis zu 15 Minuten betragen.
 
Innerhalb der RequestHandler (Zeile 10) Implementierung können sogar noch nachträgliche Aktivitäten hinzugefügt werden, falls noch weitere Bestandteile der Anwendung erst initialisiert werden wenn der erste Aufruf stattgefunden hat. Dies ist häufig der Fall bei der Verwendung von SpringData mit Hibernate, wo noch interne Mechanismen bei der ersten Verwendung ablaufen. Siehe Hinweise zum Lambda Snapstart tuning.
 
Die Anwendung ist nach diesen drei Schritten bereit für den Weg in die Lambda.
Lambda Runtime
Schauen wir uns im nächsten Schritt das Deployment der Anwendung in die Lambda an. Das Beispiel-Projekt verfügt über ein automatisiertes Deployment via Pulumi. Auf die Funktionsweise von Pulumi gehe ich hier nicht näher ein. Dazu gibt es demnächst eine weiteren Blog-Post von meinen Kollegen.
Hier nur ein paar Auszüge aus der Pulumi (TypeScript) Automatisierung. 
				
					const springLambda = new aws.lambda.Function(
  `${resourcePrefix}spring-app-lambda`,
  {
    packageType: "Zip",
    handler: "de.aclue.blog.springwithcrac.StreamLambdaHandler::handleRequest",
    s3Bucket: springBootArtifact.bucket,
    s3Key: springBootArtifact.key,
    s3ObjectVersion: springBootArtifact.versionId,
    runtime: aws.lambda.Runtime.Java21,
    role: springLambdaRole.arn,
    memorySize: 256,
    timeout: 10,
    snapStart: {
      applyOn: "PublishedVersions",
    },
    environment: {
      variables: functionEnvironmentVariables,
    },
    publish: true,
  }
);
				
			
Wichtig ist der Block snapStart mit PublishedVersions. Das ist auch der einzige mögliche Wert der bei SnapStart verwenden werden kann.
Als Java Runtime ist Java 21 gewählt und ein Speicher von 256MB.
Bei der Verwendung einer Java Anwendung mit CRaC oder über einen Snapshot, wie es bei den AWS Lambda heißt, ist wichtig zu wissen, dass immer ein versioniertes Deployment durchgeführt werden muss. Daraus ergibt sich auch der Parameterwert “PublishedVersions”. Erst bei der Erzeugung einer Version der Lambda greift der Mechanismus für die Snapshots. Andernfalls wird die Lambda “normal” ausgeführt.
Die Erzeugung einer Version mit aktivierten Snapshotting führt dazu, dass die AWS automatisch Lambda Instanzen startet und von der JVM Speicherabbilder erzeugt, die irgendwo im AWS Universum gespeichert werden.
Die Erstellung einer Version kann mehrere Minuten dauern. In meinen Fall waren es knapp 2 Minuten. Nach Fertigstellung bietet es sich an, einen Alias auf die neueste Version zu setzen. Ein Alias kann eine eigene Function Url bekommen, auf die dann problemlos zugegriffen werden kann. So spart man sich auch ein API-Gateway vor der Lambda, falls diese über einen Rest Endpunkt angesprochen werden soll.

 

Nachfolgend der Log-Auszug bei der Initialisierung einer neuen Version einer mit Snapshot aktivierten Lambda:
				
					2024-02-01T10:53:11.290+01:00	INIT_START Runtime Version: java:21.v10 Runtime Version ARN: arn:aws:lambda:eu-central-1::runtime:2b04f677de1d4fc77196dcb1051a81ee8856b6dc4bb78e941f2bc400187237e1
2024-02-01T10:53:11.366+01:00	Picked up JAVA_TOOL_OPTIONS: -XX:+TieredCompilation -XX:TieredStopAtLevel=1 -XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions -Djdk.crac.trace-startup-time=true
2024-02-01T10:53:15.976+01:00	09:53:15.917 [main] INFO com.amazonaws.serverless.proxy.internal.LambdaContainerHandler -- Starting Lambda Container Handler
2024-02-01T10:53:17.335+01:00	Standard Commons Logging discovery in action with spring-jcl: please remove commons-logging.jar from classpath in order to avoid potential conflicts
2024-02-01T10:53:21.935+01:00	2024-02-01T09:53:21.836Z INFO 8 --- [ main] c.a.s.l.runtime.api.client.AWSLambda : Starting AWSLambda v2.4.1 using Java 21.0.1 with PID 8 (/var/runtime/lib/aws-lambda-java-runtime-interface-client-2.4.1-linux-x86_64.jar started by sbx_user1051 in /var/task)
2024-02-01T10:53:21.936+01:00	2024-02-01T09:53:21.936Z INFO 8 --- [ main] c.a.s.l.runtime.api.client.AWSLambda : No active profile set, falling back to 1 default profile: "default"
2024-02-01T10:53:29.175+01:00	2024-02-01T09:53:29.175Z INFO 8 --- [ main] c.a.s.p.i.servlet.AwsServletContext : Initializing Spring embedded WebApplicationContext
2024-02-01T10:53:29.177+01:00	2024-02-01T09:53:29.176Z INFO 8 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 6696 ms
2024-02-01T10:53:34.238+01:00	2024-02-01T09:53:34.237Z INFO 8 --- [ main] CracConfiguration$CracLifecyclePublisher : crac start, running:false
2024-02-01T10:53:34.397+01:00	2024-02-01T09:53:34.397Z INFO 8 --- [ main] c.a.s.l.runtime.api.client.AWSLambda : Started AWSLambda in 16.399 seconds (process running for 23.031)
2024-02-01T10:53:34.435+01:00	2024-02-01T09:53:34.435Z INFO 8 --- [ main] c.a.s.p.i.servlet.AwsServletContext : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-02-01T10:53:34.435+01:00	2024-02-01T09:53:34.435Z INFO 8 --- [ main] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2024-02-01T10:53:34.436+01:00	2024-02-01T09:53:34.436Z INFO 8 --- [ main] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
2024-02-01T10:53:34.478+01:00	2024-02-01T09:53:34.478Z INFO 8 --- [ main] CracConfiguration$CracLifecyclePublisher : crac stop, running:true
2024-02-01T10:53:34.578+01:00	INIT_REPORT Init Duration: 23287.65 ms
				
			
Die Anwendung startet bis sie vollständig initialisiert ist. 
Bei der Erstellung einer Version für die Snapshots wird die Anwendung ohne zusätzliche Power an CPU erstellt. Was die lange Startup-Zeit von ~23 Sekunden in den obigen Beispiel zur Folge hat.

 

So sieht ein Log-Auszug eines ersten Aufrufs einer versionierten Lambda mit aktivierten Snapshot (CRaC) aus:
				
					2024-02-01T10:55:48.168+01:00	RESTORE_START Runtime Version: java:21.v10 Runtime Version ARN: arn:aws:lambda:eu-central-1::runtime:2b04f677de1d4fc77196dcb1051a81ee8856b6dc4bb78e941f2bc400187237e1
2024-02-01T10:55:48.929+01:00	2024-02-01T09:55:48.921Z INFO 8 --- [ main] o.s.c.support.DefaultLifecycleProcessor : Restarting Spring-managed lifecycle beans after JVM restore
2024-02-01T10:55:48.935+01:00	2024-02-01T09:55:48.934Z INFO 8 --- [ main] CracConfiguration$CracLifecyclePublisher : crac start, running:false
2024-02-01T10:55:48.970+01:00	2024-02-01T09:55:48.970Z INFO 8 --- [ main] o.s.c.support.DefaultLifecycleProcessor : Spring-managed lifecycle restart completed (restored JVM running for -1 ms)
2024-02-01T10:55:48.988+01:00	RESTORE_REPORT Restore Duration: 820.05 ms
				
			
Der Restore Report wird immer bei einer frischen Lambda Instanz erzeugt und gibt die Dauer der Wiederherstellung vom Speicherabbild wieder.
Der Log des ersten Request sieht dann wie folgt aus:
				
					2024-02-01T10:55:48.992+01:00	START RequestId: 4114aaa2-3a20-4879-a375-6f0a2a1b8442 Version: 9
2024-02-01T10:55:53.929+01:00	2024-02-01T09:55:53.928Z INFO 8 --- [ main] c.a.s.p.internal.LambdaContainerHandler : 2a04:4540:3500:9800:8db8:6d15:4df8:2647 -- [01/02/2024:09:55:48Z] "GET /posts HTTP/1.1" 200 24519 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" combined
2024-02-01T10:55:54.050+01:00	END RequestId: 4114aaa2-3a20-4879-a375-6f0a2a1b8442
2024-02-01T10:55:54.050+01:00	REPORT RequestId: 4114aaa2-3a20-4879-a375-6f0a2a1b8442 Duration: 5057.30 ms Billed Duration: 5370 ms Memory Size: 256 MB Max Memory Used: 185 MB Restore Duration: 820.05 ms Billed Resto
				
			
Der erste Request hat hier 5 Sekunden gedauert. Das liegt leider an den langsamen Verbindungsaufbauten zu externen Netzwerken innerhalb der AWS. Die Posts, die in den Beispiel abgefragt werden, sind von einen fremden Service. Beim zweiten Aufruf des /posts Endpunkts liegt die Antwortzeit im niedrigen Millisekunden Bereich. Dieses Problem ist bei jeder Anwendung mit oder ohne Snapstart vorhanden.
 
Wichtig: Ein Timeout für eine Lambda Instanz sollte möglichst nicht zu klein gewählt werden. Sollte die Lambda auf einen Timeout laufen, so wird die Anwendung neu gestartet. Nur in diesem Fall greift leider nicht der Snapshot erneut, sondern die Anwendung startet in der selben JVM frisch mit neuen Speicher. Das bedeutet, dass der erneute Call auf diese Instanz noch einmal deutlich länger dauert, da die Spring Boot Anwendung komplett frisch initialisiert.

Fazit

CRaC funktioniert mit SpringBoot (ab Version 3.2) gut und zuverlässig. Durch relativ wenig Anpassung an der Anwendung ist der Einsatz einer bestehenden SpringBoot Anwendung mit Rest-Schnittstelle schnell erledigt. Gerade bei Anwendungen, die unregelmäßig und vielleicht auch eher selten verwendet werden, bietet sich ein Betrieb in einer Lambda gegenüber Containern im ECS oder Kubernetes Cluster an.
Das Hauptproblem besteht aus meiner Sicht beim langsamen Netzwerkverbindungsaufbau. Es ist aber auf jeden Fall einen Versuch wert, die eigene Anwendung einmal in der Lambda zu deployen, um zu prüfen wie hoch die Restore-Zeit jeweils ist.
Lambdas passen vom Prinzip nicht unbedingt ideal zu einer SpringBoot Anwendung, die sehr viele Requests gleichzeitig verarbeiten kann, da die Lambda selber immer nur einen Request zur Zeit verarbeitet.
Als Alternative im Servless Umfeld, wo nach pay as you go abgerechnet wird, ist CloudRun aus der Google Cloud. Bei CloudRun können mehrere Requests von einer Instanz verarbeitet werden, was deutlich besser zu einer Java Anwendung passt, damit nicht so häufig und viele neue Instanzen erzeugt werden müssen. In einem weiteren Blog Beitrag werde ich daher beleuchten wie der Einsatz in CloudRun möglich ist.

Das beschriebene Beispielprojekt findest du im Github-Repository.