Imagen Nativa con GraalVM
Tradicionalmente el diseño de Software se ha realizado con arquitecturas monoliticas, pero con el surgimiento de la nube se ha visto que este tipo de arquitectura no escala de manera efectiva. Por lo que han surgido nuevas arquitecturas para solventar estos problemas modernos, como lo son la arquitectura de microservicios y la arquitectura sin servidor.
Para estas nuevas arquitecturas es indispensable el desarrollo de aplicaciones que consuman poco espacio en disco, con tiempos de inicio muy reducidos, con un buen rendimiento y que consuman poca memoria. GraalVM es un nuevo proyecto de software que nos permite alcanzar estos objetivos.
¿Qué es GraalVM?
GraalVM es una nueva máquina virtual de Oracle Labs, de la cual se pueden destacar cuatro características fundamentales:
- Es un JDK completo: que ha pasado todos los test de certificación (TCK) de Oracle, por lo que puede remplazar cualquier instalación de JDK u OpenJDK de manera transparente.
- Cuenta con un compilador JIT mejorado: que ha mostrado un mejor desempeño que el propio compilador JIT incluido en el JDK de Oracle.
- Es una Máquina Virtual Políglota: que nos permite no solo trabajar con código Java y otros basados en JVM (Groovy, Scala, Kotlin), sino también Javascript, Ruby, Python, R entre otros.
- Puede crear Imágenes Nativas: mediante un compilador AOT para producir código nativo, que no requiere la JVM para ejecutarse. El cual va ser el tema central de esta publicación.
Para comprender mejor los beneficios que nos provee la creación de estas imágenes nativas vamos a crear dos imagenes de Docker con un microservicio muy simple, la primera versión se ejecutará sobre JRE 11 de Java y la segunda versión será con imagen nativa, para posteriormente compararlas.
Instalación
Lo primero a realizar, es instalar GraalVM, al día de hoy la última versión es la 21.2.0, en el repositorio de Github del proyecto se pueden bajar las diferentes versiones para Windows, Linux y MacOS. En el caso de Linux/MacOS se puede utilizar SDKMAN para facilitar está tarea, ejecutando:
$ sdk install java 21.2.0.r11-grl
El utilitario de native-image requerido para crear la imágenes nativas, no viene como parte de la instalación de GraalVM, pero se puede instalar con la herramienta GraalVM Updater, ejecutando el comando:
$ gu install native-image
Para el caso de Linux se requiere adicionalmente instalar
glibc-devel
yzlib-devel
.Para el caso de Windows se requiere la instalación Microsoft Visual C++ (MSVC) que viene con Visual Studio 2017 15.5.5 o superior y la configuración de
x64 Native Tools Command Prompt
.
Creación del Microservicio
Para el desarrollo del microservicio seleccioné Gradle como herramienta de construcción del proyecto y Spark para el desarrollo del servicio web, por lo que se deben agregar las siguientes dependencias al proyecto:
dependencies {
implementation 'com.sparkjava:spark-core:2.9.3'
implementation 'org.slf4j:slf4j-simple:1.7.25'
}
Se agrega la clase src/main/java/com/example/App.java
que contendrá la lógica del servicio web. La cual responderá con el mensaje Hello World
cuando se haga una petición GET a http://localhost:4567/hello
.
package com.example;
import static spark.Spark.*;
public class App {
public static void main(String[] args) {
get("/hello", (req, res) -> "Hello World");
}
}
Con esto ya está listo el microservicio, para compilar el proyecto como un único JAR con todas sus dependencias (un FatJar) agregamos el plugin id 'com.github.johnrengelman.shadow' version '7.0.0'
en la configuración del proyecto y realizamos la compilación del mismo.
$ ./gradlew build
Starting a Gradle Daemon, 1 incompatible Daemon could not be reused, use --status for details
BUILD SUCCESSFUL in 14s
10 actionable tasks: 9 executed, 1 up-to-date
Ya con esto tenemos un JAR con todas sus dependencias el cual podemos ejecutar con:
$ java -jar build/libs/graalvm-sample-rest-service-1.0.0-all.jar
[Thread-0] INFO org.eclipse.jetty.util.log - Logging initialized @154ms to org.eclipse.jetty.util.log.Slf4jLog
[Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - == Spark has ignited ...
[Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - >> Listening on 0.0.0.0:4567
[Thread-0] INFO org.eclipse.jetty.server.Server - jetty-9.4.31.v20200723; built: 2020-07-23T17:57:36.812Z; git: 450ba27947e13e66baa8cd1ce7e85a4461cacc1d; jvm 11.0.12+6-jvmci-21.2-b08
[Thread-0] INFO org.eclipse.jetty.server.session - DefaultSessionIdManager workerName=node0
[Thread-0] INFO org.eclipse.jetty.server.session - No SessionScavenger set, using defaults
[Thread-0] INFO org.eclipse.jetty.server.session - node0 Scavenging every 600000ms
[Thread-0] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@57285265{HTTP/1.1, (http/1.1)}{0.0.0.0:4567}
[Thread-0] INFO org.eclipse.jetty.server.Server - Started @265ms
Ahora con el comando native-image
procedemos a crear la imagen nativa en nuestro equipo:
$ native-image -jar build/libs/graalvm-sample-rest-service-1.0.0-all.jar
[graalvm-sample-rest-service-1.0.0-all:25197] classlist: 1,323.67 ms, 0.96 GB
[graalvm-sample-rest-service-1.0.0-all:25197] (cap): 633.48 ms, 0.96 GB
[graalvm-sample-rest-service-1.0.0-all:25197] setup: 2,100.08 ms, 0.96 GB
[graalvm-sample-rest-service-1.0.0-all:25197] analysis: 10,708.20 ms, 1.77 GB
Fatal error:com.oracle.graal.pointsto.util.AnalysisError$ParsingError: Error encountered while parsing org.eclipse.jetty.util.TypeUtil.<clinit>()
Parsing context: <no parsing context available>
...
El error que obtenemos es debido a la forma de inicialización de las clases en la generación de imágenes nativas de GraalVM, Christian Wimmer ha escrito un par de artículos al respecto para comprender mejor este comportamiento (Parte 1 y Parte 2), pero en resumen se requiere indicarle a GraalVM que inicialice en tiempo de compilación los bloques estáticos de código, que en nuestro caso se encuentran en las dependencias de Spark, por lo que debemos ejecutar native-image
de la siguiente forma:
$ native-image \
-H:+ReportUnsupportedElementsAtRuntime \
-H:TraceClassInitialization=true \
--enable-http \
--static \
--no-fallback \
--initialize-at-build-time=org.eclipse.jetty,org.slf4j,javax.servlet,org.sparkjava \
-jar build/libs/graalvm-sample-rest-service-1.0.0-all.jar
[graalvm-sample-rest-service-1.0.0-all:27341] classlist: 1,489.61 ms, 0.96 GB
[graalvm-sample-rest-service-1.0.0-all:27341] (cap): 702.42 ms, 0.96 GB
[graalvm-sample-rest-service-1.0.0-all:27341] setup: 2,253.13 ms, 0.96 GB
[ForkJoinPool-2-worker-1] INFO org.eclipse.jetty.util.log - Logging initialized @11521ms to org.eclipse.jetty.util.log.Slf4jLog
[graalvm-sample-rest-service-1.0.0-all:27341] (clinit): 394.06 ms, 3.80 GB
[graalvm-sample-rest-service-1.0.0-all:27341] (typeflow): 14,132.81 ms, 3.80 GB
[graalvm-sample-rest-service-1.0.0-all:27341] (objects): 11,593.03 ms, 3.80 GB
[graalvm-sample-rest-service-1.0.0-all:27341] (features): 949.99 ms, 3.80 GB
[graalvm-sample-rest-service-1.0.0-all:27341] analysis: 27,852.26 ms, 3.80 GB
[graalvm-sample-rest-service-1.0.0-all:27341] universe: 1,667.32 ms, 3.80 GB
[graalvm-sample-rest-service-1.0.0-all:27341] (parse): 2,079.10 ms, 3.82 GB
[graalvm-sample-rest-service-1.0.0-all:27341] (inline): 2,737.85 ms, 3.83 GB
[graalvm-sample-rest-service-1.0.0-all:27341] (compile): 28,728.56 ms, 4.30 GB
[graalvm-sample-rest-service-1.0.0-all:27341] compile: 35,150.84 ms, 4.30 GB
[graalvm-sample-rest-service-1.0.0-all:27341] image: 3,858.71 ms, 4.31 GB
[graalvm-sample-rest-service-1.0.0-all:27341] write: 1,573.59 ms, 4.31 GB
[graalvm-sample-rest-service-1.0.0-all:27341] [total]: 74,079.75 ms, 4.31 GB
En esta ocasión logramos realizar la compilación de nuestra aplicación en nativo y procedemos a la ejecución de la misma:
$ ./graalvm-sample-rest-service-1.0.0-all
[Thread-0] ERROR spark.Spark - ignite failed
java.lang.IllegalStateException: java.lang.NoSuchMethodException: no such method: org.eclipse.jetty.server.ForwardedRequestCustomizer$Forwarded.handleCipherSuite(HttpField)void/invokeVirtual
...
El error que obtenemos es debido a que GraalVM al generar la imagen nativa, realiza un análisis estático del código. Sin embargo, este análisis tiene algunas limitaciones a la fecha y es incapaz de predecir todos los usos de la interfaz nativa de Java (JNI), el uso de Reflection, los proxy dinámicos (java.lang.reflect.Proxy
) o los recursos en el class path (Class.getResource
).
Los usos no detectados de estas características dinámicas deben proporcionarse a la herramienta de native-image
en forma de archivos de configuración en formato JSON, preferiblemente dentro de la carpeta META-INF/native-image
en los recursos del proyecto.
Para estos casos GraalVM proporciona un Agente que nos facilita la tarea de crear estos archivos, ejecutando el siguiente comando, el agente realizará un rastreo de las características dinámicas usadas:
java \
-agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar build/libs/graalvm-sample-rest-service-1.0.0-all.jar
Para que el agente sea capaz de detectar todos los usos de características dinámicas, se deben ejecutar todos los flujos de la aplicación, en este caso realizando la petición GET a http://localhost:4567/hello
.
Procedemos a realizar la compilación nuevamente:
$ ./gradlew build
...
$ native-image \
-H:+ReportUnsupportedElementsAtRuntime \
-H:TraceClassInitialization=true \
--enable-http \
--static \
--no-fallback \
--initialize-at-build-time=org.eclipse.jetty,org.slf4j,javax.servlet,org.sparkjava \
-jar build/libs/graalvm-sample-rest-service-1.0.0-all.jar
...
Procedemos a la ejecución de la imagen nativa:
./graalvm-sample-rest-service-1.0.0-all
[Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - == Spark has ignited ...
[Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - >> Listening on 0.0.0.0:4567
[Thread-0] INFO org.eclipse.jetty.server.Server - jetty-9.4.31.v20200723; built: 2020-07-23T17:57:36.812Z; git: 450ba27947e13e66baa8cd1ce7e85a4461cacc1d; jvm 11.0.12
[Thread-0] INFO org.eclipse.jetty.server.session - DefaultSessionIdManager workerName=node0
[Thread-0] INFO org.eclipse.jetty.server.session - No SessionScavenger set, using defaults
[Thread-0] INFO org.eclipse.jetty.server.session - node0 Scavenging every 660000ms
[Thread-0] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@7c4b47c2{HTTP/1.1, (http/1.1)}{0.0.0.0:4567}
[Thread-0] INFO org.eclipse.jetty.server.Server - Started @6ms
Realizamos la petición GET con cURL y observamos que en esta ocasión obtenemos una respuesta satisfactoria:
$ curl -X GET -w '\n' http://localhost:4567/hello
Hello World
Crear imágenes de Docker
Para la creación de la imagen con JRE 11 creamos el archivo Dockerfile.jre y para la imagen nativa creamos el archivo Dockerfile.native, ambas con multistage-build para garantizar el menor tamaño en la imagen final, creamos las imágenes ejecutando:
$ docker build -t graalvm-sample-rest-service-jre -f Dockerfile.jre .
$ docker build -t graalvm-sample-rest-service-native -f Dockerfile.native .
Ejecutamos ambos contenedores con:
$ docker run -d -p 4567:4567 --name native graalvm-sample-rest-service-native
$ docker run -d -p 4568:4567 --name jre graalvm-sample-rest-service-jre
Revisamos el tamaño resultante de las imágenes:
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
graalvm-sample-rest-service-jre latest 16bdf8457704 2 minutes ago 225MB
graalvm-sample-rest-service-native latest b97d8eca3f1a 3 minutes ago 33.6MB
Revisamos el consumo de recursos de las imágenes:
$ docker stats native jre
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
8a285db0a1c4 native 0.00% 2.047MiB / 11.58GiB 0.02% 4.89kB / 1.59kB 0B / 0B 12
be3f83b4f2b0 jre 0.12% 49.59MiB / 11.58GiB 0.42% 4.63kB / 1.59kB 36.7MB / 3.35MB 28
En la siguiente tabla podemos comparar los valores obtenidos del contenedor JRE contra el contenedor con imagen nativa:
JRE | Imagen Nativa | |
---|---|---|
Tamaño de imagen | 225MB | 33.6MB |
Velocidad de inicio | 415ms promedio | 3ms promedio |
RAM usada | 49,59MB | 2,05MB |
Conclusiones
La imágenes nativas son ideales para aplicaciones que requieran una gran capacidad para escalar horizontalmente, permitiendo iniciar o finalizar numerosas instancias en muy poco tiempo, siguiendo el principio de desechabilidad de “twelve-factor”. A pesar de ser un servicio web muy simple, en la tabla se aprecia lo rápido que inicia la aplicación nativa en comparación a la que usa JRE.
La disminución del tamaño de la imagen de docker también es significativa, muy a pesar que el JAR con sus dependencias pesa solo 4,6MB, al momento de crear la imagen del contenedor con JRE se usa como base openjdk:11-jre-slim-buster
la cual le suma 221MB del sistema operativo más la instalación del JRE para correr el JAR. Por su parte la imagen nativa pesa 33MB pero cuenta con todo lo necesario para ejecutarse por si misma, por lo que se puede usar como base la imagen scratch
para el contenedor de docker que prácticamente no le suma nada al peso final.
Por otra parte, en esta publicación realizamos un servicio web con Spark, que no cuenta con integración “Out-of-the-Box” con GraalVM con el propósito de ver las limitaciones y como tratar con ellas. Pero en el caso particular de Java, es muy común la utilización de frameworks para el desarrollo de microservicios como lo son Spring Boot, Micronaut, Quarkus o Helidon, que nos facilitan la integración con GraalVM y nos ahorran en la mayoría de los casos mucho tiempo y trabajo, ya que se encargan por nosotros de indicarle a la herramienta de native-image
como realizar la compilación y como resolver las características dinámicas (JNI, Reflection, Dynamic Proxy, Class Path Resources) que son usadas por el framework y por las librerías de terceros con las que se integran.