Profile Software Services

Ejecutar aplicaciones Spring Boot como imágenes nativas de GraalVM

En este post te mostramos paso a paso cómo ejecutar aplicaciones Spring Boot como imágenes nativas de GraalVM, para conseguir que nuestras aplicaciones arranquen más rápido y tengan un menor consumo de memoria.

Uso de memoria en Java

Como todos sabemos, Java es uno de los lenguajes más populares en la actualidad junto a otros lenguajes como Python, Go y Node.js. Sin embargo, si hemos usado Java como uno de nuestros lenguajes principales, sabemos que éste tiene algunos problemas con el uso de memoria, en comparación a los demás lenguajes mencionados anteriormente.

Recuerdo que en el pasado, para un proyecto necesitábamos crear imágenes Docker para poder escalar nuestra aplicación de una manera un poco más sencilla. Pero las imágenes resultantes eran imágenes que pesaban alrededor de 600 MB y, al ejecutarlas, teníamos que reservar por lo menos 500 MB de memoria para cada instancia. Cosa que para una arquitectura cloud es demasiado, si estamos pensando en tener por lo menos 3 instancias de cada microservicio. Si hacemos cuentas son: 600 * 3 = 1.8GB en disco y 500 * 3 = 1.5GB en memoria, para solamente 3 instancias de un solo microservicio.

Reducir el consumo de memoria de una aplicación

Investigando un poco encontramos un proyecto llamado Alpine, el cual nos permitía tener una imagen Docker con un JDK. Éste tenía unas mínimas características, pero era bastante funcional para lo que se necesitaba en ese momento. Esto redujo nuestra imagen de 600MB a tan solo 180MB. Bastante bien, ¿no? Sin embargo, aun así seguíamos teniendo el mismo problema de consumo de memoria.

Para nuestra suerte, este tipo de inconvenientes los ha manejado Spring de una manera bastante acertada. Sabemos que lo fantástico de frameworks como Spring Boot es que nos ayuda a tener una aplicación en unos pocos pasos. No obstante, esta facilidad también es su talón de Aquiles. Ya que, al ser tan dinámico en sus configuraciones, en ocasiones estamos agregando funcionalidades a nuestra aplicación que lo más probable es que no lleguemos a necesitar. Y estas configuraciones se expresan como procesos y consumo de memoria.

Una alternativa que encontramos fue usar la funcionalidad que nos ofrece spring-context-indexer, para de esta manera poder declarar todos los beans que definimos en nuestra aplicación en tiempo de compilación y no en ejecución.

Como nos indican los integrantes de Spring, en esta documentación del framework, el escaneo de classpath es muy rápido. Es posible mejorar el inicio de nuestra aplicación haciendo uso de esta dependencia, ya que esta crea una lista estática de beans candidatos en el momento de compilación de nuestra aplicación y no de ejecución.

Esto lo que hace es evitar que nuestra aplicación realice el escaneo de los beans que hemos definido y los registre directamente, ya que estos ya han sido registrados al momento de compilar nuestra aplicación. Si ejecutamos el comando mvn clean install, podremos ver los beans candidatos que serán registrados al iniciar nuestra aplicación. Estos estarán definidos en la siguiente ruta de nuestro archivo jar:M

META-INF/spring.components

Reducir el consumo de memoria de una aplicación Spring

Así, esto parecía ser la solución a nuestros problemas. Pero después de un par de pruebas notamos que, aunque sí se reducía el consumo de memoria y el tiempo que tardaba esta en iniciar, esos tiempos y consumo de memoria aún estaban lejos de los números que queríamos tener.

Integrar Spring Boot con GraalVM

¿Entonces qué alternativas existen para poder manejar este tipo de situaciones? Existe Spring Native, un proyecto en el que está trabajando el equipo de desarrollo de Spring. Ya está en su versión 0.8.3 al momento de realizar esta publicación. Aunque aún es un proyecto experimental, ofrece un amplio catálogo de posibilidades para que podamos hacer uso de éste sin tener muchos problemas, si nuestro proyecto cumple con algunos requisitos como no usar reflexión. ¡Así que vamos a verlo!

