Java auf CRaC

Meine erste Begegnung mit CRaC war eher zufällig als ich in der AWS auf Erkundungstour war, was es eventuell so neues gibt.
Ziel war es, Java in einer Lambda laufen zu lassen. Ich war primär auf der Suche nach einer sehr kostengünstigen Betriebsplattform für einen typischen Microservice mit Spring Boot. Da eine Lambda als Pay as you go abgerechnet wird, war die AWS meine erste Wahl. Nun ist eine Spring Boot Anwendung auf den ersten Blick nicht die beste Idee für eine Lambda, da gerade Java und noch viel mehr Spring Boot Anwendungen Probleme mit schnellen Startup-Zeiten haben, wie ich bereits im einleitenden Artikel Java goes Cloud native beschrieben habe.
Auch das Anwendungsmodel, einen kompletten Microservice in einer Anwendung als eine Cloud Function zu betreiben, passt auf den ersten Blick nicht ganz zusammen. Aber das hat für mich auch gerade den Reiz ausgemacht, es trotzdem einmal auszuprobieren.
 

AWS Lambda Snapstart

Zunächst hatte ich überlegt, meinen Microservice als Spring Native Anwendung in die Lambda zu bringen, um schnellere Startup-Zeiten zu erreichen und gleichzeitig den Ressourcenverbrauch zu minimieren. Aber als ich mich so durch die Lambda Einstellungen in der AWS Console geklickt habe, ist mir die unscheinbare Option “SnapStart” aufgefallen. Zum Glück hat der Info Link tatsächlich direkt Hinweise gegeben, um was es sich dabei handelt.

Lambda SnapStart is a performance optimization that helps reduce startup latency at no additional cost. On supported runtimes, you can activate SnapStart for published versions. When you publish a function version, Lambda takes a snapshot of the memory and disk state of the initialized execution environment, encrypts the snapshot, and caches it for low-latency access. When you invoke the function version for the first time, and as the invocations scale up, Lambda resumes the initialized snapshot and then invokes the function handler.

Perfekt dachte ich. Damit bekomme ich meine Anwendung bestimmt schnell gestartet. Allerdings habe ich mich gefragt: “Wie machen die das und geht das auch mit meiner SpringBoot Anwendung?”
 

Coordinated Restore at Checkpoint (CRaC)

Beim Durchlesen der Dokumentation wurde dann schon etwas klarer, was alles unterstützt wird und was nicht, dazu kommen wir gleich noch. Auf der dritten oder vierten Seite der AWS Dokumentation wurde es dann auch endlich verraten. Die Java Anwendung wird unter CRaC gesetzt. CRaC ist ein OpenSource-Projekt, welches einer Java Anwendung ermöglicht, koordiniert durch Runtime Hooks ein Speicherabbild der JVM zu erstellen. Dieses wird einfach abgespeichert und zu einem späteren Zeitpunkt erneut geladen.
Das bedeutet konkret, die Java Anwendung wird gestartet und, nachdem sie ready ist, mit einen Speicherabbild wieder beendet. Die lange Startup-Zeit ist somit “gesichert” im Speicherabbild. Genau dieses wird dann später verwendet und über die JVM wieder gestartet.
Die Runtime Hooks sind sehr einfach. Innerhalb des eigenen Java Codes muss das Interface org.crac.Resource implementiert werden. Dieses beinhaltet zwei Methoden beforeCheckpoint() und afterRestore(). Die Klasse, die das Interface implementiert muss dann noch im Context registriert werden. Da es sich um ein extra OpenSource-Projekt handelt, ist CRaC “noch” nicht im normalen OpenJDK oder änlichen Derivaten eingebaut. In der AWS Lambda wird dies für Java 11 und 17 direkt unterstützt. Will man eine Anwendung mit CRaC außerhalb einer Lambda betreiben, ist es am einfachsten das Azul JDK zu verwenden.
Frameworks wie Micronaut und Quarkus unterstützen CRaC bereits seit gut einem Jahr.
 

Spring Boot mit CRaC

Bei Spring(Boot) ist es aktuell noch in der Entwicklung. Mit dem Spring Framework 6.1 und damit Spring Boot 3.2 wird die CRaC Unterstützung auch dort Einzug halten. Diese soll im November 2023 veröffentlicht werden. Aktuell ist aber bereits vieles davon im Snapshot bzw. Milestone funktionsfähig.
Der Vorteil ist, es muss an einer bestehenden Spring Boot Anwendung, die auf der Version 3.2 basiert, nicht wirklich etwas verändert werden. Innerhalb von Spring wurde das Ressource Interface implementiert und in den Lifecycle Mechanismus von Spring entsprechend eingehängt.
Es stellt sich die Frage “Was muss berücksichtigt werden beim Erstellen bzw. Restore eines Checkpoints bzw. SnapShots?”:
  • Offene Netzwerkverbindungen (Sockets)
  • Offene Dateien
  • Zufallswerte

 

