BiblioTech está probado, medido y verificado en cada cambio. Y no existe para nadie.

Corre en el portátil de Diego Alonso cuando lo arranca, y en un runner de GitHub Actions durante los ocho minutos que dura la canalización. Marta Ruiz no puede abrir un navegador y consultar el catálogo, porque no hay ningún servidor donde la aplicación esté funcionando. Nuria Vidal no puede reservar «Refactorización», porque el proceso que atendería esa petición no está encendido en ninguna parte.

Esta lección cubre el trayecto que va de «funciona en mi máquina» a «está en producción». Es un trayecto con más trampas de las que parece, y casi todas se resumen en una frase que oirás en tu carrera profesional muchas veces: «pues en local funcionaba». Funcionaba porque en local había una versión distinta de Java, un fichero de configuración que no está en el repositorio, un esquema de base de datos que Hibernate había creado solo, 32 GB de memoria y ningún otro usuario compitiendo.

El objetivo de todo lo que viene es eliminar esas diferencias: empaquetar la aplicación con todo lo que necesita, configurarla desde fuera, y desplegarla de forma repetible, observable y reversible.

Al terminar sabrás empaquetar y contenerizar una aplicación Java correctamente, configurar la JVM para que respete los límites de un contenedor, versionar el esquema de la base de datos con Flyway, elegir entre las distintas plataformas de despliegue con criterio, exponer sondas de salud y apagar la aplicación sin cortar peticiones a medias, aplicar estrategias de despliegue con vuelta atrás, y montar una canalización de entrega continua completa.

Contenido

  1. Qué significa desplegar
  2. Empaquetado: jar ejecutable frente a war
  3. El jar por capas y por qué acelera las imágenes
  4. Construcción reproducible y trazabilidad
  5. Contenedores: imagen y contenedor
  6. El Dockerfile de BiblioTech, línea a línea
  7. .dockerignore
  8. Alternativas sin Dockerfile: Buildpacks y Jib
  9. Elección de imagen base y tamaño
  10. La JVM dentro de un contenedor
  11. docker-compose para el entorno local
  12. Configuración y secretos en el despliegue
  13. Migraciones de base de datos con Flyway
  14. Despliegue en dos fases para cambios de esquema
  15. Dónde se despliega: comparativa de plataformas
  16. Kubernetes: un Deployment mínimo
  17. Arranque y salud: sondas de Actuator
  18. Apagado ordenado
  19. Tiempo de arranque: CDS y Native Image
  20. Estrategias de despliegue
  21. Vuelta atrás y el límite de la base de datos
  22. Canalización de entrega continua
  23. Escalado horizontal y qué exige de la aplicación
  24. Errores Comunes y Consejos
  25. Ejercicios
  26. Conclusión

  1. Qué significa desplegar

Desplegar es poner una versión concreta del software a disposición de sus usuarios, en un entorno que no controlas del todo. Las diferencias con tu portátil son sistemáticas:

Aspecto Tu máquina Producción
Versión de Java La que tengas instalada La que decida el operador
Configuración application-dev.yml Variables de entorno
Base de datos Contenedor efímero, tú solo Compartida, con datos reales
Esquema Lo crea Hibernate Migraciones versionadas
Memoria 32 GB 512 MB con límite estricto
Fallos Reinicias Alguien recibe una llamada
Reiniciar Sin consecuencias Peticiones cortadas, usuarios afectados
Datos De prueba Irrecuperables si se pierden

De ahí salen los tres principios que gobiernan esta lección:

  1. Un artefacto, todos los entornos. El mismo jar y la misma imagen van a desarrollo, preproducción y producción. Lo único que cambia es la configuración. Si construyes una imagen distinta para producción, lo que has probado no es lo que despliegas.
  2. Configuración desde fuera. Nada específico del entorno dentro del artefacto.
  3. Todo debe poder deshacerse. Un despliegue sin vuelta atrás es una apuesta.

  1. Empaquetado: jar ejecutable frente a war

Históricamente, una aplicación Java web se empaquetaba en un .war y se desplegaba dentro de un servidor de aplicaciones instalado aparte. Spring Boot invirtió el modelo con el jar ejecutable, que lleva el servidor dentro.

Aspecto Jar ejecutable War
Servidor Embebido Instalado aparte
Ejecución java -jar app.jar Copiar en webapps/
Unidades desplegables Una Dos: servidor y aplicación
Versión del servidor La que declara el POM La del operador
Contenedores Encaja perfecto Incómodo
Varias apps por servidor No
Cuándo usarlo Prácticamente siempre Servidor de aplicaciones corporativo impuesto
<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <mainClass>com.nexussoftware.bibliotech.web.BiblioTechApplication</mainClass>
  </configuration>
</plugin>
./mvnw -pl bibliotech-web clean package
java -jar bibliotech-web/target/bibliotech-web-1.0.0.jar

Un detalle que conviene conocer: el jar ejecutable de Spring Boot no es un jar normal. Tus clases están en BOOT-INF/classes/ y las dependencias, como jars completos, en BOOT-INF/lib/. Un cargador de clases propio (JarLauncher) se encarga de leerlas. Por eso java -cp app.jar MiClase no funciona como esperarías.

bibliotech-web-1.0.0.jar
├── META-INF/MANIFEST.MF          ← Main-Class: org.springframework.boot.loader.launch.JarLauncher
├── org/springframework/boot/loader/   ← el cargador
└── BOOT-INF/
    ├── classes/                  ← tu código y tus recursos
    ├── lib/                      ← ~50 jars de dependencias
    └── classpath.idx

  1. El jar por capas y por qué acelera las imágenes

Aquí hay una optimización que parece un detalle y cambia radicalmente los tiempos de despliegue.

Una imagen Docker se compone de capas superpuestas. Cuando se publica o se descarga una imagen, solo viajan las capas que han cambiado. Si todo el jar (60 MB) está en una sola capa, cualquier cambio de una línea de código obliga a transferir 60 MB.

Pero la composición de esos 60 MB es muy desigual:

Contenido Tamaño típico Frecuencia de cambio
Dependencias (Spring, Hibernate, Jackson…) ~55 MB Cada varias semanas
Cargador de Spring Boot ~200 KB Con la versión de Spring Boot
Dependencias internas (nuestros módulos) ~500 KB A diario
Tu código ~1 MB Cada commit

Spring Boot ofrece layertools, que separa el jar en esas cuatro partes:

$ java -Djarmode=tools -jar bibliotech-web.jar list-layers
dependencies
spring-boot-loader
snapshot-dependencies
application
# Extraer cada capa en su directorio
java -Djarmode=tools -jar bibliotech-web.jar extract --layers --launcher --destination extraido/

Y en el Dockerfile, cada capa se copia en un COPY distinto, en orden de estabilidad. El resultado, medido:

Escenario Sin capas Con capas
Cambio de una línea de código 60 MB transferidos ~1 MB
Añadir una dependencia 60 MB ~57 MB
Tiempo de publicación típico 40 s 3 s
Tiempo de descarga en el despliegue 30 s 2 s

Con veinte despliegues al día, eso son horas al mes. Y el efecto psicológico importa tanto como el técnico: un despliegue de tres segundos se hace sin pensarlo; uno de dos minutos se acumula «para hacerlos todos juntos el jueves», que es exactamente la práctica que hay que evitar.

Se activa en el POM (está activo por defecto en Spring Boot 3):

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <layers>
      <enabled>true</enabled>
    </layers>
  </configuration>
</plugin>

  1. Construcción reproducible y trazabilidad

Construcción reproducible significa que el mismo código fuente produce exactamente los mismos bytes. Suena académico y tiene una consecuencia práctica muy concreta: poder verificar que el binario que está en producción corresponde al código que dice corresponder.

Lo que rompe la reproducibilidad son las marcas de tiempo dentro del jar:

<properties>
  <!-- Fija la fecha de las entradas del jar: sin esto, cada construcción difiere -->
  <project.build.outputTimestamp>2026-08-05T00:00:00Z</project.build.outputTimestamp>
</properties>
./mvnw clean package
sha256sum target/bibliotech-web-1.0.0.jar
# a3f7... (el mismo hash en cualquier máquina, hoy y dentro de un año)

Trazabilidad. Cada artefacto debe poder responder: ¿de qué commit salió?, ¿cuándo se construyó?, ¿quién lo construyó?

<plugin>
  <groupId>io.github.git-commit-id</groupId>
  <artifactId>git-commit-id-maven-plugin</artifactId>
  <version>9.0.1</version>
  <executions>
    <execution><goals><goal>revision</goal></goals></execution>
  </executions>
  <configuration>
    <generateGitPropertiesFile>true</generateGitPropertiesFile>
    <includeOnlyProperties>
      <property>^git.branch$</property>
      <property>^git.commit.id.abbrev$</property>
      <property>^git.commit.time$</property>
      <property>^git.build.version$</property>
    </includeOnlyProperties>
  </configuration>
</plugin>

Con eso, Actuator expone la información:

$ curl https://bibliotech.nexussoftware.com/actuator/info
{
  "git": {
    "branch": "main",
    "commit": { "id": "a3f7e91", "time": "2026-08-05T09:14:22Z" }
  },
  "build": { "version": "1.4.2", "artifact": "bibliotech-web", "time": "2026-08-05T09:20:11Z" }
}

La pregunta «¿qué versión hay en producción ahora mismo?» tiene una respuesta exacta, en un segundo. Sin eso, la respuesta es «creo que la de la semana pasada», y a partir de ahí ningún diagnóstico es fiable.

Versionado semántico de los artefactos:

Formato Ejemplo Uso
MAJOR.MINOR.PATCH 1.4.2 Versión liberada
MAJOR.MINOR.PATCH-SNAPSHOT 1.5.0-SNAPSHOT En desarrollo
MAJOR.MINOR.PATCH-rc.N 1.5.0-rc.1 Candidata
Con el commit 1.4.2-a3f7e91 Trazabilidad exacta

  1. Contenedores: imagen y contenedor

Dos conceptos que se confunden constantemente:

  • Una imagen es una plantilla inmutable de solo lectura: sistema de ficheros por capas + metadatos (qué comando ejecutar, qué puertos, qué usuario). Es como una clase.
  • Un contenedor es una instancia en ejecución de una imagen, con una capa de escritura encima. Es como un objeto.
flowchart TD
    B["Imagen base<br/>eclipse-temurin:21-jre-alpine"]
    L1["Capa: dependencias (~55 MB)"]
    L2["Capa: cargador (~200 KB)"]
    L3["Capa: código de BiblioTech (~1 MB)"]
    I["IMAGEN bibliotech:1.4.2"]
    C1["Contenedor 1<br/>en ejecución"]
    C2["Contenedor 2<br/>en ejecución"]

    B --> L1 --> L2 --> L3 --> I
    I --> C1
    I --> C2

Un contenedor no es una máquina virtual: comparte el núcleo del sistema anfitrión y usa mecanismos de Linux (namespaces para el aislamiento, cgroups para los límites de recursos). Por eso arranca en milisegundos y pesa megabytes en vez de gigabytes.

Aspecto Máquina virtual Contenedor
Aislamiento Total (núcleo propio) De procesos (núcleo compartido)
Arranque Minutos Milisegundos
Tamaño GB MB
Sobrecarga Notable Mínima

Lo que un contenedor resuelve de verdad, y que justifica todo lo demás: el artefacto incluye el sistema operativo base, la JVM, la aplicación y sus dependencias. La frase «en mi máquina funciona» pierde sentido, porque la máquina viaja con la aplicación.

  1. El Dockerfile de BiblioTech, línea a línea

# ==============================================================================
# ETAPA 1: CONSTRUCCIÓN
# Esta etapa tiene Maven, el JDK completo y el código fuente. Nada de eso
# llega a la imagen final: solo se usa para producir el jar.
# ==============================================================================
FROM eclipse-temurin:21-jdk-alpine AS constructor

WORKDIR /build

# Copiar SOLO los ficheros de dependencias primero.
# Docker cachea cada instrucción: mientras los POM no cambien, la descarga
# de dependencias (el paso más lento, 2-3 minutos) se salta por completo.
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY bibliotech-dominio/pom.xml          bibliotech-dominio/
COPY bibliotech-aplicacion/pom.xml       bibliotech-aplicacion/
COPY bibliotech-infraestructura/pom.xml  bibliotech-infraestructura/
COPY bibliotech-web/pom.xml              bibliotech-web/
COPY bibliotech-consola/pom.xml          bibliotech-consola/

# go-offline descarga todas las dependencias sin compilar nada.
# --mount=type=cache mantiene ~/.m2 entre construcciones (BuildKit).
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -B dependency:go-offline -DskipTests

