Block Image

Con la versione 3 di Spring Boot, è stato ufficialmente aggiunto il supporto alla generazione di immagini native, sospendendo il progetto sperimentale Spring Native.
Ho pensato quindi di generare una immagine nativa da una applicazione Spring Boot già esistente, capire se funzionasse tutto out-of-box ed effettuare dei test di carico sia sull'applicazione in versione JVM, sia in versione nativa.
Spero che la mia esperienza possa esservi utile.

L'applicazione Spring Boot utilizzata è la stessa di questo articolo:
Virtual Threads vs WebFlux: chi vince?.
Effettua operazioni comuni: recupero dati da un database e chiamata REST verso un servizio esterno.

Rispetto all'articolo linkato, le applicazioni JVM e GraalVM saranno containerizzate.

Proverò innanzitutto a creare l'immagine nativa ed eseguirla senza modificare il codice dell'applicazione, vediamo cosa succede.

Per entrambe le applicazioni containerizzate, sia la classica che quella GraalVM, non imposterò i parametri di Heap (come -Xms e -Xmx), perché voglio vedere il comportamento di default.

Due righe sulle immagini native

Le immagini native sono eseguibili specifici per piattaforma, avviabili senza l'ausilio della JVM. Sono applicazioni più leggere, che consumano meno risorse delle applicazioni che fanno uso della JVM. Inoltre promettono tempi di avvio molto migliori. Per funzionare, le immagini native richiedono una analisi statica del codice. Nella fase di build, GraalVM deve conoscere tutte le classi, i bean di Spring, le properties che l'applicativo userà a runtime. Questa elaborazione dell'analisi del codice, che avviene nella fase di build, può richiedere molto tempo e risorse, rispetto a quelli di una applicazione classica con JVM.

Inoltre, il fatto di conoscere in anticipo i bean da utilizzare, porta ad alcune limitazioni che le immagini native hanno; ad esempio non possono essere utilizzate le annotazioni @Profile e @ConditionalOnProperty poiché pilotano a runtime l'attivazione dei bean.

Abbiamo detto a inizio paragrafo che le immagini native sono eseguibili specifici per piattaforma. Questo comporta che se crei un eseguibile nativo su Windows, quest'ultimo non sarà avviabile da Linux. Ma così non perdiamo la portabilità tanto cara della JVM?
In teoria sì, nella pratica no! Da quando si è diffusa la tecnologia a container, non abbiamo bisogno della portabilità della JVM, perché già i container sono portabili. Infatti, hai due modi per creare una immagine nativa:

  1. creando un eseguibile nativo con una JDK GraalVM (non portabile)
  2. creando un container con l'ausilio di Cloud Native Buildpacks (portabile)

Se vuoi approfondire GraalVM, ti consiglio di leggere questo articolo: GraalVM Native Image Support.

Avvio dell'applicazione. Funziona tutto out-of box?

Prima di utilizzare la macchina di test AWS EC2, provo in locale la creazione dell'immagine nativa e la sua esecuzione.
Creo l'immagine nativa col comando:

./mvnw clean spring-boot:build-image -DskipTests -Pnative

Avvio l'immagine nativa col comando:

docker run --name reactive -p8080:8080 -p1099:1099 \
-e SPRING_R2DBC_USERNAME=myuser -eSPRING_R2DBC_PASSWORD=secret \
-e SPRING_R2DBC_URL=r2dbc:postgresql://<ip_pg>:5432/mydatabase \
-e CUSTOM_MOCKCLIENT_URL=<url_mock_client> \
-e JAVA_TOOL_OPTIONS="-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=1099 -Dcom.sun.management.jmxremote.rmi.port=1099 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.local.only=false -Djava.rmi.server.hostname=<ip>" \
-d reactive:0.0.1-SNAPSHOT

Sfortunatamente, l'applicazione non si avvia, poiché c'è il seguente errore:

***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to configure a ConnectionFactory: 'url' attribute is not specified and no embedded database could be configured.

Reason: Failed to determine a suitable R2DBC Connection URL

Eppure io ho valorizzato la variabile d'ambiente SPRING_R2DBC_URL!
Mi sono scontrato con questa issue: SpringNative#Issue#1470

Non basta valorizzare la variabile d'ambiente, l'applicazione deve conoscere un valore a tempo di build. Anche se questo valore verrà eventualmente sovrascritto dalla variabile d'ambiente. Ho quindi aggiunto le props del datasource nel file application.properties, in questo modo:

spring.r2dbc.url=
spring.r2dbc.username=
spring.r2dbc.password=

Come valore hanno stringa vuota, ma è sufficiente per non avere errori.

