Spring Boot y Spring Cloud

Empaquetar y desplegar Spring Boot 3: jar ejecutable, contenedores e imágenes nativas

Intermedio10 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Serás capaz de llevar una aplicación Spring Boot 3 del código fuente a producción: entender la estructura del jar ejecutable, construir una imagen de contenedor pequeña y que aproveche la caché con un Dockerfile por capas o con Cloud Native Buildpacks, dimensionar la JVM dentro de un contenedor, configurar el graceful shutdown para los despliegues progresivos y decidir cuándo conviene un WAR o una imagen nativa de GraalVM.

Lo que aprenderás

  • Describir el jar ejecutable que generan los plugins de Spring Boot para Maven y Gradle, y cómo arranca
  • Construir una imagen de contenedor con un Dockerfile multi-stage por capas o con el goal build-image y Cloud Native Buildpacks
  • Configurar la memoria y la CPU de la JVM para contenedores y pasar la configuración a través del entorno
  • Activar el graceful shutdown y la señalización de readiness para que los despliegues progresivos no pierdan ninguna petición
  • Explicar cuándo desplegar un WAR en un servidor externo y cuándo compilar una imagen nativa de GraalVM

Es en el build donde se cumplen o se rompen las promesas de un servicio Spring Boot: si el artefacto que pasó las pruebas es el mismo que llega a producción, si un cambio de una línea obliga a reconstruir la imagen entera, si un despliegue progresivo pierde peticiones y si la JVM dentro de un contenedor sabe cuánta memoria tiene realmente. Esta página sigue a una aplicación Spring Boot 3 desde package hasta un contenedor en ejecución y repasa las decisiones que hay que tomar por el camino, incluidos los dos modelos de empaquetado alternativos: el WAR y la imagen nativa.

El jar ejecutable

./mvnw package (o ./gradlew bootJar) ejecuta el paso repackage del plugin de Spring Boot, que convierte el jar normal en un ejecutable autónomo:

app.jar
├── META-INF/MANIFEST.MF          Main-Class: org.springframework.boot.loader.launch.JarLauncher
│                                 Start-Class: com.example.shop.ShopApplication
├── org/springframework/boot/loader/   the launcher classes
├── BOOT-INF/
│   ├── classes/                  your compiled classes and resources
│   ├── lib/                      every runtime dependency as a nested jar
│   ├── classpath.idx             load order of the dependencies
│   └── layers.idx                layer assignment, used for container images

java -jar app.jar arranca JarLauncher, que crea un class loader capaz de leer los jars anidados sin extraerlos (por eso se guardan sin comprimir) y luego invoca el método main de la Start-Class. No se extrae nada a disco, no hace falta ningún servidor de aplicaciones y el mismo fichero funciona en un portátil, en una máquina virtual o en un contenedor. En Maven, el jar previo al repackage se conserva como app.jar.original, y es ese jar normal el que publicas si otro módulo depende de tus clases; el ejecutable no se puede usar como biblioteca.

Con Maven, el plugin se hereda de spring-boot-starter-parent; si usas un parent propio, tienes que declarar el goal repackage explícitamente. El plugin org.springframework.boot de Gradle aporta bootJar y mantiene la tarea estándar jar, cuya salida lleva el classifier -plain para que no se confundan. build-info en Maven y springBoot { buildInfo() } en Gradle escriben META-INF/build-info.properties, que el endpoint info de Actuator expone y que es la forma más limpia de saber exactamente qué versión está en ejecución.

Ejecutar el jar tal cual está bien en una máquina virtual detrás de un gestor de procesos. Lo habitual es una unidad de systemd:

[Unit]
Description=Shop service
After=network.target

[Service]
User=shop
WorkingDirectory=/opt/shop
ExecStart=/usr/bin/java -XX:MaxRAMPercentage=75 -jar /opt/shop/app.jar
EnvironmentFile=/etc/shop/env
SuccessExitStatus=143
Restart=on-failure

[Install]
WantedBy=multi-user.target