Starker Einfluss auf die Startup-Zeit

Gerade die Herstellung von Netzwerkverbindungen dauert in jeder Art von Anwendung leider eine gewisse Zeit. Das bedeutet auch, dass CRaC zwar so einiges beschleunigt, aber die Zeiten, die beim Verbindungsaufbau “verloren” gehen, damit leider auch nicht aufgefangen werden können.
Innerhalb von Spring wird dafür gesorgt, dass über die Datasource-Pools (z.B. Hikari) vor dem Erstellen Resource#beforeCheckpoint() die Datenbank-Verbindungen beendet werden und beim Starten des Speicherabbilds Resource#afterRestore() diese wieder hergestellt werden. Der Datasource-Pool kann danach wieder mit entsprechenden Verbindungen vorgefüllt werden.
Der Start einer Spring Boot Anwendung wird durch den Einsatz von CRaC ungemein schnell. Die Verbindungsaufbauten wird man leider nicht los. Trotzdem bewegen sich die eigentlichen Startup-Zeiten im niedrigen Millisekunden-Bereich.
 

Leider nicht die ganze Wahrheit

Die eigentlichen Startup-Zeiten, die auf vielen Seiten (z.B. CRaC in Github) zu CRaC beschrieben werden, haben super niedrige Werte. So startet eine Spring Boot Anwendung anstelle von 3898 Millisekunden in 38 Millisekunden. Ein Traum, wenn man ehrlich ist. Allerdings sind diese Beispiele immer ohne Remote-Verbindungen, was auch dafür sorgt, dass es in der Realität noch ein wenig länger dauert.
Aber der entscheidende Faktor ist das eigentliche Laden des Speicherabbilds. Dies dauert z.B. innerhalb einer Lambda gerne um die 1 bis 2 Sekunden.

CloudWatch Log-Auszug

				
					| timestamp     | message                                                                                                                                                               |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1695040925152 | RESTORE_START Runtime Version: java:17.v11 Runtime Version ARN: arn:aws:lambda:eu-central-1::runtime:7c8ee8807454660e26df3e04071c9241ba6c672dffea8696c27ebe47d9707d03 |
| 1695040926715 | 2023-09-18T12:42:06.701Z INFO 8 --- [ main] o.s.c.support.DefaultLifecycleProcessor : Restarting Spring-managed lifecycle beans after JVM restore                     |
| 1695040926771 | 2023-09-18T12:42:06.770Z INFO 8 --- [ main] o.s.b.j.HikariCheckpointRestoreLifecycle : Resuming Hikari pool                                                           |
| 1695040926827 | 2023-09-18T12:42:06.826Z INFO 8 --- [ main] o.s.c.support.DefaultLifecycleProcessor : Spring-managed lifecycle restart completed in 121 ms                            |
| 1695040926841 | RESTORE_REPORT Restore Duration: 1688.38 ms                                                                                                                           |
				
			
Wie es häufig so ist, wird so etwas gerne auf “Marketing” Seiten verschwiegen. Je mehr Speicher von der Anwendung benötigt wird, um so größer wird auch das Speicherabbild und damit vermutlich auch die Restore-Zeit (das habe ich nicht näher untersucht).
 

Verwendung im Container

Neben der potenziellen Verwendung innerhalb einer Lambda kann eine Spring Boot Anwendnung natürlich auch gut in einen Container verpackt und mit CRaC verwendet werden. Dazu muss zunächst die Java Anwendung mit CRaC-Parametern im Container gestartet werden. Nach erfolgreichem Start kann dann ein Speicherabbild erstellt werden, welches im Anschluss in einen neuen Stand des Container Images gespeichert wird. Zum Einsatz kommt später dann das neue Container-Image, welches mit einen speziellen CRaC-Aufruf das Speicherabbild wieder herstellt. Damit ist es dann sehr leicht möglich, die Anwendung über den Container z.B. in einen Kubernetes Cluster laufen zu lassen, um auch dort von der deutlich schnelleren Startup-Zeit zu profitieren.

Die Schritte dahin können auch der Dokumentation entnommen werden. In einem späteren Artikel werde ich eine Beispielanwendung vorstellen und dabei einen Blick auf den Betrieb sowohl in der Lambda, als auch im Container werfen.

Sicherheit

Zum Schluss noch ein Hinweis zur Sicherheit. Wichtig zu beachten ist, dass innerhalb des Speicherabbilds auch sicherheitsrelevante Dinge wie Passwörter vorhanden sein können, da eine Anwendung diese typischerweise nach den Startup im Klartext im Speicher hält. Innerhalb der AWS Lambda wird der von der AWS erzeugte Snapshot verschlüsselt abgelegt. Wie sinnvoll damit innerhalb von Container-Images umgegangen werden kann, ist mir aktuell auch noch unklar.