Prerrequisitos

Para este ejemplo, crearemos un servicio que se conectará a una base de datos H2 y listará los productos que hacen parte de una factura.

Creación de nuestro proyecto

Para crear nuestro proyecto Spring Boot, podemos hacerlo desde nuestro IDE de preferencia o simplemente ingresando en Spring y seleccionando la siguiente configuración:

Adaptación de Spring a GraalVM

Una vez tenemos nuestro proyecto generado, deberemos hacer algunos ajustes a nuestro pom.xml. Debería quedar de este modo.

Como notamos, esto hace uso de GraalVM, el cual nos permitirá compilar nuestra aplicación a código nativo y, de esta manera, poder reducir el consumo de memoria y tiempo de inicio. Si quieres saber un poco más, puedes hacerlo en la documentación oficial de GraalVM.

Como comenté anteriormente, una de las desventajas que tiene GraalVM es que no admite el uso de reflexión. Como los proxies que maneja Spring hacen uso de esto, deberemos omitirlos. Y esto lo hacemos agregando el parámetro proxyBeanMethods a false, para evitar el procesamiento de este tipo en nuestra aplicación. Esto lo debemos hacer en nuestra clase principal SpringBootGraalvmApplication.java, para que quede de la siguiente manera:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication(
proxyBeanMethods = false
)
public class SpringBootGraalvmApplication {

public static void main(String[] args) {
SpringApplication.run(SpringBootGraalvmApplication.class, args);
}
}

Modificaciones para Hibernate

Ya que vamos a hacer uso de la funcionalidad de Spring JPA, deberemos agregar algunas modificaciones a nuestra aplicación para indicarle a Hibernate que no queremos que realice ninguna operación al momento de iniciar nuestra aplicación.

Hibernate, como Spring, puede hacer muchas cosas dinámicamente en tiempo de ejecución y, como lo indiqué anteriormente, GraalVM no se lleva bien con este tipo de cosas. Por lo tanto, necesitamos que Hibernate recopile toda la información que necesite para las entidades de nuestra aplicación en el momento de la compilación y no de ejecución. Para eso es suficiente con agregar la siguiente configuración al pom.xml de su proyecto:

<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<version>${hibernate.version}</version>
<executions>
<execution>
<configuration>
<failOnError>true</failOnError>
<enableLazyInitialization>true</enableLazyInitialization>
<enableDirtyTracking>true</enableDirtyTracking>
<enableExtendedEnhancement>false</enableExtendedEnhancement>
</configuration>
<goals>
<goal>enhance</goal>
</goals>
</execution>
</executions>
</plugin>

Lo último que tendremos que hacer es decirle a Hibernate que deshabilite el escaneo de configuraciones en tiempo de ejecución. Y para eso debemos crear un fichero en:

src/main/resources/hibernate.properties

Y en éste poner la siguiente configuración:

hibernate.bytecode.provider=none

Conexión a nuestra base de datos

Una vez tenemos esto, configuraremos la conexión a nuestra base de datos y crearemos un archivo que llamaremos data.sql, el cual cargará algunos datos de prueba al momento en que nuestra aplicación inicie. Nuestros dos archivos deberían quedar de la siguiente manera:

spring:
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:
driver-class-name: org.h2.Driver

jpa:
hibernate:
ddl-a uto: update
database-platform: org.hibernate.dialect.H2Dialect

Funcionalidad de nuestra aplicación

Para poder probar nuestra aplicación, crearemos una estructura básica en donde crearemos nuestra funcionalidad para poder listar las facturas.

InvoiceRouter: Este será nuestro punto de entrada, el cual será el encargado de procesar todas las peticiones que realicemos a GET “/invoice.

@Component
public class InvoiceRouter {

@Bean
public RouterFunction<ServerResponse> routeFunction(InvoiceHandler invoiceHandler) {
return route(
GET("/invoice").and(accept(TEXT_PLAIN)), invoiceHandler::getAllInvoices
);
}
}

InvoiceHandler: Este será el encargado de manejar todas las peticiones realizadas por el router y llamar al service.

