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:21 contiene 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 es org.springframework.boot.loader.launch.JarLauncher en 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.xml y luego ejecutar dependency:go-offline antes de copiar src. 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á en application y el directorio lib/ está en dependencies, después de unir todo /app tiene la estructura app.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.0 permite que el heap use como máximo el 75% del límite de memoria del contenedor. ExitOnOutOfMemoryError hace 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 tiene wget incluido desde BusyBox, así que no necesitas instalar curl.
  • ENTRYPOINT en formato exec (array JSON): java es PID 1 y recibe la señal SIGTERM cuando 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:

  • ImageFromDockerfile compila la imagen desde el Dockerfile en el directorio raíz del proyecto. El contexto de compilación es el directorio que contiene el Dockerfile. El parámetro false conserva 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 -u dentro del contenedor. Si alguien accidentalmente elimina la línea USER spring:spring, el resultado será 0 y 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.jar en formato shell. Entonces /bin/sh es PID 1 y no reenvía SIGTERM a la JVM. El contenedor se mata abruptamente después del tiempo de espera, perdiendo el shutdown elegante. Siempre usa el formato array JSON.
  • Establecer -Xmx de forma fija. Si estableces -Xmx2g pero el límite del contenedor es 1 GB, el contenedor será asesinado por OOM sin una traza de pila. Usa MaxRAMPercentage para que el heap se ajuste al límite.
  • Alpine y librerías nativas. Alpine usa musl en lugar de glibc. 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 a eclipse-temurin:21-jre-jammy o -noble. La imagen es unos cuantos MB más grande pero más estable.
  • Copiar capas en orden incorrecto. Si copias application antes que dependencies, cada cambio de código invalida el cache de la capa de dependencias. Pierdes todo el beneficio del layered jar.
  • Usar la etiqueta latest para 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 como 21.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 jdeps y jlink para 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.

spring-bootdockerjavadevopscontainer