Empaquetar Spring Boot en Docker de forma ligera
1. Resumen general
Empaquetar Spring Boot en Docker es fácil. Empaquetar de manera que la imagen sea pequeña, compile rápido, se ejecute de forma segura requiere algunos pasos adicionales. Con el famoso Dockerfile de “tres líneas”, la imagen típicamente pesa varios cientos de MB, cada vez que cambias una línea de código tienes que subir de nuevo todo el JAR, y el proceso corre bajo permisos de root.
En este artículo pasaremos de un Dockerfile ingenuo a uno multi-stage, usando layered jar y JRE mínimo. Luego verás cómo usar Buildpacks como alternativa y escribir una prueba con Testcontainers para verificar la imagen que acabas de compilar.
2. Aplicación de ejemplo
Usaremos un servicio pequeño llamado order-service, con un endpoint REST y Actuator para hacer health check.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
@RestController
@RequestMapping("/orders")
class OrderController {
record Order(long id, String customer, BigDecimal total) {}
@GetMapping("/{id}")
Order find(@PathVariable long id) {
return new Order(id, "Nguyen Van A", new BigDecimal("250000"));
}
}
El comando ./mvnw package genera el archivo target/order-service-1.0.0.jar. Es un “fat jar” que contiene tanto tu código como todas las dependencias.
3. Dockerfile ingenuo y sus problemas
La versión que muchas personas escriben primero se ve así:
FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Este Dockerfile funciona, pero tiene cuatro problemas:
- La imagen base es demasiado grande.
eclipse-temurin:21contiene todo el JDK: compilador,jshell,javadoc… En tiempo de ejecución no los necesitas. - Una sola capa para todo. El fat jar ocupa 20–30 MB, de los cuales tu código solo ocupa unos cientos de KB. Cambiar una línea de código cambia esa capa de 30 MB, tienes que compilar y subir de nuevo.
- Dependencia de la máquina de compilación. La imagen necesita el JAR ya compilado en tu máquina. El JDK y la versión de Maven en cada máquina pueden ser diferentes.
- Se ejecuta bajo permisos de root. Si la aplicación es explotada, el atacante tiene permisos de root dentro del contenedor.
Verifica el tamaño:
docker build -t order-service:naive .
docker images order-service
REPOSITORY TAG IMAGE ID SIZE
order-service naive 3f1c9a7e2b10 ~480MB
El número exacto depende de la versión de la imagen base, pero casi 500 MB para un servicio “hello world” es demasiado.
4. Multi-stage build con layered jar
4.1. ¿Qué es un layered jar?
A partir de Spring Boot 2.3, el fat jar se divide automáticamente en varias capas según su frecuencia de cambio:
| Capa | Contenido | Frecuencia de cambio |
|---|---|---|
dependencies |
Librerías con versión release | Rara |
spring-boot-loader |
Class loader de Spring Boot | Muy rara |
snapshot-dependencies |
Librerías -SNAPSHOT |
Ocasional |
application |
Código y recursos tuyo | Frecuente |
Copias cada parte en la imagen con un comando COPY separado, en orden de menos a más cambios. Docker cacheará las capas superiores. Cuando solo modificas el código, solo la capa application se recompila y se sube.
A partir de Spring Boot 3.3, extraes las capas usando jarmode=tools:
java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --destination extracted
ls extracted
application dependencies snapshot-dependencies spring-boot-loader
El directorio application contiene el archivo order-service-1.0.0.jar “adelgazado”. Este archivo referencia el directorio lib/ que está en la capa dependencies. Después de unir todas las capas, solo necesitas ejecutar java -jar como de costumbre.
Nota: Con Spring Boot 3.2 y anteriores, la sintaxis es
java -Djarmode=layertools -jar app.jar extract. El entrypoint en ese caso esorg.springframework.boot.loader.launch.JarLauncheren lugar de-jar.
4.2. Dockerfile completo
# ---------- Etapa 1: compilar con Maven ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
# ---------- Etapa 2: extraer capas ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# ---------- Etapa 3: runtime ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/spring-boot-loader/ ./
COPY --from=extract /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./
USER spring:spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
Explicación de cada parte:
- Etapa
build:COPY pom.xmly luego ejecutardependency:go-offlineantes de copiarsrc. Cuando solo modificas el código, Docker reutiliza la capa que contiene las dependencias ya descargadas. No necesitas descargar todo nuevamente de Maven Central. - Etapa
extract: extrae el JAR en cuatro directorios de capas. Esta etapa es solo intermedia y no aparece en la imagen final. - Etapa runtime: usa
21-jre-alpine, que solo contiene JRE sobre Alpine Linux. - Cuatro comandos
COPY: cada comando crea una capa de Docker, en orden de menos a más cambios. Dado que el JAR “adelgazado” (app.jar) está enapplicationy el directoriolib/está endependencies, después de unir todo/apptiene la estructuraapp.jar+lib/. USER spring:spring: el proceso Java ya no corre bajo permisos de root.JAVA_TOOL_OPTIONS: la JVM lee esta variable automáticamente.MaxRAMPercentage=75.0permite que el heap use como máximo el 75% del límite de memoria del contenedor.ExitOnOutOfMemoryErrorhace que la JVM salga completamente cuando se queda sin memoria, permitiendo que el orquestador reinicie el contenedor en lugar de dejarlo en un estado inconsistente.HEALTHCHECK: Alpine tienewgetincluido desde BusyBox, así que no necesitas instalarcurl.ENTRYPOINTen formato exec (array JSON):javaes PID 1 y recibe la señalSIGTERMcuando se detiene el contenedor. Gracias a esto, Spring Boot puede hacer un shutdown elegante.
4.3. No olvides .dockerignore
El comando build envía todo el directorio actual (build context) al daemon de Docker. Excluye lo que no necesitas:
target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile
Sin este archivo, directorios como target/ o .git/ pueden hacer que el contexto sea de cientos de MB. Cada compilación será notablemente más lenta.
4.4. Resultado
docker build -t order-service:1.0.0 .
docker images order-service
docker history order-service:1.0.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -8
REPOSITORY TAG IMAGE ID SIZE
order-service 1.0.0 9b2e44d1c0af ~200MB
order-service naive 3f1c9a7e2b10 ~480MB
0B ENTRYPOINT ["java" "-jar" "app.jar"]
...
45kB COPY /extract/extracted/application/ ./
0B COPY /extract/extracted/snapshot-dependencies/ ./
290kB COPY /extract/extracted/spring-boot-loader/ ./
22MB COPY /extract/extracted/dependencies/ ./
La imagen es menos de la mitad de tamaño. Más importante aún, la capa application ocupa solo unos cientos de KB. Cuando modificas OrderController y compilas de nuevo, solo esa capa cambia. La siguiente subida al registro tarda solo unos segundos.
5. Alternativa: Cloud Native Buildpacks
Si no quieres mantener un Dockerfile, el plugin de Maven de Spring Boot puede compilar la imagen directamente usando Buildpacks:
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=shop/order-service:1.0.0
También puedes configurar permanentemente en pom.xml:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>shop/order-service:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
</env>
</image>
</configuration>
</plugin>
Buildpacks hace automáticamente lo que hiciste manualmente arriba: separa capas, elige JRE, corre bajo un usuario non-root, calcula la memoria para la JVM. No necesitas un Dockerfile, solo el daemon de Docker en funcionamiento.
A cambio, tienes menos control. La imagen base la decide buildpack, la primera compilación es bastante lenta porque tiene que descargar la imagen del compilador, y la personalización profunda es más difícil que editar un Dockerfile. Equipos que quieren estandarizar rápido deberían usar Buildpacks. Equipos que necesitan control detallado, por ejemplo usar una imagen base interna que ha sido aprobada por seguridad, deberían usar un Dockerfile.
6. Verificar la imagen con Testcontainers
Una imagen “ligera” que no arranca es inútil. Puedes escribir una prueba que compile la imagen desde el mismo Dockerfile, ejecute un contenedor y verifique que funciona correctamente.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
Spring Boot BOM ya gestiona las versiones para ambas librerías, así que no necesitas declarar <version>.
@Testcontainers
class OrderServiceImageIT {
static final ImageFromDockerfile IMAGE =
new ImageFromDockerfile("order-service-it", false)
.withDockerfile(Path.of("./Dockerfile"));
@Container
static final GenericContainer<?> APP = new GenericContainer<>(IMAGE)
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200))
.withStartupTimeout(Duration.ofMinutes(2));
@Test
void shouldServeOrdersAndReportHealthy() throws Exception {
String baseUrl = "http://" + APP.getHost() + ":" + APP.getMappedPort(8080);
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create(baseUrl + "/orders/42")).build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("\"id\":42", "Nguyen Van A");
}
@Test
void shouldNotRunAsRoot() throws Exception {
var result = APP.execInContainer("id", "-u");
assertThat(result.getStdout().trim()).isNotEqualTo("0");
}
}
Esta prueba hace lo siguiente:
ImageFromDockerfilecompila la imagen desde elDockerfileen el directorio raíz del proyecto. El contexto de compilación es el directorio que contiene el Dockerfile. El parámetrofalseconserva la imagen después de la prueba para que la siguiente compilación sea más rápida gracias al cache.Wait.forHttp("/actuator/health")espera hasta que la aplicación esté realmente lista, no solo hasta que el contenedor haya arrancado.- Primera prueba llama al endpoint a través del puerto mapeado aleatoriamente en el host y verifica el JSON devuelto.
- Segunda prueba ejecuta
id -udentro del contenedor. Si alguien accidentalmente elimina la líneaUSER spring:spring, el resultado será0y la prueba fallará inmediatamente.
Nombra la clase con el sufijo IT y ejecútala con maven-failsafe-plugin. Esta prueba compila toda la imagen así que tarda unos cuantos segundos, no es adecuada para ejecutarse con las pruebas unitarias habituales.
7. Errores comunes y cuándo no aplicar esto
- Usar
ENTRYPOINT java -jar app.jaren formato shell. Entonces/bin/shes PID 1 y no reenvíaSIGTERMa la JVM. El contenedor se mata abruptamente después del tiempo de espera, perdiendo el shutdown elegante. Siempre usa el formato array JSON. - Establecer
-Xmxde forma fija. Si estableces-Xmx2gpero el límite del contenedor es 1 GB, el contenedor será asesinado por OOM sin una traza de pila. UsaMaxRAMPercentagepara que el heap se ajuste al límite. - Alpine y librerías nativas. Alpine usa
muslen lugar deglibc. Algunas librerías tienen código nativo (transporte nativo de Netty, algunos drivers, librerías de procesamiento de imágenes) pueden fallar o ejecutarse lentamente. En ese caso cambia aeclipse-temurin:21-jre-jammyo-noble. La imagen es unos cuantos MB más grande pero más estable. - Copiar capas en orden incorrecto. Si copias
applicationantes quedependencies, cada cambio de código invalida el cache de la capa de dependencias. Pierdes todo el beneficio del layered jar. - Usar la etiqueta
latestpara la imagen base. La compilación de hoy y la de la próxima semana pueden resultar en dos imágenes diferentes. Fija etiquetas específicas como21.0.5_11-jre-alpine, o fija el digest si necesitas compilaciones completamente reproducibles.
Nota: Si necesitas una imagen aún más pequeña, puedes usar
jdepsyjlinkpara crear una JRE que contenga solo los módulos que tu aplicación necesita, o compilar nativa con GraalVM. Ambas opciones reducen el tamaño significativamente, pero hacen la compilación mucho más compleja y frágil con reflexión. Solo aplica esto cuando el tamaño de la imagen o el tiempo de arranque sean realmente un problema, por ejemplo con serverless o scale-to-zero.
8. Conclusión
En este artículo transformaste un Dockerfile de tres líneas en uno multi-stage con los siguientes pasos:
- compilar dentro del contenedor para no depender de la máquina de compilación;
- separar el layered jar para que solo la capa con el código cambie después de cada modificación;
- usar JRE Alpine para reducir el tamaño de la imagen;
- ejecutar bajo un usuario non-root, con health check y configuración de JVM según el límite de memoria del contenedor.
También viste que Buildpacks es una alternativa que no requiere un Dockerfile. Finalmente, las pruebas con Testcontainers ayudan a asegurar que la imagen sigue arrancando correctamente y no corre bajo permisos de root después de cada cambio.