Ri-creo l'immagine e avvio nuovamente il container. Dai log leggo:

main] c.v.reactive.ReactiveApplication         : Started ReactiveApplication in 2.047 seconds (process running for 2.063)

L'applicazione si è avviata correttamente!

Provo ad invocare il servizio REST con Postman. Ricevo un 500. Vediamo i log:

 WARN 1 --- [or-http-epoll-6] r.netty.http.client.HttpClientConnect    : [475a10ef-1, L:/172.17.0.2:53468 - R:host.docker.internal:8092] The connection observed an error

org.springframework.web.reactive.function.UnsupportedMediaTypeException: Content type '' not supported for bodyType=com.vincenzoracca.reactive.model.NameDTO

Effettivamente, l'applicazione ha letto correttamente da DB, ma non è riuscita ad invocare il servizio downstream.
Il metodo incriminato è questo:

public Flux<UserDTO> mapSurnamesInUpperCase(String name, String surname) {
    long start = Instant.now().toEpochMilli();
    log.info("mapSurnamesInUpperCase started with parameters name: {}, surname: {}", name, surname);
    return userJDBCRepository.findAllByNameAndSurname(name, surname)
            .flatMap(user -> webClient.post()
                    .uri(configProperties.getMockclientUrl() + "/upper")
                    .bodyValue(new NameDTO(user.surname()))
                    .retrieve()
                    .bodyToMono(NameDTO.class))
            .map(nameDTO -> new UserDTO(name, nameDTO.name()))
            .doOnComplete(() -> log.info("mapSurnamesInUpperCase terminated: {} with parameters name: {}, surname: {}", Instant.now().toEpochMilli() - start, name, surname));

    }

In particolare il pezzo di codice incriminato è questo:
.bodyToMono(NameDTO.class)).

Qui GraalVM ha bisogni di essere "aiutato" poiché quel metodo utilizza la riflessione. Dalla documentazione di Spring, ho trovato questa annotazione che fa al caso mio: @RegisterReflectionForBinding.
Il metodo diventa:

@RegisterReflectionForBinding(NameDTO.class)
public Flux<UserDTO> mapSurnamesInUpperCase(String name, String surname) {
    long start = Instant.now().toEpochMilli();
    log.info("mapSurnamesInUpperCase started with parameters name: {}, surname: {}", name, surname);
    return userJDBCRepository.findAllByNameAndSurname(name, surname)
            .flatMap(user -> webClient.post()
                    .uri(configProperties.getMockclientUrl() + "/upper")
                    .bodyValue(new NameDTO(user.surname()))
                    .retrieve()
                    .bodyToMono(NameDTO.class))
            .map(nameDTO -> new UserDTO(name, nameDTO.name()))
            .doOnComplete(() -> log.info("mapSurnamesInUpperCase terminated: {} with parameters name: {}, surname: {}", Instant.now().toEpochMilli() - start, name, surname));

}

Ricreo l'immagine e avvio il container. Questa volta ricevo un 200! Funziona!

Prima di avviare i test di carico sulla macchina AWS EC2, verifico che la connessione JMX funzioni. E no, non funziona.
Questo perché la variabile d'ambiente JAVA_TOOL_OPTIONS non é disponibile in una immagine nativa.
Da questa issue noto con piacere che con la JDK 22 sarà disponibile la variabile NATIVE_IMAGE_OPTIONS.

Comunque nessun problema, posso inserire le Java properties come argomenti del comando "docker run", in questo modo:

docker run --name reactive -p8080:8080 -p1099:1099 \
-e SPRING_R2DBC_USERNAME=myuser -eSPRING_R2DBC_PASSWORD=secret \
-e SPRING_R2DBC_URL=r2dbc:postgresql://<ip_pg>:5432/mydatabase \
-e CUSTOM_MOCKCLIENT_URL=<url_mock_client> \
-d reactive:0.0.1-SNAPSHOT -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=1099 -Dcom.sun.management.jmxremote.rmi.port=1099 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.local.only=false -Djava.rmi.server.hostname=<ip>

Ora funziona anche la connessione JMX, posso finalmente iniziare i test!

Dati sulla preparazione dei test

  • Macchina utilizzata: AWS t3.micro (2 vCPU, 1 GiB di RAM) con sistema operativo Amazon Linux 2023.
  • Database: Tabella di Postgres con circa 1800 record.
  • Rete: l'applicazione server (quella JVM e quella GraalVM), applicazione mock-client e database Postgres sulla stessa VPC.
  • HTTP Client utilizzato: WebClient.
  • Driver connessione database: R2DBC (con Spring Data R2DBC).
  • Versione JDK: 21
  • Versione Spring Boot: 3.2.0
  • Codice applicazioni: spring-virtual-thread-test
  • Tool utilizzato per i test di carico: jMeter

