CicloUrbana es observable, sabe en qué entorno vive y trabaja por su cuenta. Y sigue siendo un JAR que alguien tiene que arrancar a mano en una máquina con Java 21 instalado, la zona horaria correcta y las variables de entorno bien puestas. Ese «alguien» y esas condiciones son el último eslabón artesanal del proyecto: la causa de que funcione en un servidor y no en otro, y de que el despliegue dependa de una lista de pasos en la cabeza de una persona.

Esta lección lo elimina empaquetando la aplicación, su JRE y su configuración en una imagen de contenedor reproducible. Veremos por qué el Dockerfile que todo el mundo escribe primero está mal, cómo aprovechar las capas del JAR de Spring Boot para que reconstruir tras cambiar una línea tarde segundos, qué imagen base elegir, la alternativa de los buildpacks, cómo se comporta la JVM dentro de un contenedor, el docker-compose.yml completo de la red de Ribalta con PostgreSQL y sus comprobaciones de salud enganchadas a las sondas de 07-01, y las prácticas de seguridad que evitan publicar una imagen con secretos dentro.

Contenido

  1. Por qué contenerizar
  2. Los conceptos mínimos de Docker
  3. El Dockerfile ingenuo y por qué está mal
  4. El JAR de Spring Boot por capas
  5. El Dockerfile multietapa de CicloUrbana
  6. Elegir la imagen base
  7. Buildpacks: spring-boot:build-image
  8. La JVM dentro de un contenedor
  9. El docker-compose.yml de la red de Ribalta
  10. Configurar la aplicación en el contenedor
  11. spring-boot-docker-compose para desarrollo
  12. Imágenes nativas con GraalVM
  13. Seguridad de la imagen
  14. Publicar en un registro y etiquetar
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. Por qué contenerizar

Un contenedor empaqueta la aplicación y todo lo que necesita para ejecutarse —el JRE, las bibliotecas del sistema, la configuración por defecto, la zona horaria— en un artefacto único e inmutable. Lo que resuelve, en concreto:

Problema sin contenedor Cómo lo resuelve la imagen
«En mi máquina funciona» El entorno de ejecución viaja dentro del artefacto
Instalar Java 21 en cada servidor y mantenerlo El JRE es una capa de la imagen
Un servidor con la zona horaria mal puesta Se fija en la imagen y es idéntica en todas partes
Despliegue como lista de pasos manuales docker run o un manifiesto declarativo
Volver a la versión anterior Arrancar la etiqueta anterior de la imagen
Ejecutar dos versiones a la vez para migrar Dos contenedores, sin conflicto de dependencias

Y una consecuencia estratégica: la imagen es la unidad que entienden Kubernetes (08-04), los servicios gestionados de AWS (08-03) y las canalizaciones de entrega continua (08-05). Contenerizar no es un fin en sí mismo; es el requisito para todo lo que viene después.

Conviene ser honesto con lo que no resuelve. Un contenedor no aísla como una máquina virtual —comparte el núcleo del anfitrión—, no arregla una aplicación con estado en disco local, y no hace que una configuración mala sea buena: si el perfil prod no se activa, se activará mal dentro del contenedor exactamente igual que fuera (07-02).

  1. Los conceptos mínimos de Docker

Concepto Qué es En CicloUrbana
Imagen Plantilla inmutable de solo lectura con un sistema de ficheros ciclourbana:2.4.0
Capa Cada instrucción del Dockerfile produce una capa apilada y cacheable La capa del JRE, la de dependencias, la del código
Contenedor Una instancia en ejecución de una imagen, con una capa escribible encima El proceso que atiende el puerto 8080
Dockerfile Receta de construcción de una imagen En la raíz del repositorio
Registro Almacén de imágenes publicadas Docker Hub, GHCR, ECR
Etiqueta (tag) Nombre de versión de una imagen 2.4.0, latest, sha-9f3a2b1
Volumen Almacenamiento persistente fuera del ciclo del contenedor Los datos de PostgreSQL
Red Espacio donde los contenedores se ven por nombre app alcanza postgres por DNS

La idea clave de todo el capítulo son las capas. Una imagen es una pila de capas de solo lectura; al reconstruir, Docker reutiliza de la caché todas las capas anteriores al primer cambio y rehace solo las posteriores. Y al publicar, solo se transfieren las capas que el registro no tiene. Toda la optimización del apartado 4 consiste en poner lo que cambia poco abajo y lo que cambia mucho arriba.

  1. El Dockerfile ingenuo y por qué está mal

Este es el fichero que casi todo el mundo escribe primero:

FROM eclipse-temurin:21-jre
COPY target/ciclourbana-2.4.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Funciona. Y tiene cinco problemas serios:

Problema Consecuencia
El JAR entero es una sola capa Cambiar una línea de EstacionService invalida los 60 MB completos: se reconstruye y se sube todo
Requiere haber compilado antes La construcción depende del Maven local; nadie garantiza que sea el mismo que el de integración continua
Se ejecuta como root Una vulnerabilidad de la aplicación se ejecuta con el usuario más privilegiado del contenedor
Imagen base completa 21-jre sin sufijo arrastra decenas de paquetes del sistema que nunca se usan: superficie de ataque y peso
ENTRYPOINT con java -jar Spring Boot debe descomprimir y resolver el JAR en cada arranque, y las señales llegan peor al proceso

El primero es el que más duele en el día a día. En un ciclo de desarrollo normal las dependencias cambian una vez al mes y el código cambia veinte veces al día; con un JAR monolítico, cada uno de esos veinte cambios reconstruye y transfiere Spring Framework, Hibernate, Jackson y el driver de PostgreSQL enteros.

  1. El JAR de Spring Boot por capas

Spring Boot resuelve el problema publicando el JAR ya dividido en capas ordenadas de menos a más volátil:

Capa Contenido Frecuencia de cambio
dependencies Dependencias de versión estable Muy baja
spring-boot-loader El cargador del JAR ejecutable Casi nunca
snapshot-dependencies Dependencias -SNAPSHOT Media
application Tu código y tus recursos Altísima

Se inspecciona y se extrae con el jarmode del propio JAR. En Spring Boot 3.3 y posteriores:

java -Djarmode=tools -jar target/ciclourbana.jar list-layers
java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extraido

En versiones anteriores el modo se llamaba layertools (java -Djarmode=layertools -jar app.jar extract); el concepto es idéntico y merece la pena conocer ambos nombres, porque la documentación y los ejemplos que circulan mezclan los dos.

El resultado es un árbol de directorios, uno por capa, que se copian a la imagen en orden. Como la capa application pesa unos pocos cientos de kilobytes y va la última, un cambio en el código reconstruye solo esa: el ciclo de construcción y publicación pasa de minutos a segundos.

  1. El Dockerfile multietapa de CicloUrbana

# ---------- Etapa 1: construcción ----------
FROM maven:3.9-eclipse-temurin-21 AS construccion
WORKDIR /construccion

# 1. Solo el POM: esta capa se cachea mientras no cambien las dependencias
COPY pom.xml .
RUN mvn -B dependency:go-offline

# 2. Ahora el código: cambia a diario, pero las dependencias ya están descargadas
COPY src ./src
RUN mvn -B clean package -DskipTests

# 3. Descomponer el JAR resultante en sus capas
RUN java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extraido

# ---------- Etapa 2: ejecución ----------
FROM eclipse-temurin:21-jre-alpine AS ejecucion

# Usuario sin privilegios: nunca ejecutar como root
RUN addgroup -S ciclo && adduser -S ciclo -G ciclo
WORKDIR /app

# Capas de menos a más volátil: aprovecha la caché en cada reconstrucción
COPY --from=construccion --chown=ciclo:ciclo /construccion/extraido/dependencies/ ./
COPY --from=construccion --chown=ciclo:ciclo /construccion/extraido/spring-boot-loader/ ./
COPY --from=construccion --chown=ciclo:ciclo /construccion/extraido/snapshot-dependencies/ ./
COPY --from=construccion --chown=ciclo:ciclo /construccion/extraido/application/ ./

USER ciclo
EXPOSE 8080 8081
ENV TZ=Europe/Madrid \
    JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"

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

Las decisiones, una a una:

  • Dos etapas. La primera trae Maven y el JDK completo; la segunda parte de una imagen con solo el JRE. La imagen final no contiene Maven, ni el JDK, ni el código fuente, ni el repositorio .m2: pasa de unos 800 MB a unos 200.
  • COPY pom.xml antes que COPY src. Es el truco central: mientras el POM no cambie, la capa de dependency:go-offline sale de la caché y la descarga de dependencias se salta por completo. Copiar todo junto haría que cualquier cambio de código volviera a descargar Internet entero.
  • -DskipTests aquí es correcto, aunque suene a herejía después del módulo 6: las pruebas ya se ejecutaron en la canalización (08-05) antes de construir la imagen. Ejecutarlas otra vez dentro del contenedor duplica el tiempo y, con Testcontainers, exigiría Docker dentro de Docker.
  • extract --layers produce los cuatro directorios del apartado anterior.
  • Usuario sin privilegios. addgroup/adduser es la sintaxis de Alpine; en imágenes basadas en Debian sería groupadd/useradd. El --chown en cada COPY evita una capa extra solo para cambiar permisos.
  • Las cuatro COPY en ese orden exacto. Es donde se materializa toda la optimización: un cambio en AlquilerService solo invalida la última.
  • EXPOSE 8080 8081 documenta el puerto de la API y el de gestión de 07-01. Es informativo, no abre nada por sí solo.
  • TZ=Europe/Madrid evita el problema de zonas horarias de 07-03: sin esto, un contenedor corre en UTC y el cron nocturno se desplaza.
  • ENTRYPOINT en forma de lista, no como cadena. La forma de cadena arranca el proceso bajo un sh -c, que se queda como PID 1 y no reenvía las señales: el SIGTERM del apagado ordenado (01-05, 07-03) nunca llega a la JVM y el contenedor muere de golpe pasados diez segundos.