SuccessExitStatus=143 evita que systemd informe de un fallo por el código de salida que devuelve la JVM tras un SIGTERM. La configuración viene de /etc/shop/env y de un config/application.yml junto al jar, ambos fuera del artefacto.

Contenedores: el problema de reconstruir rápido

Copiar app.jar en una imagen funciona, pero falla en un aspecto concreto: todo el jar es una sola capa, así que cada cambio de código invalida la capa que contiene 50 MB de dependencias que no han cambiado, y cada despliegue sube y descarga todo de nuevo. Boot lo resuelve con los layered jars. layers.idx reparte el contenido del jar en capas ordenadas de menor a mayor probabilidad de cambio: dependencies, spring-boot-loader, snapshot-dependencies, application. El modo tools del jar las extrae:

java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# Boot 3.2 and earlier: java -Djarmode=layertools -jar app.jar extract

Después, un Dockerfile multi-stage copia cada capa por separado, de modo que un cambio de código solo reconstruye el último COPY:

# ---- build stage: produce and explode the jar
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q dependency:go-offline
COPY src src
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q -DskipTests package \
 && cp target/*.jar app.jar \
 && java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---- runtime stage: small JRE, non-root, one COPY per layer
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 1001 app
WORKDIR /app
COPY --from=build --chown=app /workspace/extracted/dependencies/ ./
COPY --from=build --chown=app /workspace/extracted/spring-boot-loader/ ./
COPY --from=build --chown=app /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=app /workspace/extracted/application/ ./
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

La estructura que genera extract --layers coloca en cada capa un app.jar mínimo y un directorio lib/, y el lanzador los lee del sistema de ficheros en lugar de hacerlo desde entradas anidadas, lo que además arranca un poco más rápido que el fat jar. Como dependencies solo cambia cuando cambia pom.xml, los builds del día a día generan una única capa nueva y pequeña.

La guía de fundamentos de Docker explica en general los builds multi-stage, los cache mounts y el .dockerignore; lo específico de Boot es la extracción de capas y la elección de una imagen JRE (-jre, no -jdk) para la etapa de ejecución.

Buildpacks en lugar de un Dockerfile

./mvnw spring-boot:build-image (o ./gradlew bootBuildImage) genera una imagen OCI sin ningún Dockerfile, usando Cloud Native Buildpacks (por defecto, la implementación de Paketo). El buildpack detecta que es una aplicación Boot, elige una JVM, aplica la misma separación en capas, calcula los flags de memoria de la JVM al arrancar el contenedor a partir del límite del cgroup con su memory calculator, se ejecuta con un usuario no root y registra una lista de materiales de software (SBOM). La configuración se hace con propiedades del plugin o con variables de entorno:

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <image>
      <name>registry.example.com/shop/orders:${project.version}</name>
      <env>
        <BP_JVM_VERSION>21</BP_JVM_VERSION>
        <BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:+ExitOnOutOfMemoryError</BPE_APPEND_JAVA_TOOL_OPTIONS>
      </env>
    </image>
  </configuration>
</plugin>

Los buildpacks necesitan un daemon de Docker (o un socket compatible, como el de Podman) durante el build, y las imágenes base son las que publica Paketo, lo que es una ventaja o un impedimento según la política de imágenes de tu organización. Cuando un equipo de plataforma exige una imagen base endurecida, el camino que encaja es el del Dockerfile.

Configurar la JVM en un contenedor

Las JVM modernas (incluidas Java 17 y 21) leen los límites del cgroup del contenedor y dimensionan el heap por defecto en función de ellos, pero ese valor por defecto es una cuarta parte del límite, lo que desaprovecha la mayor parte de un contenedor de 1 GiB. Mejor un porcentaje que un -Xmx fijo, para que la misma imagen funcione con límites distintos:

AjusteRecomendación
Heap-XX:MaxRAMPercentage=75 (menos si la aplicación usa mucha memoria off-heap); evita -Xmx en las imágenes
Heap inicial-XX:InitialRAMPercentage igual al máximo evita pausas de redimensionado en servicios en régimen estable
CPULa JVM deduce su paralelismo de la cuota de CPU; un límite por debajo de 2 CPU hace que G1 y el JIT tarden bastante más en calentarse
Garbage collectorG1 es el predeterminado y va bien; ZGC o Shenandoah para servicios sensibles a la latencia con heaps grandes; Serial GC para contenedores muy pequeños
Diagnóstico-XX:+ExitOnOutOfMemoryError para que el orquestador reinicie un proceso muerto en lugar de dejar un zombi; -XX:+HeapDumpOnOutOfMemoryError hacia un volumen montado

JAVA_TOOL_OPTIONS se lee en cada arranque de la JVM y es la variable de entorno habitual para inyectar estos flags desde un manifiesto de Kubernetes sin reconstruir la imagen. La memoria total del contenedor tiene que superar el heap con margen suficiente para el metaspace, las pilas de los threads (cada thread de Tomcat reserva espacio de pila), el code cache y los direct buffers; un contenedor que muere con el código de salida 137 es el kernel haciendo cumplir el límite, no un OutOfMemoryError de Java.

Boot 3.2 y Java 21 añaden un ajuste que conviene conocer: spring.threads.virtual.enabled=true hace que Tomcat y los task executors de Boot usen virtual threads, lo que elimina el techo del pool de threads en servicios limitados por E/S sin cambiar el código.

Configuración y secretos en tiempo de ejecución

La imagen no debe contener valores específicos de un entorno. Pásalos con los mecanismos que describe la guía de configuración, por este orden de preferencia: variables de entorno para los valores sencillos, ficheros montados para los secretos (spring.config.import=optional:configtree:/run/secrets/) y un config server o un gestor de secretos para todo lo que compartan muchos servicios. Un fragmento de un deployment de Kubernetes:

env:
  - name: SPRING_PROFILES_ACTIVE
    value: prod
  - name: JAVA_TOOL_OPTIONS
    value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
  - name: SPRING_DATASOURCE_PASSWORD
    valueFrom:
      secretKeyRef: { name: orders-db, key: password }
resources:
  requests: { cpu: "500m", memory: "768Mi" }
  limits: { memory: "768Mi" }

Poner la request de memoria igual al límite le da al contenedor una asignación garantizada; el límite de CPU se suele omitir para que la JVM pueda aprovechar ráfagas durante el arranque y la compilación JIT.

Graceful shutdown y despliegues progresivos

Desplegar una versión nueva implica detener las instancias antiguas mientras hay peticiones en curso. Dos ajustes lo hacen seguro:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Al recibir SIGTERM, Boot deja de aceptar conexiones nuevas, permite que las peticiones activas terminen hasta agotar el timeout y después detiene el contexto. Con la readiness probe de Kubernetes apuntando a /actuator/health/readiness (consulta la guía de observabilidad), la secuencia queda así: el pod se marca como terminating, la readiness pasa a rechazar tráfico, el Service deja de enrutarle peticiones, las peticiones en curso terminan y el proceso sale. Configura el terminationGracePeriodSeconds del pod por encima del timeout de Boot y plantéate un sleep corto en preStop para que los balanceadores de carga tengan tiempo de notar el cambio de readiness antes de que el proceso empiece a apagarse.

El arranque también importa: una actualización progresiva que da por bueno un pod antes de que pueda servir tráfico provoca errores igual que una parada brusca. Usa una startup probe o un initialDelaySeconds de readiness que refleje el tiempo de arranque real, que puedes consultar en el endpoint startup de Actuator o en la línea de log que Boot escribe cuando la aplicación está lista.

Alternativas: ficheros WAR e imágenes nativas

El despliegue como WAR en un Tomcat, Jetty o WildFly externo se sigue admitiendo en las organizaciones que han estandarizado un servidor de aplicaciones. Cambia el packaging a war, marca spring-boot-starter-tomcat como provided y extiende SpringBootServletInitializer para que el servidor pueda arrancar la aplicación sin un método main. Pierdes el control sobre la versión de la JVM y del servidor, no ganas nada en rendimiento y complicas la configuración, así que elígelo solo cuando el modelo de operación lo exija.

Las imágenes nativas de GraalVM compilan la aplicación por adelantado en un ejecutable de la plataforma. Spring Boot 3 lo soporta mediante su motor ahead-of-time (AOT), que precalcula las definiciones de beans y genera los hints de reflexión, proxies y recursos que necesita el compilador nativo. Con las GraalVM Native Build Tools configuradas (el perfil native de un proyecto generado con Spring Initializr), ./mvnw -Pnative native:compile genera un binario que arranca en decenas de milisegundos y funciona con una fracción de la memoria; ./mvnw -Pnative spring-boot:build-image lo construye dentro de un buildpack, así que no necesitas GraalVM en local. El precio es un build lento y que consume mucha memoria, restricciones sobre la reflexión y los proxies dinámicos en tiempo de ejecución (las bibliotecas necesitan hints, que Spring proporciona para sus propios proyectos y para muchas dependencias comunes) y una suposición de mundo cerrado: los perfiles y las condiciones se evalúan en el build, de modo que una imagen nativa no puede cambiar de conjunto de beans activando un perfil en tiempo de ejecución. Prueba el propio binario nativo en CI; que los tests pasen en la JVM no demuestra que la imagen funcione.

Entre los dos extremos, Boot 3.3 introdujo una mejora más barata: Class Data Sharing (CDS). Extrae el jar con -Djarmode=tools, ejecútalo una vez durante el build de la imagen con -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh (el segundo flag hace que Boot salga en cuanto el contexto está listo, así que la ejecución de entrenamiento no necesita base de datos) y arráncalo en la imagen con -XX:SharedArchiveFile=app.jsa; el tiempo de arranque suele bajar de forma apreciable sin cambiar en nada el comportamiento de la aplicación.

Errores comunes

Construir la imagen a partir del fat jar como una sola capa. Cada despliegue vuelve a enviar todas las dependencias. Extrae las capas o usa buildpacks.

Usar una imagen JDK en ejecución. La etapa de ejecución necesita un JRE; el JDK duplica el tamaño de la imagen y añade herramientas que un atacante agradecería.

Ejecutar como root dentro del contenedor. Una instrucción USER o el usuario no root por defecto del buildpack no cuestan nada y limitan el daño de un escape del contenedor.

Un -Xmx fijo incrustado en la imagen. Así, la misma imagen no se puede programar con otro límite de memoria sin desperdiciar memoria o provocar fallos. Usa porcentajes.

Prescindir del graceful shutdown. Por defecto la parada es inmediata; cada actualización progresiva produce entonces una ráfaga de peticiones fallidas que nadie atribuye al despliegue.

Ficheros de configuración dentro de la imagen. Una imagen con application-prod.yml incrustado no se puede promover entre entornos sin modificarla y filtra la configuración de producción a cualquiera que pueda descargarla.

Probar las imágenes nativas solo en la JVM. Los problemas de reflexión y de recursos solo aparecen en el binario compilado.

Puntos clave

  • El plugin de Boot reempaqueta tu jar como un ejecutable con dependencias anidadas; publica el jar .original si el código también es una biblioteca.
  • Los layered jars extraídos con -Djarmode=tools y copiados capa a capa, o las imágenes generadas por build-image con Cloud Native Buildpacks, mantienen pequeñas las reconstrucciones y los push de contenedores.
  • Dimensiona la JVM con MaxRAMPercentage e inyecta los flags con JAVA_TOOL_OPTIONS; deja margen en el límite del contenedor para la memoria que no es heap.
  • Mantén fuera de la imagen la configuración específica de cada entorno y los secretos, y pásalos a través del entorno, de ficheros montados o de un config server.
  • server.shutdown=graceful, junto con readiness probes y un periodo de gracia de terminación adecuado, hace que los despliegues progresivos no pierdan peticiones.
  • El empaquetado WAR existe para modelos de operación heredados; las imágenes nativas de GraalVM cambian tiempo de build y comportamiento dinámico por un arranque instantáneo y poca memoria, y CDS es un término medio más barato.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Por qué mi jar de Spring Boot es mucho más grande que mi código?
Porque el paso repackage del plugin construye un ejecutable autónomo: tus clases en BOOT-INF/classes, todas las dependencias de ejecución en BOOT-INF/lib y un pequeño lanzador que sabe cargar jars anidados dentro de otro jar. El jar normal que produce el compilador también se conserva, con el sufijo .original en Maven, y es el que debes publicar si otro proyecto quiere depender de tu código como biblioteca.
¿Debo usar un Dockerfile o el goal build-image?
Usa build-image (Cloud Native Buildpacks) cuando quieras una imagen bien ajustada sin mantener un Dockerfile: elige una JVM, fija los flags de memoria, se ejecuta como usuario no root y separa la aplicación en capas automáticamente. Usa un Dockerfile cuando necesites controlar la imagen base, los certificados corporativos, herramientas adicionales o un estándar de hardening concreto. Con ambos se obtienen imágenes por capas que se reconstruyen rápido.
¿Cuánta memoria le doy a un contenedor de Spring Boot?
Parte del heap que la aplicación necesita de verdad bajo carga y añade espacio para el metaspace, las pilas de los threads, el code cache y la memoria nativa que usan la JVM y las bibliotecas; un límite de contenedor de aproximadamente el heap más un 25 a 50 % es un punto de partida habitual para servicios pequeños. Deja que la JVM calcule el heap a partir del límite con MaxRAMPercentage en lugar de un Xmx fijo, y vigila los reinicios del contenedor provocados por el kernel al matar un proceso que superó su límite.
¿Una imagen nativa de GraalVM siempre es más rápida?
Arranca en decenas de milisegundos y consume mucha menos memoria en reposo, lo que importa en funciones serverless y en clústeres densos. El rendimiento máximo suele ser algo menor que el de una JVM con el JIT ya caliente, el build tarda minutos y consume mucha memoria, y las bibliotecas que hacen un uso intensivo de la reflexión necesitan hints. Para servicios de larga duración con tráfico estable, la JVM sigue siendo la opción por defecto; elige nativo cuando la restricción sea el tiempo de arranque o el consumo de memoria.
DevOpsFundamentos de Docker: imágenes, contenedores y DockerfilesQué es de verdad un contenedor, cómo se organiza una imagen en capas, cómo escribir un buen Dockerfile Java o Node y los comandos y hábitos del día a día.Principiante7 min de lecturaDevOpsFundamentos de Kubernetes: arquitectura, Pods, Deployments y ServicesCómo se construye un clúster de Kubernetes, qué es de verdad un Pod y cómo Deployments, Services, ConfigMaps y labels forman una aplicación en marcha.Intermedio10 min de lecturaDevOpsPipelines de entrega continua: etapas, artefactos y estrategias de despliegueCómo un pipeline convierte un commit en una release: compilar una vez, promover artefactos inmutables, migrar la base de datos y desplegar sin cortes.Intermedio10 min de lecturaSpring Boot Actuator y observabilidad: health, métricas, trazas y logsHaz observable un servicio Spring Boot 3: endpoints de Actuator, probes de Kubernetes, métricas Micrometer para Prometheus, tracing y correlación de logs.Intermedio8 min de lecturaConfiguración en Spring Boot: properties, YAML, perfiles y @ConfigurationPropertiesConfiguración externalizada en Spring Boot 3: precedencia de fuentes, YAML y perfiles, @ConfigurationProperties tipado, config import y secretos.Principiante10 min de lecturaNubeCloud-native y Twelve-Factor App: qué implica cada factor para un contenedorLos doce factores aplicados a un servicio Java en contenedores — config, sin estado, desechabilidad, logs como flujos — y lo que el modelo omite hoy.Intermedio11 min de lectura