# AHORA sí copiamos el código fuente. Si solo cambia el código,
# Docker reutiliza la capa anterior y no vuelve a descargar nada.
COPY bibliotech-dominio/src          bibliotech-dominio/src
COPY bibliotech-aplicacion/src       bibliotech-aplicacion/src
COPY bibliotech-infraestructura/src  bibliotech-infraestructura/src
COPY bibliotech-web/src              bibliotech-web/src

# -DskipTests: las pruebas ya se ejecutaron en CI (12-05). Repetirlas aquí
# duplica el tiempo y requeriría Docker dentro de Docker para Testcontainers.
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -B -pl bibliotech-web -am clean package -DskipTests

# Extraer el jar en capas
RUN java -Djarmode=tools -jar bibliotech-web/target/bibliotech-web-*.jar \
         extract --layers --launcher --destination extraido

# ==============================================================================
# ETAPA 2: EJECUCIÓN
# Imagen mínima: solo JRE (no JDK), sin Maven, sin código fuente,
# sin herramientas de compilación. Menos superficie, menos vulnerabilidades.
# ==============================================================================
FROM eclipse-temurin:21-jre-alpine AS ejecucion

# Etiquetas OCI estándar: metadatos que las herramientas del ecosistema leen
LABEL org.opencontainers.image.title="BiblioTech" \
      org.opencontainers.image.description="Biblioteca técnica de Nexus Software" \
      org.opencontainers.image.vendor="Nexus Software" \
      org.opencontainers.image.licenses="Proprietary"

# Utilidades mínimas:
#  - curl para el HEALTHCHECK
#  - tzdata para que las zonas horarias funcionen (Alpine no las trae)
#  - dumb-init como PID 1: reenvía señales correctamente al proceso Java
RUN apk add --no-cache curl tzdata dumb-init && \
    rm -rf /var/cache/apk/*

ENV TZ=Europe/Madrid

# ---- Usuario NO root ----
# Si un atacante consigue ejecución de código, no debe tener root en el
# contenedor. Es la mitigación más barata y eficaz que existe.
RUN addgroup -S -g 1001 bibliotech && \
    adduser -S -u 1001 -G bibliotech -h /app bibliotech

WORKDIR /app

# ---- Las capas, en orden de estabilidad (menos cambiante primero) ----
# Cada COPY es una capa de Docker. Al cambiar solo el código, únicamente
# la última capa (~1 MB) se reconstruye y se transfiere.
COPY --from=constructor --chown=bibliotech:bibliotech /build/extraido/dependencies/ ./
COPY --from=constructor --chown=bibliotech:bibliotech /build/extraido/spring-boot-loader/ ./
COPY --from=constructor --chown=bibliotech:bibliotech /build/extraido/snapshot-dependencies/ ./
COPY --from=constructor --chown=bibliotech:bibliotech /build/extraido/application/ ./

USER bibliotech

EXPOSE 8080

# ---- Opciones de la JVM ----
#  MaxRAMPercentage=75      usa el 75 % del límite del contenedor para el heap
#  InitialRAMPercentage=50  arranca con la mitad: menos redimensionados
#  UseG1GC                  GC equilibrado; para <2 vCPU considerar SerialGC
#  ExitOnOutOfMemoryError   ante OOM, morir: que el orquestador reinicie
#                           (una JVM en OOM sirve peticiones a medias, que es peor)
#  HeapDumpOnOutOfMemoryError  volcado para diagnosticar (10-07)
#  file.encoding=UTF-8      explícito: no dependemos del entorno
ENV JAVA_OPTS="\
    -XX:MaxRAMPercentage=75.0 \
    -XX:InitialRAMPercentage=50.0 \
    -XX:+UseG1GC \
    -XX:+ExitOnOutOfMemoryError \
    -XX:+HeapDumpOnOutOfMemoryError \
    -XX:HeapDumpPath=/tmp/volcado.hprof \
    -Djava.security.egd=file:/dev/./urandom \
    -Dfile.encoding=UTF-8"

# Comprobación de salud a nivel de contenedor
HEALTHCHECK --interval=30s --timeout=3s --start-period=45s --retries=3 \
  CMD curl -fsS http://localhost:8080/actuator/health/readiness || exit 1

# dumb-init como PID 1 reenvía SIGTERM al proceso Java: sin esto,
# el apagado ordenado (sección 18) no funciona.
ENTRYPOINT ["dumb-init", "--"]

# Forma "shell" para que $JAVA_OPTS se expanda. exec hace que Java sea
# el proceso hijo directo y reciba las señales.
CMD exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher

Construcción y ejecución:

docker build -t bibliotech:1.4.2 -t bibliotech:latest .

docker run -d --name bibliotech \
  -p 8080:8080 \
  --memory=768m --cpus=1.5 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e BIBLIOTECH_DB_URL=jdbc:postgresql://db:5432/bibliotech \
  -e BIBLIOTECH_DB_USER=bibliotech \
  -e BIBLIOTECH_DB_PASSWORD="$DB_PASSWORD" \
  bibliotech:1.4.2

docker logs -f bibliotech
docker exec bibliotech curl -s localhost:8080/actuator/health

Los cinco puntos del Dockerfile que más importan, por si hay que recordar solo cinco:

  1. Multietapa: la imagen final no contiene ni Maven ni JDK ni código fuente. Pasa de ~700 MB a ~180 MB, y elimina de la superficie de ataque un compilador completo.
  2. Los POM antes que el código: la caché de dependencias se conserva entre construcciones.
  3. Usuario no root: mitigación básica y obligatoria.
  4. Capas ordenadas por estabilidad: despliegues de 1 MB en lugar de 60 MB.
  5. dumb-init + exec: sin ellos, SIGTERM no llega a la JVM y el apagado ordenado no ocurre.

  1. .dockerignore

Sin él, el contexto de construcción incluye el directorio .git completo, los target/ y posiblemente ficheros con secretos, que acaban dentro de la imagen o al menos se envían al demonio de Docker.

# Todo lo que no hace falta para construir
.git/
.github/
.idea/
.vscode/
*.iml

target/
**/target/

*.md
docs/
LICENSE

# CRÍTICO: nada de esto debe entrar en el contexto de construcción
.env
*.env
**/application-local.yml
*.pem
*.p12
*.jks
secrets/

Dockerfile
.dockerignore
compose.yaml

Comprobación del tamaño del contexto:

docker build --no-cache --progress=plain . 2>&1 | head -5
# => transferring context: 1.24MB    (bien)
# Sin .dockerignore sería, típicamente, 250 MB.

  1. Alternativas sin Dockerfile: Buildpacks y Jib

Cloud Native Buildpacks — integrado en Spring Boot, sin escribir un solo Dockerfile:

./mvnw -pl bibliotech-web spring-boot:build-image \
  -Dspring-boot.build-image.imageName=bibliotech:1.4.2
<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <image>
      <name>registro.nexussoftware.com/bibliotech:${project.version}</name>
      <env>
        <BP_JVM_VERSION>21</BP_JVM_VERSION>
        <BPE_DELIM_JAVA_TOOL_OPTIONS xml:space="preserve"> </BPE_DELIM_JAVA_TOOL_OPTIONS>
        <BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:MaxRAMPercentage=75</BPE_APPEND_JAVA_TOOL_OPTIONS>
      </env>
    </image>
  </configuration>
</plugin>

Buildpacks detecta que es una aplicación Java, elige la JVM, aplica capas, configura la memoria automáticamente según el límite del contenedor y añade un SBOM (inventario de componentes, útil para seguridad, 12-07).

Jib (Google) construye la imagen sin necesidad de un demonio Docker, lo que la hace ideal para CI:

<plugin>
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <version>3.4.3</version>
  <configuration>
    <from><image>eclipse-temurin:21-jre-alpine</image></from>
    <to><image>registro.nexussoftware.com/bibliotech:${project.version}</image></to>
    <container>
      <user>1001:1001</user>
      <ports><port>8080</port></ports>
      <jvmFlags>
        <jvmFlag>-XX:MaxRAMPercentage=75.0</jvmFlag>
      </jvmFlags>
    </container>
  </configuration>
</plugin>
./mvnw -pl bibliotech-web jib:build         # publica directamente en el registro
./mvnw -pl bibliotech-web jib:dockerBuild   # o construye en el Docker local

Comparativa:

Criterio Dockerfile Buildpacks Jib
Control total No Parcial
Requiere Docker para construir No
Capas optimizadas Manual Automático Automático
Actualizar la base Manual Automático (rebase) Cambiar una línea
Velocidad Media Lenta la 1.ª vez Muy rápida
Curva de aprendizaje Media Baja Baja
Cuándo Necesitas control fino Quieres olvidarte CI sin Docker

Recomendación para BiblioTech: Dockerfile explícito. En un curso, y en un equipo que quiere entender su despliegue, el control y la transparencia valen más que la comodidad. En un equipo grande con muchos servicios, Buildpacks o Jib ahorran trabajo repetido.

  1. Elección de imagen base y tamaño

Imagen base Tamaño (con JRE 21) Características
eclipse-temurin:21-jdk ~450 MB JDK completo. No usar en producción
eclipse-temurin:21-jre ~270 MB JRE sobre Ubuntu. Compatible y previsible
eclipse-temurin:21-jre-alpine ~180 MB Alpine + musl libc. Ligera
gcr.io/distroless/java21 ~190 MB Sin shell, sin gestor de paquetes. Muy segura
Imagen propia con jlink ~90 MB JRE recortado a los módulos necesarios

Consideraciones reales:

  • Alpine usa musl en lugar de glibc. El 99 % del código Java funciona igual, pero librerías con código nativo pueden fallar. Si aparecen errores raros de carga de bibliotecas, prueba con la variante no-Alpine antes de perder una tarde.
  • Distroless es la más segura: sin shell, un atacante que logre ejecución de código no puede lanzar comandos. El precio es que tampoco puedes tú: no hay docker exec ... sh para diagnosticar. Requiere buena observabilidad (12-07).
  • El tamaño importa menos de lo que parece. La capa base se descarga una vez y se comparte entre todas las imágenes de esa base. Lo que se transfiere en cada despliegue es la capa de aplicación (~1 MB).

Reducir con jlink, para quien necesite el mínimo:

FROM eclipse-temurin:21-jdk-alpine AS jre-minimo
RUN jlink \
    --add-modules java.base,java.logging,java.sql,java.naming,java.management,\
java.instrument,java.security.jgss,java.desktop,jdk.unsupported,jdk.crypto.ec \
    --strip-debug --no-man-pages --no-header-files --compress=2 \
    --output /jre-minimo

  1. La JVM dentro de un contenedor

Este es, con diferencia, el error de despliegue más común con Java, y merece un apartado propio.

El problema histórico. Antes de Java 10, la JVM leía la memoria de la máquina anfitriona, ignorando el límite del contenedor. En un servidor de 64 GB con un contenedor limitado a 512 MB, la JVM calculaba un heap máximo de 16 GB (una cuarta parte de 64), lo intentaba usar, y el núcleo mataba el proceso con OOMKilled (código 137) sin ningún mensaje de la JVM.

Desde Java 10 —y perfeccionado en 11, 15 y 17— la JVM es consciente de los cgroups:

$ docker run --memory=512m eclipse-temurin:21-jre java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
   size_t MaxHeapSize = 134217728    # 128 MB = 25 % de 512 MB

Las opciones que hay que ajustar y por qué:

Opción Valor recomendado Motivo
-XX:MaxRAMPercentage 75.0 El 25 % por defecto desperdicia memoria
-XX:InitialRAMPercentage 50.0 Menos redimensionados del heap al arrancar
-XX:+UseG1GC Con ≥ 2 vCPU Equilibrio entre pausas y rendimiento
-XX:+UseSerialGC Con < 2 vCPU Menos sobrecarga en contenedores pequeños
-XX:MaxMetaspaceSize 256m El metaspace no está dentro del heap
-XX:+ExitOnOutOfMemoryError Siempre Morir rápido y que el orquestador reinicie
-XX:ActiveProcessorCount Si el límite de CPU es fraccionario La JVM redondea mal las CPU fraccionarias

Un cálculo que evita muchos incidentes. Con un límite de contenedor de 512 MB:

Componente Memoria
Heap (75 %) 384 MB
Metaspace ~60 MB
Pilas de hilos (200 × 1 MB de reserva) ~30 MB reales
Caché de código (JIT) ~40 MB
GC y estructuras internas ~30 MB
Buffers directos de NIO ~20 MB
Total ~564 MB > 512 MB → OOMKilled

La memoria de la JVM no es solo el heap. Este cálculo es la razón por la que muchos contenedores Java mueren sin explicación aparente. Para diagnosticarlo, NativeMemoryTracking (10-07):

docker run -e JAVA_OPTS="-XX:NativeMemoryTracking=summary" bibliotech:1.4.2
docker exec bibliotech jcmd 1 VM.native_memory summary

