1. Multi-stage y el monolítico COPY app.jar
Multi-stage ya separó la compilación del runtime, y ese fue un paso importante. Pero después de eso, rápidamente surge el siguiente dolor: la capa final de runtime sigue compilándose con demasiada frecuencia de manera muy gruesa, como un solo app.jar grande.
Cuando consigues levantar un servicio Spring Boot en un contenedor por primera vez, aparece una sensación muy agradable: "Lo logré". Y es cierto: la victoria es merecida. Pero luego llega la segunda parte, menos romántica, del trabajo: empiezas a modificar el código todos los días y, de repente, resulta que la construcción de la imagen y su entrega en el equipo se comportan como si estuvieras mudando un apartamento entero cada vez, aunque solo hayas cambiado una taza.
Veamos ese "sencillo, pero funcional" baseline que a menudo permanece en el repositorio durante mucho tiempo:
# Imagen de runtime mínima: solo JRE y un artefacto de la aplicación
FROM eclipse-temurin:25-jre
WORKDIR /app
# Una capa grande: cualquier cambio en el JAR hace que la capa sea nueva
COPY build/libs/docker-java-catalog-service.jar app.jar
# Iniciamos la aplicación mediante el jar ejecutable
ENTRYPOINT ["java", "-jar", "app.jar"]
Es comprensible, corto y parece "ideal". Pero aquí es donde se esconde el problema: un COPY = una capa grande de Docker, y dentro de ese jar de Spring Boot no solo está tu código.
Ya mejoramos el empaquetado mediante multi-stage, y es un paso acertado. Por ejemplo, aquí hay una variante muy reconocible que separa la compilación del runtime:
# Stage 1: compilación (dentro están JDK y Gradle Wrapper)
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /workspace
# Copiamos los fuentes y los archivos de compilación
COPY . .
# Compilamos el JAR ejecutable (bootJar)
RUN ./gradlew bootJar
# Stage 2: ejecución (dentro solo está JRE, sin herramientas de compilación)
FROM eclipse-temurin:25-jre
WORKDIR /app
# Tomamos del builder solo el artefacto listo
COPY --from=builder /workspace/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Esto es realmente mejor: en la imagen final no hay Gradle, ni fuentes, ni "basura de construcción". Pero el problema del artefacto monolítico persiste: la capa final sigue conteniendo "toda la aplicación completa" en un solo archivo.
2. jar y la caché de Docker
La caché de Docker funciona de manera muy directa e incluso un poco ingenua. Observa la instrucción (COPY, RUN, etc.) y los datos de entrada de esa instrucción. Si los datos de entrada cambian, Docker dice: "De acuerdo, la capa es diferente, la caché no sirve" y reconstruye la capa desde cero. Esto funciona muy bien mientras los datos de entrada reflejen realmente "lo que cambia".
Con COPY app.jar hay un detalle engañoso. Dentro del jar hay muchas cosas diferentes, pero Docker no entiende la estructura interna del jar: simplemente ve un archivo binario. Cambiaste una línea en el controlador, recompilaste bootJar, y... todo el jar es diferente. Para Docker, parece que "cambió todo".
A veces un estudiante dice: "¿Y qué? De todas formas cambié el código". Y suena lógico... hasta que recuerdas que dentro de ese jar hay decenas de megabytes de dependencias. Desde el punto de vista de la ingeniería de contenedores, es como empaquetar tu código, Spring, Jackson, Hibernate, Flyway y la mitad del Internet en un solo archivo zip, y luego sorprenderte de que al cambiar un archivo, el zip se recrea por completo.
Para sentir esto en la práctica, basta comparar el hash del jar antes y después de un cambio pequeño:
# Vemos el hash "antes"
sha256sum build/libs/docker-java-catalog-service.jar
# 9c2e... build/libs/docker-java-catalog-service.jar
# Cambiaste una línea en el código Java, recompilaste bootJar
# Vemos el hash "después" — ya es diferente
sha256sum build/libs/docker-java-catalog-service.jar
# 3a91... build/libs/docker-java-catalog-service.jar
Para Docker, es un archivo nuevo, por lo tanto la capa COPY app.jar se considera nueva. Y eso es todo: la caché termina en ese punto, y luego viene una nueva construcción de la capa, un nuevo push/pull de esa capa, nuevos megabytes a través de la red.
3. Composición del bootJar de Spring Boot
Para dejar de percibir el jar como "un solo archivo", es útil recordar un momento qué es un jar ejecutable de Spring Boot. No son solo "tus archivos .class en un archivo". Es un contenedor dentro del cual están tanto tu código como las dependencias y el mecanismo de inicio. Es cómodo precisamente porque puedes decir java -jar y la aplicación arranca sin bailes con el classpath.
Un pequeño comando muestra la estructura en términos generales (y sí, es normal hacerlo no en Docker, sino localmente; ahora solo estamos estudiando el artefacto):
# Vemos qué carpetas y archivos están dentro del jar ejecutable
jar tf build/libs/docker-java-catalog-service.jar | head -n 8
# META-INF/
# META-INF/MANIFEST.MF
# org/springframework/boot/loader/
# BOOT-INF/
# BOOT-INF/classes/
# BOOT-INF/lib/
# ...
Aquí no necesitamos memorizar las rutas, sino ver lo principal: dentro hay varias partes lógicamente diferentes. Y aquí es donde nace la idea de la imagen por capas: diferentes partes cambian con diferente frecuencia.
Para una comprensión "humana", es útil desglosarlo en una tabla:
| Qué hay dentro del bootJar | Ejemplo "qué es" | Con qué frecuencia cambia | Por qué es importante para Docker |
|---|---|---|---|
| Dependencias de bibliotecas | Spring, Jackson, driver PostgreSQL, etc. | generalmente rara vez (cuando actualizas versiones) | es la parte pesada que se quiere reutilizar |
| Cargador de Spring Boot | clases de servicio para que java -jar funcione | casi nunca (cambia al actualizar Boot) | base estable, también se quiere almacenar en caché |
| Tu código y recursos | controladores, servicios, application.yml | a menudo (todos los días) | esto es lo que realmente debe cambiar entre compilaciones |
Por ahora no necesitamos nombres concretos de capas. Aquí es más importante ver el principio en sí: unas partes son casi invariables, otras están en constante movimiento. Y COPY app.jar obliga a Docker a tratar todo como una sola pieza.
4. Cambiar una línea y entregar 80 MB
Mientras trabajas solo y todo funciona en tu máquina, el problema puede parecer "teórico". Pero en cuanto aparece trabajo en equipo o CI (a veces basta con un segundo portátil), la imagen cambia. No solo construyes la imagen, sino que también la mueves regularmente: al registro local, entre entornos, o al menos la reconstruyes con tanta frecuencia que deseas que sea más rápido.
Imaginemos un servicio Boot típico: las dependencias ocupan 60–120 MB, y tu propio código, unos pocos megabytes. Las cifras dependen del conjunto de starters, pero la proporción suele ser aproximadamente así: "las bibliotecas son pesadas, el código es ligero". En Container-Ready Catalog Service esto es especialmente plausible, porque incluso un servicio "de aprendizaje" arrastra bibliotecas de infraestructura reales (JPA, Flyway, Actuator, etc.), no solo un controlador "Hello".
Si empaquetamos todo en un solo jar y lo copiamos con una sola instrucción, entonces con cualquier cambio de código, en realidad:
- construyes un nuevo jar,
- creas una nueva capa de Docker con ese jar,
- al publicar la imagen, envías al registro la nueva capa (que incluye tanto el código como las dependencias),
- el colega/CI descarga esa capa nuevamente.
Y aquí aparece una sensación desagradable: "Cambié un mensaje de log, ¿por qué estamos arrastrando medio Internet otra vez?". No es una histeria, es una reacción normal de ingeniería.
Para ver por qué sucede así, basta un pensamiento: Docker no puede "reutilizar parcialmente un archivo zip". O reutiliza la capa completa o no. Por lo tanto, nuestro objetivo es que en la imagen final haya diferentes capas para diferentes partes de la aplicación, y no una sola "capa-maleta".
5. Idea de la imagen por capas: descomponer y copiar por partes
Ahora, un punto importante: la imagen por capas no es "una magia que reemplaza las capas de Docker". Las capas de Docker siguen siendo capas de Docker. La gracia de Spring Boot es que ayuda a preparar el contenido de manera que puedas crear capas de Docker de forma más precisa, reflejando la estructura real de la aplicación.
La formulación que conviene tener en mente es: Spring Boot sabe describir capas dentro del jar, y Docker sabe convertir esas partes en capas de la imagen.
Dicho de manera muy práctica, el camino de las capas se ve como dos pasos:
- "descomponemos" el jar en directorios (conceptualmente: "dependencias aparte", "código aparte"),
- en el Dockerfile hacemos varios COPY para que cada directorio se convierta en una capa separada.
En el Dockerfile se ve más o menos así (todavía sin detalles de dónde viene el directorio layers/; ahora es importante la idea en sí):
# Capa de dependencias: cambia raramente, por lo que Docker podrá almacenarla bien en caché
COPY --from=extract /build/layers/dependencies/ ./
# Capa de aplicación: cambia a menudo, por lo que se reconstruirá con más frecuencia
COPY --from=extract /build/layers/application/ ./
Aquí no importa la carpeta concreta, sino la idea. Ahora Docker tiene la oportunidad de decir: "Oh, dependencies no cambiaron, conservaré la capa anterior. En cambio, application cambió, la actualizaremos".
Observa un detalle sutil pero fundamental. El camino de capas no reemplaza multi-stage. Lo complementa. Multi-stage separa el "mundo de compilación" del "mundo de ejecución". La imagen por capas, a su vez, hace que el "mundo de ejecución" sea menos monolítico: no una gran capa de "toda la aplicación", sino varias capas según su significado.
Y sí, el Dockerfile se alargará. Pero en este momento, la longitud no es un problema. El problema es cuando un Dockerfile corto te obliga a pagar regularmente con tiempo y tráfico de red por lo que en realidad no cambió.
6. Cómo se ve en nuestro proyecto
Para que no sea una filosofía abstracta, imaginemos la vida real de Container-Ready Catalog Service. Hoy modificas un endpoint, mañana agregas logging, pasado mañana cambias el formato de la respuesta porque el tester lo pidió. Todo esto son cambios en tu código. Mientras tanto, las dependencias (Spring Boot, Jackson, driver PostgreSQL) las actualizas mucho menos a menudo: generalmente por una tarea "actualizar versión", no "cada commit".
Aquí hay un cambio microscópico típico que hace cualquier desarrollador. Por ejemplo, agregamos una línea de log en el controlador:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class CatalogController {
// Logger — no afecta la idea de capas, pero muestra un cambio "pequeño" en el código
private static final Logger log = LoggerFactory.getLogger(CatalogController.class);
@GetMapping("/api/catalog/items")
String list() {
// Agregamos una línea — y el JAR ya es diferente, por lo tanto la capa de Docker con COPY app.jar también es diferente
log.info("Listing catalog items");
return "ok"; // Respuesta condicional: aquí no importa la lógica de negocio, sino el hecho de que el artefacto cambió
}
}
Desde el punto de vista de Java, es una "nimiedad": una sola línea. Pero bootJar después de esto es diferente, por lo tanto app.jar es diferente, y la capa de Docker con COPY app.jar es diferente. Y así vuelves a mover la capa que contiene Spring y todo lo demás.
La imagen por capas es una forma de hacer que la parte "cara" (dependencias y elementos de inicio de servicio) sea más estable a nivel de capas de la imagen, y la parte "rápida" (tu código) se pueda modificar por separado.
Y es especialmente agradable que en esta etapa no necesites cambiar el código Java ni "reescribir el proyecto para Docker". Simplemente mejoramos el empaquetado. Precisamente por eso este tema está en el curso de Docker, no en el curso "cómo reescribir la aplicación".
7. Errores típicos al usar imágenes por capas
Error n.º 1: pensar que las imágenes por capas reemplazan completamente la compilación multi-stage.
Multi-stage resuelve el problema de "no arrastrar la cadena de herramientas de compilación al runtime". Las imágenes por capas resuelven el problema de "no arrastrar toda la aplicación en una sola capa". Si haces extracción por capas pero aún así compilas y ejecutas en un solo stage, volverás a tener parte de los viejos problemas (tamaño, basura, legibilidad).
Error n.º 2: evaluar el Dockerfile por su longitud, no por la estructura de cambios.
Un Dockerfile corto es agradable a la vista, pero Docker no es un concurso de minimalismo. Se trata de reproducibilidad y previsibilidad. Si un Dockerfile más largo expresa claramente "estable por separado, variable por separado", esa longitud es útil, no "ruido innecesario".
Error n.º 3: confundir "capas de Spring Boot" y "capas de Docker", como si fueran lo mismo.
Spring Boot proporciona una división lógica del contenido de la aplicación. Docker crea capas físicas de la imagen. Son dos niveles diferentes. Boot no "crea capas de Docker", te ayuda a preparar los archivos para que puedas crear capas de Docker de manera más inteligente.
Error n.º 4: esperar un milagro instantáneo sin entender dónde estará realmente la ganancia.
Las imágenes por capas se destacan especialmente cuando reconstruyes y "entregas" imágenes con frecuencia (push/pull, CI, trabajo en equipo). En una sola máquina sin registro, también verás el efecto en la caché de Docker, pero la ganancia principal a menudo se siente en que no es necesario arrastrar cada vez una capa grande donde están escondidas las dependencias.
Error n.º 5: intentar "descomponer" manualmente el jar en capas sin usar las capacidades de Boot.
Técnicamente puedes descomprimir el jar como zip y copiar carpetas manualmente, pero eso se convierte rápidamente en un mecanismo casero frágil: cambió la versión de Boot — cambió la estructura; aparecieron matices — se rompieron las rutas. El sentido del layering de Boot es precisamente que la división sea estándar y mantenible, no "construida con cinta adhesiva y esperanza".
GO TO FULL VERSION