Construir y ejecutar:

docker build -t ciclourbana:2.4.0 .
docker run --rm -p 8080:8080 -e SPRING_PROFILES_ACTIVE=dev ciclourbana:2.4.0

  1. Elegir la imagen base

Imagen base Tamaño aproximado Notas
eclipse-temurin:21-jre ~270 MB Debian completo; la opción segura y mejor documentada
eclipse-temurin:21-jre-alpine ~180 MB Alpine; muy usada, pero ojo con musl
amazoncorretto:21-alpine ~190 MB Distribución de Amazon, soporte largo; natural en AWS (08-03)
bellsoft/liberica-openjre-debian:21 ~200 MB La que usan los buildpacks de Spring por defecto
gcr.io/distroless/java21-debian12 ~230 MB Sin shell ni gestor de paquetes: superficie mínima

El aviso sobre Alpine. Alpine usa musl como biblioteca de C en lugar de glibc. La mayoría de las aplicaciones Java funcionan sin problema, pero hay dos áreas conflictivas: las bibliotecas con código nativo (algunos clientes criptográficos, compresores, netty-tcnative) pueden fallar o rendir peor, y ciertos escenarios de DNS y de resolución de nombres se comportan de forma distinta. Si la imagen alpine funciona con la suite de pruebas de CicloUrbana ejecutada dentro del contenedor, adelante; si aparece un error nativo raro, la primera hipótesis debe ser esta.

Sobre distroless: no tiene shell, así que docker exec -it ... sh no funciona. Eso es exactamente lo que la hace segura —un atacante que consiga ejecución tampoco tiene shell— y lo que la hace incómoda de depurar. Es la elección correcta cuando el diagnóstico se hace por Actuator y por logs centralizados (09-05), que es justo hacia donde va este curso.

  1. Buildpacks: spring-boot:build-image

Spring Boot puede construir la imagen sin ningún Dockerfile, usando Cloud Native Buildpacks:

./mvnw spring-boot:build-image -DskipTests

El plugin inspecciona el proyecto, detecta que es una aplicación Java, elige un JRE adecuado, aplica capas por sí mismo, crea un usuario sin privilegios y añade ajustes de memoria calculados. El resultado es una imagen OCI lista para ejecutar.

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>ghcr.io/ayuntamiento-ribalta/ciclourbana:${project.version}</name>
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
                <BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:MaxRAMPercentage=75</BPE_APPEND_JAVA_TOOL_OPTIONS>
            </env>
            <publish>true</publish>
        </image>
        <docker>
            <publishRegistry>
                <username>${env.REGISTRY_USER}</username>
                <password>${env.REGISTRY_TOKEN}</password>
            </publishRegistry>
        </docker>
    </configuration>
</plugin>
Criterio Dockerfile propio Buildpacks
Control sobre el contenido Total Limitado a las opciones del constructor
Conocimiento de Docker necesario Medio Casi ninguno
Actualizaciones de seguridad de la base Manuales: hay que cambiar el FROM Se rehacen sin recompilar con pack rebase
Buenas prácticas por defecto Las que escribas tú Usuario no root, capas, SBOM, memoria ajustada
Reproducibilidad Alta si fijas versiones Muy alta
Depuración de la construcción Directa Más opaca cuando algo falla
Tamaño de la imagen Menor si la cuidas Algo mayor

La recomendación honesta: si el equipo no tiene experiencia con Docker y quiere buenas prácticas por defecto, buildpacks. Si necesita control fino —una biblioteca nativa, una imagen base corporativa aprobada, un análisis de vulnerabilidades concreto— el Dockerfile del apartado 5. Ambos caminos son legítimos y ambos producen imágenes correctas; lo que no es legítimo es el Dockerfile ingenuo del apartado 3.

  1. La JVM dentro de un contenedor

Hubo una época en que la JVM no veía los límites del contenedor: leía la memoria de la máquina anfitriona, dimensionaba el heap con la cuarta parte de esos 64 GB y el orquestador mataba el proceso por exceder su límite de 512 MB. Eso está resuelto desde Java 10: UseContainerSupport está activo por defecto y la JVM lee los cgroups. No hace falta activarlo.

Lo que sí hay que entender es el reparto de la memoria. -Xmx fija un número absoluto que hay que revisar cada vez que cambia el límite del contenedor; MaxRAMPercentage fija una proporción del límite y se adapta solo:

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
Ajuste Efecto
-XX:MaxRAMPercentage=75 El heap usa como mucho el 75 % del límite del contenedor
-XX:+ExitOnOutOfMemoryError Ante un OutOfMemoryError, el proceso termina en vez de quedarse medio vivo
-XX:ActiveProcessorCount=N Fuerza el número de núcleos que ve la JVM cuando la cuota confunde el cálculo

