Dockerfile para Spring Boot: capas, caché y arranque rápido

El Dockerfile de una aplicación Spring Boot suele escribirse una vez, copiado de un tutorial, y no se vuelve a mirar. Y ese fichero decide cuánto tarda cada build, cuánto pesa cada despliegue, con qué permisos corre tu aplicación y cuánta memoria puede usar. Aquí van cuatro versiones, de la ingenua a la que llevaría a producción, medidas una a una.
Casi todos los proyectos Spring Boot que reviso tienen un Dockerfile que funciona. El problema es que funcionar no es suficiente: suele ejecutarse como root, reconstruir todo en cada commit, subir 20 MB al registro por cambiar una línea y limitar la JVM a una cuarta parte de la memoria que le da Kubernetes. Nada de eso falla en las pruebas. Aparece en la factura del registro, en el tiempo de la pipeline y en el primer OOMKilled que nadie entiende.
Todo lo que sigue está ejecutado con Docker 29.2 (sobre Colima), Java 25.0.4 (Eclipse Temurin), Spring Boot 4.1.2 y Maven, en un Mac con Apple Silicon. Los tamaños, tiempos y salidas son los reales. Los tamaños de imagen son comprimidos, lo que se sube al registro y se descarga en cada nodo (docker image inspect); en disco ocupan entre 2,3 y 2,9 veces más. Los tiempos de arranque son la mediana de cinco ejecuciones con 2 CPU y 1 GB de memoria, con la máquina en reposo.
La aplicación de ejemplo
Un servicio de pedidos mínimo con Spring Boot 4.1 y Java 25: spring-boot-starter-webmvc, Actuator para el health y un controlador con dos endpoints.
@RestController
@RequestMapping("/pedidos")
class PedidoController {
record Pedido(int id, String producto) {}
record NuevoPedido(String producto) {}
private final List<Pedido> pedidos = new CopyOnWriteArrayList<>();
@GetMapping
List<Pedido> listar() {
return pedidos;
}
@PostMapping
Pedido crear(@RequestBody NuevoPedido nuevo) {
var pedido = new Pedido(pedidos.size() + 1, nuevo.producto());
pedidos.add(pedido);
return pedido;
}
}
Un detalle que me costó un 400 en la primera prueba: el POST recibe un NuevoPedido y no un Pedido. Con un record que tiene un int y un JSON que no lo trae, Jackson 3 —el de Spring Boot 4— rechaza la petición en lugar de poner un cero. Mejor un tipo de entrada propio que adivinar.
Y un .dockerignore, que es lo primero que se olvida:
target/
.git/
*.md
Sin él, COPY . . mete en el contexto de construcción los artefactos locales y la historia de git, y cualquier cambio en ellos invalida la caché.
Versión 1: el Dockerfile que todos escribimos primero
FROM eclipse-temurin:25-jdk
WORKDIR /app
COPY . .
RUN ./mvnw -q package -DskipTests
ENTRYPOINT ["java", "-jar", "target/pedidos-0.0.1-SNAPSHOT.jar"]
Funciona. Y tiene casi todos los problemas posibles:
- 236 MB. La imagen lleva el JDK completo y, sobre todo, la caché de Maven entera: la capa del
RUN ./mvnw packagepesa 109 MB, de los que el jar son 22. El resto son las dependencias descargadas en/root/.m2, que no sirven para nada en producción. - Cada cambio de código vuelve a descargar todas las dependencias.
COPY . .copia el código antes de compilar, así que cualquier cambio invalida esa capa y la siguiente: 20,7 segundos de reconstrucción por cambiar una línea, en un proyecto con dos dependencias. En uno real son minutos. - Corre como root, porque las imágenes base de Java no definen otro usuario.
Versión 2: multietapa, compilar no es ejecutar
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw -q dependency:go-offline
COPY src src
RUN ./mvnw -q package -DskipTests
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /src/target/pedidos-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Es la versión que uso en la guía de Docker Compose y la que recomiendan la mayoría de tutoriales. Dos mejoras claras:
- Dos etapas. La primera tiene el JDK, Maven y las dependencias; la segunda, solo el JRE y el jar. La imagen baja de 236 a 135 MB comprimidos (de 542 a 379 MB en disco): 115 MB son el JRE base y 22 MB el jar.
- Dependencias antes que código. Copiando primero el
pom.xmly ejecutandodependency:go-offline, esa capa se reutiliza mientras no cambies elpom.xml. Reconstruir tras un cambio de código pasa de 20,7 a 4,8 segundos.
Pero quedan tres problemas, y el primero no se ve hasta que miras el historial de la imagen:
$ docker history pedidos:2-multietapa
22.1MB COPY /src/target/pedidos-0.0.1-SNAPSHOT.jar app.jar
El jar es una sola capa de 22 MB, y el 99 % de ese jar son librerías que no cambian. Pero como el jar entero es nuevo en cada build, cada despliegue sube y descarga 22 MB por cambiar una línea de tu código. Multiplica por diez microservicios, tres entornos y varios despliegues al día.
El segundo: tocar el pom.xml cuesta 31,4 segundos, porque go-offline vuelve a descargarlo todo desde cero. Añadir una dependencia es frecuente.
Y el tercero: sigue corriendo como root.
Versión 3: capas, caché de Maven y sin root
Esta es la que llevaría a producción si no necesitas arranques rápidos:
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
COPY src src
RUN --mount=type=cache,target=/root/.m2 \
./mvnw -q package -DskipTests
RUN java -Djarmode=tools -jar target/pedidos-0.0.1-SNAPSHOT.jar \
extract --layers --launcher --destination extraido
FROM eclipse-temurin:25-jre
RUN groupadd --system --gid 1001 app && useradd --system --uid 1001 --gid app app
WORKDIR /app
COPY --from=build /src/extraido/dependencies/ ./
COPY --from=build /src/extraido/spring-boot-loader/ ./
COPY --from=build /src/extraido/snapshot-dependencies/ ./
COPY --from=build /src/extraido/application/ ./
USER 1001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Cada bloque resuelve uno de los problemas anteriores. Vamos por partes.
Las capas de Spring Boot: un cambio de código pesa 94 KB
Spring Boot sabe partir su propio jar en capas ordenadas de lo que menos cambia a lo que más. El modo tools del jar las extrae:
java -Djarmode=tools -jar target/pedidos-0.0.1-SNAPSHOT.jar \
extract --layers --launcher --destination extraido
Salen cuatro directorios, y cada uno se copia en su propia capa de la imagen, en este orden:
| Capa | Qué contiene | Tamaño en la imagen |
|---|---|---|
dependencies | Las librerías de terceros con versión estable | 21,9 MB |
spring-boot-loader | El cargador de Spring Boot | 696 KB |
snapshot-dependencies | Dependencias -SNAPSHOT, que cambian más a menudo | 4 KB |
application | Tu código y tus recursos | 94 KB |
El orden importa porque Docker reutiliza una capa solo si todas las anteriores siguen iguales. Con tu código en la última, un commit normal solo regenera esos 94 KB: el build reutiliza los 22 MB de librerías, el docker push no las vuelve a subir y cada nodo de Kubernetes no las vuelve a descargar.
Con --launcher, la aplicación se arranca con la clase JarLauncher de Spring Boot en lugar de java -jar. Sin esa opción, extract deja un jar ejecutable con sus librerías al lado, en lib/, que es lo que hará falta en la versión 4.
⚠️ El modo tools existe desde Spring Boot 3.3. En tutoriales anteriores verás -Djarmode=layertools ... extract, el modo antiguo. En Spring Boot 4.1 ya no existe:
$ java -Djarmode=layertools -jar target/pedidos-0.0.1-SNAPSHOT.jar list
Error: Unsupported jarmode 'layertools'
La caché de Maven, fuera de la imagen
RUN --mount=type=cache,target=/root/.m2 \
./mvnw -q package -DskipTests
--mount=type=cache es de BuildKit: monta un directorio que persiste entre builds pero no forma parte de la imagen. Maven encuentra sus dependencias ya descargadas aunque haya cambiado el pom.xml, y la imagen no arrastra la caché.
Lo medí añadiendo spring-boot-starter-validation al pom.xml:
| Versión | Reconstruir tras tocar el pom.xml |
|---|---|
2 (dependency:go-offline) | 31,4 s |
| 3 (caché de BuildKit) | 7,8 s |
Con este cambio ya no hace falta separar dependency:go-offline: el código se copia de una vez y la caché hace el trabajo.
⚠️ La caché vive en el builder, no en el registro. En tu máquina persiste; en un runner de CI efímero, que arranca limpio en cada ejecución, se pierde, porque --cache-to exporta las capas pero no las cachés montadas. Ahí lo que de verdad ahorra tiempo son las capas, no el montaje.
Un usuario sin privilegios, y que sea un número
RUN groupadd --system --gid 1001 app && useradd --system --uid 1001 --gid app app
USER 1001
La diferencia, comprobada dentro de los contenedores:
$ docker run --rm --entrypoint id pedidos:2-multietapa
uid=0(root) gid=0(root) groups=0(root)
$ docker run --rm --entrypoint id pedidos:3-capas
uid=1001(app) gid=1001(app) groups=1001(app)
Si alguien explota una vulnerabilidad de tu aplicación, hereda los permisos del proceso. Como root, dentro del contenedor puede hacer cualquier cosa, y un contenedor no es una frontera de seguridad perfecta.
Por qué USER 1001 y no USER app: mi primera versión usaba el nombre, y funcionaba en Docker. Pero en Kubernetes, con runAsNonRoot: true en el securityContext, el kubelet no puede comprobar que un usuario dado por nombre no sea root, y se niega a arrancar el contenedor con el error image has non-numeric user (app), cannot verify user is non-root. Con el UID numérico lo comprueba y arranca.
Memoria: el 25 % que nadie cambia
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
La JVM detecta el límite de memoria del contenedor, pero por defecto solo usa el 25 % para el heap. Medido con un límite de 512 MB:
$ docker run --rm --memory=512m eclipse-temurin:25-jre \
java -XX:+PrintFlagsFinal -version | grep ' MaxHeapSize '
# 128 MB
$ docker run --rm --memory=512m eclipse-temurin:25-jre \
java -XX:MaxRAMPercentage=75 -XX:+PrintFlagsFinal -version | grep ' MaxHeapSize '
# 384 MB
Ese 25 % es el heap máximo, no toda la memoria del proceso: la JVM usa además memoria nativa (metaspace, pilas de los hilos, buffers, el propio JIT). Aun así, con el valor por defecto una aplicación con 512 MB de límite en Kubernetes se queda con un heap de 128 MB y puede lanzar OutOfMemoryError con buena parte de la memoria del pod sin usar. El 75 % deja margen para esa memoria nativa en una aplicación como esta; en la tuya, compruébalo con carga real mirando el consumo total del pod, y con límites pequeños, por debajo de 256 MB, conviene bajarlo.
JAVA_TOOL_OPTIONS lo lee la JVM en cualquier caso, sin tocar el ENTRYPOINT, y deja rastro en el log (Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75). Así se puede cambiar por entorno desde el manifiesto de Kubernetes.
--platform=$BUILDPLATFORM: compilar una sola vez
FROM --platform=$BUILDPLATFORM eclipse-temurin:25-jdk AS build
Si construyes una imagen multiarquitectura —linux/amd64 para el clúster y linux/arm64 para el Mac o para nodos Graviton—, sin esta línea Docker compila el proyecto dos veces, una bajo emulación. Con ella, la etapa de compilación corre en la arquitectura de tu máquina y solo la etapa final se construye para cada destino. En este proyecto el jar es el mismo en las dos: el bytecode no depende de la arquitectura. Si tu build empaqueta binarios nativos, compruébalo antes.
docker buildx build --platform linux/amd64,linux/arm64 -t pedidos:1.0 .
Medido sin caché de capas: 17,6 s solo para arm64 y 20,0 s para las dos arquitecturas. El detalle de por qué un Mac con Apple Silicon necesita esto para desplegar en un clúster amd64 está en la guía de Docker Compose.
Versión 4: arrancar en la mitad con la caché AOT de Java 25
Java 25 trae una forma práctica de que la JVM no repita en cada arranque el trabajo de cargar y enlazar las mismas clases: la caché AOT (ahead-of-time). La base llegó en Java 24 con el JEP 483, y Java 25 la hizo cómoda con el JEP 514 (una sola opción para generarla) y el JEP 515 (guarda también perfiles de los métodos). Lo cuento con más detalle en las novedades de Java 25.
En un contenedor encaja de forma natural, porque la caché se genera durante la construcción de la imagen y viaja dentro de ella:
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
COPY src src
RUN --mount=type=cache,target=/root/.m2 \
./mvnw -q package -DskipTests
RUN java -Djarmode=tools -jar target/pedidos-0.0.1-SNAPSHOT.jar \
extract --layers --destination extraido
FROM eclipse-temurin:25-jre
RUN groupadd --system --gid 1001 app && useradd --system --uid 1001 --gid app app
WORKDIR /app
COPY --from=build /src/extraido/dependencies/ ./
COPY --from=build /src/extraido/spring-boot-loader/ ./
COPY --from=build /src/extraido/snapshot-dependencies/ ./
COPY --from=build /src/extraido/application/ ./
# Ejecución de entrenamiento: arranca el contexto, para y deja la caché AOT
RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh \
-jar pedidos-0.0.1-SNAPSHOT.jar
USER 1001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "pedidos-0.0.1-SNAPSHOT.jar"]
Tres cambios respecto a la versión 3:
extractsin--launcher: deja un jar ejecutable conlib/al lado, y la aplicación se arranca conjava -jar. La caché AOT necesita que el classpath sea exactamente el mismo al entrenar y al ejecutar, y este formato lo garantiza.- La ejecución de entrenamiento:
-XX:AOTCacheOutputarranca la aplicación, registra qué clases carga y genera la caché al salir.-Dspring.context.exit=onRefreshhace que Spring levante el contexto completo y termine justo después, sin quedarse escuchando: el build no se cuelga. -XX:AOTCache=app.aoten elENTRYPOINT, para usarla.
El resultado, en mediana de cinco arranques:
| Versión | Arranque (Started PedidosApplication in …) | Imagen |
|---|---|---|
| 2 · multietapa | 1,90 s | 135 MB |
| 3 · capas | 1,68 s | 135 MB |
| 4 · caché AOT | 0,74 s | 149 MB |
Arranca en menos de la mitad de tiempo. Repetí la medición más tarde, con la máquina muy cargada por otros procesos, intercalando las versiones y siete arranques de cada una: los tiempos absolutos subieron (2,49 s con capas, 1,30 s con caché AOT), pero la caché siguió dejando el arranque en algo más de la mitad. La caché ocupa 61 MB en disco y añade unos 14 MB comprimidos a la imagen. En una aplicación grande, con JPA, seguridad y decenas de beans, la ganancia en segundos es mayor, porque hay muchas más clases que cargar.
Lo que hay que saber antes de adoptarla:
- La caché va ligada a la JVM exacta, a la arquitectura y a los jars del classpath, hasta su fecha de modificación. Si cambia la imagen base o cualquier librería, hay que regenerarla. Generarla dentro del mismo Dockerfile lo resuelve solo: cada build la vuelve a crear con el JRE y los jars de esa imagen.
- Si no puede usarla, por defecto arranca sin ella. Lo provoqué cambiando la fecha de un jar: la JVM escribe en el log
[error][aot] shared class paths mismatchy la aplicación arranca, solo que sin la ganancia. Es fácil no verlo. Con-XX:AOTMode=onla JVM se niega a arrancar si no puede usar la caché; yo lo usé para comprobar que funcionaba en las dos arquitecturas. - Entrenar es arrancar, así que la caché cubre lo que pasa al arrancar, no el código de las peticiones. Para arrancar rápido es suficiente.
- En multiarquitectura, el entrenamiento corre emulado para la arquitectura ajena: la compilación para amd64 y arm64 pasó de 20,0 a 82,9 s. Si construyes en CI sobre un runner amd64 para un clúster amd64, el problema desaparece. Desde un Mac, cuesta un minuto más por build.
- La etapa de entrenamiento necesita lo que necesite el arranque. Este ejemplo no tiene base de datos. Una aplicación con JPA o Flyway intentará conectarse al levantar el contexto durante el build, y habrá que desactivar esas conexiones en el entrenamiento o darle una base de datos.
Vale la pena cuando el arranque importa: autoescalado que tiene que responder rápido, despliegues con muchas réplicas, o jobs que arrancan y terminan. Para un servicio que arranca una vez a la semana, la versión 3 es suficiente y más simple.
Las cuatro versiones, en una tabla
| 1 · ingenua | 2 · multietapa | 3 · capas | 4 · caché AOT | |
|---|---|---|---|---|
| Imagen comprimida | 236 MB | 135 MB | 135 MB | 149 MB |
| Imagen en disco | 542 MB | 379 MB | 379 MB | 433 MB |
| Reconstruir tras cambiar código | 20,7 s | 4,8 s | 5,3 s | — |
Reconstruir tras tocar pom.xml | — | 31,4 s | 7,8 s | — |
| Capa que cambia en cada commit | 109 MB | 22,1 MB | 94 KB | 94 KB + caché |
| Usuario | root | root | 1001 | 1001 |
| Heap con 512 MB de límite | 128 MB | 128 MB | 384 MB | 384 MB |
| Arranque (mediana, en reposo) | — | 1,90 s | 1,68 s | 0,74 s |
Fíjate en una cosa: reconstruir la versión 3 no es más rápido que la 2 tras un cambio de código (5,3 frente a 4,8 s). Su ventaja no está en el build local, sino en lo que viaja: 94 KB en lugar de 22 MB en cada push al registro y en cada pull de cada nodo. Es lo que se nota en la pipeline y en el despliegue.
¿Y sin Dockerfile? Buildpacks
Spring Boot puede construir la imagen sin Dockerfile, con Cloud Native Buildpacks:
./mvnw spring-boot:build-image
Genera una imagen por capas, sin root y con la memoria de la JVM ajustada, a costa de controlar menos qué hay dentro y de depender de la imagen del builder. Es una buena opción si tu equipo no quiere mantener Dockerfiles. Yo prefiero el Dockerfile explícito cuando hay que entender exactamente qué se despliega, y en un proyecto real eso suele ser siempre.
Si trabajas con Spring Boot
Este Dockerfile es el primer paso del camino de una aplicación Spring Boot hasta producción. Los siguientes —subir la imagen a un registro, desplegarla en Kubernetes con sus sondas de salud, configurarla con Helm y Kustomize, automatizarlo en una pipeline— son los que desarrollo en Docker y Kubernetes para desarrolladores, con esta misma idea de ejecutar cada ejemplo y enseñar el resultado real.
Y si la aplicación que vas a empaquetar es la que hay que mejorar —arquitectura hexagonal, mensajería, observabilidad—, eso es lo que cubre Spring Boot Avanzado. Para saber qué versión de Spring Boot usar y hasta cuándo tiene soporte, la tabla de versiones de Spring Boot.
En resumen
Un buen Dockerfile para Spring Boot no es largo, pero cada línea tiene un motivo:
- Multietapa: compilar con el JDK, ejecutar con el JRE. De 236 a 135 MB.
- Capas de Spring Boot con
-Djarmode=tools ... extract --layers: cada commit mueve 94 KB, no 22 MB. - Caché de Maven de BuildKit: tocar el
pom.xmlcuesta 7,8 s en lugar de 31,4 s. - Usuario sin privilegios con UID numérico, o Kubernetes no lo arrancará con
runAsNonRoot. -XX:MaxRAMPercentage=75, o la JVM usará solo una cuarta parte de la memoria del pod.--platform=$BUILDPLATFORMen la etapa de compilación para no compilar dos veces.- Caché AOT de Java 25 si el arranque importa: de 1,68 a 0,74 s.
Si en tu equipo el Dockerfile lleva años sin que nadie lo mire, merece media hora: es el fichero que más veces se ejecuta de todo el repositorio.
¿Buscas un consultor IT para tu empresa? Conoce mis servicios de consultoría IT: arquitectura cloud, desarrollo fullstack y liderazgo técnico. ¿Empresa en Mallorca o Baleares? Consultor IT en Mallorca.