Compartir esta publicación

Antecedentes

Uno de los principales argumentos de venta del lenguaje de programación Java, cuando se lanzó, era la promesa de «escribir una vez, ejecutar en cualquier parte». Es decir, que el código escrito en Java no necesitaría compilarse en código nativo específico para una arquitectura informática concreta. En su lugar, el código Java se compilaría en «código byte», es decir, instrucciones que serían interpretadas y ejecutadas por la máquina virtual Java (JVM) que se instalaría en el ordenador anfitrión. Esto proporcionó libertad en el proceso de desarrollo de software a los desarrolladores de Java -sin necesidad de mantener máquinas de compilación para Linux, MacOS, Windows, etc.- a costa de un menor rendimiento debido, entre otros factores, a varios:

  • Paso intermedio en el que la JVM interpreta y ejecuta el código de bytes compilado.
  • La propia JVM tendría que inicializarse y empezar a cargar/ejecutar el código, es decir, un «arranque en frío» que añadiría retraso antes de que el programa pudiera ejecutarse.

En respuesta, Oracle creó GraalVM, una de cuyas características es la capacidad de compilar código compatible con JVM en un ejecutable «nativo» y reducir drásticamente el tiempo de arranque de dicho programa. Una restricción clave para ello es que el programa JVM pasa de utilizar la compilación «Just-In-Time» (JOT) a la compilación «Ahead-Of-Time» (AOT), lo que significa que no existe ningún mecanismo para cargar y modificar la estructura de clases de un programa en tiempo de ejecución cuando se compila en un ejecutable. Aunque GraalVM emplea algunos métodos limitados para convertir instancias de programación reflexiva en código compatible con AOT, en muchos casos, el desarrollador tendrá que proporcionar «pistas» de configuración al compilador de GraalVM en forma de archivos de configuración. Esto sería una molestia para los programas JVM que aprovechan el framework Spring Boot, ya que Spring Boot aprovecha en gran medida la reflexión y la carga dinámica de clases para proporcionar beneficios como la inyección de dependencias y la generación de delegados de clase para una variedad de objetivos.

Afortunadamente, esta situación ha empezado a cambiar. Los desarrolladores de Spring Boot han lanzado la versión 3.0 del framework ampliamente utilizado, y uno de los atractivos «estrella» de la nueva versión es la refactorización y el desarrollo que se ha hecho para que un programa Spring Boot sea compatible con GraalVM. Esto permitiría teóricamente lo mejor de ambos mundos: un programa que aprovecha el autocableado de dependencias mediante inyección de dependencias y que además se inicia rápidamente. Curioso acerca de cómo funciona esto, he creado un programa de ejemplo que intentaría poner en marcha como un ejecutable compilado; cualquier modificación en el programa se llevaría a cabo en una rama separada para proporcionar un contraste A / B de lo que el código funciona para cualquiera de los paradigmas.

El trabajo

Notas de configuración

El programa de ejemplo es una aplicación Spring Boot WebFlux basada en Gradle que está escrita en Kotlin y aprovecha las siguientes características:

  • Controladores REST
  • Coroutines y funciones de suspensión en Kotlin
  • Serialización basada en Kotlin
  • JPA e Hibernate
  • Una capa de base de datos (H2)
  • Programación Orientada a Aspectos (AOP) para registrar cuánto tiempo transcurre en cada petición web.
  • Seguridad de inicio de sesión
  • Carga de bean basada en perfiles para utilizar o desactivar el mecanismo de seguridad

Al ejecutar esta aplicación y realizar tres peticiones GET consecutivas al endpoint authors/1, se produce la siguiente salida de registro:

Started BootImageDemoKt in 4.691 seconds (process running for 5.263)
Time elapsed for getAuthor: 15095 µs
Time elapsed for getAuthor: 435 µs
Time elapsed for getAuthor: 166 µs

Hasta aquí, bastante típico para un programa Spring Boot. Como se mencionó anteriormente, el arranque en frío del programa significa que transcurrirán varios segundos antes de que la aplicación web pueda responder a la primera solicitud, algo que impide utilizar este tipo de programa en un entorno en el que se requieran respuestas rápidas para solicitudes esporádicas, por ejemplo, en una aplicación de microservicios basada en AWS lambda.

En cualquier caso, un entorno basado en Docker debe estar en funcionamiento al realizar la compilación del ejecutable, ya que el resultado final será una imagen que contiene el ejecutable que posteriormente se puede ejecutar utilizando comandos Docker (o Podman); más información sobre los comandos de compilación específicos se puede encontrar aquí. Además, el trabajo de desarrollo se llevó a cabo en un MacOS y utiliza Podman en lugar del motor Docker tradicional para ejecutar contenedores. Esto no debería causar problemas, pero puede ser necesaria una configuración adicional, como describiré a continuación.

  Mejores prácticas de despliegue de software