Por qué 75 y no 100. Una JVM no consume solo heap: hay metaespacio, pilas de hilos, búferes directos, código compilado y el propio sistema operativo. Dejar un 25 % de margen evita que el orquestador mate el contenedor por OOMKilled —un fallo que, además, es difícil de diagnosticar porque no deja rastro en el log de la aplicación—.

JAVA_TOOL_OPTIONS es la variable a usar, y no meter las opciones en el ENTRYPOINT, por dos razones: la JVM la lee automáticamente, y se puede sobrescribir al arrancar el contenedor sin reconstruir la imagen. El propio arranque deja constancia en el log (Picked up JAVA_TOOL_OPTIONS: ...), lo que sirve de confirmación.

  1. El docker-compose.yml de la red de Ribalta

En 04-02 creamos un docker-compose.yml con solo PostgreSQL. Ahora se completa con la aplicación:

services:
  postgres:
    image: postgres:16-alpine
    container_name: ciclourbana-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ciclourbana
      POSTGRES_USER: ciclourbana
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?falta POSTGRES_PASSWORD}
      TZ: Europe/Madrid
    volumes:
      - postgres-datos:/var/lib/postgresql/data
    networks: [red-ciclourbana]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ciclourbana -d ciclourbana"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

  app:
    build: .
    image: ciclourbana:${VERSION:-2.4.0}
    container_name: ciclourbana-app
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ciclourbana
      SPRING_DATASOURCE_USERNAME: ciclourbana
      SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
      JWT_SECRETO: ${JWT_SECRETO:?falta JWT_SECRETO}
      JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75"
    ports:
      - "8080:8080"
      - "127.0.0.1:8081:8081"          # gestión: solo accesible desde el anfitrión
    networks: [red-ciclourbana]
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health/readiness"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 45s
    deploy:
      resources:
        limits:
          memory: 1g

volumes:
  postgres-datos:

networks:
  red-ciclourbana:
    driver: bridge

Los puntos que hay que entender:

  • depends_on con condition: service_healthy hace que la aplicación no arranque hasta que PostgreSQL responda a pg_isready. Sin esa condición, depends_on solo garantiza el orden de inicio, no que el servicio esté listo, y CicloUrbana fallaría al conectar y entraría en un bucle de reinicios.
  • ${POSTGRES_PASSWORD:?falta POSTGRES_PASSWORD} hace que docker compose up falle inmediatamente si la variable no está definida, en lugar de arrancar con una cadena vacía. Es la misma filosofía de fallo temprano de 07-02.
  • La URL apunta a postgres, no a localhost. Dentro de la red del Compose, cada servicio es alcanzable por su nombre gracias al DNS interno. localhost dentro del contenedor de la aplicación es el propio contenedor.
  • 127.0.0.1:8081:8081 publica el puerto de gestión solo en el anfitrión, no en todas las interfaces. Es la traducción a Docker de la práctica de 07-01: Actuator nunca alcanzable desde fuera.
  • El healthcheck de la aplicación consulta /actuator/health/readiness, la sonda que escribimos en 07-01. Aquí se ve por qué se llamaba «la pieza que necesita el orquestador»: es exactamente el mismo mecanismo que usará Kubernetes en 08-04.
  • start_period: 45s da margen al arranque de Spring —contexto, Flyway, pool— sin que los fallos de ese periodo cuenten como reintentos.
  • limits.memory: 1g es lo que da sentido a MaxRAMPercentage=75: sin un límite declarado, el porcentaje se calcula sobre la memoria del anfitrión.

Los secretos van en un fichero .env junto al docker-compose.yml y fuera de Git:

# .env — NUNCA se versiona
POSTGRES_PASSWORD=una-contrasena-larga-y-aleatoria
JWT_SECRETO=otro-secreto-de-al-menos-32-caracteres
VERSION=2.4.0
# .gitignore
.env

Un .env versionado es el mismo error que un application-prod.yml con credenciales (07-02), con el agravante de que parece un fichero de infraestructura inofensivo. Y conviene saber que las variables de entorno de un contenedor son visibles con docker inspect para cualquiera que pueda hablar con el demonio de Docker: para secretos de verdad, en producción se usan los secrets del orquestador montados como ficheros y leídos con spring.config.import: optional:configtree:/run/secrets/ (07-02).

  1. Configurar la aplicación en el contenedor

La regla es la del factor III de 12-Factor App, ya aplicada en 07-02: la configuración viene del entorno. Dentro de un contenedor eso significa que la imagen es idéntica en todos los entornos y lo único que cambia son las variables:

docker run --rm -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://bd-ribalta:5432/ciclourbana \
  -e SPRING_DATASOURCE_PASSWORD="$POSTGRES_PASSWORD" \
  -e JWT_SECRETO="$JWT_SECRETO" \
  ghcr.io/ayuntamiento-ribalta/ciclourbana:2.4.0

Nunca se hornea el perfil en la imagen. Escribir ENV SPRING_PROFILES_ACTIVE=prod en el Dockerfile produce una imagen que solo sirve para producción y rompe el principio de 07-02: la imagen que se prueba en preproducción dejaría de ser la que se despliega. El perfil se decide al ejecutar.

Cuando la configuración es demasiado extensa para variables, se monta un fichero:

volumes:
  - ./config/application-prod.yml:/app/config/application-prod.yml:ro

Spring lo encuentra solo, porque ./config/ junto al JAR es una de las ubicaciones de la cadena de 07-02, y el :ro lo monta de solo lectura.

Y sobre el apagado: Docker envía SIGTERM y espera diez segundos antes del SIGKILL. Como la aplicación tiene apagado ordenado con hasta cuarenta segundos de espera (07-03), hay que ampliar ese plazo o el contenedor morirá a media petición:

stop_grace_period: 45s

  1. spring-boot-docker-compose para desarrollo

En 06-05 apareció el módulo que arranca los servicios del docker-compose.yml junto con la aplicación en desarrollo:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-docker-compose</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>
spring:
  docker:
    compose:
      enabled: true
      file: compose-dev.yml
      lifecycle-management: start-and-stop

Al ejecutar ./mvnw spring-boot:run, Spring levanta los servicios declarados, detecta PostgreSQL y configura solo spring.datasource.* con el puerto asignado —igual que hacía @ServiceConnection en las pruebas— y los detiene al parar la aplicación. Quien clone CicloUrbana no necesita instalar nada ni recordar ningún comando previo.

Dos precauciones. El fichero apuntado debe ser de desarrollo (compose-dev.yml, solo con PostgreSQL), no el docker-compose.yml de producción que además construye e inicia la propia aplicación: eso produciría dos CicloUrbana peleándose por el puerto 8080. Y lifecycle-management: start-only es preferible si molesta que los contenedores se detengan cada vez, a costa de tener que pararlos a mano.

  1. Imágenes nativas con GraalVM

GraalVM compila la aplicación a un ejecutable nativo de antemano, sin JVM en tiempo de ejecución:

./mvnw -Pnative native:compile          # binario local, requiere GraalVM instalado
./mvnw spring-boot:build-image -Pnative # imagen de contenedor, sin instalar nada
Aspecto JVM tradicional Imagen nativa
Tiempo de arranque 2-4 segundos 40-90 milisegundos
Memoria residente 300-500 MB 80-150 MB
Rendimiento sostenido Mejor (el JIT optimiza con el tiempo) Algo peor en cargas largas
Tiempo de compilación ~30 segundos 5-15 minutos
Reflexión y proxies dinámicos Sin restricciones Deben declararse de antemano
Herramientas de diagnóstico Completas Limitadas

Dónde brilla: funciones sin servidor, escalado a cero, arranques muy frecuentes, entornos con memoria cara. Dónde no compensa: una aplicación como CicloUrbana, que arranca una vez y corre durante semanas, gana poco y paga una compilación de diez minutos en cada construcción.

El obstáculo técnico es la reflexión: la compilación anticipada necesita conocer en tiempo de construcción todas las clases que se instanciarán dinámicamente. Spring Boot 3 genera automáticamente gran parte de esos metadatos, pero el código propio que use reflexión debe declararla con RuntimeHints:

@Component
public class PistasNativas implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader cl) {
        hints.reflection().registerType(ResumenRed.class, MemberCategory.values());
    }
}

Conviene conocerlo y saber que existe; para CicloUrbana, la imagen con JRE del apartado 5 es la elección correcta hoy.

  1. Seguridad de la imagen

Una imagen es un artefacto que se publica y se distribuye. Todo lo que entra en ella, entra para siempre: borrar un fichero en una capa posterior no lo elimina de la anterior, sigue ahí y se puede extraer.

Práctica Por qué
Usuario no root (USER ciclo) Limita el daño de una vulnerabilidad de la aplicación
Imagen base mínima Menos paquetes, menos CVE que parchear
Sin secretos en capas Un ARG con una contraseña queda en el historial de la imagen
.dockerignore Evita copiar .git, .env, target/ o claves al contexto de construcción
Versiones fijadas eclipse-temurin:21-jre-alpine, nunca latest
Escaneo periódico docker scout cves o Trivy en la canalización (08-05)
Actualizar la base Las vulnerabilidades aparecen después de publicar: hay que reconstruir
# .dockerignore
.git
.env
target/
*.log
.idea/
**/application-local.yml

El .dockerignore es más importante de lo que parece. Sin él, COPY . . mete el directorio .git completo dentro de la imagen —con todo el historial, incluidos los secretos que alguien subió y borró después— y también el .env que tanto cuidado pusimos en no versionar. Además, el contexto de construcción entero se transfiere al demonio de Docker, lo que ralentiza cada construcción.