Reglas prácticas:

  • Con MaxRAMPercentage=75, deja al menos 256 MB de límite de contenedor por encima de lo que necesita el heap.
  • Un servicio Spring Boot típico necesita como mínimo 512 MB; 768 MB es más cómodo.
  • Si ves OOMKilled (código 137), no es un fallo de la aplicación: es memoria fuera del heap.

  1. docker-compose para el entorno local

En 12-01 solo levantábamos PostgreSQL. Ahora, la aplicación completa:

# compose.yaml
name: bibliotech

services:

  db:
    image: postgres:16-alpine
    container_name: bibliotech-db
    environment:
      POSTGRES_DB: bibliotech
      POSTGRES_USER: bibliotech
      POSTGRES_PASSWORD: ${DB_PASSWORD:-bibliotech}
    ports:
      - "5432:5432"
    volumes:
      - datos-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U bibliotech -d bibliotech"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: bibliotech:${VERSION:-dev}
    container_name: bibliotech-app
    depends_on:
      db:
        condition: service_healthy      # espera al healthcheck, no solo al arranque
    environment:
      SPRING_PROFILES_ACTIVE: docker
      BIBLIOTECH_DB_URL: jdbc:postgresql://db:5432/bibliotech
      BIBLIOTECH_DB_USER: bibliotech
      BIBLIOTECH_DB_PASSWORD: ${DB_PASSWORD:-bibliotech}
      JAVA_OPTS: "-XX:MaxRAMPercentage=75 -XX:+UseG1GC"
    ports:
      - "8080:8080"
    deploy:
      resources:
        limits:
          memory: 768M
          cpus: '1.5'
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health/readiness"]
      interval: 15s
      timeout: 3s
      retries: 5
      start_period: 60s
    restart: unless-stopped

volumes:
  datos-db:
docker compose up -d --build       # construir y levantar
docker compose logs -f app         # seguir los registros
docker compose ps                  # estado y salud
docker compose exec db psql -U bibliotech    # entrar a la base de datos
docker compose down                # parar (los datos sobreviven)
docker compose down -v             # parar y BORRAR los datos

depends_on con condition: service_healthy es el detalle que evita el fallo más frecuente en desarrollo: la aplicación arranca antes de que PostgreSQL acepte conexiones y muere al primer intento.

  1. Configuración y secretos en el despliegue

La regla que retoma 12-01 y que gobierna todo:

Una imagen para todos los entornos. La misma imagen bibliotech:1.4.2 va a desarrollo, preproducción y producción. Solo cambian las variables de entorno.

Si construyes una imagen distinta para producción, lo que has probado en preproducción no es lo que despliegas.

Mecanismos, en orden de robustez:

Mecanismo Cómo Ventajas Riesgos
Variables de entorno -e CLAVE=valor Estándar de facto, simple Visibles en docker inspect y en el entorno del proceso
Ficheros montados -v /secretos:/app/config:ro No aparecen en el entorno Hay que gestionar permisos
Secretos del orquestador Kubernetes Secret, Docker Secret Integrados en la plataforma Base64 no es cifrado
Gestor de secretos Vault, AWS Secrets Manager Rotación, auditoría, cifrado Más complejidad operativa

Configuración externa con fichero, útil en servidores propios:

java -jar bibliotech-web.jar \
     --spring.config.additional-location=file:/etc/bibliotech/

Spring Boot busca application.yml y application-{perfil}.yml en esa ruta, con mayor precedencia que los empaquetados (12-01).

En Kubernetes, la separación entre configuración y secretos:

apiVersion: v1
kind: ConfigMap
metadata:
  name: bibliotech-config
data:
  SPRING_PROFILES_ACTIVE: "prod"
  BIBLIOTECH_PRESTAMO_DIASPORDEFECTO: "15"
  LOGGING_LEVEL_COM_NEXUSSOFTWARE_BIBLIOTECH: "INFO"
---
apiVersion: v1
kind: Secret
metadata:
  name: bibliotech-secretos
type: Opaque
stringData:
  BIBLIOTECH_DB_PASSWORD: "…"        # gestionado con Sealed Secrets o un gestor externo
  METADATOS_API_KEY: "…"

Aviso. Un Secret de Kubernetes está codificado en base64, que no es cifrado: cualquiera con acceso de lectura al espacio de nombres puede decodificarlo. Para secretos reales hacen falta Sealed Secrets, External Secrets Operator o un gestor externo, y cifrado en reposo en etcd. Se retoma en 12-07.

Verificación de que la configuración es la esperada:

curl -s localhost:8080/actuator/env/bibliotech.multa.importe-por-dia | jq
# muestra el valor efectivo Y de qué fuente vino

  1. Migraciones de base de datos con Flyway

Aquí se paga una deuda de 11-03: ddl-auto no vale en producción.

Valor Qué hace Producción
create-drop Borra y recrea al arrancar Catastrófico
create Borra y recrea Catastrófico
update Añade lo que falta No. Ver abajo
validate Comprueba que el esquema encaja
none No hace nada

Por qué update no vale, con precisión:

  1. No elimina nada. Columnas y tablas borradas del modelo se quedan para siempre.
  2. No renombra. Cambiar fecha_venc a fecha_vencimiento crea una columna nueva y deja los datos en la vieja.
  3. No versiona. No hay forma de saber qué esquema tiene un entorno ni de reproducirlo.
  4. No es reversible. No hay vuelta atrás.
  5. Depende del orden de arranque. Con varias instancias arrancando a la vez, pueden generar DDL simultáneo y bloquearse.
  6. No hace migración de datos. Partir nombre_completo en nombre y apellidos es imposible.

Flyway resuelve todo eso con scripts SQL versionados que se aplican en orden, una sola vez, registrados en una tabla de control.

<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-database-postgresql</artifactId>
</dependency>
spring:
  jpa:
    hibernate:
      ddl-auto: validate        # Hibernate VERIFICA, Flyway MANDA
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true   # para una base de datos que ya existía
    validate-on-migrate: true   # detecta scripts ya aplicados que han cambiado
    out-of-order: false         # no permite aplicar una versión anterior a la última

Convenio de nombres: V<versión>__<descripción>.sql

bibliotech-infraestructura/src/main/resources/db/migration/
├── V1__esquema_inicial.sql
├── V2__indices_catalogo.sql
├── V3__anadir_columna_valor_material.sql
├── V4__tabla_reservas.sql
├── V5__estado_devolucion_y_recargos.sql
└── R__vista_estadisticas_uso.sql        ← R = repetible: se reejecuta si cambia
-- V1__esquema_inicial.sql
CREATE TABLE material (
    id                    BIGSERIAL PRIMARY KEY,
    tipo                  VARCHAR(20)  NOT NULL,   -- discriminador SINGLE_TABLE (ADR-004)
    isbn                  VARCHAR(17)  NOT NULL UNIQUE,
    titulo                VARCHAR(200) NOT NULL,
    autor                 VARCHAR(150),
    anio_publicacion      INTEGER,
    unidades_totales      INTEGER      NOT NULL DEFAULT 1 CHECK (unidades_totales >= 0),
    unidades_disponibles  INTEGER      NOT NULL DEFAULT 1 CHECK (unidades_disponibles >= 0),
    version               BIGINT       NOT NULL DEFAULT 0,   -- @Version (11-03)
    creado_en             TIMESTAMPTZ  NOT NULL DEFAULT now(),
    CONSTRAINT chk_disponibles_no_supera_totales
        CHECK (unidades_disponibles <= unidades_totales)
);

CREATE TABLE empleado (
    id            BIGSERIAL PRIMARY KEY,
    nombre        VARCHAR(150) NOT NULL,
    correo        VARCHAR(200) NOT NULL UNIQUE,
    departamento  VARCHAR(100),
    fecha_alta    DATE         NOT NULL,
    version       BIGINT       NOT NULL DEFAULT 0
);

CREATE TABLE prestamo (
    id                 BIGSERIAL PRIMARY KEY,
    material_id        BIGINT      NOT NULL REFERENCES material(id),
    empleado_id        BIGINT      NOT NULL REFERENCES empleado(id),
    fecha_prestamo     DATE        NOT NULL,
    fecha_vencimiento  DATE        NOT NULL,
    fecha_devolucion   DATE,
    estado             VARCHAR(20) NOT NULL,
    version            BIGINT      NOT NULL DEFAULT 0,
    CONSTRAINT chk_vencimiento_posterior CHECK (fecha_vencimiento >= fecha_prestamo),
    CONSTRAINT chk_devolucion_posterior
        CHECK (fecha_devolucion IS NULL OR fecha_devolucion >= fecha_prestamo)
);

-- Índices para las consultas que de verdad se hacen
CREATE INDEX idx_prestamo_empleado_estado ON prestamo(empleado_id, estado);
CREATE INDEX idx_prestamo_vencimiento     ON prestamo(fecha_vencimiento)
                                          WHERE fecha_devolucion IS NULL;  -- parcial
CREATE INDEX idx_material_titulo          ON material USING gin(to_tsvector('spanish', titulo));

-- Datos de referencia que la aplicación necesita para arrancar
INSERT INTO empleado (nombre, correo, departamento, fecha_alta) VALUES
    ('Marta Ruiz',  '[email protected]',  'Arquitectura', '2024-03-01'),
    ('Diego Alonso','[email protected]','Backend',      '2025-01-15'),
    ('Nuria Vidal', '[email protected]', 'Plataforma',   '2023-09-10');

Reglas de oro de las migraciones:

Regla Motivo
Un script aplicado NUNCA se modifica Flyway guarda una suma de comprobación; si cambia, falla el arranque
Para corregir, un script nuevo Es la única forma de que todos los entornos converjan
Migraciones idempotentes cuando se pueda CREATE TABLE IF NOT EXISTS
Los cambios destructivos, en dos fases Ver la siguiente sección
Probar la migración con datos reales Un ALTER TABLE sobre 10 millones de filas puede tardar horas y bloquear
Un ALTER que bloquea, en ventana de mantenimiento En PostgreSQL, ADD COLUMN con default es rápido; ALTER TYPE reescribe la tabla

Comandos útiles:

./mvnw flyway:info       # qué migraciones hay y cuáles están aplicadas
./mvnw flyway:validate   # comprueba las sumas de comprobación
./mvnw flyway:migrate    # aplica las pendientes
./mvnw flyway:repair     # arregla la tabla de control (¡con cuidado!)

Y una prueba que evita sorpresas, integrada con Testcontainers (12-05):

@Test
void todasLasMigracionesSeAplicanSobrePostgresLimpio() {
    Flyway flyway = Flyway.configure()
            .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
            .locations("classpath:db/migration")
            .load();

    MigrateResult resultado = flyway.migrate();

    assertThat(resultado.success).isTrue();
    assertThat(resultado.migrationsExecuted).isGreaterThan(0);
    // Y que el esquema resultante coincide con lo que espera Hibernate:
    assertThatNoException().isThrownBy(() -> validarEsquemaContraEntidades());
}

  1. Despliegue en dos fases para cambios de esquema

El problema: durante una actualización progresiva conviven la versión antigua y la nueva de la aplicación contra la misma base de datos. Un cambio destructivo rompe la versión antigua antes de que termine de retirarse.

Ejemplo: renombrar prestamo.fecha_venc a prestamo.fecha_vencimiento.

Lo que NO se puede hacer:

-- V6__renombrar_columna.sql
ALTER TABLE prestamo RENAME COLUMN fecha_venc TO fecha_vencimiento;

En el instante en que se aplica, todas las instancias de la versión antigua —que siguen sirviendo peticiones— fallan con «columna inexistente».

La solución, en tres despliegues:

flowchart TD
    F1["FASE 1 · Expandir<br/>Añadir fecha_vencimiento<br/>Copiar datos + trigger de sincronía<br/>App v1 usa la vieja; ambas columnas conviven"]
    F2["FASE 2 · Migrar<br/>App v2 escribe y lee la nueva<br/>El trigger mantiene la vieja al día<br/>Vuelta atrás a v1 aún posible"]
    F3["FASE 3 · Contraer<br/>Eliminar el trigger y la columna vieja<br/>Solo cuando v1 ya no existe"]
    F1 --> F2 --> F3
-- FASE 1: V6__anadir_fecha_vencimiento.sql   (compatible con la app v1)
ALTER TABLE prestamo ADD COLUMN fecha_vencimiento DATE;

UPDATE prestamo SET fecha_vencimiento = fecha_venc WHERE fecha_vencimiento IS NULL;

-- Trigger de doble escritura: da igual qué versión de la app escriba
CREATE OR REPLACE FUNCTION sincronizar_fecha_vencimiento() RETURNS TRIGGER AS $$
BEGIN
    IF NEW.fecha_vencimiento IS DISTINCT FROM OLD.fecha_vencimiento THEN
        NEW.fecha_venc := NEW.fecha_vencimiento;
    ELSIF NEW.fecha_venc IS DISTINCT FROM OLD.fecha_venc THEN
        NEW.fecha_vencimiento := NEW.fecha_venc;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sincronizar_fecha
    BEFORE INSERT OR UPDATE ON prestamo
    FOR EACH ROW EXECUTE FUNCTION sincronizar_fecha_vencimiento();