Problema nº 0: Configuración de Gradle

En primer lugar, el plugin de construcción nativa GraalVM debe estar instalado en el archivo de construcción Gradle:

plugins {
   kotlin("jvm") version "1.8.20"
   kotlin("plugin.allopen") version "1.8.20"
   kotlin("plugin.jpa") version "1.8.20"
   kotlin("plugin.serialization") version "1.8.20"
   id("org.springframework.boot") version "3.0.5"
   id("io.spring.dependency-management") version "1.1.0"
   id("org.graalvm.buildtools.native") version "0.9.20" // <= Add this
}

Esto proporcionará las tareas aotClasses y aotTestClasses – para las clases de producción y de prueba, respectivamente – que llevarán a cabo la generación de código para varios componentes de Spring que el compilador AOT requiere.

Problema nº 1: Configuración de tareas

Ejecución de la tarea Gradle bootBuildImage por primera vez se encuentra rápidamente con un error:

Unable to parse name "BootImageDemo". Image name must be in the form '[domainHost:port/][path/]name', with 'path' and 'name' containing only [a-z0-9][.][_][-]

Este es un problema muy fácil de solucionar mediante la especificación de cómo se llamará la imagen compilada en la configuración de la tarea Gradle, en este caso cambiando a usar snake case en lugar de camel case para el nombre de la imagen del proyecto:

tasks {
   named<BootBuildImage>("bootBuildImage") {
       imageName.set("boot_image_demo")
   }
   // Other tasks omitted
}

Una nota: si se ejecuta Podman, puede haber problemas con la tarea Gradle para conectarse al socket de la máquina virtual. La dirección debería ser unix:///var/run/docker.sock y accesible por defecto, pero puede ser necesario configurarlo también en la configuración de tareas de Gradle:

tasks {
   named<BootBuildImage>("bootBuildImage") {
       imageName.set("boot_image_demo")
       docker {
           host.set("unix:///var/run/docker.sock")
           bindHostToBuilder.set(true)
       }
   }
   // Other tasks omitted
}

Problema nº 2: Configuración de la máquina virtual

Una vez que la imagen de salida está configurada correctamente, los siguientes errores encontrados están relacionados con la configuración de la máquina virtual que aloja el GraalVM que realiza la compilación, el primero de los cuales fue:

Docker API call to 'localhost/v1.24/containers/create' failed with status code 500 "Internal Server Error" and message "container create: statfs /var/run/docker.sock: permission denied"

Este error se debe a la forma en que Podman instancia las máquinas virtuales. De acuerdo con el principio de mínimo privilegio, Podman no lanza máquinas virtuales con privilegios de root por defecto, como se muestra a continuación:

% podman machine start
Starting machine "podman-machine-default"
Waiting for VM ...
Mounting volume... /Users/severneverett:/Users/severneverett

This machine is currently configured in rootless mode. If your containers
require root permissions (e.g. ports < 1024), or if you run into compatibility
issues with non-podman clients, you can switch using the following command: 

	podman machine set --rootful

API forwarding listening on: /var/run/docker.sock
Docker API clients default to this address. You do not need to set DOCKER_HOST.

Sin embargo, el bootBuildImage requerirá privilegios de root para realizar sus operaciones, por lo que la máquina virtual debe configurarse como tal:

% podman machine stop                            
Waiting for VM to stop running...
Machine "podman-machine-default" stopped successfully
% podman machine set --rootful=true
% podman machine start             
Starting machine "podman-machine-default"
Waiting for VM ...
Mounting volume... /Users/severneverett:/Users/severneverett
API forwarding listening on: /var/run/docker.sock
Docker API clients default to this address. You do not need to set DOCKER_HOST.

Machine "podman-machine-default" started successfully

A continuación, el proceso de compilación de la imagen acaba fallando debido al siguiente error:

    [creator]     Error: Image build request failed with exit status 137

De nuevo, esto es un problema con los valores por defecto de Podman. Sin especificar lo contrario, las máquinas virtuales creadas en Podman en mi máquina poseen 2GB de RAM, mientras que se recomienda que la máquina virtual tenga al menos 8GB de RAM. Así, tuve que crear una nueva máquina virtual con 8GB de RAM y 2 CPUs, tras lo cual la compilación procedió a finalizar sin más mensajes de error… tras al menos 10 minutos. Bastante tiempo para esperar una imagen compilada, pero veremos como el rendimiento del programa se beneficia de esta compilación.

