Una vez que hemos desacoplado la ejecución de una aplicación de nuestro equipo y su entorno desplegándola en un contenedor, el siguiente paso es independizar también el proceso de compilación y construcción. El objetivo es evitar también problemas de configuraciones y dependencias relacionadas con el entorno de desarrollo, moviendo la responsabilidad a un proceso automatizado. De esta forma nos acercarnos más a un entorno de producción.

Enfoque de partida

El Dockerfile con el que hemos trabajado previamente incluye lo mínimo para desplegar el JAR:

1
2
3
4
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/app.jar app.jar
ENTRYPOINT ["java","-jar","app.jar"]

Los problemas que presenta son:

  • Necesitamos compilar la aplicación fuera del contenedor.
  • Dependencias de Maven instaladas en el entorno de desarrollo.
  • No es reproducible al 100%.
  • El proceso de compilación y despliegue depende de varios pasos manuales.

Para completar el proceso, podríamos incorporar en el mismo contenedor lo necesario para realizar la compilación, pero todas las herramientas necesarias para ello convivirían con la aplicación desplegada en el contenedor de producción.

Qué es un multi-stage build

Un multi-stage build es una técnica de Docker que consiste en usar un contenedor para construir la aplicación y otro distinto, mucho más pequeño, solo para ejecutarla. Así Docker, compila dentro de un contenedor pesado con las dependencias necesarias para ese proceso, descarta todo lo innecesario y se queda con la aplicación final compilada para desplegarla en otro contenedor mucho más ligero.

Esquema del proceso

  1. Stage 1 (builder)
    • Tiene Maven
    • Compila el proyecto
    • Genera el JAR
  2. Stage 2 (runtime)
    • Solo tiene Java
    • Copia el JAR
    • Ejecuta la app

De esta forma, el contenedor final no contiene Maven, ni código fuente, ni caché.

Nuevo Dockerfile multi-stage

El nuevo Dockerfile completo con las dos etapas quedaría así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# ---------- STAGE 1: build ----------
FROM maven:3.9.9-eclipse-temurin-21-alpine AS build
WORKDIR /app 
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests

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

Stage 1 - Build con Maven

FROM maven:3.9.9-eclipse-temurin-21-alpine AS build

  • Imagen base con Maven + JDK 21.
  • AS build: le ponemos nombre a esta fase para poder referenciarla después.

WORKDIR /app

  • Establece el directorio de trabajo dentro del contenedor.
  • A partir de aquí, todos los comandos se ejecutan desde /app. (equivalente a hacer cd /app)

COPY pom.xml .

  • Copia únicamente el pom.xml al contenedor.
  • Se hace así para aprovechar la caché de Docker y no tener que descargar dependencias cada vez que cambia el código fuente.

RUN mvn dependency:go-offline

  • Descarga todas las dependencias declaradas en el pom.xml.
  • Si después cambia el código pero no las dependencias, Docker reutiliza esta capa y el build es mucho más rápido.
  • go-offline es un goal del plugin de Maven, indica que se deben descargar todas las dependencias necesarias para que el proyecto pueda compilarse sin conexión a internet. Es útil para mantener las descargas en la caché de Docker.

COPY src ./src

  • Copia el código fuente de la aplicación dentro del contenedor.
  • Ya tenemos todo lo necesario para compilar.

RUN mvn clean package -DskipTests

  • clean elimina compilaciones anteriores.
  • package compila y genera el JAR ejecutable.
  • -DskipTests evita ejecutar los tests durante la construcción de la imagen. En entornos reales, los tests suelen ejecutarse previamente en un pipeline de integración continua. Separar la fase de validación de la fase de construcción permite builds más rápidos y predecibles.
  • Aquí se genera el mismo JAR que antes creábamos en local, pero ahora dentro del contenedor.

Stage 2 - Runtime limpio

Comienza una nueva fase completamente independiente.

FROM eclipse-temurin:21-jre-alpine-3.23

  • Imagen ligera con solo el entorno de ejecución Java.
  • No incluye Maven ni herramientas de desarrollo.

WORKDIR /app

  • De nuevo, definimos el directorio de trabajo.
  • Cada FROM reinicia el contexto, por eso hay que volver a declararlo.

COPY --from=build /app/target/*-SNAPSHOT.jar app.jar

  • --from=build indica que copiamos desde el stage anterior.
  • Copiamos el JAR generado dentro del contenedor de build.

EXPOSE 8080

  • Indica que la aplicación escucha en el puerto 8080.
  • No abre el puerto, solo lo documenta a nivel de imagen.

ENTRYPOINT ["java","-jar","app.jar"]

  • Define el comando que se ejecutará cuando el contenedor arranque.
  • Lanza la aplicación Spring Boot.
  • Es equivalente a ejecutar java -jar app.jar

Con esto, hemos separado el proceso de construcción del proceso de ejecución. El contenedor ya no depende del entorno del desarrollador, sino de un proceso reproducible y aislado. Igual que separamos responsabilidades en el código, aquí separamos responsabilidades en el contenedor.

Construcción de los contenedores

De forma similar a cómo construíamos la imagen anterior, lanzamos el siguiente comando en consola que, ahora realizará la compilación de la aplicación y generará la imagen para desplegarla (etiquetada como version 2.0).

docker build -t hello-world-api:2.0 .

En este caso el tiempo para finalizar será superior, aunque será más evidente solo en la primera ejecución. Si el proceso finaliza correctamente, tendremos la nueva imagen disponible.

Resultado en terminal tras lanzar la construcción de la imagen del contenedor

Para desplegar el contenedor con la aplicación, ejecutamos el siguiente comando:

docker run -p 8080:8080 hello-world-api:2.0

De esta forma, tendremos la aplicación compilada y desplegada, sin haber ejecutado Maven en nuestro equipo.

Hello World ejecutado en el navegador desde el contenedor final

Comparativa con el enfoque anterior

Hemos conseguido desacoplar la compilación del entorno del desarrollador y mover esa responsabilidad al contenedor. Pero además usando multi-stage conseguimos que esa imagen final:

  • No incluye herramientas de construcción innecesarias.
  • Mantiene una imagen final ligera.
  • Evita inflar la imagen con Maven y código fuente.
  • Reproducible totalmente en otros entornos.
  • Lista para CI/CD.
  • Más segura (al incluir solo los elementos mínimos para su ejecución).

Reflexión

Existe una premisa fundamental a la hora de trabajar con contenedores: Una imagen debe contener solo lo necesario para ejecutarse. Nada más. Debido a ello, multi-stage es el planteamiento recomendado en entornos reales, salvo para la construcción de prototipos ultra rápidos.

Por otro lado, desacoplando la compilación del entorno del desarrollador ya no importa que versión de Maven tiene, si está instalado, si otro desarrollador usa otro sistema operativo, si el servidor se actualiza, etc. El contenedor se convierte en el entorno estándar de construcción de la aplicación. Esto es el enfoque DevOps.