docker scout cves ciclourbana:2.4.0
trivy image --severity HIGH,CRITICAL ciclourbana:2.4.0

Y un aviso sobre los secretos en la construcción: ARG TOKEN seguido de un RUN que lo use deja el valor en los metadatos de la imagen, visible con docker history. Para credenciales durante la construcción existe RUN --mount=type=secret, que no persiste nada en las capas.

  1. Publicar en un registro y etiquetar

docker tag ciclourbana:2.4.0 ghcr.io/ayuntamiento-ribalta/ciclourbana:2.4.0
docker push ghcr.io/ayuntamiento-ribalta/ciclourbana:2.4.0

La política de etiquetado decide si un despliegue es reproducible:

Etiqueta Uso Riesgo
2.4.0 Versión semántica, inmutable Ninguno: es la que se despliega
sha-9f3a2b1 El commit exacto Ninguno; casa con /actuator/info de 07-01
latest Comodidad en desarrollo Alto: nadie sabe qué contiene ni se puede reproducir
2.4 o 2 Alias móviles Medio: cambian bajo los pies

La regla: desplegar siempre por versión o por commit, nunca por latest. Con la etiqueta sha-9f3a2b1 y el /actuator/info de 07-01 devolviendo ese mismo hash, la pregunta «¿qué hay corriendo en Ribalta?» tiene respuesta exacta y verificable. En 08-05 este etiquetado lo genera la canalización automáticamente.

Errores Comunes y Consejos

COPY . . antes de resolver las dependencias. Cada cambio de código vuelve a descargar el repositorio Maven entero. Primero el pom.xml, después el src.

El JAR como una sola capa. Cambiar una línea invalida 60 MB. Extrae las capas con el jarmode.

Ejecutar como root. Es el valor por defecto y hay que cambiarlo explícitamente.

ENTRYPOINT en forma de cadena. El sh -c intermedio se come el SIGTERM y el apagado ordenado no ocurre nunca.

depends_on sin condition: service_healthy. Garantiza el orden de arranque, no que la base de datos esté lista.

Usar localhost en la URL de la base de datos dentro del contenedor. Cada contenedor tiene su propio localhost; hay que usar el nombre del servicio.

No declarar un límite de memoria. MaxRAMPercentage se calcula sobre la memoria del anfitrión y el contenedor acaba OOMKilled.

Hornear SPRING_PROFILES_ACTIVE=prod en la imagen. La imagen deja de ser la misma en todos los entornos y se rompe el principio de 07-02.

Versionar el .env o no tener .dockerignore. Ambos meten secretos donde no deben estar: en Git o dentro de las capas de la imagen.

Consejo: fija las versiones de todas las imágenes base, y programa reconstrucciones periódicas para incorporar los parches de seguridad de la base.

Consejo: prueba la imagen, no solo el JAR. Arrancar el contenedor y consultar /actuator/health/readiness en la canalización detecta problemas de zona horaria, permisos y variables que ninguna prueba de JVM ve.

Consejo: mide el tiempo de reconstrucción. Si cambiar una línea tarda más de treinta segundos en producir una imagen nueva, el orden de las capas está mal.

Ejercicios

Ejercicio 1: revisar un Dockerfile real

Encuentra todos los problemas de este fichero, explica la consecuencia de cada uno y escribe la versión corregida.

FROM openjdk:latest
WORKDIR /app
COPY . .
RUN mvn clean package
ARG DB_PASSWORD
ENV SPRING_DATASOURCE_PASSWORD=$DB_PASSWORD
ENV SPRING_PROFILES_ACTIVE=prod
EXPOSE 8080
ENTRYPOINT java -jar target/ciclourbana-2.4.0.jar

Ejercicio 2: entorno completo con Compose

Escribe un compose-pre.yml para el entorno de preproducción del ayuntamiento con: PostgreSQL 16 con volumen y comprobación de salud; CicloUrbana con el perfil pre, límite de 1 GB, esperando a que la base de datos esté sana, con el puerto de gestión accesible solo desde el anfitrión y su propia comprobación de salud contra readiness; secretos desde .env; y un tiempo de gracia de apagado coherente con los 40 segundos del apartado 13 de 07-03. Explica en qué orden arranca todo y qué ocurre si PostgreSQL tarda un minuto en estar listo.

Ejercicio 3: la imagen que tarda cuatro minutos

El equipo se queja de que cambiar una línea en EstacionService y ver el resultado en un contenedor tarda cuatro minutos. El Dockerfile es multietapa y correcto salvo por la etapa de construcción:

FROM maven:3.9-eclipse-temurin-21 AS construccion
WORKDIR /construccion
COPY . .
RUN mvn -B clean package

Diagnostica el problema, propón la corrección y calcula aproximadamente cuánto debería tardar después. Indica además qué otra cosa habría que revisar si tras la corrección siguiera tardando más de un minuto.

Soluciones

Solución 1

Nueve problemas:

# Problema Consecuencia
1 FROM openjdk:latest Imagen no fijada y además openjdk está descontinuado; cada construcción puede dar una JVM distinta
2 Una sola etapa con Maven La imagen final arrastra el JDK, Maven y el repositorio .m2: ~800 MB para ejecutar 60 MB
3 COPY . . antes de las dependencias Sin caché útil: cualquier cambio de código vuelve a descargarlo todo
4 Sin .dockerignore Copia .git, .env y target/ dentro de la imagen
5 ARG DB_PASSWORD + ENV La contraseña queda en el historial de la imagen, visible con docker history
6 ENV SPRING_PROFILES_ACTIVE=prod La imagen solo sirve para producción; se rompe «un artefacto, muchos entornos»
7 Se ejecuta como root Toda la superficie del contenedor con el máximo privilegio
8 ENTRYPOINT en forma de cadena sh -c como PID 1: el SIGTERM no llega a la JVM y no hay apagado ordenado
9 Sin capas del JAR ni ajustes de JVM Reconstrucciones lentas y riesgo de OOMKilled

La corrección es el Dockerfile del apartado 5, con dos matices sobre el enunciado. La contraseña no se pasa en la construcción de ninguna forma: es configuración de ejecución y se inyecta como variable al arrancar el contenedor; si de verdad hiciera falta una credencial durante la construcción —por ejemplo, para un repositorio Maven privado— la forma correcta es RUN --mount=type=secret,id=maven, que no deja rastro en las capas. Y el perfil desaparece del Dockerfile por completo, pasando a docker run -e SPRING_PROFILES_ACTIVE=... o al Compose.

Solución 2

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ciclourbana
      POSTGRES_USER: ciclourbana
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?falta POSTGRES_PASSWORD}
      TZ: Europe/Madrid
    volumes:
      - postgres-pre:/var/lib/postgresql/data
    networks: [red-pre]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ciclourbana -d ciclourbana"]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 20s

  app:
    image: ghcr.io/ayuntamiento-ribalta/ciclourbana:${VERSION:?falta VERSION}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: pre
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ciclourbana
      SPRING_DATASOURCE_USERNAME: ciclourbana
      SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
      JWT_SECRETO: ${JWT_SECRETO:?falta JWT_SECRETO}
      JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
      TZ: Europe/Madrid
    ports:
      - "8080:8080"
      - "127.0.0.1:8081:8081"
    networks: [red-pre]
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health/readiness"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 45s
    stop_grace_period: 50s
    deploy:
      resources:
        limits:
          memory: 1g

volumes:
  postgres-pre:

networks:
  red-pre:

Orden de arranque. Compose crea la red y el volumen; arranca postgres; durante los primeros 20 segundos (start_period) los fallos de pg_isready no cuentan; cuando responde, el servicio pasa a healthy; solo entonces arranca app, que resuelve postgres por DNS interno, aplica las migraciones de Flyway y levanta el contexto; durante 45 segundos su propio healthcheck no penaliza, y al responder /actuator/health/readiness con 200 el servicio queda healthy y puede recibir tráfico.

Si PostgreSQL tarda un minuto, el comportamiento sigue siendo correcto: con interval: 10s, retries: 6 y start_period: 20s, hay margen suficiente y app simplemente espera. Si superara ese margen, PostgreSQL se marcaría como unhealthy, app no arrancaría en absoluto —que es el comportamiento deseado: mejor no arrancar que arrancar sin base de datos— y docker compose ps mostraría claramente cuál es el servicio que falla.

stop_grace_period: 50s es la pieza que hace coherente la cadena de apagado: la aplicación necesita hasta 40 segundos para terminar peticiones y vaciar los ejecutores (07-03), así que darle solo los 10 segundos por defecto de Docker cortaría el apagado ordenado justo a la mitad. La regla general es stop_grace_period > timeout-per-shutdown-phase > await-termination-period.

Solución 3

El diagnóstico. COPY . . copia el código antes de resolver las dependencias, así que la capa que ejecuta mvn package se invalida con cualquier cambio de fichero. Docker no puede reutilizar nada: cada construcción vuelve a descargar todo el árbol de dependencias de Maven —Spring Boot, Hibernate, Jackson, Spring Security, JJWT, MapStruct, los drivers— desde cero. Los cuatro minutos son, casi enteros, descargas repetidas de artefactos que ya se descargaron ayer.

La corrección es separar la copia en dos pasos, con la resolución de dependencias entre ambos:

FROM maven:3.9-eclipse-temurin-21 AS construccion
WORKDIR /construccion
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
RUN java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extraido

