Escribir Dockerfile bien: multi-stage build para Spring Boot

1. Descripción general

Un Dockerfile escrito apresuradamente suele resultar en imágenes que pesan cientos de MB. Esa imagen contiene JDK, Maven, código fuente y el directorio .m2. Cada vez que modificas una línea de código, Docker descarga todas las dependencias desde cero.

Este artículo te enseña a usar multi-stage build para separar la fase de compilación de la fase de ejecución. Después de este artículo, podrás escribir un Dockerfile para aplicaciones Spring Boot 3.x (Java 21) con imágenes pequeñas, compilación rápida gracias al caché, pruebas en una etapa separada y procesos ejecutados por un usuario sin privilegios de root.

2. Problemas con un Dockerfile de una sola etapa

Usamos una pequeña aplicación con un endpoint que devuelve el estado de un pedido:

package com.nonla.orders;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

@RestController
class OrderController {

    @GetMapping("/orders/{id}/status")
    String status(@PathVariable long id) {
        return id > 0 ? "CONFIRMED" : "UNKNOWN";
    }
}

Las dependencias en pom.xml solo requieren web y test:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

La forma típica de escribir un Dockerfile “que funcione” se ve así:

FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]

Este Dockerfile tiene tres problemas:

  • Imagen pesada: la imagen final contiene Maven, JDK completo, código fuente y toda la caché .m2.
  • Caché deficiente: COPY . . aparece antes de mvn package. Si modificas un archivo .java, esta capa se invalida y todas las dependencias se descargan de nuevo.
  • Superficie de ataque grande: las herramientas de compilación y el código fuente permanecen en el contenedor de producción.

3. Multi-stage build básico

Multi-stage build permite que un Dockerfile tenga múltiples comandos FROM. Cada FROM abre una nueva etapa. Una etapa posterior puede copiar archivos de una anterior usando COPY --from=<nombre_etapa>. Solo la etapa final se convierte en la imagen resultante. Las etapas intermedias se descartan.

3.1. Etapa de compilación y etapa de ejecución

# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests

# Stage 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Explicación de cada parte:

  • AS build nombra la etapa inicial. Usas este nombre en COPY --from=build.
  • La etapa de compilación usa una imagen con Maven y JDK para compilar y empaquetar el JAR.
  • La etapa de ejecución usa eclipse-temurin:21-jre. Esta imagen contiene solo JRE, sin javac ni Maven.
  • COPY --from=build solo copia el archivo JAR a la imagen final.
  • ENTRYPOINT en forma exec (matriz JSON). De esta manera, java se ejecuta como PID 1 y recibe la señal SIGTERM cuando se detiene el contenedor.

3.2. Comparación de resultados

Compilar y ver el tamaño:

docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders

Resultado ilustrativo (los datos reales dependen de las versiones de la imagen y las dependencias):

REPOSITORY   TAG      SIZE
orders       single   ~560MB
orders       multi    ~290MB

La mayoría del tamaño restante proviene de JRE y del sistema operativo base. Puedes reducir más usando una imagen base más compacta como eclipse-temurin:21-jre-alpine. Sin embargo, verifica bien si tu aplicación utiliza bibliotecas nativas.

4. Aprovechar el caché de capas

Cada comando RUN y COPY crea una capa. Docker reutiliza una capa si el comando y la entrada no cambian. Cuando una capa cambia, todas las capas posteriores deben compilarse de nuevo. Por lo tanto, el principio general es colocar lo que cambia menos primero y lo que cambia más frecuentemente después.

4.1. Separar la descarga de dependencias

pom.xml cambia mucho menos que el código fuente. Lo copiamos primero y descargamos las dependencias en una capa separada:

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

Ahora, cuando solo modificas OrderController.java, Docker reutiliza la capa dependency:go-offline. El registro de compilación mostrará algo como:

 => CACHED [build 3/5] COPY pom.xml .
 => CACHED [build 4/5] RUN mvn -B dependency:go-offline
 => [build 5/5] COPY src ./src

Nota: dependency:go-offline no siempre descarga el 100% de los plugins que Maven necesita en la fase package. Algunos plugins pueden seguir descargándose después. Aún así, el resultado es mucho mejor que sin separar.

4.2. Cache mount con BuildKit

BuildKit (disponible por defecto en Docker actualmente) admite --mount=type=cache. El directorio montado se conserva entre compilaciones pero no se incluye en la imagen:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
  • La línea # syntax=docker/dockerfile:1 activa la sintaxis completa de Dockerfile más reciente.
  • /root/.m2 se almacena en caché en el lado del compilador. Incluso si pom.xml cambia, Maven solo descarga las nuevas dependencias en lugar de descargar todo de nuevo.

Puedes combinar ambas técnicas. En CI, el cache mount solo funciona si el compilador se mantiene entre ejecuciones. Si no es así, separar capas como en la sección 4.1 combinado con caché de registro sigue siendo una opción más confiable.

5. Separar capas para JAR de Spring Boot

Un fat JAR de Spring Boot contiene tu código y decenas de MB de bibliotecas. Si copias el JAR completo en la imagen, cada cambio de código crea una capa nueva de varios MB. El registro debe almacenar esa capa adicional y los servidores deben descargar todo nuevamente.

Spring Boot admite descomponer el JAR en capas según su frecuencia de cambio. A partir de Spring Boot 3.3, usas jarmode=tools:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests

FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Puntos clave:

  • La etapa extract descompone el JAR en cuatro directorios: dependencies, spring-boot-loader, snapshot-dependencies, application.
  • Los cuatro comandos COPY crean cuatro capas, ordenadas de menos a más frecuente cambio.
  • Cuando modificas el código, solo la capa application cambia. Esta capa suele tener solo unos cientos de KB.
  • La opción --launcher preserva el lanzador. Por lo tanto, ENTRYPOINT invoca JarLauncher en lugar de -jar.

Nota: Con Spring Boot 3.0–3.2, usas la sintaxis anterior java -Djarmode=layertools -jar app.jar extract. El paquete del lanzador también varía entre versiones: desde 3.2 es org.springframework.boot.loader.launch.JarLauncher, antes era org.springframework.boot.loader.JarLauncher. Usar un nombre de clase incorrecto causa ClassNotFoundException cuando el contenedor inicia.

6. Ejecutar pruebas en una etapa separada

Usar -DskipTests en la etapa de compilación acelera la construcción, pero no quieres enviar al registro una imagen sin pruebas. Multi-stage permite separar las pruebas en una etapa propia y elegir qué etapa compilar con --target.

Las pruebas del controlador usan JUnit 5 y AssertJ (incluidas en spring-boot-starter-test):

package com.nonla.orders;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
        String body = mockMvc.perform(get("/orders/42/status"))
                .andReturn().getResponse().getContentAsString();

        assertThat(body).isEqualTo("CONFIRMED");
    }

    @Test
    void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
        String body = mockMvc.perform(get("/orders/0/status"))
                .andReturn().getResponse().getContentAsString();

        assertThat(body).isEqualTo("UNKNOWN");
    }
}

Añade una etapa test basada en la etapa de compilación:

FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test

En CI, ejecutas las pruebas primero y luego compilas la imagen de ejecución:

docker build --target test -t orders:test .
docker build -t orders:1.0.0 .

El primer comando solo compila hasta la etapa test. Si una prueba falla, el comando RUN devuelve un código de error distinto de 0 y la compilación se detiene:

[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1

El segundo comando compila la etapa final. Con BuildKit, la etapa test no es referenciada por la etapa final, por lo que se descarta. La imagen de producción no vuelve a ejecutar las pruebas. Gracias al caché, las etapas comunes tampoco se recompilan.

Nota: No ejecutes pruebas de integración que requieren Docker (por ejemplo, Testcontainers) dentro de docker build. El entorno de compilación no tiene un daemon Docker. Ejecuta ese tipo de pruebas en un paso de CI externo.

7. Seguridad: usuario sin privilegios de root y .dockerignore

7.1. No ejecutar como root

Por defecto, los procesos en el contenedor se ejecutan como root. Si la aplicación es explotada, el atacante tiene permisos de root en el contenedor. Crea un usuario separado en la etapa de ejecución:

FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Verificación rápida:

docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)

Los UID/GID específicos pueden variar, siempre que no sean 0. Con imágenes Alpine, usa addgroup -S spring && adduser -S spring -G spring en lugar de groupadd y useradd.

7.2. Agregar .dockerignore

El contexto de compilación es el directorio completo que pasas a docker build. Sin filtrar, Docker envía target/, .git/ y archivos de configuración del IDE al compilador. Esto ralentiza la compilación, rompe el caché y puede exponer información sensible.

target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*

Coloca este archivo en la raíz del proyecto, junto a Dockerfile.

8. Errores frecuentes

  • COPY . . demasiado pronto: rompe el caché de dependencias. Copia pom.xml primero, src después.
  • Usar JDK en la etapa de ejecución: funciona pero desperdicia decenas a cientos de MB. Producción solo necesita JRE, a menos que necesites herramientas como jcmd para diagnóstico.
  • Wildcard que coincide con múltiples JAR: COPY --from=build /workspace/target/*.jar app.jar falla si target/ tiene *-plain.jar o *-sources.jar. Establece <finalName> fijo en pom.xml o desactiva artefactos secundarios.
  • Forma shell para ENTRYPOINT: ENTRYPOINT java -jar app.jar se ejecuta a través de /bin/sh -c. Entonces java no recibe SIGTERM, Spring no se detiene correctamente y el contenedor se mata después del tiempo de espera.
  • Etiqueta latest para imágenes base: la compilación hoy y el próximo mes pueden diferir. Fija la versión (por ejemplo, eclipse-temurin:21-jre) o el digest si necesitas reproducibilidad total.
  • Pasar secretos como ARG/ENV: los valores pueden exponerse a través de docker history. Cuando necesites tokens para descargar dependencias privadas, usa RUN --mount=type=secret.

¿Cuándo no necesitas ser tan cuidadoso? Para scripts pequeños o imágenes solo para desarrollo personal, una etapa es suficiente. Además, si tu equipo ya usa mvn spring-boot:build-image (Cloud Native Buildpacks) o Jib, estas herramientas ya manejan la separación de capas y non-root. Ya no necesitas escribir Dockerfile a mano.

9. Conclusión

Multi-stage build separa claramente el “entorno de compilación” del “entorno de ejecución”. La imagen final contiene solo lo que tu aplicación realmente necesita. Combinado con el orden correcto de capas, cache mounts, separación de capas de Spring Boot, etapas de prueba dedicadas y usuarios sin privilegios de root, tienes un Dockerfile que compila rápido, imágenes compactas y mucho más seguras.

Puedes comenzar con el Dockerfile de la sección 3. Luego aplica gradualmente cada mejora y mide nuevamente el tiempo de compilación y el tamaño de la imagen después de cada paso para ver claramente el impacto.

dockerdockerfilemulti-stage buildspring bootjava