-- FASE 3: V8__eliminar_fecha_venc.sql   (solo tras confirmar que v1 ya no corre)
DROP TRIGGER IF EXISTS trg_sincronizar_fecha ON prestamo;
DROP FUNCTION IF EXISTS sincronizar_fecha_vencimiento();
ALTER TABLE prestamo DROP COLUMN fecha_venc;
ALTER TABLE prestamo ALTER COLUMN fecha_vencimiento SET NOT NULL;

Este patrón se llama expand and contract (expandir y contraer), y la regla que lo resume es fácil de recordar:

Toda migración debe ser compatible con la versión anterior de la aplicación. Los cambios destructivos se aplican al menos un despliegue después del que dejó de necesitar lo que se elimina.

  1. Dónde se despliega: comparativa de plataformas

Plataforma Cómo funciona Control Complejidad Coste Cuándo
Servidor propio + systemd jar como servicio de Linux Total Baja Bajo 1-2 servicios, equipo pequeño
PaaS (Heroku, Render, Railway, Fly.io) Empujas código o imagen Bajo Muy baja Medio-alto Prototipos, equipos sin operaciones
Contenedores gestionados (ECS, Cloud Run, App Service) Despliegas imágenes Medio Media Medio La mayoría de aplicaciones
Kubernetes Orquestador completo Total Alta Variable Muchos servicios, escala real

Servidor propio con systemd, que sigue siendo perfectamente válido y a menudo la mejor opción:

# /etc/systemd/system/bibliotech.service
[Unit]
Description=BiblioTech - Biblioteca técnica de Nexus Software
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=bibliotech
Group=bibliotech
WorkingDirectory=/opt/bibliotech

EnvironmentFile=/etc/bibliotech/entorno      # secretos, con permisos 600
ExecStart=/usr/bin/java $JAVA_OPTS -jar /opt/bibliotech/bibliotech-web.jar

SuccessExitStatus=143                        # 128 + SIGTERM: apagado ordenado, no es fallo
Restart=on-failure
RestartSec=10

# Apagado ordenado: SIGTERM, y 60 s antes de SIGKILL
KillSignal=SIGTERM
TimeoutStopSec=60

# Endurecimiento: mínimo privilegio (12-07)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/bibliotech /var/lib/bibliotech

StandardOutput=journal
StandardError=journal
SyslogIdentifier=bibliotech

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now bibliotech
sudo systemctl status bibliotech
sudo journalctl -u bibliotech -f

Criterios de elección, sin rodeos:

Si… Elige
Tienes 1-3 servicios y un equipo pequeño Servidor propio o contenedores gestionados
No quieres gestionar infraestructura PaaS o Cloud Run
Necesitas escalar a cero cuando no hay tráfico Cloud Run, Fly.io
Tienes 20+ servicios y equipo de plataforma Kubernetes
Tienes 3 servicios y no tienes equipo de plataforma No Kubernetes

Sobre este último punto conviene ser explícito: Kubernetes es una herramienta excelente y con un coste operativo real y permanente. Adoptarlo para tres servicios porque «es lo que se usa» es una decisión que se paga cada semana en tiempo del equipo.

  1. Kubernetes: un Deployment mínimo

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bibliotech
  labels:
    app: bibliotech
spec:
  replicas: 3                       # tres instancias: alta disponibilidad
  revisionHistoryLimit: 5           # historial para vuelta atrás

  strategy:
    type: RollingUpdate             # actualización progresiva (sección 20)
    rollingUpdate:
      maxSurge: 1                   # como mucho 1 pod extra durante la transición
      maxUnavailable: 0             # NUNCA menos de 3 disponibles: sin corte de servicio

  selector:
    matchLabels:
      app: bibliotech

  template:
    metadata:
      labels:
        app: bibliotech
        version: "1.4.2"
    spec:
      # Margen para que el apagado ordenado termine (sección 18)
      terminationGracePeriodSeconds: 60

      securityContext:              # mínimo privilegio a nivel de pod
        runAsNonRoot: true
        runAsUser: 1001
        fsGroup: 1001

      containers:
        - name: bibliotech
          image: registro.nexussoftware.com/bibliotech:1.4.2   # etiqueta EXACTA, nunca 'latest'
          imagePullPolicy: IfNotPresent

          ports:
            - name: http
              containerPort: 8080

          envFrom:
            - configMapRef: { name: bibliotech-config }
            - secretRef:    { name: bibliotech-secretos }

          resources:
            requests:                # lo que el planificador reserva
              memory: "512Mi"
              cpu: "250m"
            limits:                  # el techo; superarlo en memoria = OOMKilled
              memory: "768Mi"
              cpu: "1500m"

          # --- Sondas (sección 17) ---
          startupProbe:              # protege el arranque: hasta 100 s
            httpGet: { path: /actuator/health/liveness, port: http }
            failureThreshold: 20
            periodSeconds: 5

          livenessProbe:             # ¿sigue vivo? Si no, REINICIAR
            httpGet: { path: /actuator/health/liveness, port: http }
            periodSeconds: 10
            failureThreshold: 3

          readinessProbe:            # ¿puede atender? Si no, SACAR DEL BALANCEADOR
            httpGet: { path: /actuator/health/readiness, port: http }
            periodSeconds: 5
            failureThreshold: 2

          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true       # nada escribe en la raíz
            capabilities: { drop: ["ALL"] }

          volumeMounts:
            - name: tmp
              mountPath: /tmp                  # necesario: Tomcat y los volcados escriben aquí

      volumes:
        - name: tmp
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: bibliotech
spec:
  selector:
    app: bibliotech
  ports:
    - port: 80
      targetPort: http
  type: ClusterIP
---
apiVersion: policy/v1
kind: PodDisruptionBudget           # protege durante el mantenimiento del clúster
metadata:
  name: bibliotech
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: bibliotech
kubectl apply -f k8s/
kubectl rollout status deployment/bibliotech
kubectl get pods -l app=bibliotech
kubectl logs -f -l app=bibliotech --tail=100
kubectl rollout undo deployment/bibliotech         # vuelta atrás inmediata

Dos detalles que merecen atención especial:

  • maxUnavailable: 0 garantiza que durante la actualización nunca hay menos instancias de las declaradas. Es lo que convierte un despliegue en algo invisible para los usuarios.
  • image: bibliotech:1.4.2, nunca :latest. Con latest no sabes qué se está ejecutando, la vuelta atrás no funciona y dos pods pueden acabar con versiones distintas.

  1. Arranque y salud: sondas de Actuator

Spring Boot Actuator distingue dos preguntas que parecen la misma y no lo son:

Sonda Pregunta Si falla
Liveness (vitalidad) ¿El proceso está vivo y no bloqueado? Reiniciar el contenedor
Readiness (disponibilidad) ¿Puede atender peticiones ahora? Sacarlo del balanceador, sin reiniciar

La diferencia es crítica. Si la base de datos se cae, la aplicación no está lista (readiness falla) pero está viva (liveness pasa). Reiniciarla no arreglaría nada, y reiniciar todas las instancias a la vez porque la base de datos tuvo un hipo es una forma excelente de convertir una incidencia menor en una caída total.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus     # SOLO lo necesario (12-07)
      base-path: /actuator
  endpoint:
    health:
      probes:
        enabled: true                # habilita /health/liveness y /health/readiness
      show-details: when-authorized  # el detalle, solo a quien tiene permiso
      group:
        readiness:
          include: db, diskSpace     # si la BD no responde, no estamos listos
        liveness:
          include: livenessState     # solo el estado del proceso
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
$ curl localhost:8080/actuator/health/liveness
{"status":"UP"}

$ curl localhost:8080/actuator/health/readiness
{"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"}}}

Un indicador de salud propio, para lo que solo tú sabes que es crítico:

@Component
public class SaludPasarelaMetadatos implements HealthIndicator {

    private final PasarelaMetadatos pasarela;

    @Override
    public Health health() {
        try {
            boolean disponible = pasarela.comprobarDisponibilidad();
            return disponible
                    ? Health.up().withDetail("pasarela", "disponible").build()
                    // DEGRADED, no DOWN: la aplicación funciona sin metadatos externos.
                    // Marcar DOWN aquí sacaría del balanceador una app perfectamente usable.
                    : Health.status("DEGRADED").withDetail("pasarela", "no responde").build();
        } catch (Exception e) {
            return Health.status("DEGRADED").withException(e).build();
        }
    }
}

Ese matiz —degradado en lugar de caído para las dependencias no esenciales— es lo que separa un sistema resiliente de uno que se cae entero porque un servicio secundario tuvo un problema.

  1. Apagado ordenado

Cuando el orquestador quiere parar una instancia, envía SIGTERM. Sin preparación, la JVM muere de inmediato y las peticiones en curso se cortan: usuarios con errores, transacciones a medias, mensajes sin confirmar.

server:
  shutdown: graceful            # deja de aceptar peticiones nuevas y espera a las actuales

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

La secuencia completa:

sequenceDiagram
    participant O as Orquestador
    participant K as Kubelet / Docker
    participant A as BiblioTech
    participant B as Balanceador

    O->>K: parar el pod
    K->>B: quitarlo de los endpoints
    K->>A: SIGTERM
    A->>A: readiness = DOWN
    A->>A: dejar de aceptar peticiones nuevas
    A->>A: terminar las 12 peticiones en curso
    A->>A: cerrar el pool de conexiones
    A->>A: ejecutar los shutdown hooks
    A-->>K: proceso terminado (código 143)
    Note over K,A: si tras terminationGracePeriodSeconds sigue vivo, SIGKILL

Y los ganchos de cierre, que retoman el módulo 7 y 12-03:

@Component
public class CierreOrdenado {

    private static final Logger log = LoggerFactory.getLogger(CierreOrdenado.class);

    private final ExecutorService ejecutorImportaciones;

    /**
     * @PreDestroy se ejecuta cuando el contexto de Spring se cierra,
     * lo que ocurre al recibir SIGTERM.
     */
    @PreDestroy
    public void alApagar() {
        log.info("Apagado ordenado: cerrando recursos");

        ejecutorImportaciones.shutdown();     // no acepta tareas nuevas
        try {
            if (!ejecutorImportaciones.awaitTermination(20, TimeUnit.SECONDS)) {
                log.warn("Importaciones sin terminar tras 20 s; forzando");
                ejecutorImportaciones.shutdownNow();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            ejecutorImportaciones.shutdownNow();
        }
        log.info("Apagado ordenado completado");
    }
}

Un detalle crítico y muy poco conocido: hay una ventana de carrera entre el momento en que el orquestador envía SIGTERM y el momento en que el balanceador deja de enviar tráfico. Durante ese lapso —hasta unos segundos— llegan peticiones a una instancia que ya se está apagando. La solución estándar es una espera antes de empezar el apagado:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]     # dar tiempo al balanceador a actualizarse

Y en el Dockerfile, dumb-init como PID 1: sin él, el proceso Java (que sería PID 1) no recibe las señales por defecto y todo lo anterior no sirve de nada.

  1. Tiempo de arranque: CDS y Native Image

El arranque importa en tres situaciones: escalado automático ante un pico, reinicio tras un fallo, y funciones sin servidor con escalado a cero.

Técnica Arranque de BiblioTech Coste
Jar normal ~4,5 s
CDS (archivo de clases compartidas) ~3,2 s Un paso extra en la construcción
AOT de Spring (-Dspring.aot.enabled) ~2,8 s Limita la configuración dinámica
CRaC (restaurar desde un checkpoint) ~0,3 s JVM específica, complejidad alta
GraalVM Native Image ~0,08 s Construcción de 5-10 min; reflexión declarada

CDS es la mejora más barata: memoriza el resultado de cargar y verificar las clases.

# Generar el archivo CDS durante la construcción de la imagen
RUN java -XX:ArchiveClassesAtExit=/app/app.jsa \
         -Dspring.context.exit=onRefresh \
         org.springframework.boot.loader.launch.JarLauncher

ENV JAVA_OPTS="$JAVA_OPTS -XX:SharedArchiveFile=/app/app.jsa"

GraalVM Native Image compila a un ejecutable nativo:

<profile>
  <id>native</id>
  <build>
    <plugins>
      <plugin>
        <groupId>org.graalvm.buildtools</groupId>
        <artifactId>native-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</profile>
./mvnw -Pnative native:compile -pl bibliotech-web
./bibliotech-web/target/bibliotech-web       # arranca en 80 ms
Aspecto JVM Native Image
Arranque 4,5 s 0,08 s
Memoria en reposo ~350 MB ~90 MB
Rendimiento máximo Mayor (JIT optimiza con datos reales) Menor
Tiempo de construcción 30 s 5-10 min
Reflexión dinámica Libre Debe declararse
Herramientas de diagnóstico JFR, jcmd, JMX Limitadas

Cuándo compensa Native Image: funciones sin servidor, CLI (12-03), microservicios que escalan a cero, entornos con memoria muy limitada. Cuándo no: servicios de larga vida con carga sostenida, donde el JIT acaba produciendo código más rápido que la compilación anticipada.

Para BiblioTech como API de larga vida: JVM con CDS. Para la CLI: Native Image tiene mucho sentido.

  1. Estrategias de despliegue

Estrategia Cómo funciona Corte Coste Vuelta atrás Cuándo
Recreación Parar todo, arrancar lo nuevo Bajo Redesplegar Desarrollo; apps que no toleran dos versiones
Progresiva (rolling) Sustituir instancia a instancia No Bajo Progresiva inversa Por defecto
Azul-verde Dos entornos completos; conmutar el tráfico No Doble Instantánea Cambios de riesgo
Canario Enviar el 5 % del tráfico a la nueva; ir subiendo No Medio Instantánea Cambios de alto riesgo, tráfico alto
flowchart TD
    subgraph AZUL_VERDE["Azul-verde"]
        LB1["Balanceador"] -->|"100%"| AZ["AZUL v1.4.1<br/>(en producción)"]
        LB1 -.->|"0%"| VE["VERDE v1.4.2<br/>(desplegado, probado)"]
        N1["Conmutar: el tráfico pasa a VERDE en un instante.<br/>AZUL se conserva encendido por si hay que volver."]
    end

    subgraph CANARIO["Canario"]
        LB2["Balanceador"] -->|"95%"| E1["v1.4.1 (9 instancias)"]
        LB2 -->|"5%"| E2["v1.4.2 (1 instancia)"]
        N2["Vigilar errores y latencia.<br/>Si todo va bien: 25%, 50%, 100%.<br/>Si no: volver a 0% de inmediato."]
    end

Banderas de funcionalidad (feature flags). Son el complemento que cambia el juego, porque separan el despliegue de la activación:

@Service
public class GestorPrestamos {

    private final PropiedadesBiblioTech props;

    public Prestamo prestar(Isbn isbn, Long idEmpleado, Integer dias) {
        if (props.funcionalidades().reservaAutomatica()) {
            // Código nuevo, desplegado pero desactivado hasta que se decida
            return prestarConReservaAutomatica(isbn, idEmpleado, dias);
        }
        return prestarClasico(isbn, idEmpleado, dias);
    }
}
bibliotech:
  funcionalidades:
    reserva-automatica: false          # se activa con una variable de entorno, sin desplegar
    prestamo-prioritario: true
    informe-pdf: false

Con banderas, desactivar una funcionalidad problemática es un cambio de configuración de segundos, no un despliegue de vuelta atrás de minutos. Y permiten desplegar código incompleto sin riesgo, lo que a su vez permite integrar a diario en lugar de mantener ramas largas (12-01).

El precio: cada bandera es una rama más que probar, y las banderas olvidadas se acumulan. Se retiran en cuanto la decisión es definitiva.

  1. Vuelta atrás y el límite de la base de datos

Volver atrás el código es fácil:

kubectl rollout undo deployment/bibliotech
docker compose up -d --force-recreate   # con la etiqueta anterior
sudo systemctl stop bibliotech && cp bibliotech-1.4.1.jar bibliotech-web.jar && sudo systemctl start bibliotech

Volver atrás la base de datos casi nunca lo es, y esta es la razón:

Cambio ¿Reversible? Por qué
ADD COLUMN (nullable) La versión antigua la ignora
CREATE TABLE Nadie la usa
CREATE INDEX Solo afecta al rendimiento
ADD COLUMN NOT NULL sin default No La versión antigua no la rellena al insertar
DROP COLUMN No Los datos se han perdido
RENAME COLUMN No La versión antigua busca el nombre viejo
ALTER TYPE con pérdida No Los datos truncados no vuelven
Migración de datos Depende Solo si se guardó el estado anterior

De ahí la regla del despliegue seguro:

La base de datos siempre va por delante y siempre es compatible hacia atrás. Primero se despliega la migración compatible; después, el código que la usa. Nunca al revés, y nunca las dos cosas en el mismo paso si el cambio es destructivo.

Con el patrón expandir-contraer (sección 14), la vuelta atrás funciona en las fases 1 y 2. En la fase 3, ya no: por eso la fase 3 se aplica días después, cuando la nueva versión está confirmada.

Copias de seguridad — y la parte que casi nadie hace:

# Copia diaria
pg_dump -h db -U bibliotech -Fc bibliotech > bibliotech-$(date +%F).dump

# Copia previa a CUALQUIER migración destructiva
pg_dump -h db -U bibliotech -Fc bibliotech > pre-migracion-v8-$(date +%F-%H%M).dump

Nota. Una copia de seguridad que nunca se ha restaurado no es una copia de seguridad: es un fichero con esperanzas. Programa una restauración de prueba periódica en un entorno aparte y mide cuánto tarda. Ese tiempo es tu RTO (tiempo objetivo de recuperación) real, y suele ser mucho mayor de lo que la gente supone. Igual de importante es el RPO (punto objetivo de recuperación): con copias diarias, puedes perder hasta 24 horas de datos. Si eso es inaceptable, necesitas archivado de WAL o replicación.

  1. Canalización de entrega continua

La CI de 12-05 verificaba. La entrega continua además construye la imagen, la publica y la despliega.

flowchart LR
    A["Push a main"] --> B["CI: pruebas<br/>cobertura, análisis"]
    B --> C["Construir imagen<br/>multiarquitectura"]
    C --> D["Publicar en el<br/>registro"]
    D --> E["Desplegar en<br/>preproducción"]
    E --> F["Pruebas de humo"]
    F --> G{"Aprobación<br/>manual"}
    G -->|"aprobado"| H["Desplegar en<br/>producción"]
    H --> I["Verificar salud"]
    I -->|"fallo"| J["Vuelta atrás<br/>automática"]

    style G fill:#fff3e0,stroke:#e65100
    style J fill:#ffebee,stroke:#c62828
# .github/workflows/cd.yml
name: Entrega continua

on:
  push:
    branches: [main]
    tags: ['v*']

env:
  REGISTRO: ghcr.io
  IMAGEN: ${{ github.repository }}

permissions:
  contents: read
  packages: write
  id-token: write          # para firmar la imagen con cosign

jobs:

  # ---------------------------------------------------------------
  # 1. Reutiliza la verificación completa de 12-05
  # ---------------------------------------------------------------
  verificar:
    uses: ./.github/workflows/ci.yml

  # ---------------------------------------------------------------
  # 2. Construir y publicar la imagen
  # ---------------------------------------------------------------
  imagen:
    name: Construir y publicar imagen
    runs-on: ubuntu-latest
    needs: verificar
    outputs:
      digest: ${{ steps.construir.outputs.digest }}
      etiquetas: ${{ steps.meta.outputs.tags }}

    steps:
      - uses: actions/checkout@v4

      - name: Configurar QEMU
        uses: docker/setup-qemu-action@v3        # para construir para arm64

      - name: Configurar Buildx
        uses: docker/setup-buildx-action@v3

      - name: Autenticarse en el registro
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRO }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Calcular etiquetas y metadatos
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRO }}/${{ env.IMAGEN }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=,format=short          # trazabilidad al commit
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Construir y publicar
        id: construir
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha                    # caché de capas entre ejecuciones
          cache-to: type=gha,mode=max
          provenance: true
          sbom: true                              # inventario de componentes (12-07)

      - name: Analizar vulnerabilidades de la imagen
        uses: aquasecurity/[email protected]
        with:
          image-ref: ${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.construir.outputs.digest }}
          severity: 'CRITICAL,HIGH'
          exit-code: '1'                          # bloquea si hay vulnerabilidades graves

      - name: Firmar la imagen
        uses: sigstore/cosign-installer@v3
      - run: cosign sign --yes ${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.construir.outputs.digest }}

  # ---------------------------------------------------------------
  # 3. Preproducción: automático
  # ---------------------------------------------------------------
  preproduccion:
    name: Desplegar en preproducción
    runs-on: ubuntu-latest
    needs: imagen
    environment:
      name: preproduccion
      url: https://preproduccion.bibliotech.nexussoftware.com

    steps:
      - name: Desplegar
        run: |
          kubectl set image deployment/bibliotech \
            bibliotech=${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ needs.imagen.outputs.digest }} \
            --namespace=preproduccion
          kubectl rollout status deployment/bibliotech -n preproduccion --timeout=5m

      - name: Pruebas de humo
        run: |
          BASE=https://preproduccion.bibliotech.nexussoftware.com
          curl -fsS "$BASE/actuator/health/readiness" | jq -e '.status == "UP"'
          curl -fsS "$BASE/api/materiales?size=1"     | jq -e '.contenido | length >= 0'
          echo "Pruebas de humo correctas"

  # ---------------------------------------------------------------
  # 4. Producción: requiere aprobación manual
  # ---------------------------------------------------------------
  produccion:
    name: Desplegar en producción
    runs-on: ubuntu-latest
    needs: [imagen, preproduccion]
    if: startsWith(github.ref, 'refs/tags/v')     # solo desde una etiqueta de versión
    environment:
      name: produccion                            # con revisores obligatorios configurados
      url: https://bibliotech.nexussoftware.com

    steps:
      - name: Guardar la revisión actual, por si hay que volver
        id: actual
        run: |
          ACTUAL=$(kubectl get deployment/bibliotech -n produccion \
                   -o jsonpath='{.spec.template.spec.containers[0].image}')
          echo "imagen_anterior=$ACTUAL" >> $GITHUB_OUTPUT

      - name: Desplegar (actualización progresiva)
        run: |
          kubectl set image deployment/bibliotech \
            bibliotech=${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ needs.imagen.outputs.digest }} \
            --namespace=produccion
          kubectl rollout status deployment/bibliotech -n produccion --timeout=10m

      - name: Verificar salud tras el despliegue
        id: verificar
        run: |
          sleep 30
          BASE=https://bibliotech.nexussoftware.com
          for i in $(seq 1 10); do
            if curl -fsS "$BASE/actuator/health/readiness" | jq -e '.status == "UP"' > /dev/null; then
              echo "Instancia sana (intento $i)"; sleep 5
            else
              echo "::error::Verificación de salud fallida"; exit 1
            fi
          done
          # Comprobar que la tasa de errores no se ha disparado
          ERRORES=$(curl -fsS "$BASE/actuator/metrics/http.server.requests?tag=outcome:SERVER_ERROR" \
                    | jq '.measurements[0].value // 0')
          if (( $(echo "$ERRORES > 10" | bc -l) )); then
            echo "::error::Demasiados errores 5xx tras el despliegue"; exit 1
          fi

      - name: Vuelta atrás automática si algo falló
        if: failure()
        run: |
          echo "::warning::Despliegue fallido; volviendo a la versión anterior"
          kubectl rollout undo deployment/bibliotech -n produccion
          kubectl rollout status deployment/bibliotech -n produccion --timeout=5m

      - name: Notificar el resultado
        if: always()
        run: |
          ESTADO="${{ job.status }}"
          curl -X POST "${{ secrets.WEBHOOK_EQUIPO }}" \
            -H 'Content-Type: application/json' \
            -d "{\"texto\":\"Despliegue de BiblioTech ${{ github.ref_name }}: $ESTADO\"}"

Puntos clave de esta canalización:

Decisión Motivo
Desplegar por digest, no por etiqueta Una etiqueta se puede mover; un digest identifica bytes exactos
Análisis de vulnerabilidades bloqueante No publicar una imagen con CVE críticos (12-07)
Firma con cosign Verificable: esta imagen la construyó nuestra canalización
Preproducción automático, producción manual Velocidad donde no hay riesgo, control donde sí
environment con revisores La aprobación es del sistema, no un mensaje en un chat
Verificación de salud + vuelta atrás automática Un despliegue fallido se revierte en 2 minutos, sin humanos
Solo desde etiqueta v* en producción Todo despliegue de producción tiene una versión identificable

  1. Escalado horizontal y qué exige de la aplicación

Escalar verticalmente es dar más recursos a una instancia; escalar horizontalmente, tener más instancias. La segunda es lo que da alta disponibilidad y escala sin límite superior, pero exige propiedades de la aplicación.

Requisitos, todos ellos verificables:

Requisito Por qué Estado de BiblioTech
Sin estado en memoria La petición 2 puede ir a otra instancia ✅ Nada en memoria desde 12-01
Sesión compartida o sin sesión Idem ✅ API sin estado; JWT en 12-07
Sin ficheros locales Cada instancia tiene su disco ⚠️ Revisar exportaciones
Tareas programadas coordinadas Tres instancias = tres ejecuciones del mismo @Scheduled ⚠️ Pendiente
Caché distribuida o local coherente Cachés locales divergen ⚠️ Revisar CatalogoConCache
Migraciones con bloqueo Tres instancias arrancando a la vez ✅ Flyway usa bloqueo

Aquí se retoma la sesión del módulo 7. Cualquier estado que hoy viva en memoria —el Map de sesiones, la caché local, la pila de deshacer de la CLI— deja de funcionar con más de una instancia. La solución no es «no escalar», sino sacar ese estado a un lugar compartido: base de datos, Redis, o eliminarlo por diseño.

El problema de las tareas programadas, que es el más frecuente y el que más sorprende:

// CON 3 INSTANCIAS: esto envía TRES avisos a cada empleado, todos los días
@Scheduled(cron = "0 0 8 * * *")
public void enviarAvisosDiarios() {
    servicioAvisos.avisarVencimientosProximos(3);
}

Soluciones, de menor a mayor robustez:

// Opción A: ShedLock — bloqueo distribuido en la base de datos
@Scheduled(cron = "0 0 8 * * *")
@SchedulerLock(name = "avisosDiarios", lockAtMostFor = "10m", lockAtLeastFor = "1m")
public void enviarAvisosDiarios() {
    servicioAvisos.avisarVencimientosProximos(3);
}
# Opción B: un CronJob de Kubernetes que invoca la CLI de 12-03.
# Ventaja: el planificador no vive en la aplicación.
apiVersion: batch/v1
kind: CronJob
metadata:
  name: bibliotech-avisos
spec:
  schedule: "0 8 * * *"
  concurrencyPolicy: Forbid           # no solapar ejecuciones
  jobTemplate:
    spec:
      backoffLimit: 3
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: cli
              image: registro.nexussoftware.com/bibliotech-cli:1.4.2
              args: ["avisos", "enviar", "--dias-antelacion=3"]
              envFrom:
                - secretRef: { name: bibliotech-secretos }

Y el escalado automático, que en Kubernetes es declarativo:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: bibliotech
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: bibliotech
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300     # no reducir a la primera bajada: evita oscilación

Con un aviso importante: escalar la aplicación no escala la base de datos. Diez instancias con 20 conexiones cada una son 200 conexiones a PostgreSQL, que por defecto acepta 100. Ajusta el pool o pon un pgbouncer delante. El cuello de botella se mueve; no desaparece.

Errores Comunes y Consejos

1. Usar la etiqueta latest en producción. No sabes qué se está ejecutando, la vuelta atrás no funciona y dos instancias pueden acabar con versiones distintas. Etiquetas semánticas o digests.

2. Ejecutar el contenedor como root. Es el valor por defecto y es una vulnerabilidad gratuita. USER no root, siempre.

3. Meter secretos en la imagen. Quedan en el historial de capas; docker history los revela. Variables de entorno o gestores de secretos.

4. No limitar la memoria del contenedor. Un proceso Java puede consumir toda la del anfitrión y tumbar a los vecinos.

5. Limitar la memoria sin ajustar MaxRAMPercentage. El 25 % por defecto desperdicia recursos, y no contar la memoria fuera del heap causa OOMKilled sin ningún mensaje de la JVM.

6. ddl-auto: update en producción. No borra, no renombra, no versiona, no es reversible y falla con varias instancias arrancando a la vez. Flyway.

7. Modificar una migración ya aplicada. Flyway detecta el cambio de suma de comprobación y la aplicación no arranca. Para corregir, un script nuevo.

8. No probar la vuelta atrás. Es lo que necesitas exactamente el día que todo va mal. Pruébala en preproducción, cronometrada.

9. Sondas mal configuradas. Confundir liveness y readiness hace que la aplicación se reinicie en bucle cuando la base de datos tiene un problema pasajero, convirtiendo una incidencia menor en una caída total.

10. Olvidar dumb-init o exec. Sin ellos, SIGTERM no llega a la JVM y el apagado ordenado no ocurre: peticiones cortadas en cada despliegue.

11. Tareas programadas sin coordinar. Con tres instancias, tres correos a cada empleado. ShedLock o un CronJob externo.

12. Copias de seguridad que nunca se han restaurado. No son copias de seguridad. Prueba la restauración y cronométrala.

Consejo final: la mejor medida de la calidad de un despliegue es cuánto se tarda en volver atrás. Si son treinta segundos, desplegarás a menudo y con tranquilidad. Si son dos horas, desplegarás poco, en lotes grandes y con miedo — que es precisamente lo que hace que los despliegues salgan mal.

Ejercicios

Ejercicio 1: Dockerfile para la CLI

Escribe el Dockerfile multietapa del módulo bibliotech-consola (12-03), teniendo en cuenta que:

  • La CLI se ejecuta y termina: no es un servicio de larga vida.
  • Debe arrancar lo más rápido posible (se invoca desde cron).
  • No necesita puerto expuesto ni comprobación de salud.
  • Debe aceptar argumentos: docker run bibliotech-cli catalogo listar --formato=json.
  • Debe ser usable como imagen de un CronJob de Kubernetes.
  • Optimiza las opciones de la JVM para arranque, no para rendimiento sostenido.

Incluye además el CronJob de Kubernetes que envía los avisos diarios.

Ejercicio 2: migración en dos fases

BiblioTech debe partir el campo empleado.nombre (que hoy contiene «Marta Ruiz») en nombre y apellidos, sin corte de servicio y con vuelta atrás posible en todo momento.

Escribe:

  • Los scripts de Flyway de las tres fases.
  • Qué versión de la aplicación acompaña a cada fase y qué hace su código.
  • El plan de despliegue con los puntos donde la vuelta atrás es posible y dónde deja de serlo.
  • Una prueba que verifique que la migración es correcta con datos reales, incluidos los casos difíciles (un solo nombre, apellidos compuestos, nombres con partículas).

Ejercicio 3: canalización con azul-verde

Escribe el flujo de GitHub Actions que despliegue BiblioTech con estrategia azul-verde en Kubernetes:

  • Determinar cuál es el color activo actualmente.
  • Desplegar la versión nueva en el color inactivo.
  • Ejecutar pruebas de humo contra el color inactivo, sin tráfico real.
  • Conmutar el tráfico cambiando el selector del Service.
  • Vigilar cinco minutos y volver atrás automáticamente si la tasa de errores sube.
  • Dejar el color anterior encendido una hora antes de retirarlo.

Incluye los manifiestos de Kubernetes necesarios.


Soluciones

Solución 1

# ==============================================================================
# ETAPA 1: CONSTRUCCIÓN
# ==============================================================================
FROM eclipse-temurin:21-jdk-alpine AS constructor

WORKDIR /build

COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY bibliotech-dominio/pom.xml          bibliotech-dominio/
COPY bibliotech-aplicacion/pom.xml       bibliotech-aplicacion/
COPY bibliotech-infraestructura/pom.xml  bibliotech-infraestructura/
COPY bibliotech-consola/pom.xml          bibliotech-consola/

RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -B -pl bibliotech-consola -am dependency:go-offline -DskipTests

COPY bibliotech-dominio/src          bibliotech-dominio/src
COPY bibliotech-aplicacion/src       bibliotech-aplicacion/src
COPY bibliotech-infraestructura/src  bibliotech-infraestructura/src
COPY bibliotech-consola/src          bibliotech-consola/src

RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -B -pl bibliotech-consola -am clean package -DskipTests

RUN java -Djarmode=tools -jar bibliotech-consola/target/bibliotech-cli.jar \
         extract --layers --launcher --destination extraido

# ==============================================================================
# ETAPA 2: GENERAR EL ARCHIVO CDS
# Se ejecuta la aplicación una vez con --help para que cargue las clases,
# y se memoriza el resultado. Esto recorta ~1,5 s de cada arranque, que
# multiplicado por las ejecuciones de cron sí importa.
# ==============================================================================
FROM eclipse-temurin:21-jre-alpine AS cds

WORKDIR /app
COPY --from=constructor /build/extraido/ ./

RUN java -XX:ArchiveClassesAtExit=/app/cli.jsa \
         org.springframework.boot.loader.launch.JarLauncher --help > /dev/null 2>&1 || true

# ==============================================================================
# ETAPA 3: EJECUCIÓN
# ==============================================================================
FROM eclipse-temurin:21-jre-alpine AS ejecucion

LABEL org.opencontainers.image.title="BiblioTech CLI" \
      org.opencontainers.image.description="Herramienta de línea de comandos de BiblioTech" \
      org.opencontainers.image.vendor="Nexus Software"