Per i risultati dei test, verrà preso in considerazione il parametro Throughput (puoi trovare la definizione del parametro qui) e il tempo medio di risposta, espresso in millisecondi.
Più è alto il valore di Throughput, meglio è.
Più è basso il valore del tempo medio di risposta, meglio è.

Il test utilizza dei valori casuali di name e surname (valori però presenti nel Database) per ogni chiamata HTTP effettuata.

In ogni test, per ogni tipologia di parametro, il vincitore verrà colorato di verde, il perdente di rosso. Verranno colorati entrambi di grigio se non c'è un vincitore.

Verranno mostrati i grafici di utilizzo di CPU e Heap, utilizzando VisualVM. A fine articolo farà delle considerazioni su essi.

Dati sui tempi di generazione immagine e startup degli applicativi

Startup Time: JVM 8.049 seconds - GraalVM 2.047 seconds (- 6.002 per GraalVM)

Image size: JVM 361MB - GraalVM 136MB (- 225 per GraalVM)

Build Time: JVM 45 seconds - GraalVM NA

Con la macchina t3.micro, dopo un'ora, il comando per generare l'immagine nativa, ./mvnw clean spring-boot:build-image -DskipTests -Pnative ancora doveva terminare la sua esecuzione.
Ho quindi deciso di generare l'immagine dalla mia macchina Linux x86 con CPU Intel i5-4300M (2 core), 16 GB di RAM.
I dati sui tempi di build sono stati i seguenti:

Build Time con la mia macchina personale: JVM 25 seconds - GraalVM 5 minutes (- 4.75 minuti per JVM)

Primo test di carico: 100 utenti concorrenti, ripetuto 20 volte

  • Number of Threads (users): 100.
  • Loop Count: 20.
  • Total requests: 2000.

Average Response Time: JVM 361 - GraalVM 354 (Nessun vincitore)

Block Image
JVM
Block Image
GraalVM

Throughput: JVM 249.5 - GraalVM 246.1 (Nessun vincitore)

Block Image
JVM
Block Image
GraalVM

CPU/Heap:

Block Image
JVM
Block Image
GraalVM

Secondo test di carico: 200 utenti concorrenti, ripetuto 20 volte

  • Number of Threads (users): 200.
  • Loop Count: 20.
  • Total requests: 4000.

Average Response Time: JVM 387 - GraalVM 370 (- 17 per GraalVM)

Block Image
JVM
Block Image
GraalVM

Throughput: JVM 359.7/sec - GraalVM 396.0/sec (+ 36.3 per GraalVM)

Block Image
JVM
Block Image
GraalVM

CPU/Heap:

Block Image
JVM
Block Image
GraalVM

Terzo test di carico: 300 utenti concorrenti, ripetuto 20 volte

  • Number of Threads (users): 300.
  • Loop Count: 20.
  • Total requests: 6000.

Average Response Time: JVM 433 - GraalVM 409 (- 24 per GraalVM)

Block Image
JVM
Block Image
GraalVM

Throughput: JVM 433.4/sec - GraalVM 449.8/sec (+ 16.4 per GraalVM)

Block Image
JVM
Block Image
GraalVM

CPU and heap usage:

Block Image
JVM
Block Image
GraalVM

Conclusioni

Da questi test, si evince che l'immagine nativa vince sia in termini di throughput che in tempi medi di risposta.
Inoltre l'immagine nativa utilizza meno CPU. Sull'uso dell'heap, vince l'app JVM. Sinceramente non me lo aspettavo.

Un dato molto importante da sottolineare, è quello del tempo di startup. L'applicazione nativa si è avviata più velocemente di quella JVM, quasi 4 volte più veloce. Questo comporta uno scaling orizzontale molto piu veloce!

Non dobbiamo però trascurare i tempi di build dell'immagine nativa. La macchina t3.micro non è riuscita a generarla. Se prendete in considerazione di utilizzare GraalVM in produzione, assicuratevi di avere una macchina performante per creare l'immagine.

Consiglio di utilizzare GraalVM per sfruttare il Function-as-a-Service in Java (ad esempio, se utilizzi AWS, scrivere funzioni con le AWS Lambda). Non consiglio di utilizzarlo per una applicazione enterprise.
Ricordiamoci anche che il progetto GraalVM è giovane e man mano migliorerà sempre più.

Altri articoli su Spring: Spring.
Articoli su Docker: Docker.

Libri consigliati su Spring, Docker e Kubernetes: