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 demvn 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 buildnombra la etapa inicial. Usas este nombre enCOPY --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, sinjavacni Maven. COPY --from=buildsolo copia el archivo JAR a la imagen final.ENTRYPOINTen forma exec (matriz JSON). De esta manera,javase ejecuta como PID 1 y recibe la señalSIGTERMcuando 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-offlineno siempre descarga el 100% de los plugins que Maven necesita en la fasepackage. 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:1activa la sintaxis completa de Dockerfile más reciente. /root/.m2se almacena en caché en el lado del compilador. Incluso sipom.xmlcambia, 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
extractdescompone el JAR en cuatro directorios:dependencies,spring-boot-loader,snapshot-dependencies,application. - Los cuatro comandos
COPYcrean cuatro capas, ordenadas de menos a más frecuente cambio. - Cuando modificas el código, solo la capa
applicationcambia. Esta capa suele tener solo unos cientos de KB. - La opción
--launcherpreserva el lanzador. Por lo tanto,ENTRYPOINTinvocaJarLauncheren 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 esorg.springframework.boot.loader.launch.JarLauncher, antes eraorg.springframework.boot.loader.JarLauncher. Usar un nombre de clase incorrecto causaClassNotFoundExceptioncuando 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. Copiapom.xmlprimero,srcdespué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
jcmdpara diagnóstico. - Wildcard que coincide con múltiples JAR:
COPY --from=build /workspace/target/*.jar app.jarfalla sitarget/tiene*-plain.jaro*-sources.jar. Establece<finalName>fijo enpom.xmlo desactiva artefactos secundarios. - Forma shell para
ENTRYPOINT:ENTRYPOINT java -jar app.jarse ejecuta a través de/bin/sh -c. Entoncesjavano recibeSIGTERM, Spring no se detiene correctamente y el contenedor se mata después del tiempo de espera. - Etiqueta
latestpara 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 dedocker history. Cuando necesites tokens para descargar dependencias privadas, usaRUN --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.