# tzdata para las fechas; dumb-init para que Ctrl+C y SIGTERM lleguen al proceso
# (la cancelación limpia de 12-03 depende de esto).
# NO se instala curl: no hay healthcheck que hacer.
RUN apk add --no-cache tzdata dumb-init && rm -rf /var/cache/apk/*
ENV TZ=Europe/Madrid

RUN addgroup -S -g 1001 bibliotech && \
    adduser -S -u 1001 -G bibliotech -h /app bibliotech

WORKDIR /app

COPY --from=cds --chown=bibliotech:bibliotech /app/ ./

USER bibliotech

# Opciones ORIENTADAS AL ARRANQUE, no al rendimiento sostenido.
# El proceso vive segundos: compilar a fondo con C2 no se amortiza nunca.
#   TieredStopAtLevel=1    solo C1: compilación rápida y ligera
#   UseSerialGC            el GC más barato de inicializar (una sola tarea, poca memoria)
#   SharedArchiveFile      usa el CDS de la etapa 2
#   MaxRAMPercentage=75    consciente del cgroup, como siempre
ENV JAVA_OPTS="\
    -XX:TieredStopAtLevel=1 \
    -XX:+UseSerialGC \
    -XX:SharedArchiveFile=/app/cli.jsa \
    -XX:MaxRAMPercentage=75.0 \
    -Xshare:auto \
    -Dspring.main.banner-mode=off \
    -Dfile.encoding=UTF-8"

# SIN EXPOSE: la CLI no escucha en ningún puerto.
# SIN HEALTHCHECK: el proceso termina; no hay nada que vigilar.

ENTRYPOINT ["dumb-init", "--", "sh", "-c", \
            "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher \"$@\"", "--"]

# CMD son los argumentos POR DEFECTO, sustituibles al ejecutar
CMD ["--help"]
docker build -f Dockerfile.cli -t bibliotech-cli:1.4.2 .

docker run --rm bibliotech-cli:1.4.2 catalogo listar --formato=json
docker run --rm -e BIBLIOTECH_DB_URL=… bibliotech-cli:1.4.2 avisos enviar --dias-antelacion=3

# Comprobar el efecto del CDS
docker run --rm bibliotech-cli:1.4.2 --version   # ~1,1 s en lugar de ~2,6 s

El ENTRYPOINT merece explicación, porque es la parte que más se atasca: la forma con sh -c y "$@" permite a la vez expandir $JAVA_OPTS y recibir los argumentos del usuario. El -- final es el $0 del script, sin el cual el primer argumento del usuario se perdería.

El CronJob de Kubernetes:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: bibliotech-avisos-diarios
  labels:
    app: bibliotech
    componente: tareas-programadas
spec:
  schedule: "0 8 * * 1-5"           # de lunes a viernes a las 8:00
  timeZone: "Europe/Madrid"         # Kubernetes 1.27+: fundamental con horario de verano

  concurrencyPolicy: Forbid         # si la anterior sigue corriendo, NO lanzar otra
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  startingDeadlineSeconds: 600      # si el clúster estaba caído, hay 10 min de margen

  jobTemplate:
    spec:
      backoffLimit: 2               # 2 reintentos ante fallo
      activeDeadlineSeconds: 900    # matar si supera 15 minutos
      ttlSecondsAfterFinished: 86400

      template:
        metadata:
          labels:
            app: bibliotech
            tarea: avisos
        spec:
          restartPolicy: OnFailure

          securityContext:
            runAsNonRoot: true
            runAsUser: 1001

          containers:
            - name: cli
              image: registro.nexussoftware.com/bibliotech-cli:1.4.2
              imagePullPolicy: IfNotPresent

              args:
                - "avisos"
                - "enviar"
                - "--dias-antelacion=3"
                - "--silencioso"       # sin decoración: la salida va al log

              envFrom:
                - configMapRef: { name: bibliotech-config }
                - secretRef:    { name: bibliotech-secretos }

              resources:
                requests: { memory: "256Mi", cpu: "100m" }
                limits:   { memory: "512Mi", cpu: "1000m" }

              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities: { drop: ["ALL"] }

              volumeMounts:
                - name: tmp
                  mountPath: /tmp

          volumes:
            - name: tmp
              emptyDir: {}

Y aquí es donde se cobra el diseño de 12-03: los códigos de salida gobiernan el comportamiento de Kubernetes. El código 0 (correcto) y el 3 (nada que enviar) marcan el Job como exitoso; el 7 (correo no disponible) lo marca como fallido y backoffLimit: 2 reintenta automáticamente. Sin esos códigos diferenciados, o se reintentaría siempre o nunca.

kubectl get cronjob bibliotech-avisos-diarios
kubectl create job --from=cronjob/bibliotech-avisos-diarios prueba-manual   # ejecutar ya
kubectl logs job/prueba-manual

Solución 2

Fase 1 — Expandir. Migración compatible con la aplicación v1.4.x, que sigue usando nombre.

-- V9__partir_nombre_empleado_fase1.sql

ALTER TABLE empleado ADD COLUMN nombre_pila VARCHAR(80);
ALTER TABLE empleado ADD COLUMN apellidos   VARCHAR(120);

-- Poblar con una heurística conservadora:
-- la PRIMERA palabra es el nombre; el resto, los apellidos.
-- Es imperfecta para nombres compuestos ("José María"), y por eso
-- se conserva la columna original y se genera un informe de revisión.
UPDATE empleado
SET nombre_pila = split_part(trim(nombre), ' ', 1),
    apellidos   = NULLIF(trim(substring(trim(nombre) from position(' ' in trim(nombre)) + 1)), '')
WHERE nombre_pila IS NULL;

-- Caso especial: un solo término (sin espacios) → todo es nombre
UPDATE empleado
SET nombre_pila = trim(nombre), apellidos = NULL
WHERE position(' ' in trim(nombre)) = 0;

-- Trigger de sincronía bidireccional: da igual qué versión de la app escriba
CREATE OR REPLACE FUNCTION sincronizar_nombre_empleado() RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        IF NEW.nombre_pila IS NULL AND NEW.nombre IS NOT NULL THEN
            -- Escribió la app v1 (columna antigua): derivar las nuevas
            NEW.nombre_pila := split_part(trim(NEW.nombre), ' ', 1);
            NEW.apellidos   := NULLIF(trim(substring(trim(NEW.nombre)
                                    from position(' ' in trim(NEW.nombre)) + 1)), '');
        ELSIF NEW.nombre IS NULL AND NEW.nombre_pila IS NOT NULL THEN
            -- Escribió la app v2 (columnas nuevas): derivar la antigua
            NEW.nombre := trim(NEW.nombre_pila || ' ' || COALESCE(NEW.apellidos, ''));
        END IF;
    ELSIF TG_OP = 'UPDATE' THEN
        IF NEW.nombre IS DISTINCT FROM OLD.nombre THEN
            NEW.nombre_pila := split_part(trim(NEW.nombre), ' ', 1);
            NEW.apellidos   := NULLIF(trim(substring(trim(NEW.nombre)
                                    from position(' ' in trim(NEW.nombre)) + 1)), '');
        ELSIF NEW.nombre_pila IS DISTINCT FROM OLD.nombre_pila
           OR NEW.apellidos   IS DISTINCT FROM OLD.apellidos THEN
            NEW.nombre := trim(NEW.nombre_pila || ' ' || COALESCE(NEW.apellidos, ''));
        END IF;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sincronizar_nombre
    BEFORE INSERT OR UPDATE ON empleado
    FOR EACH ROW EXECUTE FUNCTION sincronizar_nombre_empleado();

-- Vista de revisión manual: los casos que la heurística probablemente falló
CREATE OR REPLACE VIEW v_empleados_revision_nombre AS
SELECT id, nombre, nombre_pila, apellidos,
       CASE
         WHEN nombre_pila IN ('José','Jose','María','Maria','Juan','Ana','Luis','Francisco')
              AND apellidos LIKE '% %'                    THEN 'posible nombre compuesto'
         WHEN apellidos ~ '^(de|del|la|las|los|van|von|di|da) ' THEN 'apellido con partícula'
         WHEN apellidos IS NULL                           THEN 'sin apellidos'
         ELSE 'revisar'
       END AS motivo
FROM empleado
WHERE nombre_pila IN ('José','Jose','María','Maria','Juan','Ana','Luis','Francisco')
   OR apellidos ~ '^(de|del|la|las|los|van|von|di|da) '
   OR apellidos IS NULL;

Fase 2 — Migrar. La aplicación v1.5.0 usa las columnas nuevas.

@Entity
public class Empleado {

    @Column(name = "nombre_pila", length = 80)
    private String nombrePila;

    @Column(name = "apellidos", length = 120)
    private String apellidos;

    /**
     * La columna antigua sigue existiendo y la mantiene el trigger.
     * insertable/updatable a false: JPA NUNCA la escribe.
     * Se conserva mapeada solo para poder leerla si hiciera falta.
     */
    @Column(name = "nombre", insertable = false, updatable = false)
    private String nombreCompletoHeredado;

    public String nombreCompleto() {
        return apellidos == null ? nombrePila : nombrePila + " " + apellidos;
    }
}

Fase 3 — Contraer. Solo cuando v1.4.x ya no existe en ningún entorno.

-- V11__partir_nombre_empleado_fase3.sql

-- Verificación previa: si algo quedó incoherente, ABORTAR
DO $$
DECLARE incoherentes INTEGER;
BEGIN
    SELECT count(*) INTO incoherentes
    FROM empleado
    WHERE nombre_pila IS NULL
       OR trim(nombre) IS DISTINCT FROM trim(nombre_pila || ' ' || COALESCE(apellidos, ''));

    IF incoherentes > 0 THEN
        RAISE EXCEPTION 'Hay % empleados con nombre incoherente. Revisa v_empleados_revision_nombre antes de contraer.', incoherentes;
    END IF;
END $$;

DROP TRIGGER IF EXISTS trg_sincronizar_nombre ON empleado;
DROP FUNCTION IF EXISTS sincronizar_nombre_empleado();
DROP VIEW IF EXISTS v_empleados_revision_nombre;

ALTER TABLE empleado ALTER COLUMN nombre_pila SET NOT NULL;
ALTER TABLE empleado DROP COLUMN nombre;

CREATE INDEX idx_empleado_apellidos ON empleado(apellidos, nombre_pila);

Plan de despliegue con los puntos de no retorno:

Paso Acción Vuelta atrás Duración
1 Copia de seguridad completa 10 min
2 Aplicar V9 (fase 1) : eliminar columnas y trigger 2 min
3 Verificar la vista de revisión y corregir a mano 1-2 h
4 Desplegar la app v1.5.0 : volver a v1.4.x 5 min
5 Vigilar 48 horas 2 días
6 Aplicar V11 (fase 3) NO. Punto de no retorno 1 min

Prueba de la migración con datos difíciles:

@Tag("integracion")
class MigracionNombreEmpleadoIT extends PruebaConPostgres {

    @Test
    void particionaCorrectamenteLosNombresConocidos() {
        // Estado inicial: hasta V8, con la columna antigua
        flywayHasta("8");
        jdbc.update("""
                insert into empleado (nombre, correo, fecha_alta) values
                    ('Marta Ruiz',              '[email protected]',  '2024-03-01'),
                    ('Diego Alonso',            '[email protected]',  '2025-01-15'),
                    ('Nuria Vidal',             '[email protected]',  '2023-09-10'),
                    ('José María Pérez Gómez',  '[email protected]',   '2022-05-20'),
                    ('Ana de la Torre',         '[email protected]',    '2021-11-02'),
                    ('Prince',                  '[email protected]', '2020-01-01')
                """);

        flywayHasta("9");     // aplicar la fase 1

        assertThat(consultar("[email protected]"))
                .containsExactly("Marta", "Ruiz");
        assertThat(consultar("[email protected]"))
                .containsExactly("Nuria", "Vidal");

        // Casos difíciles: la heurística los divide mal, y eso es ESPERADO
        assertThat(consultar("[email protected]"))
                .containsExactly("José", "María Pérez Gómez");     // requiere revisión manual
        assertThat(consultar("[email protected]"))
                .containsExactly("Ana", "de la Torre");

        // Un solo término
        assertThat(consultar("[email protected]"))
                .containsExactly("Prince", null);

        // Y todos aparecen en la vista de revisión, que es lo importante:
        // la migración no pretende acertar siempre, pretende NO PERDER DATOS
        // y señalar lo que hay que revisar.
        assertThat(jdbc.queryForList("select correo from v_empleados_revision_nombre", String.class))
                .contains("[email protected]", "[email protected]",
                          "[email protected]");
    }

    @Test
    void elTriggerSincronizaEnAmbasDirecciones() {
        flywayHasta("9");

        // La app v1 escribe la columna antigua
        jdbc.update("insert into empleado (nombre, correo, fecha_alta) values (?,?,?)",
                    "Carlos Sanz", "[email protected]", Date.valueOf("2026-01-01"));
        assertThat(consultar("[email protected]")).containsExactly("Carlos", "Sanz");

        // La app v2 escribe las columnas nuevas
        jdbc.update("""
                insert into empleado (nombre_pila, apellidos, correo, fecha_alta)
                values (?,?,?,?)""",
                "Elena", "Ferrer Rico", "[email protected]", Date.valueOf("2026-01-02"));
        assertThat(jdbc.queryForObject(
                "select nombre from empleado where correo = ?", String.class,
                "[email protected]"))
                .isEqualTo("Elena Ferrer Rico");
    }

    @Test
    void laFase3AbortaSiQuedanIncoherencias() {
        flywayHasta("9");
        // Provocar una incoherencia saltándose el trigger
        jdbc.update("alter table empleado disable trigger trg_sincronizar_nombre");
        jdbc.update("insert into empleado (nombre, correo, fecha_alta) values (?,?,?)",
                    "Sin Partir", "[email protected]", Date.valueOf("2026-01-03"));
        jdbc.update("alter table empleado enable trigger trg_sincronizar_nombre");

        assertThatThrownBy(() -> flywayHasta("11"))
                .hasMessageContaining("nombre incoherente");

        // Y lo más importante: la columna antigua SIGUE AHÍ. Nada se ha perdido.
        assertThat(existeColumna("empleado", "nombre")).isTrue();
    }
}

Solución 3

Manifiestos de Kubernetes:

# k8s/azul-verde/service.yaml
# El Service apunta a UN color. Conmutar = cambiar el selector.
apiVersion: v1
kind: Service
metadata:
  name: bibliotech
  labels: { app: bibliotech }
spec:
  selector:
    app: bibliotech
    color: azul                  # ← esto es lo que cambia en la conmutación
  ports:
    - port: 80
      targetPort: 8080
---
# Service auxiliar para probar el color inactivo SIN tráfico real
apiVersion: v1
kind: Service
metadata:
  name: bibliotech-preview
spec:
  selector:
    app: bibliotech
    color: verde                 # se ajusta antes de las pruebas de humo
  ports:
    - port: 80
      targetPort: 8080
---
# k8s/azul-verde/deployment-plantilla.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bibliotech-COLOR
  labels: { app: bibliotech, color: COLOR }
spec:
  replicas: 3
  selector:
    matchLabels: { app: bibliotech, color: COLOR }
  template:
    metadata:
      labels: { app: bibliotech, color: COLOR }
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: bibliotech
          image: IMAGEN
          ports: [{ name: http, containerPort: 8080 }]
          envFrom:
            - configMapRef: { name: bibliotech-config }
            - secretRef:    { name: bibliotech-secretos }
          resources:
            requests: { memory: "512Mi", cpu: "250m" }
            limits:   { memory: "768Mi", cpu: "1500m" }
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: http }
            failureThreshold: 20
            periodSeconds: 5
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: http }
            periodSeconds: 5

El flujo de trabajo:

# .github/workflows/cd-azul-verde.yml
name: Despliegue azul-verde

on:
  push:
    tags: ['v*']

env:
  NAMESPACE: produccion
  REGISTRO: ghcr.io
  IMAGEN: ${{ github.repository }}

