El pipeline de compilación AOT
GraalVM Native Image hace compilación ahead-of-time, convirtiendo tu bytecode Java en un ejecutable nativo standalone. A diferencia de la JVM, que interpreta bytecode y compila JIT los métodos hot en runtime, native image hace toda la compilación en build time a través de un proceso llamado análisis estático.
# Build básico de native image para una app Spring Boot
./mvnw -Pnative native:compile
Spring Boot 3.x incluye soporte de native image via Spring AOT (Ahead-of-Time) processing. Durante el build, Spring AOT analiza tu application context y genera hints de reflection, registraciones de proxy, metadata de serialización y patrones de registro de recursos.
// build.gradle.kts
plugins {
id("org.graalvm.buildtools.native") version "0.10.4"
}
graalvmNative {
binaries {
named("main") {
imageName.set("auth-service")
mainClass.set("com.example.AuthServiceApplication")
buildArgs.add("-O2")
buildArgs.add("-march=native")
buildArgs.add("--gc=G1")
buildArgs.add("-H:+ReportExceptionStackTraces")
}
}
}
La pesadilla de configuración de reflection
Acá es donde empieza la diversión. La API de reflection de Java, Class.forName(), Method.invoke(), Field.get(), está en todos lados. Spring la usa para inyección de dependencias, Jackson para serialización JSON, Hibernate para mapping de entidades. El análisis estático de native image no puede ver a través de reflection, así que cada acceso reflexivo debe declararse por adelantado.
El native-image-agent es tu mejor amigo. Es un agente JVM que registra todos los accese es reflexivos, llamadas JNI, creación de proxies y carga de recurse es durante runtime:
# Ejecutar tu app con el tracing agent
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar target/auth-service.jar
# Ahora ejercitá cada code path, ejecutar tu suite completa de tests
./run-integration-tests.sh
El reflect-config.json generado se ve así:
[
{
"name": "com.example.model.User",
"allDeclaredConstructors": true,
"allPublicMethods": true,
"allDeclaredFields": true
},
{
"name": "com.fasterxml.jackson.databind.ser.std.ToStringSerializer",
"methods": [
{ "name": "<init>", "parameterTypes": [] }
]
}
]
El problema: el agente solo registra code paths que realmente se ejecutan durante el tracing. Si se omite un code path, aparece un NoSuchMethodException en producción. Un ejemplo común: un endpoint de refresh de tokens, que solo se dispara cuando los tokens están cerca de expirar, que tira un error de reflection dos semanas después del deploy.
Un approach robusto: correr el agente contra tres escenarios diferentes:
# 1. Suite completa de tests de integración
java -agentlib:native-image-agent=config-merge-dir=native-config \
-jar app.jar &
./run-integration-tests.sh
kill %1
# 2. Smoke test manual de paths poco testeados
java -agentlib:native-image-agent=config-merge-dir=native-config \
-jar app.jar &
curl -X POST localhost:8080/auth/refresh -H "Authorization: Bearer expired_token"
curl -X POST localhost:8080/auth/logout
kill %1
# 3. Paths de error, disparar errores de validación, 404s, auth failures
java -agentlib:native-image-agent=config-merge-dir=native-config \
-jar app.jar &
curl -X POST localhost:8080/auth/login -d '{"bad":"json"}'
curl -X GET localhost:8080/nonexistent
kill %1
El flag config-merge-dir es crítico, combina entradas nuevas con config existente en vez de sobreescribir.
Tiempo de startup: el antes y después
Los números reales de nuestro auth-service:
JVM (OpenJDK 21, settings por defecto):
Startup: 8.247ms
Tiempo a primer request: 9.100ms
RSS en idle: 287MB
RSS bajo carga: 412MB
Native Image (GraalVM 21, -O2, G1 GC):
Startup: 47ms
Tiempo a primer request: 52ms
RSS en idle: 43MB
RSS bajo carga: 198MB
Eso es una mejora de 175x en tiempo de startup. Pero observar algo: bajo carga, la brecha de memoria se achica de 6.7x a 2.1x. La ventaja de native image es principalmente en escenarios de idle/startup.
Benchmarks de throughput para el mismo servicio manejando validación de JWT:
JVM (después de warmup): 47.200 req/s a p99=2.1ms
Native Image: 31.400 req/s a p99=3.8ms
La JVM es 50% más rápida en throughput pico. Este es el trade-off fundamental: native image te da startup rápido y poca memoria en idle a costa de throughput pico. Para nuestro servicio de auth (50 req/min), la diferencia de throughput es irrelevante. Para un API gateway de alto tráfico, podría importar.
Tiempo de build e impacto en CI/CD
La compilación de native image es brutalmente intensiva en recursos:
Build JVM (mvn package): 14 segundos, 512MB RAM
Build native image (-O2): 11 minutos 23 segundos, 14.2GB RAM
Build native image (-Ob): 4 minutos 8 segundos, 8.1GB RAM
Los CI runners necesitan máquinas potentes. Instancias c6a.2xlarge (8 vCPU, 16GB RAM) son un baseline razonable para builds nativos.
Nuestro Dockerfile multi-stage:
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests \
-Dspring.native.build.args="-O2 --gc=G1 -march=x86-64-v3"
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends libz-dev \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/auth-service /app/auth-service
EXPOSE 8080
ENTRYPOINT ["/app/auth-service"]
La imagen final pesa 82MB, comparada con 340MB de la imagen JVM con JDK. En un cluster Kubernetes corriendo 40 instancias, son 10GB menos para descargar en deploys.
Lecciones de producción
Después de seis meses de experiencia en producción, las conclusiones clave para equipos considerando GraalVM Native Image:
Utilizarlo para: herramientas CLI, funciones serverless, batch jobs de corta vida, microservicios de bajo tráfico, contenedores sidecar, y cualquier workload donde el startup time o la memoria en idle importe más que el throughput pico.
Evitarlo para: servicios de alto throughput donde la optimización JIT importa, apps con uso pesado de carga dinámica de clases (plugins, OSGI), servicios que dependen de librerías sin soporte de native image.
El problema de reflection no desaparece: Incluso con Spring AOT, las librerías de terceros te sorprenden. Presupuestar tiempo para corridas del tracing agent después de cada upgrade de dependencias.
Monitoreo: Los MBeans de JMX de los que dependés para monitoreo JVM no existen en native image. VisualVM no conecta. Necesitás instrumentar tu aplicación diferente, Micrometer con Prometheus funciona, pero Java Flight Recorder no.
Un servicio de auth corriendo en native images durante seis meses demuestra los beneficios claramente. Deploys que tardaban 45 segundos ahora se completan en menos de 3 segundos. Los contenedores bajan de 512MB a 256MB con margen. La diferencia de throughput es invisible a niveles de tráfico típicos. Para este tipo de workload, native image es una victoria indiscutible. Para otros, la respuesta es más matizada, y ese matiz es justamente el punto de este artículo.