Problema nº 3: Kotlin Hints

Con la imagen compilada ya lista, es posible crear un contenedor para la imagen utilizando el comando docker run -p 8080:8080 docker.io/library/boot_image_demo, tras lo cual vemos los primeros resultados de los esfuerzos:

Started BootImageDemoKt in 0.233 seconds (process running for 0.238)

¡*Mucho* más rápido que ejecutando el programa de forma tradicional! Desgraciadamente, el éxito sigue siendo efímero en este punto. Ejecutando el mismo authors/1 La petición GET ahora se cuelga; dentro de los registros del contenedor se encuentra este fragmento:

Caused by: java.lang.NoSuchMethodException: kotlin.internal.jdk8.JDK8PlatformImplementations.<init>()
        at [email protected]/java.lang.Class.getConstructor0(DynamicHub.java:3585) ~[com.severett.bootimagedemo.BootImageDemoKt:na]
        at [email protected]/java.lang.Class.newInstance(DynamicHub.java:626) ~[com.severett.bootimagedemo.BootImageDemoKt:na]
        ... 203 common frames omitted

La buena noticia es que se trata de un problema conocido, y la solución es sencilla: crea el archivo /src/main/resources/META-INF/native-image/reflect-config.json y rellenarlo con el contenido descrito aquí.

Problema nº 4: Sustitución de la carga basada en perfiles

La reconstrucción de la imagen con el nuevo archivo de configuración produce ahora una imagen que no se bloquea cuando se realizan peticiones web, por lo que ahora centraremos nuestra atención en las partes del programa original que no se incluyeron en la compilación de la imagen nativa. Para entender la primera de estas cuestiones, es necesario profundizar brevemente en la forma en que una aplicación Spring Boot se adapta a la compilación AOT. Como se ha mencionado anteriormente, las aplicaciones Spring Boot aprovechan la inyección de dependencias para construir y poblar objetos en el sistema sin que el desarrollador tenga que escribir él mismo este código de cableado. Es un tema bastante complicado, pero resumiendo, los objetos que se designan como «beans» – ya sea a través de una clase anotada con una anotación que hereda de la anotación @Component de Spring o como una instancia devuelta en una función anotada @Bean dentro de una clase anotada @Configuration – se reúnen, instancian y suministran donde sea necesario dentro de la aplicación (normalmente como una dependencia autoconectada en otra clase bean).

  Qué es Plop - File Generator Tool System

Como este registro y autocableado de beans se realiza reflexivamente en Spring, es incompatible con el sistema de compilación AOT de GraalVM. En su lugar, los desarrolladores de Spring Boot construyeron un mecanismo que genera el código necesario para ejecutar este auto-cableado de forma programática en lugar de utilizar la reflexión. Por ejemplo, aquí están las clases que Spring Boot genera para la imagen ejecutable:

O5jd 8vKEUCThZ mEeyPpy9GLmU0aKrfOSEtFZKLqwLQ9blxEdafn7CO VRjLPBF8ASCTu

Esto es lo que se genera dentro de uno de los BeanDefinitions classes, concretamente, la clase generada para KotlinSerializationConfiguration:

public class KotlinSerializationConfiguration__BeanDefinitions {
   public KotlinSerializationConfiguration__BeanDefinitions() {
   }

   public static BeanDefinition getKotlinSerializationConfigurationBeanDefinition() {
       Class<?> beanType = KotlinSerializationConfiguration.class;
       RootBeanDefinition beanDefinition = new RootBeanDefinition(beanType);
       ConfigurationClassUtils.initializeConfigurationClass(KotlinSerializationConfiguration.class);
       beanDefinition.setInstanceSupplier(KotlinSerializationConfiguration..SpringCGLIB..0::new);
       return beanDefinition;
   }

   private static BeanInstanceSupplier<KotlinSerializationJsonHttpMessageConverter> getConfigBeanInstanceSupplier() {
       return BeanInstanceSupplier.forFactoryMethod(KotlinSerializationConfiguration.class, "configBean", new Class[0]).withGenerator((registeredBean) -> {
           return ((KotlinSerializationConfiguration)registeredBean.getBeanFactory().getBean(KotlinSerializationConfiguration.class)).configBean();
       });
   }

   public static BeanDefinition getConfigBeanBeanDefinition() {
       Class<?> beanType = KotlinSerializationJsonHttpMessageConverter.class;
       RootBeanDefinition beanDefinition = new RootBeanDefinition(beanType);
       beanDefinition.setInstanceSupplier(getConfigBeanInstanceSupplier());
       return beanDefinition;
   }
}

Por último, así es como el bean declarado dentro de KotlinSerializationConfiguration se registra dentro del sistema de definición de bean compatible con AOT en BootImageDemoKt__BeanFactoryRegistrations::registerBeanDefinitions():

public void registerBeanDefinitions(DefaultListableBeanFactory beanFactory) {
   /* Other registrations */
   beanFactory.registerBeanDefinition("kotlinSerializationConfiguration", KotlinSerializationConfiguration__BeanDefinitions.getKotlinSerializationConfigurationBeanDefinition());
   /* Other registrations */
   beanFactory.registerBeanDefinition("configBean", KotlinSerializationConfiguration__BeanDefinitions.getConfigBeanBeanDefinition());
   /* Other registrations */
}

Todo va bien, ¿verdad? Error. Echa un vistazo de nuevo a las clases generadas dentro del paquete config: notarás que no hay clases generadas para los beans de configuración de seguridad. Esto se debe a que la carga dinámica de beans es incompatible con el sistema de compilación AOT, por lo que cualquier beans que emplee la anotación @Profile, la anotación @ConditionalOnProperty, etc. no está permitido y por lo tanto no aparece dentro del ejecutable nativo. La solución, en este caso, es migrar la lógica de carga de beans condicionales para que resida dentro de la función que genera el bean Spring:

private const val SECURITY_ENABLED = "bootimagedemo.securityEnabled"

@Configuration
class SecurityConfiguration(private val env: Environment) {
   @Bean
   fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
       val securityEnabled = env[SECURITY_ENABLED]?.toBoolean() ?: false
       return if (securityEnabled) {
           http.authorizeExchange { exchanges ->
               exchanges.anyExchange().authenticated()
           }
               .httpBasic(withDefaults())
               .build()
       } else {
           http.authorizeExchange { exchanges ->
               exchanges.anyExchange().permitAll()
           }
               .csrf().disable()
               .build()
       }
   }
}

La otra refactorización es cómo activar las distintas configuraciones de seguridad: en lugar de pasar un perfil para la carga, ahora será necesario pasar el archivo bootimagedemo.securityEnabled en el archivo de configuración o mediante un argumento de la línea de comandos.

Problema nº 5: Arreglar la AOP

Por último, la imagen compilada realiza las peticiones web correctamente, pero todavía falta una parte: la salida de registro de la función AOP que mide el tiempo que transcurre en cada función del controlador. Al igual que la cuestión anterior, ésta requiere un poco de explicación de fondo antes de sumergirse en ella. La AOP en Spring funciona insertando código alrededor de una función objetivo – designada bien por una anotación específica o por coincidir con un patrón de cadena (como se hace en este proyecto) – que debe ejecutarse antes de la función objetivo, después de ella, o ambas. Spring se encarga de ello generando una clase de paso por encima de la clase que contiene la función de destino; cuando se llama a la función de destino, la clase de paso comprueba si existe una función de devolución de llamada para la función de destino y la llama en lugar de a la función de destino (en teoría, la función de devolución de llamada debería llamar posteriormente a la función de destino). Estas clases se generan en tiempo de ejecución en una aplicación Spring Boot normal, pero la compilación AOT genera las clases de antemano. Así, como AuthorController y BookController contienen funciones que se ven afectadas por la concordancia de patrones AOP, se generan clases pass-through para ellas como puede verse aquí:

Observe las otras clases que tienen clases de paso generadas para ellas; éstas se deben a que el mismo mecanismo es necesario para clases anotadas con @Configuration o @Transactional, entre otras. Entonces, ¿cuál es la causa de que el código AOP no funcione correctamente en el ejecutable nativo? Esto se debe a un par de problemas. En primer lugar, hay un error que requiere pistas adicionales en tiempo de ejecución para que el programa compilado con AOT reconozca correctamente el código AOP:

class AspectRuntimeHints : RuntimeHintsRegistrar {
   override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) {
       hints.reflection().registerType(TimingAspect::class.java) { builder ->
           builder.withMembers(MemberCategory.INVOKE_DECLARED_METHODS)
       }
       hints.proxies().registerJdkProxy(
           FactoryBean::class.java,
           BeanClassLoaderAware::class.java,
           ApplicationListener::class.java
       )
       hints.proxies().registerJdkProxy(ApplicationAvailability::class.java, ApplicationListener::class.java)
   }
}

Además, es necesario añadir la anotación @ImportRuntimeHints a la clase que contiene el código AOP:

@Aspect
@ImportRuntimeHints(AspectRuntimeHints::class)
@Component
class TimingAspect {

Resultado final

Por último, el código se compila completamente y produce la salida que deseamos:

Started BootImageDemoKt in 0.21 seconds (process running for 0.226)
Time elapsed for getAuthor: 815 µs
Time elapsed for getAuthor: 68 µs
Time elapsed for getAuthor: 68 µs

Como muestran los resultados, no sólo el arranque del programa es mucho más rápido, sino que las llamadas GET iniciales a /author/1 también tardan mucho menos en completarse.

  JSON en Kotlin: Comparando Opciones

Reflexiones finales

Aunque la prueba básica de rendimiento mostró una notable mejora entre la aplicación Spring Boot tradicional y la aplicación compilada con AOT, dudaría si recomendar convertir cada aplicación Spring Boot en su equivalente AOT, al menos en este momento.

  • Dado que la ejecución dinámica de programas no es posible con una aplicación compilada mediante AOT, hay que sacrificar varios puntos fuertes clave tanto de Spring Boot como de la JVM, a saber, la carga de clases dependiente de perfiles y configuraciones y las optimizaciones de compilación JIT, respectivamente. Es posible que los desarrolladores de Spring Boot encuentren una forma de habilitar perfiles y similares en el código compilado con AOT, aunque, como en el caso del código de plantillas de C++, puede que sea a costa de generar código adicional. No obstante, esto sigue dejando las optimizaciones JIT que serían imposibles en un programa compilado con AOT, lo que significa que no hay optimización de los puntos calientes del código (es decir, el código que se ejecuta con frecuencia).
  • Los requisitos de hardware y tiempo para crear un ejecutable nativo son considerables. Como ya se ha dicho, el compilador GraalVM requiere una máquina virtual con al menos 8 GB de RAM en mi ordenador -lo que significa la mitad de la memoria de mi ordenador sólo para la compilación de la imagen nativa- y tarda al menos diez minutos en construir el ejecutable nativo para lo que es, esencialmente, un programa trivial. Aparte de los costes de tener que mantener un proceso de compilación que cumpla estos requisitos de hardware, también está el dilema de qué compensaciones entre qué programas ejecutar -o incluso si comprar un ordenador más potente- puede tener que considerar un desarrollador cuando trabaja y compila un programa más grande.
  • El flujo de trabajo de desarrollo prescrito no es fiable. Como se ha mencionado anteriormente, las tareas de Gradle aotClasses y aotTestClasses generan las clases de Spring necesarias para la compilación AOT. Con estas clases generadas, es teóricamente posible ejecutar el programa Spring Boot en «modo AOT», en el que el programa se ejecutará como una aplicación JVM tradicional, pero las clases se cargarán al estilo del equivalente compilado AOT, es decir, cargando los beans de Spring a través del código generado en lugar de dinámicamente. El objetivo de esto es precisamente evitar el largo proceso de compilación, sin embargo el problema con el código AOP planteó algunas preocupaciones serias. Como describe el bug relacionado con AOP, el código funcionaba perfectamente bien en este «modo AOT», sin embargo, cuando se ejecutaba el código compilado real, surgían los problemas reales. Esto significa que no hay garantía real de que un desarrollador pueda evitar este costoso paso de compilación cuando trabaja en un proyecto compilado en AOT.

A pesar de esto, es alentador ver que se están haciendo progresos serios para abordar lo que ha sido perennemente una de las debilidades del ecosistema JVM. Esta es la primera versión del mecanismo de compilación AOT para Spring Boot – y el propio proceso de compilación AOT dentro de GraalVM también es relativamente joven – por lo que es probable que haya mucho margen de mejora en el futuro. El propio Java se enfrentó a muchas críticas en sus inicios por ser muy deficiente en rendimiento en comparación con lenguajes como C++, sin embargo, su evolución progresó de tal manera que finalmente se produjo un importante videojuego escrito en Java (es decir, Minecraft), algo que habría sido inconcebible en los inicios de Java. El tiempo dirá si en el futuro GraalVM y Spring Boot experimentarán un nivel de mejora similar.

Si te ha gustado este artículo, te sugiero que eches un ojo al blog de Apiumhub. Todas las semanas se publica contenido nuevo e interesante sobre desarrollo backend, desarrollo frontend, arquitectura de software, DevOps, automatización de QA y mucho más.

Author

  • SevernEverett

    Software engineer with more than 10 years of experience working with different technologies such as Java, Docker, Kubernetes, React, CDK, Kotlin.... With high ability to deal with different environments and technologies.

    Ver todas las entradas

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Suscríbete a nuestro boletín de noticias

Recibe actualizaciones de los últimos descubrimientos tecnológicos

¿Tienes un proyecto desafiante?

Podemos trabajar juntos

apiumhub software development projects barcelona
Secured By miniOrange