jobs:

  # ---------------------------------------------------------------
  # 1. Determinar los colores
  # ---------------------------------------------------------------
  colores:
    runs-on: ubuntu-latest
    outputs:
      activo:   ${{ steps.detectar.outputs.activo }}
      inactivo: ${{ steps.detectar.outputs.inactivo }}
    steps:
      - name: Configurar kubectl
        uses: azure/k8s-set-context@v4
        with:
          kubeconfig: ${{ secrets.KUBECONFIG }}

      - name: Detectar el color activo
        id: detectar
        run: |
          ACTIVO=$(kubectl get service bibliotech -n $NAMESPACE \
                   -o jsonpath='{.spec.selector.color}')
          if [ "$ACTIVO" = "azul" ]; then INACTIVO="verde"; else INACTIVO="azul"; fi
          echo "activo=$ACTIVO"     >> $GITHUB_OUTPUT
          echo "inactivo=$INACTIVO" >> $GITHUB_OUTPUT
          echo "::notice::Activo: $ACTIVO — se desplegará en: $INACTIVO"

  # ---------------------------------------------------------------
  # 2. Desplegar en el color inactivo (sin tráfico)
  # ---------------------------------------------------------------
  desplegar-inactivo:
    runs-on: ubuntu-latest
    needs: colores
    steps:
      - uses: actions/checkout@v4
      - uses: azure/k8s-set-context@v4
        with: { kubeconfig: '${{ secrets.KUBECONFIG }}' }

      - name: Generar y aplicar el Deployment del color inactivo
        run: |
          COLOR=${{ needs.colores.outputs.inactivo }}
          IMG=${{ env.REGISTRO }}/${{ env.IMAGEN }}:${{ github.ref_name }}

          sed -e "s|COLOR|$COLOR|g" -e "s|IMAGEN|$IMG|g" \
              k8s/azul-verde/deployment-plantilla.yaml | kubectl apply -n $NAMESPACE -f -

          kubectl rollout status deployment/bibliotech-$COLOR -n $NAMESPACE --timeout=10m

      - name: Apuntar el Service de previsualización al color inactivo
        run: |
          kubectl patch service bibliotech-preview -n $NAMESPACE \
            -p '{"spec":{"selector":{"app":"bibliotech","color":"${{ needs.colores.outputs.inactivo }}"}}}'

  # ---------------------------------------------------------------
  # 3. Pruebas de humo contra el color inactivo
  # ---------------------------------------------------------------
  humo:
    runs-on: ubuntu-latest
    needs: [colores, desplegar-inactivo]
    steps:
      - uses: azure/k8s-set-context@v4
        with: { kubeconfig: '${{ secrets.KUBECONFIG }}' }

      - name: Ejecutar pruebas contra el color inactivo
        run: |
          kubectl port-forward service/bibliotech-preview 18080:80 -n $NAMESPACE &
          PF=$!
          sleep 8
          set -e

          BASE=http://localhost:18080

          echo "→ Salud"
          curl -fsS "$BASE/actuator/health/readiness" | jq -e '.status == "UP"'

          echo "→ Versión desplegada"
          VERSION=$(curl -fsS "$BASE/actuator/info" | jq -r '.build.version')
          test "v$VERSION" = "${{ github.ref_name }}" \
            || { echo "::error::Versión inesperada: $VERSION"; exit 1; }

          echo "→ Catálogo"
          curl -fsS "$BASE/api/materiales?size=1" | jq -e '.contenido | length >= 0'

          echo "→ Errores esperados (404 y 400)"
          test "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/materiales/978-9999999999")" = "404"
          test "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/materiales/no-isbn")" = "400"

          kill $PF
          echo "Pruebas de humo correctas"

  # ---------------------------------------------------------------
  # 4. Conmutar el tráfico (con aprobación manual)
  # ---------------------------------------------------------------
  conmutar:
    runs-on: ubuntu-latest
    needs: [colores, humo]
    environment:
      name: produccion            # con revisores obligatorios
      url: https://bibliotech.nexussoftware.com
    steps:
      - uses: azure/k8s-set-context@v4
        with: { kubeconfig: '${{ secrets.KUBECONFIG }}' }

      - name: Conmutar el Service al color nuevo
        run: |
          kubectl patch service bibliotech -n $NAMESPACE \
            -p '{"spec":{"selector":{"app":"bibliotech","color":"${{ needs.colores.outputs.inactivo }}"}}}'
          echo "::notice::Tráfico conmutado a ${{ needs.colores.outputs.inactivo }}"

  # ---------------------------------------------------------------
  # 5. Vigilar 5 minutos; volver atrás si la tasa de error sube
  # ---------------------------------------------------------------
  vigilar:
    runs-on: ubuntu-latest
    needs: [colores, conmutar]
    steps:
      - uses: azure/k8s-set-context@v4
        with: { kubeconfig: '${{ secrets.KUBECONFIG }}' }

      - name: Vigilar la tasa de errores durante 5 minutos
        id: vigilancia
        run: |
          BASE=https://bibliotech.nexussoftware.com
          UMBRAL_ERRORES=0.02          # 2 % de 5xx

          for i in $(seq 1 10); do
            sleep 30

            TOTAL=$(curl -fsS "$BASE/actuator/metrics/http.server.requests" \
                    | jq '.measurements[] | select(.statistic=="COUNT") | .value')
            ERRORES=$(curl -fsS "$BASE/actuator/metrics/http.server.requests?tag=outcome:SERVER_ERROR" \
                      | jq '.measurements[] | select(.statistic=="COUNT") | .value // 0')

            TASA=$(echo "scale=4; $ERRORES / ($TOTAL + 1)" | bc)
            echo "Comprobación $i/10 — total=$TOTAL errores=$ERRORES tasa=$TASA"

            if (( $(echo "$TASA > $UMBRAL_ERRORES" | bc -l) )); then
              echo "::error::Tasa de errores $TASA por encima del umbral $UMBRAL_ERRORES"
              exit 1
            fi

            if ! curl -fsS "$BASE/actuator/health/readiness" | jq -e '.status == "UP"' > /dev/null; then
              echo "::error::La sonda de disponibilidad falla"
              exit 1
            fi
          done
          echo "Vigilancia superada"

      - name: VUELTA ATRÁS automática
        if: failure()
        run: |
          echo "::warning::Volviendo al color ${{ needs.colores.outputs.activo }}"
          # La vuelta atrás es INSTANTÁNEA: el color anterior sigue encendido y sano
          kubectl patch service bibliotech -n $NAMESPACE \
            -p '{"spec":{"selector":{"app":"bibliotech","color":"${{ needs.colores.outputs.activo }}"}}}'

          curl -X POST "${{ secrets.WEBHOOK_EQUIPO }}" \
            -H 'Content-Type: application/json' \
            -d '{"texto":"🔴 BiblioTech ${{ github.ref_name }}: vuelta atrás automática a ${{ needs.colores.outputs.activo }}"}'
          exit 1

  # ---------------------------------------------------------------
  # 6. Retirar el color antiguo, una hora después
  # ---------------------------------------------------------------
  retirar-antiguo:
    runs-on: ubuntu-latest
    needs: [colores, vigilar]
    steps:
      - uses: azure/k8s-set-context@v4
        with: { kubeconfig: '${{ secrets.KUBECONFIG }}' }

      - name: Esperar una hora antes de retirar
        run: sleep 3600      # ventana de seguridad: vuelta atrás instantánea durante 1 hora

      - name: Reducir el color antiguo a cero réplicas
        run: |
          # No se BORRA el Deployment: se escala a 0.
          # Así el manifiesto se conserva y volver a levantarlo es un comando.
          kubectl scale deployment/bibliotech-${{ needs.colores.outputs.activo }} \
            --replicas=0 -n $NAMESPACE

          curl -X POST "${{ secrets.WEBHOOK_EQUIPO }}" \
            -H 'Content-Type: application/json' \
            -d '{"texto":"✅ BiblioTech ${{ github.ref_name }} estable. Color ${{ needs.colores.outputs.activo }} retirado."}'

Ventajas de azul-verde frente a la actualización progresiva, que es lo que evalúa el ejercicio:

Aspecto Progresiva Azul-verde
Vuelta atrás Progresiva inversa: minutos Instantánea: un patch
Convivencia de versiones Sí, inevitable No: todo el tráfico va a una
Probar antes de exponer No , con el Service de previsualización
Recursos necesarios 1× + 1 pod durante la transición
Compatibilidad de esquema Obligatoria Recomendable igualmente, por la vuelta atrás

Y el punto que cierra el círculo con la sección 21: la vuelta atrás instantánea solo funciona si la base de datos es compatible con las dos versiones. Si la versión nueva aplicó una migración destructiva, conmutar el Service de vuelta al color antiguo no arregla nada: la aplicación antigua se encontrará un esquema que no entiende. Por eso el patrón expandir-contraer no es opcional, sino la condición que hace que azul-verde signifique algo.

Conclusión

BiblioTech está en producción.

Entiendes qué diferencia realmente tu portátil de un entorno real —versión de Java, configuración, esquema, memoria, consecuencias de un fallo— y los tres principios que gobiernan un despliegue sano: un artefacto para todos los entornos, configuración desde fuera y todo debe poder deshacerse.

Empaquetas en jar ejecutable, sabiendo por qué desplazó al war y cómo está construido por dentro. Y usas el jar por capas, que no es un detalle: convierte un despliegue de 60 MB en uno de 1 MB, de cuarenta segundos a tres, y con ello cambia el comportamiento del equipo — porque un despliegue de tres segundos se hace sin pensarlo y uno de dos minutos se acumula «para el jueves». Con construcción reproducible y trazabilidad al commit, de modo que «¿qué versión hay en producción?» tiene una respuesta exacta en un segundo.

Contenerizas con un Dockerfile multietapa que entiendes línea a línea: la etapa de construcción con Maven que no llega a la imagen final; los POM copiados antes que el código para que la caché de dependencias funcione; el usuario no root; las capas ordenadas por estabilidad; dumb-init como PID 1 sin el cual SIGTERM no llega a la JVM; y las opciones de la JVM con MaxRAMPercentage y ExitOnOutOfMemoryError. Con un .dockerignore que impide que secretos y el directorio .git entren en el contexto, y conociendo las alternativas —Buildpacks y Jib— con sus ventajas reales.

Sabes lo que casi nadie sabe sobre la JVM en un contenedor: que desde Java 10 es consciente de los cgroups, que el 25 % por defecto desperdicia memoria, y sobre todo que la memoria de la JVM no es solo el heap — metaspace, pilas, caché de código, buffers directos —, que es la razón por la que un contenedor Java muere con OOMKilled sin que la aplicación registre nada.

Gobiernas el esquema con Flyway, con las seis razones concretas por las que ddl-auto: update no vale en producción, el convenio de versiones, las reglas de oro —un script aplicado nunca se modifica— y el patrón expandir-contraer en tres fases, que es lo que permite que una migración conviva con dos versiones de la aplicación y que la vuelta atrás siga siendo posible.

Eliges dónde desplegar con criterio, sabiendo que systemd sobre un servidor propio sigue siendo una respuesta perfectamente válida y que adoptar Kubernetes para tres servicios sin equipo de plataforma es una decisión que se paga cada semana. Y si es Kubernetes, tienes un Deployment con maxUnavailable: 0, etiquetas exactas en lugar de latest, límites de recursos y PodDisruptionBudget.

Expones sondas distinguiendo vitalidad de disponibilidad —la diferencia entre reiniciar todas las instancias porque la base de datos tuvo un hipo y simplemente sacarlas del balanceador— con indicadores propios que marcan degradado en lugar de caído para las dependencias no esenciales. Apagas de forma ordenada con server.shutdown: graceful, @PreDestroy y la espera del preStop que cubre la ventana de carrera con el balanceador. Y conoces las opciones de arranque rápido —CDS, AOT, CRaC, Native Image— con el criterio de cuándo compensa cada una.

Manejas las cuatro estrategias de despliegue con sus costes, las banderas de funcionalidad que separan el despliegue de la activación, y el límite duro de la vuelta atrás: el código vuelve, los datos no. De ahí la regla que resume todo: la base de datos va por delante y siempre es compatible hacia atrás.

Tienes la canalización de entrega continua completa: construcción multiarquitectura, análisis de vulnerabilidades bloqueante, firma de la imagen, despliegue por digest, preproducción automática, producción con aprobación, verificación de salud posterior y vuelta atrás automática. Y sabes qué exige el escalado horizontal de la aplicación —sin estado en memoria, tareas programadas coordinadas con ShedLock o CronJob, y el recordatorio de que escalar la aplicación no escala la base de datos.

BiblioTech funciona, está probado, está desplegado y se puede actualizar sin cortar el servicio. Y tiene dos agujeros del tamaño de un proyecto entero.

El primero: cualquiera puede hacer cualquier cosa. No hay autenticación, no hay autorización, las contraseñas no existen, la API está abierta y nadie ha revisado si es vulnerable a las cosas que hacen que las aplicaciones aparezcan en las noticias.

El segundo: cuando algo falle, te enterarás por una llamada. No hay métricas, no hay trazas, no hay alertas, y el único registro es texto plano en un contenedor efímero.

La lección final cierra los dos, y cierra el curso: seguridad —vulnerabilidades comunes y su prevención concreta, Spring Security con BCrypt y JWT, y la advertencia sobre lo que un curso no puede sustituir—, observabilidad —los tres pilares, Micrometer y Actuator, Prometheus y Grafana, trazas distribuidas y alertas útiles—, y evolución —versionado de la API, deuda técnica, actualizaciones y cómo hacer crecer el sistema. Y al final, la recapitulación del viaje completo de BiblioTech, lo que sabes hacer ahora, y por dónde seguir.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados