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
- Por qué contenerizar
- Los conceptos mínimos de Docker
- El
Dockerfileingenuo y por qué está mal - El JAR de Spring Boot por capas
- El
Dockerfilemultietapa de CicloUrbana - Elegir la imagen base
- Buildpacks:
spring-boot:build-image - La JVM dentro de un contenedor
- El
docker-compose.ymlde la red de Ribalta - Configurar la aplicación en el contenedor
spring-boot-docker-composepara desarrollo- Imágenes nativas con GraalVM
- Seguridad de la imagen
- Publicar en un registro y etiquetar
- Errores Comunes y Consejos
- Ejercicios
- 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).
- 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.
- El
Dockerfile ingenuo y por qué está mal
Dockerfile ingenuo y por qué está malEste 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.
- 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 extraidoEn 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.
- El
Dockerfile multietapa de CicloUrbana
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.xmlantes queCOPY src. Es el truco central: mientras el POM no cambie, la capa dedependency:go-offlinesale 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.-DskipTestsaquí 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 --layersproduce los cuatro directorios del apartado anterior.- Usuario sin privilegios.
addgroup/adduseres la sintaxis de Alpine; en imágenes basadas en Debian seríagroupadd/useradd. El--chownen cadaCOPYevita una capa extra solo para cambiar permisos. - Las cuatro
COPYen ese orden exacto. Es donde se materializa toda la optimización: un cambio enAlquilerServicesolo invalida la última. EXPOSE 8080 8081documenta el puerto de la API y el de gestión de 07-01. Es informativo, no abre nada por sí solo.TZ=Europe/Madridevita el problema de zonas horarias de 07-03: sin esto, un contenedor corre en UTC y el cron nocturno se desplaza.ENTRYPOINTen forma de lista, no como cadena. La forma de cadena arranca el proceso bajo unsh -c, que se queda como PID 1 y no reenvía las señales: elSIGTERMdel 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
- 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.
- Buildpacks:
spring-boot:build-image
spring-boot:build-imageSpring Boot puede construir la imagen sin ningún Dockerfile, usando Cloud Native Buildpacks:
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.
- 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:
| 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.
- El
docker-compose.yml de la red de Ribalta
docker-compose.yml de la red de RibaltaEn 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: bridgeLos puntos que hay que entender:
depends_onconcondition: service_healthyhace que la aplicación no arranque hasta que PostgreSQL responda apg_isready. Sin esa condición,depends_onsolo 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 quedocker compose upfalle 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 alocalhost. Dentro de la red del Compose, cada servicio es alcanzable por su nombre gracias al DNS interno.localhostdentro del contenedor de la aplicación es el propio contenedor. 127.0.0.1:8081:8081publica 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
healthcheckde 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: 45sda margen al arranque de Spring —contexto, Flyway, pool— sin que los fallos de ese periodo cuenten como reintentos.limits.memory: 1ges lo que da sentido aMaxRAMPercentage=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.0Un .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).
- 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.0Nunca 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:
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:
spring-boot-docker-compose para desarrollo
spring-boot-docker-compose para desarrolloEn 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>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.
- 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.
- 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 |
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.
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.
- 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.0La 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.jarEjercicio 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 packageDiagnostica 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 extraidoAhora 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:
- ¿Se están ejecutando las pruebas? Sin
-DskipTests,mvn packageejecuta 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. - ¿Hay
.dockerignore? Sin él, el contexto de construcción incluye.gitytarget/, y transferirlos al demonio antes de empezar puede llevar decenas de segundos. - ¿La caché se está invalidando por otra vía? Un
COPYde 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. - ¿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.m2medianteRUN --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
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
