Aufwärmen von Spring Boot Anwendungen
Kategorie Software Engineering | Keine Kommentare »
Spring Boot ist ein ausgereiftes Werkzeug zur Entwicklung von Java & Kotlin Backends. Startet man solch ein Backend neu, dauert der erste REST Aufruf immer extrem lange. Ich habe untersucht, woran das liegt und wie man die Anwendung beim Start aufwärmen kann, damit auch der erste Request schnell verarbeitet wird.
Spring Boot
An dieser Stelle kann ich keinen vollumfänglichen Überblick zu Spring Boot geben, sondern möchte nur grob skizzieren, was es ist, damit der geneigte Leser dem Beitrag folgen kann.
Spring bzw. Spring Boot ist ein riesiges Framework, um mittels Java oder Kotlin Server Applikationen zu entwickeln. Ich nutze es, um verschiedene REST Webservices in einer Microservice Architektur zu entwickeln. Spring kümmert sich um die Infrastruktur. Möchte ich zum Beispiel einen HTTP POST Endpunkt unter dem Pfad /api erzeugen, erstelle ich eine kurze Klasse mit einigen Spring-Annotationen. Hier ein kurzes Beispiel solch einer Klasse:
@RestController
@RequestMapping(
path = "/api",
consumes = APPLICATION_JSON_VALUE
)
public class SomeController {
@Autowired
private SomeService someService;
@PostMapping
public ResponseEntity<SomeResponseDto> post(@RequestBody @Valid SomeRequestDto someRequestDto) {
final SomeResponseDto responseDto = new SomeResponseDto();
responseDto.setMessage(someRequestDto.getInputMessage());
responseDto.setUuid(someService.getUuid());
return ResponseEntity.ok(responseDto);
}
}
Mein vollständiges Beispiel für dieses Post gibt es auf GitHub. Als Bauwerkzeug verwende ich Maven. Für die Installation von Maven und einem JDK nutze ich das geniale SDKMAN Werkzeug, das ich an dieser Stelle ja schon mal vorgestellt hatte. Ist Maven installiert, kann die App folgendermaßen gebaut und anschließend gestartet werden:
mvn clean package
...
java -jar target/warm-me-up*.jar
Typischerweise erzeugt man mit Spring Boot ein so genanntes Fat-JAR, also ein Paket, das nicht nur die eigene Applikation enthält, sondern auch den zugehörigen Web Server (Apache Tomcat) samt aller benötigten Bibliotheken. Dadurch kann ich das erzeugte JAR direkt in einem Docker Container mit installierter JVM starten und muss nicht noch zusätzlich einen Applikationsserver wie Glassfish & Co. verwalten.
Langsamer erster Request in Spring Boot
Nach dem Start der App horcht sie standardmäßig auf dem Port 8080 und ist bereit, Anfragen an die REST Schnittstelle zu verarbeiten. In meinem Beispiel gibt es nur einen POST Endpunkt, den ich folgendermaßen mittels curl aufrufe:
time curl --location --request POST 'http://localhost:8080/api' \
--header 'Content-Type: application/json' \
--data-raw '{
"inputMessage": "abc",
"someNumber": 123.4,
"patternString": "this is a fixed string",
"selectOne": "TWO"
}'
Der time Befehl misst die Laufzeit des curl Aufrufs. Die Ausgabe sieht ungefähr so aus:
{"message":"abc","uuid":"892c5f35-10ac-4439-8ba4-088d5ea1ae05"}curl --location --request POST
'http://localhost:8080/' --header --data-raw 0,01s user 0,00s system 3% cpu 0,221 total
Die letzte Zahl gibt die Laufzeit des curl Kommandos in Millisekunden an. Mein Aufruf hat 221ms benötigt. Das ist sehr langsam für einen lokalen Server, der nichts weiter tut, als eine Zufallszeichenkette zu erzeugen.
Und tatsächlich, führe ich das gleiche curl Kommando nochmal aus, dauert der Request nur noch knapp 20ms. Das ist schon wesentlich besser!
Warum muss der erste Request schnell sein?
Der geneigte Leser könnte nun einwänden, es ist ja nicht schlimm, wenn der erste Request langsam ist, solange es danach flott weiter geht! Wohl wahr, aber leider nicht hinnehmbar für mein aktuelles Projekt :-(
In diesem gibt es für das API vertraglich vereinbarte Antwortzeiten und die Laufzeit des ersten Requests ist in der Regel 10-mal höher, als der erlaubte Wert. Hinzu kommt, dass die Applikation mehrfach wöchentlich aktualisiert wird, es mehrere Instanzen gibt und sich dadurch schon sehr viele „erste Requests“ pro Woche ergeben.
Ich komme also nicht drumrum, mich um das Problem zu kümmern…
Was passiert beim Start einer Spring Boot Applikation?
Um mich dem Problem zu nähern, habe ich zunächst die App in VisualVM überwacht. VisualVM habe ich natürlich auch über SDKMAN installiert :-) Folgender Graph fiel mir ins Auge:
Während des Starts der Spring Boot App lädt die JVM eine Vielzahl von Klassen. Beim ersten REST Request lädt die JVM nochmals viele Klassen, in meinem Fall fast 1.000 Stück. Bei allen weiteren Aufrufen des REST Endpunkts werden keine weiteren Klassen geladen.
Tatsächlich ist dieses Verhalten überraschend für mich, denn normalerweise instantiiert Spring alle von Spring verwalteten Klassen (so genannte Spring Beans) und erstellt nicht erst Instanzen, wenn die Klassen benötigt werden. Deshalb muss es sich hier um Klassen handeln, deren Instanzen nicht von direkt Spring verwaltet werden.
Nachladen von JVM Klassen anzeigen
Doch welche Klassen werden denn noch nachgeladen? Um zu sehen, wenn die JVM eine Klasse lädt, starte ich meine Applikation neu und füge den Parameter -verbose:class hinzu:
java -verbose:class -jar target/warm-me-up*.jar
Beim Start hat mein Terminal ordentlich zu tun, aber nach ein paar Sekunden kommt es zur Ruhe. Ich lösche den Inhalt des Terminals, um nun den ersten Request mittels curl abzusetzen. Mein Terminal füllt sich wieder. Auffällig ist, dass viele Zeilen in etwa so aussehen:
[105,831s][info][class,load] com.fasterxml.jackson.databind.... .../target/warm-me-up-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/jackson-databind-2.12.5.jar!/
Jackson ist eine umfangreiche Sammlung von Bibliotheken, die zum Beispiel für das Parsen von JSON in Java Klassen und die anschließende Validierung der erzeugten Objekte genutzt wird.
Beide Features nutze ich auch in meiner App. Der Spring REST Controller erwartet im Request Body ein JSON Objekt, das in eine Instanz der Klasse SomeRequestDto übergeben werden soll. Hier ein kurzer Auszug dieses DTOs:
public class SomeRequestDto {
@NotNull
private String inputMessage;
@Min(100)
@Max(200)
private BigDecimal someNumber;
@Pattern(regexp = "this is a fixed string")
@NotBlank
private String patternString;
@Valid
@NotNull
private SomeOptionsDto selectOne;
...
}
Die DTO Klasse definiert die erwarteten Attribute. Jedes Attribut ist mit Annotationen zur Validierung versehen. Laut diesen Annotationen werden zum Beispiel für das Attribut someNumber nur rationale Zahlen zwischen 100 und 200 akzeptiert.
Bei jedem REST Request gegen diesen Endpunkt versucht Spring zunächst mittels Jackson den Request Body in eine Instanz der SomeRequestDto Klasse umzuwandeln und prüft dann die Validierungsregeln für jedes Attribut. Klappt das nicht, wird der Request automatisch mit HTTP Status Code 400 Bad Request abgelehnt. Geht das Parsen und die Eingabevalidierung hingegen gut, wird meine Controller Methode mit dem fertigen Eingabeobjekt aufgerufen.
Jeden Spring Boot Endpunkt initial aufrufen?
Wie soll ich nun die Applikation vorwärmen? Eine erste naive Idee ist, einfach alle Endpunkte der REST API beim Start aufzurufen, damit alle Klassen geladen sind.
Das gestaltet sich in der Realität aber schwierig, denn meine App stellt sehr viele Endpunkte mit teils recht komplexen DTOs zur Verfügung und viele dieser Endpunkte sind nicht zustandslos. Ich kann auch nicht einfach Quatschdaten in meinem System erzeugen, da viele weitere Systeme die von der App erzeugten Daten verarbeiten.
Nächste Idee: Vielleicht ist gar kein REST Request notwendig, sondern es reicht aus beim Start der Applikation JSON mittels Jackson zu parsen und zu validieren. Leider zeigt dies nicht den gewünschten Effekt. Falls der geneigte Leser es selbst ausprobieren will, kann er die Funktionen manualValidation() und mapJson() in der PreloadComponent aktivieren und selbst nachmessen.
Lösung: Spezieller Endpunkt, um Spring Boot aufzuwärmen
In meinen Experimenten fiel mir auf, dass immer nur der erste Request auf irgendeinen Endpunkt langsam ist. Der nächste Request auf einen anderen Endpunkt war schon wesentlich schneller. Auf Basis dieser Beobachtung kam mir die Idee, einen speziellen REST Endpunkt zu schreiben, der nur für das Aufwärmen verwendet wird.
Das ist die Lösung! Wichtig dabei ist, dass dieser Endpunkt ebenfalls ein DTO verwendet, in dem:
- alle normalerweise verwendeten Java Datentypen (also String, BigInteger, etc.) und
- alle normalerweise verwendeten Annotationen zur Validierung vorkommen.
In meinem Beispiel habe ich genau das mit dem WarmUpController plus zugehörigen Request und Response DTO getan. Im WarmUpRequestDto gibt es alle in den normalen Endpunkten verwendeten Datentypen und auch eine Enum Klasse wird genutzt. Alle Attribute sind mit Annotationen für die Validierung versehen.
public class WarmUpRequestDto {
@NotBlank
@Pattern(regexp = "warm me up")
private String warmUpString;
@Min(10)
@Max(20)
private int warmUpNumber;
@Valid
private WarmUpEnumDto warmUpEnumDto;
@NotNull
private BigDecimal warmUpBigDecimal;
...
}
Es ist also nicht notwendig, die originalen DTOs zu verwenden. Das vereinfacht die Sache erheblich!
Ich habe in der PreloadComponent einen Event Listener implementiert, der auf das ApplicationReadyEvent wartet. Das ApplicationReadyEvent wird von Spring erzeugt, wenn die Anwendung aus Sicht von Spring vollständig gestartet wurde. In der Funktion sendWarmUpRestRequest() sende ich mittels Spring WebClient einen REST Request gegen meinen WarmUpController.
private void sendWarmUpRestRequest() {
final String serverPort = environment.getProperty("local.server.port");
final String baseUrl = "http://localhost:" + serverPort;
final String warmUpEndpoint = baseUrl + "/warmup";
logger.info("Sending REST request to force initialization of Jackson...");
final String response = webClientBuilder.build().post()
.uri(warmUpEndpoint)
.header(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.body(Mono.just(createSampleMessage()), WarmUpRequestDto.class)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(5))
.block();
logger.info("...done, response received: " + response);
}
In meinem Beispiel-Code auf GitHub habe ich den Aufruf der onApplicationEvent(ApplicationReadyEvent event) Funktion auskommentiert.
// instead, a full request including validation is required
// sendWarmUpRestRequest();
Falls der geneigte Leser es nachvollziehen will, muss er dieses Kommentar entfernen und die Applikation neu kompilieren und starten:
mvn package
...
java -verbose:class -jar target/warm-me-up*.jar
Nach einigen Sekunden beruhigt sich mein Terminal und ich lösche wieder den Inhalt. Nun führe ich mittels curl wieder den ersten Request aus:
$ time curl --location --request POST 'http://localhost:8080/api' \
--header 'Content-Type: application/json' \
--data-raw '{
"inputMessage": "abc",
"someNumber": 123.4,
"patternString": "this is a fixed string",
"selectOne": "TWO"
}'
{"message":"abc","uuid":"52578dd4-283b-4a4c-919a-25d021dab6c6"}curl --location --request POST 'http://localhost:8080/api' --header 0,01s user 0,01s system 42% cpu 0,037 total
Statt weit über 200ms hat der erste Aufruf nur noch 37ms gedauert. Das ist zwar immer noch um den Faktor 2 bis 3 langsamer als alle späteren Requests, aber zuminderst in meinem Fall ist das akzeptabel.
Tatsächlich werden nur noch relativ wenig Klassen nachgeladen. So ist es wenig überraschend, dass die echte DTO Klasse noch geladen werden muss:
[94,030s][info][class,load] services.progressit.warmmeup.rest.dto.SomeOptionsDto source: jar:file:/.../target/warm-me-up-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/ [94,040s][info][class,load] services.progressit.warmmeup.rest.dto.SomeResponseDto source: jar:file:/.../target/warm-me-up-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/ [94,042s][info][class,load] org.apache.coyote.Constants source: jar:file:/.../target/warm-me-up-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/tomcat-embed-core-9.0.54.jar!/ [94,043s][info][class,load] org.springframework.util.TypeUtils source: jar:file:/.../target/warm-me-up-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-core-5.3.12.jar!/
Was muss noch in Spring Boot vorgewärmt werden?
Meine Beispiel-App ist nun ausreichend vorgewärmt. In der Realität ist die Sache natürlich komplizierter und erfordert mehr Arbeit. Folgende Komponenten müssen ebenfalls vorgewärmt werden, damit der erste Request möglichst schnell verarbeitet wird:
- Datenbankverbindung herstellen und Abfrage tätigen
- Verbindung zu Message Brokern wie Kafka aufbauen
- andere Parsing Bibliotheken instantiieren, zum Beispiel JAXB für XML Verarbeitung
- Laden von nativem C-Bibliotheken
- etc. pp.
Die Behebung jedes dieser Punkte kann durchaus mehrere Tage Arbeit verursachen. Schön ist aber, dass ich Dank der JVM Option
-verbose:class
und anderer Ausgaben im Spring Log schnell die großen Cluster identifizieren kann!