Micronaut e GraalVM: alleati per il Cloud Native

Micronaut & GraalVM

Chi sviluppa servizi sulla JVM, quindi tipicamente in Java con Spring o qualche altro framework, sicuramente è rimasto deluso dalla poca reattività delle soluzioni serverless offerte dai vari provider Cloud.

Io in primis, da veterano sviluppatore Spring, mi sono imbattuto nel classico problema del Cold Start. Ovvero quando si usa un servizio in cloud, tipo le Google Cloud Function o le AWS Lambda la piattaforma inizializza la JVM alla prima richiesta, e in generale tutte le volte che trova il servizio sottostante non in uso e quindi spento.

Problema: ColdStart e JVM

Il coldstart se si una una JVM può essere un problema, in quanto la risposta del servizio può impiegare svariati secondi per essere pronta. Sebbene per API REST (HTTP) 1 o 2 secondi di attesa potrebbero essere accettabili, non lo sono 6 o peggio 10 secondi.

Soluzione: Micronaut e GraalVM

Micronaut supporta una serie di plugin che gli consentono di essere compilato in un eseguibile nativo, o in alternativa in un container Docker altamente performante e che non ha bisogno della JVM. Questo fa si che anche l'inizializzazione in contesti Cloud ne tragga un enorme beneficio, riducendo di fatto il Could Start.

In questo articolo vi voglio far vedere come sviluppare un semplicissimo Hello World con Micronaut per poi compilarlo sia in immagine nativa che in un Container Docker.

Potete trovare il progetto completo con qualche indicazione aggiuntiva qui su Github.

1 - Progetto Micronaut

Creare un progetto Micronaut è molto semplice. Vi consiglio di usare la CLI ufficiale, installandola con SDKMAN.

sdk install micronaut 2.5.7
mn create-app it.aitlab.tests.mngraal --build=maven

Aggiungete poi a piacere un controller, tipo l'Hello World di seguito

@Controller("/")
public class HelloWorld {

    @Get
    public String getHello() {
        return "Hello World!!!";
    }
}

2 - Run del servizio

Ora provate a compilare ed avviare il servizio e annotatevi lo startup time.

$ ./mvnw mn:run 

 __  __ _                                  _
|  \/  (_) ___ _ __ ___  _ __   __ _ _   _| |_
| |\/| | |/ __| '__/ _ \| '_ \ / _` | | | | __|
| |  | | | (__| | | (_) | | | | (_| | |_| | |_
|_|  |_|_|\___|_|  \___/|_| |_|\__,_|\__,_|\__|
  Micronaut (v2.5.7)

17:25:17.974 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 688ms. Server Running: http://localhost:8080

A me ha impiegato 688ms per essere disponibile, che non comprendono il tempo di lancio della JVM. Sebbene in locale sembri comunque abbastanza veloce, i problemi si hanno sul cloud, con l'inizializzazione della JVM che da sola impiega molti secondi.

Per un test rapido del servizio curl 127.0.0.1:8080.

3 - GraalVM e compilazione nativa

Ok ora proviamo a compilare il progetto in un eseguibile usando GraalVM. Occorre avere una macchina Graal installata sul sistema, ed in tool native image installabile a partire da Graal. Come sempre consiglio SDKMAN.

# Example: Install Graal and Native Image
sdk install java 21.1.0.r11-grl
gu install native-image
# Point to the graal jdk and then package the app
sdk use java 21.1.0.r11-grl

Ok bene ora il terminale punta a Graal come versione di Java da usare. Possiamo usare il plugin di Micronaut, già configurato nel pom per procedere.

# Compile
$ ./mvnw package -Dpackaging=native-image

# #Run 
$ ./target/mngraal
 __  __ _                                  _
|  \/  (_) ___ _ __ ___  _ __   __ _ _   _| |_
| |\/| | |/ __| '__/ _ \| '_ \ / _` | | | | __|
| |  | | | (__| | | (_) | | | | (_| | |_| | |_
|_|  |_|_|\___|_|  \___/|_| |_|\__,_|\__,_|\__|
  Micronaut (v2.5.7)

17:43:31.164 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 17ms. Server Running: http://localhost:8080

Bhe ora i tempi direi che sono già migliorati, 17ms contro i quasi 700ms di prima. Già un enorme vantaggio, che va sommato a quello che si ottiene evitando di inizializzare la JVM.

4 - Pensiamo al Cloud: il Container Docker

Come per l'immagine nativa, Micronaut è già pronto per la creazione dell'immagine docker che a sua volta la incapsula. Per creare l'immagine occorre Docker installato, e con memoria assegnata adeguata (consiglio almeno 8GB)

# Create the image locally
./mvnw package -Dpackaging=docker-native

Bene ora che l'immagina è nel repo locale, basta invocarla.

# Run the image locally
docker run -p 8080:8080 mngraal

 __  __ _                                  _
|  \/  (_) ___ _ __ ___  _ __   __ _ _   _| |_
| |\/| | |/ __| '__/ _ \| '_ \ / _` | | | | __|
| |  | | | (__| | | (_) | | | | (_| | |_| | |_
|_|  |_|_|\___|_|  \___/|_| |_|\__,_|\__,_|\__|
  Micronaut (v2.5.7)

15:50:32.117 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 18ms. Server Running: http://ad1c24f29e22:8080

Anche per docker il tempo di start è di poche decine di millisecondi. Ora che avete un immagine docker in locale potete usare il vostro provider preferito per il deploy e per i test di performance (sarà sicuramente oggetto dei prossimi articoli).

In Conclusione

Non è vero che Java non è adatto a soluzioni Serverless, il fatto di poter essere compilato in immagine nativa apre la via a molti utilizzi che prima non venivano presi in considerazione per via della JVM.

Lascio a voi ulteriori test di performance in base al provider, e anche comparazioni a livello di memoria occupata. Sicuramente in ambito legacy o fortemente legato al mondo Java, GraalVM da delle possibilità aggiuntive, soprattutto per l'avvicinamento a soluzioni Serverless.