@Component
public class InvoiceHandler {

private final IInvoiceService invoiceService;

public InvoiceHandler(IInvoiceService invoiceService) {
this.invoiceService = invoiceService;
}

public Mono<ServerResponse> getAllInvoices(ServerRequest request) {
return ServerResponse.ok()
.contentType(APPLICATION_JSON)
.body(invoiceService.getAllInvoices(), Invoice.class);
}
}

InvoiceServiceImpl: Este será el encargado de recuperar la información de nuestra base de datos y retornarla.

@Service
public class InvoiceServiceImpl implements IInvoiceService {

private final InvoiceRepository invoiceRepository;

public InvoiceServiceImpl(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}

@Override
public Flux<Invoice> getAllInvoices() {
return Flux.fromIterable(invoiceRepository.findAll());
}
}

Tip: En este ejemplo usé la base de datos H2 para ser práctico. Pero, si lo prefieres, puedes usar una base de datos reactiva y así no tener que pasar nuestra lista a un objeto de tipo Flux.

Prueba de nuestro servicio

En este momento no estamos usando las características que nos ofrece GraalVm, así que si ejecutamos nuestra aplicación, veremos que esta inició en 4.578 ms y nuestra primer solicitud tardó 578 ms.

¿Nada mal para un punto de partida, verdad? Pero ahora imaginemos que no tenemos solo 3 clases, sino que debemos llamar a otros servicios e implementar algún sistema de caché. Esto elevaría nuestros anteriores valores. Cosa que no queremos. Nuestro fin es poder iniciar nuestra aplicación en el menor tiempo posible y tener un bajo consumo de memoria.

Creación de nuestra imagen nativa de GraalVM

Como se mencionó anteriormente, Spring Boot una de las cosas que hace al iniciar es crear todos los beans necesarios para que nuestra aplicación pueda funcionar de una manera fácil y rápida. Pero esto no es lo que queremos en todos los casos, ya que no siempre usamos todos los beans que Spring define por nosotros.

Indexación de beans necesarios

Para solucionar este problema, deberemos crear la siguiente carpeta en la siguiente ruta:

src/main/resources/META-INF/native-image

Después de esto, abriremos un terminal y, en la ruta de nuestro proyecto, ejecutaremos el siguiente comando:

java 
-agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image -jar target/spring-boot-graalvm 
-1.0.0-SNAPSHOT.jar

Esto lo que hace es iniciar un agente Java, el cual indexara únicamente los beans que nuestra aplicación realmente necesita para poder funcionar de manera correcta.

Una vez nuestra aplicación esté iniciada, lo único que deberemos hacer es realizar una solicitud a todos los endpoint de nuestra aplicación, para que de esta manera se puedan recopilar únicamente los beans necesarios.

Al terminar de ejecutar las peticiones, detendremos nuestro agente y en nuestra carpeta src/main/resources/META-INF/native-image deberíamos tener 4 ficheros con extensión json como los siguientes:

Estos ficheros harán referencia únicamente a los componentes que necesita nuestra aplicación para funcionar. En esta parte es donde toma partida GraalVM, para poder recopilar esta información y realizar todas las configuraciones necesarias en tiempo de compilación.

Modificación manual del fichero

Nota: Por alguna razón que aún no identifico, el fichero resource-config.json se genera con la siguiente estructura:

{
"resources":{
  "includes":[

Para corregir el formato, debemos modificar manualmente el fichero. Queda de la siguiente manera:

{
"resources":[

Creación de un Dockerfile

Como último paso y para mayor facilidad, crearemos un Dockerfile con toda la configuración necesaria para poder tener un contenedor corriendo con nuestra imagen nativa. El fichero deberá tener la siguiente configuración:

FROM oracle/graalvm-ce:20.2.0-java11
ADD . /build
WORKDIR /build
# For SDKMAN to work we need unzip & zip
RUN yum install -y unzip zip
RUN \
# Install SDKMAN
curl -s "https://get.sdkman.io" | bash; \
source "$HOME/.sdkman/bin/sdkman-init.sh"; \
sdk install maven 3.6.3; \
# Install GraalVM Native Image
gu install native-image;
RUN source "$HOME/.sdkman/bin/sdkman-init.sh" && mvn -Pnative clean package
# We use a Docker multi-stage build here in order to only take the compiled native Spring Boot App from the first build container
FROM oraclelinux:7-slim
# Add Spring Boot Native app spring-boot-graalvm to Container
COPY --from=0 "/build/target/spring-boot-graalvm" spring-boot-graalvm
# Fire up our Spring Boot Native app by default
CMD [ "sh", "-c", "./spring-boot-graalvm", "-XX:PercentTimeInIncrementalCollection=40"]

Instalación de SDKMAN

En esta configuración lo que hacemos es instalar SDKMAN para poder compilar nuestro código fuente con el paquete native-image. Puedes conocer un poco más de este gestor de SDKs.

Creación de nuestra imagen

Una vez tenemos nuestro Dockerfile listo, crearemos nuestra imagen. Este proceso puede tardar algunos minutos, por lo que no se preocupen si notan que el proceso tarda demasiado. Para crear nuestra imagen abrimos un terminal en la ruta de nuestro proyecto y ejecutamos el siguiente comando:

docker build -t invoice-service:1.0.0

Al terminar el proceso de creación de nuestra imagen deberíamos tener algo como lo siguiente:

Test de nuestra imagen con Docker

Para esto crearemos un nuevo contenedor, el cual expondrá el puerto 8080, que es en el que nuestra aplicación recibirá todas las solicitudes. Para esto podemos correr el siguiente comando:

docker run -p 8080:8080 invoice-service:1.0.0

Después de esto, vemos que el tiempo que tardó en iniciar nuestra aplicación fue:

Bastante mejor, ¿no? Pero ahora miremos los tiempos de respuesta en nuestra primer solicitud y el consumo de memoria que tenemos:

Mirando estos resultados, hemos pasado de iniciar nuestra aplicación de 4.578 ms a 0.199 ms y de tener un consumo de memoria de 500 MB a 135.6 MB. Aunque este consumo de memoria se puede reducir un poco más, está bastante bien para un acercamiento inicial.

Desventajas de las imágenes nativas de GraalVM

Una de las desventajas que tiene pasar nuestro código a imagen nativa es la memoria que necesitaremos para esto. Si bien este consumo se realiza únicamente al momento de generar nuestra imagen, lo recomiendo usar una máquina que tenga por lo menos 16 GB de memoria. En algunas ocasiones nuestro agente de Java no recopila toda la información necesaria para que nuestra imagen pueda funcionar de una manera correcta y al intentar generar nuestra imagen podremos ver errores como el siguiente:

sun.instrument.InstrumentationImpl was unintentionally initialized at build time. 
sun.instrument.InstrumentationImpl has been initialized without the native-image initialization instrumentation and the stack trace can't be tracked. Try marking this class for build-time initialization with --initialize-at-build-time=sun.instrument.InstrumentationImpl

Para solucionar esto, deberemos agregar el argumento

--initialize-at-build-time=sun.instrument.InstrumentationImpl

como nos indica el error en nuestro pom.xml en el de apartado de nuestro perfil native.

Conclusiones

Aunque GraalVM aún es un proyecto experimental, vemos que es una alternativa bastante seria para usar en nuestros proyectos, ya que nos permite tener aplicaciones con tiempo de disponibilidad muy cortos y con el mínimo consumo de memoria posible. De esta manera, podemos ofrecer un servicio de alta disponibilidad sin tener que recurrir a gastos excesivos en infraestructura.

Encontrarás el código de este proyecto en GitHub, para que lo puedas ver con más detalle. ¡Y recuerda que todos los comentarios serán bienvenidos!

Si quieres aprender otras formas de sacar el máximo partido a tus aplicaciones Spring Boot, no te pierdas este artículo sobre cómo ejecutar una aplicación Spring Boot como un servicio Linux y este otro sobre cómo crear un WebClient mediante un starter. Encontrarás más claves sobre Spring Boot en nuestro canal de YouTube. ¡Suscríbete!

Salir de la versión móvil