Ahora un cambio en EstacionService invalida la capa de COPY src y las posteriores, pero la de las dependencias sale de la caché. El tiempo baja a lo que tarde la compilación más el empaquetado: entre veinte y cuarenta segundos en un proyecto del tamaño de CicloUrbana. Y como las capas dependencies y spring-boot-loader de la imagen final tampoco cambian, la publicación al registro transfiere solo unos cientos de kilobytes en lugar de sesenta megabytes.

Si tras la corrección siguiera tardando más de un minuto, hay cuatro cosas que revisar, en este orden:

  1. ¿Se están ejecutando las pruebas? Sin -DskipTests, mvn package ejecuta toda la suite del módulo 6, incluidos los Testcontainers, que necesitarían Docker dentro de Docker. Las pruebas van en la canalización, antes de construir la imagen.
  2. ¿Hay .dockerignore? Sin él, el contexto de construcción incluye .git y target/, y transferirlos al demonio antes de empezar puede llevar decenas de segundos.
  3. ¿La caché se está invalidando por otra vía? Un COPY de un fichero que cambia siempre —una marca de tiempo, un fichero generado— por encima de la etapa de dependencias tiene el mismo efecto que el problema original.
  4. ¿La caché existe? En integración continua, cada ejecución suele empezar en una máquina limpia sin caché de capas; ahí la solución es una caché de capas del propio sistema (--cache-from, cache-to) o un volumen persistente para el repositorio .m2 mediante RUN --mount=type=cache,target=/root/.m2, que es la forma moderna y la que mejor funciona en canalizaciones (08-05).

Conclusión

CicloUrbana ya no es un JAR que alguien arranca a mano: es una imagen reproducible que se ejecuta igual en el portátil de un desarrollador, en el servidor de preproducción y en la infraestructura del ayuntamiento de Ribalta. Sabes qué resuelve un contenedor y qué no, y manejas los conceptos que lo sostienen —imagen, capa, contenedor, registro, etiqueta, volumen, red—, con la idea central de que las capas se cachean y que por eso lo que cambia poco va abajo y lo que cambia mucho va arriba.

Sabes por qué el Dockerfile de tres líneas está mal —capa monolítica, dependencia del Maven local, root, imagen gorda, señales que no llegan— y has escrito el que sí está bien: multietapa, con el pom.xml copiado antes que el src para que las dependencias salgan de la caché, con el JAR descompuesto en sus cuatro capas por el jarmode tools, con usuario sin privilegios, zona horaria fijada, ajustes de memoria por JAVA_TOOL_OPTIONS y un ENTRYPOINT en forma de lista que sí deja llegar el SIGTERM al apagado ordenado. Conoces las imágenes base y sus compromisos, incluida la advertencia sobre musl en Alpine y el precio de distroless, y sabes que existe un camino sin Dockerfile —los buildpacks de spring-boot:build-image, con pack rebase para parchear la base sin recompilar—, con criterios claros para elegir entre ambos.

Entiendes cómo se comporta la JVM dentro de un contenedor: UseContainerSupport ya no hay que activarlo, pero MaxRAMPercentage=75 sí, y el 25 % de margen es lo que evita el OOMKilled silencioso. Tienes el docker-compose.yml completo de la red de Ribalta, con PostgreSQL 16, volumen con nombre, red propia, depends_on: condition: service_healthy, el puerto de gestión publicado solo en 127.0.0.1, secretos en un .env fuera de Git y —la pieza que enlaza esta lección con la primera del módulo— un HEALTHCHECK que consulta /actuator/health/readiness. Sabes configurar la aplicación desde el entorno sin hornear nunca el perfil en la imagen, montar un YAML externo de solo lectura y ampliar stop_grace_period para que el apagado ordenado quepa. Y tienes spring-boot-docker-compose levantando la base de datos en desarrollo, la visión general de GraalVM con sus tiempos reales y sus RuntimeHints, la lista de prácticas de seguridad de la imagen —usuario no root, base mínima, cero secretos en capas, .dockerignore, escaneo con docker scout o Trivy— y una política de etiquetado que hace verificable qué versión corre en Ribalta.

Hasta aquí, todo lo que hemos construido es una sola aplicación: un monolito bien hecho, probado, observable, configurable y ahora contenerizado. Es una arquitectura perfectamente respetable y, para una red municipal de bicicletas, probablemente la correcta. Pero antes o después alguien plantea la pregunta: ¿y si el módulo de facturación tuviera su propio ciclo de vida? ¿Y si el equipo de estaciones desplegara sin coordinarse con el de alquileres? ¿Y si hubiera que escalar solo la parte que consulta la disponibilidad, que recibe cien veces más tráfico que el resto? La siguiente lección, Spring Boot y Microservicios, responde a esas preguntas con honestidad: qué problema resuelven realmente los microservicios, qué se paga por ellos, cómo se decide dónde cortar, qué se rompe cuando cada servicio tiene su propia base de datos, y por qué la primera respuesta correcta casi siempre es modularizar el monolito.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados