BiblioTech està provat, mesurat i verificat a cada canvi. I no existeix per a ningú.

Corre al portàtil d'en Diego Alonso quan l'arrenca, i en un runner de GitHub Actions durant els vuit minuts que dura la canonada. La Marta Ruiz no pot obrir un navegador i consultar el catàleg, perquè no hi ha cap servidor on l'aplicació estigui funcionant. La Núria Vidal no pot reservar «Refactorització», perquè el procés que atendria aquesta petició no està encès enlloc.

Aquesta lliçó cobreix el trajecte que va de «funciona a la meva màquina» a «està en producció». És un trajecte amb més paranys dels que sembla, i gairebé tots es resumeixen en una frase que sentiràs moltes vegades a la teva carrera professional: «doncs en local funcionava». Funcionava perquè en local hi havia una versió diferent de Java, un fitxer de configuració que no és al repositori, un esquema de base de dades que Hibernate havia creat sol, 32 GB de memòria i cap altre usuari competint.

L'objectiu de tot el que ve és eliminar aquestes diferències: empaquetar l'aplicació amb tot el que necessita, configurar-la des de fora, i desplegar-la de manera repetible, observable i reversible.

En acabar sabràs empaquetar i contenir una aplicació Java correctament, configurar la JVM perquè respecti els límits d'un contenidor, versionar l'esquema de la base de dades amb Flyway, triar entre les diferents plataformes de desplegament amb criteri, exposar sondes de salut i aturar l'aplicació sense tallar peticions a mig fer, aplicar estratègies de desplegament amb tornada enrere, i muntar una canonada de lliurament continu completa.

Contingut

  1. Què significa desplegar
  2. Empaquetatge: jar executable enfront de war
  3. El jar per capes i per què accelera les imatges
  4. Construcció reproduïble i traçabilitat
  5. Contenidors: imatge i contenidor
  6. El Dockerfile de BiblioTech, línia a línia
  7. .dockerignore
  8. Alternatives sense Dockerfile: Buildpacks i Jib
  9. Elecció d'imatge base i mida
  10. La JVM dins d'un contenidor
  11. docker-compose per a l'entorn local
  12. Configuració i secrets al desplegament
  13. Migracions de base de dades amb Flyway
  14. Desplegament en dues fases per a canvis d'esquema
  15. On es desplega: comparativa de plataformes
  16. Kubernetes: un Deployment mínim
  17. Arrencada i salut: sondes d'Actuator
  18. Aturada ordenada
  19. Temps d'arrencada: CDS i Native Image
  20. Estratègies de desplegament
  21. Tornada enrere i el límit de la base de dades
  22. Canonada de lliurament continu
  23. Escalat horitzontal i què exigeix de l'aplicació
  24. Errors Comuns i Consells
  25. Exercicis
  26. Conclusió

  1. Què significa desplegar

Desplegar és posar una versió concreta del programari a disposició dels seus usuaris, en un entorn que no controles del tot. Les diferències amb el teu portàtil són sistemàtiques:

Aspecte La teva màquina Producció
Versió de Java La que tinguis instal·lada La que decideixi l'operador
Configuració application-dev.yml Variables d'entorn
Base de dades Contenidor efímer, tu sol Compartida, amb dades reals
Esquema El crea Hibernate Migracions versionades
Memòria 32 GB 512 MB amb límit estricte
Fallades Reinicies Algú rep una trucada
Reiniciar Sense conseqüències Peticions tallades, usuaris afectats
Dades De prova Irrecuperables si es perden

D'aquí surten els tres principis que governen aquesta lliçó:

  1. Un artefacte, tots els entorns. El mateix jar i la mateixa imatge van a desenvolupament, preproducció i producció. L'única cosa que canvia és la configuració. Si construeixes una imatge diferent per a producció, el que has provat no és el que despleges.
  2. Configuració des de fora. Res específic de l'entorn dins de l'artefacte.
  3. Tot ha de poder desfer-se. Un desplegament sense tornada enrere és una aposta.

  1. Empaquetatge: jar executable enfront de war

Històricament, una aplicació Java web s'empaquetava en un .war i es desplegava dins d'un servidor d'aplicacions instal·lat a part. Spring Boot va invertir el model amb el jar executable, que porta el servidor a dins.

Aspecte Jar executable War
Servidor Incrustat Instal·lat a part
Execució java -jar app.jar Copiar a webapps/
Unitats desplegables Una Dues: servidor i aplicació
Versió del servidor La que declara el POM La de l'operador
Contenidors Encaixa perfecte Incòmode
Diverses apps per servidor No
Quan usar-lo Pràcticament sempre Servidor d'aplicacions corporatiu imposat
<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 detall que convé conèixer: el jar executable de Spring Boot no és un jar normal. Les teves classes són a BOOT-INF/classes/ i les dependències, com a jars complets, a BOOT-INF/lib/. Un carregador de classes propi (JarLauncher) s'encarrega de llegir-les. Per això java -cp app.jar LaMevaClasse no funciona com esperaries.

bibliotech-web-1.0.0.jar
├── META-INF/MANIFEST.MF          ← Main-Class: org.springframework.boot.loader.launch.JarLauncher
├── org/springframework/boot/loader/   ← el carregador
└── BOOT-INF/
    ├── classes/                  ← el teu codi i els teus recursos
    ├── lib/                      ← ~50 jars de dependències
    └── classpath.idx

  1. El jar per capes i per què accelera les imatges

Aquí hi ha una optimització que sembla un detall i canvia radicalment els temps de desplegament.

Una imatge Docker es compon de capes superposades. Quan es publica o es descarrega una imatge, només viatgen les capes que han canviat. Si tot el jar (60 MB) és en una sola capa, qualsevol canvi d'una línia de codi obliga a transferir 60 MB.

Però la composició d'aquests 60 MB és molt desigual:

Contingut Mida típica Freqüència de canvi
Dependències (Spring, Hibernate, Jackson…) ~55 MB Cada diverses setmanes
Carregador de Spring Boot ~200 KB Amb la versió de Spring Boot
Dependències internes (els nostres mòduls) ~500 KB Diàriament
El teu codi ~1 MB Cada commit

Spring Boot ofereix layertools, que separa el jar en aquestes quatre parts:

$ java -Djarmode=tools -jar bibliotech-web.jar list-layers
dependencies
spring-boot-loader
snapshot-dependencies
application
# Extreure cada capa al seu directori
java -Djarmode=tools -jar bibliotech-web.jar extract --layers --launcher --destination extret/

I al Dockerfile, cada capa es copia en un COPY diferent, en ordre d'estabilitat. El resultat, mesurat:

Escenari Sense capes Amb capes
Canvi d'una línia de codi 60 MB transferits ~1 MB
Afegir una dependència 60 MB ~57 MB
Temps de publicació típic 40 s 3 s
Temps de descàrrega al desplegament 30 s 2 s

Amb vint desplegaments al dia, això són hores al mes. I l'efecte psicològic importa tant com el tècnic: un desplegament de tres segons es fa sense pensar-hi; un de dos minuts s'acumula «per fer-los tots junts el dijous», que és exactament la pràctica que cal evitar.

S'activa al POM (està actiu per defecte a 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ó reproduïble i traçabilitat

Construcció reproduïble significa que el mateix codi font produeix exactament els mateixos bytes. Sona acadèmic i té una conseqüència pràctica molt concreta: poder verificar que el binari que és en producció correspon al codi que diu correspondre.

El que trenca la reproduïbilitat són les marques de temps dins del jar:

<properties>
  <!-- Fixa la data de les entrades del jar: sense aixo, cada construccio difereix -->
  <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 mateix hash en qualsevol màquina, avui i d'aquí a un any)

Traçabilitat. Cada artefacte ha de poder respondre: de quin commit va sortir?, quan es va construir?, qui el va construir?

<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>

Amb això, Actuator exposa la informació:

$ 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 «quina versió hi ha en producció ara mateix?» té una resposta exacta, en un segon. Sense això, la resposta és «em sembla que la de la setmana passada», i a partir d'aquí cap diagnòstic no és fiable.

Versionat semàntic dels artefactes:

Format Exemple Ús
MAJOR.MINOR.PATCH 1.4.2 Versió alliberada
MAJOR.MINOR.PATCH-SNAPSHOT 1.5.0-SNAPSHOT En desenvolupament
MAJOR.MINOR.PATCH-rc.N 1.5.0-rc.1 Candidata
Amb el commit 1.4.2-a3f7e91 Traçabilitat exacta

  1. Contenidors: imatge i contenidor

Dos conceptes que es confonen constantment:

  • Una imatge és una plantilla immutable de només lectura: sistema de fitxers per capes + metadades (quina ordre executar, quins ports, quin usuari). És com una classe.
  • Un contenidor és una instància en execució d'una imatge, amb una capa d'escriptura a sobre. És com un objecte.
flowchart TD
    B["Imatge base<br/>eclipse-temurin:21-jre-alpine"]
    L1["Capa: dependències (~55 MB)"]
    L2["Capa: carregador (~200 KB)"]
    L3["Capa: codi de BiblioTech (~1 MB)"]
    I["IMATGE bibliotech:1.4.2"]
    C1["Contenidor 1<br/>en execució"]
    C2["Contenidor 2<br/>en execució"]

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

Un contenidor no és una màquina virtual: comparteix el nucli del sistema amfitrió i fa servir mecanismes de Linux (namespaces per a l'aïllament, cgroups per als límits de recursos). Per això arrenca en mil·lisegons i pesa megabytes en lloc de gigabytes.

Aspecte Màquina virtual Contenidor
Aïllament Total (nucli propi) De processos (nucli compartit)
Arrencada Minuts Mil·lisegons
Mida GB MB
Sobrecàrrega Notable Mínima

El que un contenidor resol de debò, i que justifica tota la resta: l'artefacte inclou el sistema operatiu base, la JVM, l'aplicació i les seves dependències. La frase «a la meva màquina funciona» perd sentit, perquè la màquina viatja amb l'aplicació.

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

# ==============================================================================
# ETAPA 1: CONSTRUCCIÓ
# Aquesta etapa té Maven, el JDK complet i el codi font. Res d'això
# arriba a la imatge final: només es fa servir per produir el jar.
# ==============================================================================
FROM eclipse-temurin:21-jdk-alpine AS constructor

WORKDIR /build

# Copiar NOMÉS els fitxers de dependències primer.
# Docker posa cada instrucció a la memòria cau: mentre els POM no canviïn, la
# descàrrega de dependències (el pas més lent, 2-3 minuts) se salta del tot.
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY bibliotech-domini/pom.xml           bibliotech-domini/
COPY bibliotech-aplicacio/pom.xml        bibliotech-aplicacio/
COPY bibliotech-infraestructura/pom.xml  bibliotech-infraestructura/
COPY bibliotech-web/pom.xml              bibliotech-web/
COPY bibliotech-consola/pom.xml          bibliotech-consola/

# go-offline descarrega totes les dependències sense compilar res.
# --mount=type=cache manté ~/.m2 entre construccions (BuildKit).
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -B dependency:go-offline -DskipTests

# ARA sí que copiem el codi font. Si només canvia el codi,
# Docker reutilitza la capa anterior i no torna a descarregar res.
COPY bibliotech-domini/src           bibliotech-domini/src
COPY bibliotech-aplicacio/src        bibliotech-aplicacio/src
COPY bibliotech-infraestructura/src  bibliotech-infraestructura/src
COPY bibliotech-web/src              bibliotech-web/src

# -DskipTests: les proves ja es van executar a CI (12-05). Repetir-les aquí
# duplica el temps i requeriria Docker dins de Docker per a Testcontainers.
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw -B -pl bibliotech-web -am clean package -DskipTests

# Extreure el jar en capes
RUN java -Djarmode=tools -jar bibliotech-web/target/bibliotech-web-*.jar \
         extract --layers --launcher --destination extret

# ==============================================================================
# ETAPA 2: EXECUCIÓ
# Imatge mínima: només JRE (no JDK), sense Maven, sense codi font,
# sense eines de compilació. Menys superfície, menys vulnerabilitats.
# ==============================================================================
FROM eclipse-temurin:21-jre-alpine AS execucio

# Etiquetes OCI estàndard: metadades que les eines de l'ecosistema llegeixen
LABEL org.opencontainers.image.title="BiblioTech" \
      org.opencontainers.image.description="Biblioteca tecnica de Nexus Software" \
      org.opencontainers.image.vendor="Nexus Software" \
      org.opencontainers.image.licenses="Proprietary"

# Utilitats mínimes:
#  - curl per al HEALTHCHECK
#  - tzdata perquè les zones horàries funcionin (Alpine no les porta)
#  - dumb-init com a PID 1: reenvia els senyals correctament al procés Java
RUN apk add --no-cache curl tzdata dumb-init && \
    rm -rf /var/cache/apk/*

ENV TZ=Europe/Madrid

# ---- Usuari NO root ----
# Si un atacant aconsegueix execució de codi, no ha de tenir root al
# contenidor. És la mitigació més barata i eficaç que existeix.
RUN addgroup -S -g 1001 bibliotech && \
    adduser -S -u 1001 -G bibliotech -h /app bibliotech

WORKDIR /app

# ---- Les capes, en ordre d'estabilitat (menys canviant primer) ----
# Cada COPY és una capa de Docker. En canviar només el codi, únicament
# l'última capa (~1 MB) es reconstrueix i es transfereix.
COPY --from=constructor --chown=bibliotech:bibliotech /build/extret/dependencies/ ./
COPY --from=constructor --chown=bibliotech:bibliotech /build/extret/spring-boot-loader/ ./
COPY --from=constructor --chown=bibliotech:bibliotech /build/extret/snapshot-dependencies/ ./
COPY --from=constructor --chown=bibliotech:bibliotech /build/extret/application/ ./

USER bibliotech

EXPOSE 8080

# ---- Opcions de la JVM ----
#  MaxRAMPercentage=75      fa servir el 75 % del límit del contenidor per al heap
#  InitialRAMPercentage=50  arrenca amb la meitat: menys redimensionats
#  UseG1GC                  GC equilibrat; per a <2 vCPU considerar SerialGC
#  ExitOnOutOfMemoryError   davant d'un OOM, morir: que l'orquestrador reiniciï
#                           (una JVM en OOM serveix peticions a mig fer, que és pitjor)
#  HeapDumpOnOutOfMemoryError  bolcat per diagnosticar (10-07)
#  file.encoding=UTF-8      explícit: no depenem de l'entorn
ENV JAVA_OPTS="\
    -XX:MaxRAMPercentage=75.0 \
    -XX:InitialRAMPercentage=50.0 \
    -XX:+UseG1GC \
    -XX:+ExitOnOutOfMemoryError \
    -XX:+HeapDumpOnOutOfMemoryError \
    -XX:HeapDumpPath=/tmp/bolcat.hprof \
    -Djava.security.egd=file:/dev/./urandom \
    -Dfile.encoding=UTF-8"

# Comprovació de salut a nivell de contenidor
HEALTHCHECK --interval=30s --timeout=3s --start-period=45s --retries=3 \
  CMD curl -fsS http://localhost:8080/actuator/health/readiness || exit 1

# dumb-init com a PID 1 reenvia SIGTERM al procés Java: sense això,
# l'aturada ordenada (secció 18) no funciona.
ENTRYPOINT ["dumb-init", "--"]

# Forma "shell" perquè $JAVA_OPTS s'expandeixi. exec fa que Java sigui
# el procés fill directe i rebi els senyals.
CMD exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher

Construcció i execució:

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

Els cinc punts del Dockerfile que més importen, per si cal recordar-ne només cinc:

  1. Multietapa: la imatge final no conté ni Maven ni JDK ni codi font. Passa de ~700 MB a ~180 MB, i elimina de la superfície d'atac un compilador complet.
  2. Els POM abans que el codi: la memòria cau de dependències es conserva entre construccions.
  3. Usuari no root: mitigació bàsica i obligatòria.
  4. Capes ordenades per estabilitat: desplegaments d'1 MB en lloc de 60 MB.
  5. dumb-init + exec: sense ells, SIGTERM no arriba a la JVM i l'aturada ordenada no passa.

  1. .dockerignore

Sense ell, el context de construcció inclou el directori .git complet, els target/ i possiblement fitxers amb secrets, que acaben dins de la imatge o com a mínim s'envien al dimoni de Docker.

# Tot el que no cal per construir
.git/
.github/
.idea/
.vscode/
*.iml

target/
**/target/

*.md
docs/
LICENSE

# CRÍTIC: res d'això no ha d'entrar al context de construcció
.env
*.env
**/application-local.yml
*.pem
*.p12
*.jks
secrets/

Dockerfile
.dockerignore
compose.yaml

Comprovació de la mida del context:

docker build --no-cache --progress=plain . 2>&1 | head -5
# => transferring context: 1.24MB    (bé)
# Sense .dockerignore seria, típicament, 250 MB.

  1. Alternatives sense Dockerfile: Buildpacks i Jib

Cloud Native Buildpacks — integrat a Spring Boot, sense escriure ni un sol 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>registre.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 és una aplicació Java, tria la JVM, aplica capes, configura la memòria automàticament segons el límit del contenidor i afegeix un SBOM (inventari de components, útil per a seguretat, 12-07).

Jib (Google) construeix la imatge sense necessitat d'un dimoni Docker, cosa que la fa ideal per a 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>registre.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 directament al registre
./mvnw -pl bibliotech-web jib:dockerBuild   # o construeix al Docker local

Comparativa:

Criteri Dockerfile Buildpacks Jib
Control total No Parcial
Requereix Docker per construir No
Capes optimitzades Manual Automàtic Automàtic
Actualitzar la base Manual Automàtic (rebase) Canviar una línia
Velocitat Mitjana Lenta el 1r cop Molt ràpida
Corba d'aprenentatge Mitjana Baixa Baixa
Quan Necessites control fi Vols oblidar-te'n CI sense Docker

Recomanació per a BiblioTech: Dockerfile explícit. En un curs, i en un equip que vol entendre el seu desplegament, el control i la transparència valen més que la comoditat. En un equip gran amb molts serveis, Buildpacks o Jib estalvien feina repetida.

  1. Elecció d'imatge base i mida

Imatge base Mida (amb JRE 21) Característiques
eclipse-temurin:21-jdk ~450 MB JDK complet. No usar en producció
eclipse-temurin:21-jre ~270 MB JRE sobre Ubuntu. Compatible i previsible
eclipse-temurin:21-jre-alpine ~180 MB Alpine + musl libc. Lleugera
gcr.io/distroless/java21 ~190 MB Sense shell, sense gestor de paquets. Molt segura
Imatge pròpia amb jlink ~90 MB JRE retallat als mòduls necessaris

Consideracions reals:

  • Alpine fa servir musl en lloc de glibc. El 99 % del codi Java funciona igual, però llibreries amb codi natiu poden fallar. Si apareixen errors estranys de càrrega de biblioteques, prova amb la variant no-Alpine abans de perdre una tarda.
  • Distroless és la més segura: sense shell, un atacant que aconsegueixi execució de codi no pot llançar ordres. El preu és que tu tampoc no pots: no hi ha docker exec ... sh per diagnosticar. Requereix bona observabilitat (12-07).
  • La mida importa menys del que sembla. La capa base es descarrega una vegada i es comparteix entre totes les imatges d'aquesta base. El que es transfereix a cada desplegament és la capa d'aplicació (~1 MB).

Reduir amb jlink, per a qui necessiti el mínim:

FROM eclipse-temurin:21-jdk-alpine AS jre-minim
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-minim

  1. La JVM dins d'un contenidor

Aquest és, amb diferència, l'error de desplegament més comú amb Java, i mereix un apartat propi.

El problema històric. Abans de Java 10, la JVM llegia la memòria de la màquina amfitriona, ignorant el límit del contenidor. En un servidor de 64 GB amb un contenidor limitat a 512 MB, la JVM calculava un heap màxim de 16 GB (una quarta part de 64), l'intentava fer servir, i el nucli matava el procés amb OOMKilled (codi 137) sense cap missatge de la JVM.

Des de Java 10 —i perfeccionat a l'11, 15 i 17— la JVM és conscient dels 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

Les opcions que cal ajustar i per què:

Opció Valor recomanat Motiu
-XX:MaxRAMPercentage 75.0 El 25 % per defecte malgasta memòria
-XX:InitialRAMPercentage 50.0 Menys redimensionats del heap en arrencar
-XX:+UseG1GC Amb ≥ 2 vCPU Equilibri entre pauses i rendiment
-XX:+UseSerialGC Amb < 2 vCPU Menys sobrecàrrega en contenidors petits
-XX:MaxMetaspaceSize 256m El metaspace no és dins del heap
-XX:+ExitOnOutOfMemoryError Sempre Morir de pressa i que l'orquestrador reiniciï
-XX:ActiveProcessorCount Si el límit de CPU és fraccionari La JVM arrodoneix malament les CPU fraccionàries

Un càlcul que evita molts incidents. Amb un límit de contenidor de 512 MB:

Component Memòria
Heap (75 %) 384 MB
Metaspace ~60 MB
Piles de fils (200 × 1 MB de reserva) ~30 MB reals
Memòria cau de codi (JIT) ~40 MB
GC i estructures internes ~30 MB
Búfers directes de NIO ~20 MB
Total ~564 MB > 512 MB → OOMKilled

La memòria de la JVM no és només el heap. Aquest càlcul és la raó per la qual molts contenidors Java moren sense explicació aparent. Per diagnosticar-ho, NativeMemoryTracking (10-07):

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

Regles pràctiques:

  • Amb MaxRAMPercentage=75, deixa almenys 256 MB de límit de contenidor per damunt del que necessita el heap.
  • Un servei Spring Boot típic necessita com a mínim 512 MB; 768 MB és més còmode.
  • Si veus OOMKilled (codi 137), no és una fallada de l'aplicació: és memòria fora del heap.

  1. docker-compose per a l'entorn local

A 12-01 només aixecàvem PostgreSQL. Ara, l'aplicació 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:
      - dades-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 el healthcheck, no només l'arrencada
    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:
  dades-db:
docker compose up -d --build       # construir i aixecar
docker compose logs -f app         # seguir els registres
docker compose ps                  # estat i salut
docker compose exec db psql -U bibliotech    # entrar a la base de dades
docker compose down                # aturar (les dades sobreviuen)
docker compose down -v             # aturar i ESBORRAR les dades

depends_on amb condition: service_healthy és el detall que evita la fallada més freqüent en desenvolupament: l'aplicació arrenca abans que PostgreSQL accepti connexions i mor al primer intent.

  1. Configuració i secrets al desplegament

La regla que reprèn 12-01 i que ho governa tot:

Una imatge per a tots els entorns. La mateixa imatge bibliotech:1.4.2 va a desenvolupament, preproducció i producció. Només canvien les variables d'entorn.

Si construeixes una imatge diferent per a producció, el que has provat a preproducció no és el que despleges.

Mecanismes, en ordre de robustesa:

Mecanisme Com Avantatges Riscos
Variables d'entorn -e CLAU=valor Estàndard de facto, simple Visibles a docker inspect i a l'entorn del procés
Fitxers muntats -v /secrets:/app/config:ro No apareixen a l'entorn Cal gestionar permisos
Secrets de l'orquestrador Kubernetes Secret, Docker Secret Integrats a la plataforma Base64 no és xifratge
Gestor de secrets Vault, AWS Secrets Manager Rotació, auditoria, xifratge Més complexitat operativa

Configuració externa amb fitxer, útil en servidors propis:

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

Spring Boot busca application.yml i application-{perfil}.yml en aquest camí, amb més precedència que els empaquetats (12-01).

A Kubernetes, la separació entre configuració i secrets:

apiVersion: v1
kind: ConfigMap
metadata:
  name: bibliotech-config
data:
  SPRING_PROFILES_ACTIVE: "prod"
  BIBLIOTECH_PRESTEC_DIESPERDEFECTE: "15"
  LOGGING_LEVEL_COM_NEXUSSOFTWARE_BIBLIOTECH: "INFO"
---
apiVersion: v1
kind: Secret
metadata:
  name: bibliotech-secrets
type: Opaque
stringData:
  BIBLIOTECH_DB_PASSWORD: "…"        # gestionat amb Sealed Secrets o un gestor extern
  METADADES_API_KEY: "…"

Avís. Un Secret de Kubernetes està codificat en base64, que no és xifratge: qualsevol amb accés de lectura a l'espai de noms el pot descodificar. Per a secrets reals calen Sealed Secrets, External Secrets Operator o un gestor extern, i xifratge en repòs a etcd. Es reprèn a 12-07.

Verificació que la configuració és l'esperada:

curl -s localhost:8080/actuator/env/bibliotech.multa.euros-per-dia | jq
# mostra el valor efectiu I de quina font va venir

  1. Migracions de base de dades amb Flyway

Aquí es paga un deute d'11-03: ddl-auto no val en producció.

Valor Què fa Producció
create-drop Esborra i recrea en arrencar Catastròfic
create Esborra i recrea Catastròfic
update Afegeix el que falta No. Veure a sota
validate Comprova que l'esquema encaixa
none No fa res

Per què update no val, amb precisió:

  1. No elimina res. Columnes i taules esborrades del model es queden per sempre.
  2. No reanomena. Canviar data_venc a data_venciment crea una columna nova i deixa les dades a la vella.
  3. No versiona. No hi ha manera de saber quin esquema té un entorn ni de reproduir-lo.
  4. No és reversible. No hi ha tornada enrere.
  5. Depèn de l'ordre d'arrencada. Amb diverses instàncies arrencant alhora, poden generar DDL simultani i bloquejar-se.
  6. No fa migració de dades. Partir nom_complet en nom i cognoms és impossible.

Flyway ho resol tot això amb scripts SQL versionats que s'apliquen en ordre, una sola vegada, registrats en una taula 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 MANA
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true   # per a una base de dades que ja existia
    validate-on-migrate: true   # detecta scripts ja aplicats que han canviat
    out-of-order: false         # no permet aplicar una versió anterior a l'última

Convenció de noms: V<versió>__<descripció>.sql

bibliotech-infraestructura/src/main/resources/db/migration/
├── V1__esquema_inicial.sql
├── V2__indexs_cataleg.sql
├── V3__afegir_columna_valor_material.sql
├── V4__taula_reserves.sql
├── V5__estat_devolucio_i_recarrecs.sql
└── R__vista_estadistiques_us.sql        ← R = repetible: es reexecuta si canvia
-- V1__esquema_inicial.sql
CREATE TABLE materials (
    id                    BIGSERIAL PRIMARY KEY,
    tipus                 VARCHAR(20)  NOT NULL,   -- discriminador SINGLE_TABLE (ADR-004)
    isbn                  VARCHAR(17)  NOT NULL UNIQUE,
    titol                 VARCHAR(200) NOT NULL,
    autor                 VARCHAR(150),
    any_publicacio        INTEGER,
    unitats_totals        INTEGER      NOT NULL DEFAULT 1 CHECK (unitats_totals >= 0),
    unitats_disponibles   INTEGER      NOT NULL DEFAULT 1 CHECK (unitats_disponibles >= 0),
    version               BIGINT       NOT NULL DEFAULT 0,   -- @Version (11-03)
    creat_el              TIMESTAMPTZ  NOT NULL DEFAULT now(),
    CONSTRAINT chk_disponibles_no_supera_totals
        CHECK (unitats_disponibles <= unitats_totals)
);

CREATE TABLE empleats (
    id            BIGSERIAL PRIMARY KEY,
    nom           VARCHAR(150) NOT NULL,
    correu        VARCHAR(200) NOT NULL UNIQUE,
    departament   VARCHAR(100),
    data_alta     DATE         NOT NULL,
    version       BIGINT       NOT NULL DEFAULT 0
);

CREATE TABLE prestecs (
    id                 BIGSERIAL PRIMARY KEY,
    material_id        BIGINT      NOT NULL REFERENCES materials(id),
    empleat_id         BIGINT      NOT NULL REFERENCES empleats(id),
    data_prestec       DATE        NOT NULL,
    data_venciment     DATE        NOT NULL,
    data_devolucio     DATE,
    estat              VARCHAR(20) NOT NULL,
    version            BIGINT      NOT NULL DEFAULT 0,
    CONSTRAINT chk_venciment_posterior CHECK (data_venciment >= data_prestec),
    CONSTRAINT chk_devolucio_posterior
        CHECK (data_devolucio IS NULL OR data_devolucio >= data_prestec)
);

-- Índexs per a les consultes que de debò es fan
CREATE INDEX idx_prestec_empleat_estat ON prestecs(empleat_id, estat);
CREATE INDEX idx_prestec_venciment     ON prestecs(data_venciment)
                                       WHERE data_devolucio IS NULL;  -- parcial
CREATE INDEX idx_material_titol        ON materials USING gin(to_tsvector('spanish', titol));

-- Dades de referència que l'aplicació necessita per arrencar
INSERT INTO empleats (nom, correu, departament, data_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');

Regles d'or de les migracions:

Regla Motiu
Un script aplicat MAI no es modifica Flyway guarda una suma de comprovació; si canvia, falla l'arrencada
Per corregir, un script nou És l'única manera que tots els entorns convergeixin
Migracions idempotents quan es pugui CREATE TABLE IF NOT EXISTS
Els canvis destructius, en dues fases Veure la secció següent
Provar la migració amb dades reals Un ALTER TABLE sobre 10 milions de files pot trigar hores i bloquejar
Un ALTER que bloqueja, en finestra de manteniment A PostgreSQL, ADD COLUMN amb default és ràpid; ALTER TYPE reescriu la taula

Ordres útils:

./mvnw flyway:info       # quines migracions hi ha i quines estan aplicades
./mvnw flyway:validate   # comprova les sumes de comprovació
./mvnw flyway:migrate    # aplica les pendents
./mvnw flyway:repair     # arregla la taula de control (amb compte!)

I una prova que evita sorpreses, integrada amb Testcontainers (12-05):

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

    MigrateResult resultat = flyway.migrate();

    assertThat(resultat.success).isTrue();
    assertThat(resultat.migrationsExecuted).isGreaterThan(0);
    // I que l'esquema resultant coincideix amb el que espera Hibernate:
    assertThatNoException().isThrownBy(() -> validarEsquemaContraEntitats());
}

  1. Desplegament en dues fases per a canvis d'esquema

El problema: durant una actualització progressiva conviuen la versió antiga i la nova de l'aplicació contra la mateixa base de dades. Un canvi destructiu trenca la versió antiga abans que acabi de retirar-se.

Exemple: reanomenar prestecs.data_venc a prestecs.data_venciment.

El que NO es pot fer:

-- V6__reanomenar_columna.sql
ALTER TABLE prestecs RENAME COLUMN data_venc TO data_venciment;

En l'instant en què s'aplica, totes les instàncies de la versió antiga —que continuen servint peticions— fallen amb «columna inexistent».

La solució, en tres desplegaments:

flowchart TD
    F1["FASE 1 · Expandir<br/>Afegir data_venciment<br/>Copiar dades + trigger de sincronia<br/>App v1 fa servir la vella; les dues columnes conviuen"]
    F2["FASE 2 · Migrar<br/>App v2 escriu i llegeix la nova<br/>El trigger manté la vella al dia<br/>Tornada enrere a v1 encara possible"]
    F3["FASE 3 · Contraure<br/>Eliminar el trigger i la columna vella<br/>Només quan v1 ja no existeix"]
    F1 --> F2 --> F3
-- FASE 1: V6__afegir_data_venciment.sql   (compatible amb l'app v1)
ALTER TABLE prestecs ADD COLUMN data_venciment DATE;

UPDATE prestecs SET data_venciment = data_venc WHERE data_venciment IS NULL;

-- Trigger de doble escriptura: tant se val quina versió de l'app escrigui
CREATE OR REPLACE FUNCTION sincronitzar_data_venciment() RETURNS TRIGGER AS $$
BEGIN
    IF NEW.data_venciment IS DISTINCT FROM OLD.data_venciment THEN
        NEW.data_venc := NEW.data_venciment;
    ELSIF NEW.data_venc IS DISTINCT FROM OLD.data_venc THEN
        NEW.data_venciment := NEW.data_venc;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sincronitzar_data
    BEFORE INSERT OR UPDATE ON prestecs
    FOR EACH ROW EXECUTE FUNCTION sincronitzar_data_venciment();
-- FASE 3: V8__eliminar_data_venc.sql   (només després de confirmar que v1 ja no corre)
DROP TRIGGER IF EXISTS trg_sincronitzar_data ON prestecs;
DROP FUNCTION IF EXISTS sincronitzar_data_venciment();
ALTER TABLE prestecs DROP COLUMN data_venc;
ALTER TABLE prestecs ALTER COLUMN data_venciment SET NOT NULL;

Aquest patró es diu expand and contract (expandir i contraure), i la regla que el resumeix és fàcil de recordar:

Tota migració ha de ser compatible amb la versió anterior de l'aplicació. Els canvis destructius s'apliquen almenys un desplegament després del que va deixar de necessitar allò que s'elimina.

  1. On es desplega: comparativa de plataformes

Plataforma Com funciona Control Complexitat Cost Quan
Servidor propi + systemd jar com a servei de Linux Total Baixa Baix 1-2 serveis, equip petit
PaaS (Heroku, Render, Railway, Fly.io) Empenys codi o imatge Baix Molt baixa Mitjà-alt Prototips, equips sense operacions
Contenidors gestionats (ECS, Cloud Run, App Service) Despleges imatges Mitjà Mitjana Mitjà La majoria d'aplicacions
Kubernetes Orquestrador complet Total Alta Variable Molts serveis, escala real

Servidor propi amb systemd, que continua sent perfectament vàlid i sovint la millor opció:

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

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

EnvironmentFile=/etc/bibliotech/entorn       # secrets, amb permisos 600
ExecStart=/usr/bin/java $JAVA_OPTS -jar /opt/bibliotech/bibliotech-web.jar

SuccessExitStatus=143                        # 128 + SIGTERM: aturada ordenada, no és fallada
Restart=on-failure
RestartSec=10

# Aturada ordenada: SIGTERM, i 60 s abans de SIGKILL
KillSignal=SIGTERM
TimeoutStopSec=60

# Enduriment: mínim privilegi (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

Criteris d'elecció, sense embuts:

Si… Tria
Tens 1-3 serveis i un equip petit Servidor propi o contenidors gestionats
No vols gestionar infraestructura PaaS o Cloud Run
Necessites escalar a zero quan no hi ha trànsit Cloud Run, Fly.io
Tens 20+ serveis i equip de plataforma Kubernetes
Tens 3 serveis i no tens equip de plataforma No Kubernetes

Sobre aquest últim punt convé ser explícit: Kubernetes és una eina excel·lent i amb un cost operatiu real i permanent. Adoptar-lo per a tres serveis perquè «és el que es fa servir» és una decisió que es paga cada setmana en temps de l'equip.

  1. Kubernetes: un Deployment mínim

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bibliotech
  labels:
    app: bibliotech
spec:
  replicas: 3                       # tres instàncies: alta disponibilitat
  revisionHistoryLimit: 5           # històric per a la tornada enrere

  strategy:
    type: RollingUpdate             # actualització progressiva (secció 20)
    rollingUpdate:
      maxSurge: 1                   # com a molt 1 pod extra durant la transició
      maxUnavailable: 0             # MAI menys de 3 disponibles: sense tall de servei

  selector:
    matchLabels:
      app: bibliotech

  template:
    metadata:
      labels:
        app: bibliotech
        version: "1.4.2"
    spec:
      # Marge perquè l'aturada ordenada acabi (secció 18)
      terminationGracePeriodSeconds: 60

      securityContext:              # mínim privilegi a nivell de pod
        runAsNonRoot: true
        runAsUser: 1001
        fsGroup: 1001

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

          ports:
            - name: http
              containerPort: 8080

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

          resources:
            requests:                # el que el planificador reserva
              memory: "512Mi"
              cpu: "250m"
            limits:                  # el sostre; superar-lo en memòria = OOMKilled
              memory: "768Mi"
              cpu: "1500m"

          # --- Sondes (secció 17) ---
          startupProbe:              # protegeix l'arrencada: fins a 100 s
            httpGet: { path: /actuator/health/liveness, port: http }
            failureThreshold: 20
            periodSeconds: 5

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

          readinessProbe:            # pot atendre? Si no, TREURE DEL BALANCEJADOR
            httpGet: { path: /actuator/health/readiness, port: http }
            periodSeconds: 5
            failureThreshold: 2

          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true       # res no escriu a l'arrel
            capabilities: { drop: ["ALL"] }

          volumeMounts:
            - name: tmp
              mountPath: /tmp                  # necessari: Tomcat i els bolcats hi escriuen

      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           # protegeix durant el manteniment 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         # tornada enrere immediata

Dos detalls que mereixen atenció especial:

  • maxUnavailable: 0 garanteix que durant l'actualització mai no hi ha menys instàncies de les declarades. És el que converteix un desplegament en una cosa invisible per als usuaris.
  • image: bibliotech:1.4.2, mai :latest. Amb latest no saps què s'està executant, la tornada enrere no funciona i dos pods poden acabar amb versions diferents.

  1. Arrencada i salut: sondes d'Actuator

Spring Boot Actuator distingeix dues preguntes que semblen la mateixa i no ho són:

Sonda Pregunta Si falla
Liveness (vitalitat) El procés està viu i no bloquejat? Reiniciar el contenidor
Readiness (disponibilitat) Pot atendre peticions ara? Treure'l del balancejador, sense reiniciar

La diferència és crítica. Si la base de dades cau, l'aplicació no està llesta (readiness falla) però està viva (liveness passa). Reiniciar-la no arreglaria res, i reiniciar totes les instàncies alhora perquè la base de dades va tenir un singlot és una manera excel·lent de convertir una incidència menor en una caiguda total.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus     # NOMÉS el necessari (12-07)
      base-path: /actuator
  endpoint:
    health:
      probes:
        enabled: true                # habilita /health/liveness i /health/readiness
      show-details: when-authorized  # el detall, només a qui té permís
      group:
        readiness:
          include: db, diskSpace     # si la BD no respon, no estem llestos
        liveness:
          include: livenessState     # només l'estat del procés
  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 salut propi, per al que només tu saps que és crític:

@Component
public class SalutPassarelaMetadades implements HealthIndicator {

    private final PassarelaMetadades passarela;

    @Override
    public Health health() {
        try {
            boolean disponible = passarela.comprovarDisponibilitat();
            return disponible
                    ? Health.up().withDetail("passarela", "disponible").build()
                    // DEGRADED, no DOWN: l'aplicacio funciona sense metadades externes.
                    // Marcar DOWN aqui trauria del balancejador una app perfectament usable.
                    : Health.status("DEGRADED").withDetail("passarela", "no respon").build();
        } catch (Exception e) {
            return Health.status("DEGRADED").withException(e).build();
        }
    }
}

Aquest matís —degradat en lloc de caigut per a les dependències no essencials— és el que separa un sistema resilient d'un que cau sencer perquè un servei secundari va tenir un problema.

  1. Aturada ordenada

Quan l'orquestrador vol aturar una instància, envia SIGTERM. Sense preparació, la JVM mor immediatament i les peticions en curs es tallen: usuaris amb errors, transaccions a mig fer, missatges sense confirmar.

server:
  shutdown: graceful            # deixa d'acceptar peticions noves i espera les actuals

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

La seqüència completa:

sequenceDiagram
    participant O as Orquestrador
    participant K as Kubelet / Docker
    participant A as BiblioTech
    participant B as Balancejador

    O->>K: aturar el pod
    K->>B: treure'l dels endpoints
    K->>A: SIGTERM
    A->>A: readiness = DOWN
    A->>A: deixar d'acceptar peticions noves
    A->>A: acabar les 12 peticions en curs
    A->>A: tancar el pool de connexions
    A->>A: executar els shutdown hooks
    A-->>K: procés acabat (codi 143)
    Note over K,A: si passat terminationGracePeriodSeconds continua viu, SIGKILL

I els ganxos de tancament, que reprenen el mòdul 7 i 12-03:

@Component
public class TancamentOrdenat {

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

    private final ExecutorService executorImportacions;

    /**
     * @PreDestroy s'executa quan el context de Spring es tanca,
     * cosa que passa en rebre SIGTERM.
     */
    @PreDestroy
    public void enAturar() {
        log.info("Aturada ordenada: tancant recursos");

        executorImportacions.shutdown();      // no accepta tasques noves
        try {
            if (!executorImportacions.awaitTermination(20, TimeUnit.SECONDS)) {
                log.warn("Importacions sense acabar despres de 20 s; forcant");
                executorImportacions.shutdownNow();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            executorImportacions.shutdownNow();
        }
        log.info("Aturada ordenada completada");
    }
}

Un detall crític i molt poc conegut: hi ha una finestra de cursa entre el moment en què l'orquestrador envia SIGTERM i el moment en què el balancejador deixa d'enviar trànsit. Durant aquest lapse —fins a uns segons— arriben peticions a una instància que ja s'està aturant. La solució estàndard és una espera abans de començar l'aturada:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]     # donar temps al balancejador a actualitzar-se

I al Dockerfile, dumb-init com a PID 1: sense ell, el procés Java (que seria PID 1) no rep els senyals per defecte i tot l'anterior no serveix de res.

  1. Temps d'arrencada: CDS i Native Image

L'arrencada importa en tres situacions: escalat automàtic davant d'un pic, reinici després d'una fallada, i funcions sense servidor amb escalat a zero.

Tècnica Arrencada de BiblioTech Cost
Jar normal ~4,5 s
CDS (arxiu de classes compartides) ~3,2 s Un pas extra a la construcció
AOT de Spring (-Dspring.aot.enabled) ~2,8 s Limita la configuració dinàmica
CRaC (restaurar des d'un checkpoint) ~0,3 s JVM específica, complexitat alta
GraalVM Native Image ~0,08 s Construcció de 5-10 min; reflexió declarada

CDS és la millora més barata: memoritza el resultat de carregar i verificar les classes.

# Generar l'arxiu CDS durant la construcció de la imatge
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 executable natiu:

<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       # arrenca en 80 ms
Aspecte JVM Native Image
Arrencada 4,5 s 0,08 s
Memòria en repòs ~350 MB ~90 MB
Rendiment màxim Més gran (el JIT optimitza amb dades reals) Menor
Temps de construcció 30 s 5-10 min
Reflexió dinàmica Lliure S'ha de declarar
Eines de diagnòstic JFR, jcmd, JMX Limitades

Quan compensa Native Image: funcions sense servidor, CLI (12-03), microserveis que escalen a zero, entorns amb memòria molt limitada. Quan no: serveis de llarga vida amb càrrega sostinguda, on el JIT acaba produint codi més ràpid que la compilació anticipada.

Per a BiblioTech com a API de llarga vida: JVM amb CDS. Per a la CLI: Native Image té molt de sentit.

  1. Estratègies de desplegament

Estratègia Com funciona Tall Cost Tornada enrere Quan
Recreació Aturar tot, arrencar el nou Baix Redesplegar Desenvolupament; apps que no toleren dues versions
Progressiva (rolling) Substituir instància a instància No Baix Progressiva inversa Per defecte
Blau-verd Dos entorns complets; commutar el trànsit No Doble Instantània Canvis de risc
Canari Enviar el 5 % del trànsit a la nova; anar pujant No Mitjà Instantània Canvis d'alt risc, trànsit alt
flowchart TD
    subgraph BLAU_VERD["Blau-verd"]
        LB1["Balancejador"] -->|"100%"| AZ["BLAU v1.4.1<br/>(en producció)"]
        LB1 -.->|"0%"| VE["VERD v1.4.2<br/>(desplegat, provat)"]
        N1["Commutar: el trànsit passa a VERD en un instant.<br/>BLAU es conserva encès per si cal tornar."]
    end

    subgraph CANARI["Canari"]
        LB2["Balancejador"] -->|"95%"| E1["v1.4.1 (9 instàncies)"]
        LB2 -->|"5%"| E2["v1.4.2 (1 instància)"]
        N2["Vigilar errors i latència.<br/>Si tot va bé: 25%, 50%, 100%.<br/>Si no: tornar a 0% immediatament."]
    end

Marcadors de funcionalitat (feature flags). Són el complement que canvia el joc, perquè separen el desplegament de l'activació:

@Service
public class GestorPrestecs {

    private final PropietatsBiblioTech props;

    public Prestec prestar(Isbn isbn, Long idEmpleat, Integer dies) {
        if (props.funcionalitats().reservaAutomatica()) {
            // Codi nou, desplegat pero desactivat fins que es decideixi
            return prestarAmbReservaAutomatica(isbn, idEmpleat, dies);
        }
        return prestarClassic(isbn, idEmpleat, dies);
    }
}
bibliotech:
  funcionalitats:
    reserva-automatica: false          # s'activa amb una variable d'entorn, sense desplegar
    prestec-prioritari: true
    informe-pdf: false

Amb marcadors, desactivar una funcionalitat problemàtica és un canvi de configuració de segons, no un desplegament de tornada enrere de minuts. I permeten desplegar codi incomplet sense risc, cosa que al seu torn permet integrar diàriament en lloc de mantenir branques llargues (12-01).

El preu: cada marcador és una branca més per provar, i els marcadors oblidats s'acumulen. Es retiren tan bon punt la decisió és definitiva.

  1. Tornada enrere i el límit de la base de dades

Tornar enrere el codi és fàcil:

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

Tornar enrere la base de dades gairebé mai no ho és, i aquesta és la raó:

Canvi És reversible? Per què
ADD COLUMN (nullable) La versió antiga la ignora
CREATE TABLE Ningú no la fa servir
CREATE INDEX Només afecta el rendiment
ADD COLUMN NOT NULL sense default No La versió antiga no l'omple en inserir
DROP COLUMN No Les dades s'han perdut
RENAME COLUMN No La versió antiga busca el nom vell
ALTER TYPE amb pèrdua No Les dades truncades no tornen
Migració de dades Depèn Només si es va desar l'estat anterior

D'aquí la regla del desplegament segur:

La base de dades sempre va per davant i sempre és compatible cap enrere. Primer es desplega la migració compatible; després, el codi que la fa servir. Mai a l'inrevés, i mai les dues coses en el mateix pas si el canvi és destructiu.

Amb el patró expandir-contraure (secció 14), la tornada enrere funciona a les fases 1 i 2. A la fase 3, ja no: per això la fase 3 s'aplica dies després, quan la nova versió està confirmada.

Còpies de seguretat — i la part que gairebé ningú no fa:

# Còpia diària
pg_dump -h db -U bibliotech -Fc bibliotech > bibliotech-$(date +%F).dump

# Còpia prèvia a QUALSEVOL migració destructiva
pg_dump -h db -U bibliotech -Fc bibliotech > pre-migracio-v8-$(date +%F-%H%M).dump

Nota. Una còpia de seguretat que mai no s'ha restaurat no és una còpia de seguretat: és un fitxer amb esperances. Programa una restauració de prova periòdica en un entorn a part i mesura quant triga. Aquest temps és el teu RTO (temps objectiu de recuperació) real, i sol ser molt més gran del que la gent suposa. Igual d'important és l'RPO (punt objectiu de recuperació): amb còpies diàries, pots perdre fins a 24 hores de dades. Si això és inacceptable, necessites arxivat de WAL o replicació.

  1. Canonada de lliurament continu

La CI de 12-05 verificava. El lliurament continu a més construeix la imatge, la publica i la desplega.

flowchart LR
    A["Push a main"] --> B["CI: proves<br/>cobertura, anàlisi"]
    B --> C["Construir imatge<br/>multiarquitectura"]
    C --> D["Publicar al<br/>registre"]
    D --> E["Desplegar a<br/>preproducció"]
    E --> F["Proves de fum"]
    F --> G{"Aprovació<br/>manual"}
    G -->|"aprovat"| H["Desplegar a<br/>producció"]
    H --> I["Verificar salut"]
    I -->|"fallada"| J["Tornada enrere<br/>automàtica"]

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

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

env:
  REGISTRE: ghcr.io
  IMATGE: ${{ github.repository }}

permissions:
  contents: read
  packages: write
  id-token: write          # per signar la imatge amb cosign

jobs:

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

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

    steps:
      - uses: actions/checkout@v4

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

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

      - name: Autenticar-se al registre
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRE }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Calcular etiquetes i metadades
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRE }}/${{ env.IMATGE }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=,format=short          # traçabilitat al commit
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Construir i 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                    # memòria cau de capes entre execucions
          cache-to: type=gha,mode=max
          provenance: true
          sbom: true                              # inventari de components (12-07)

      - name: Analitzar vulnerabilitats de la imatge
        uses: aquasecurity/[email protected]
        with:
          image-ref: ${{ env.REGISTRE }}/${{ env.IMATGE }}@${{ steps.construir.outputs.digest }}
          severity: 'CRITICAL,HIGH'
          exit-code: '1'                          # bloqueja si hi ha vulnerabilitats greus

      - name: Signar la imatge
        uses: sigstore/cosign-installer@v3
      - run: cosign sign --yes ${{ env.REGISTRE }}/${{ env.IMATGE }}@${{ steps.construir.outputs.digest }}

  # ---------------------------------------------------------------
  # 3. Preproducció: automàtic
  # ---------------------------------------------------------------
  preproduccio:
    name: Desplegar a preproducció
    runs-on: ubuntu-latest
    needs: imatge
    environment:
      name: preproduccio
      url: https://preproduccio.bibliotech.nexussoftware.com

    steps:
      - name: Desplegar
        run: |
          kubectl set image deployment/bibliotech \
            bibliotech=${{ env.REGISTRE }}/${{ env.IMATGE }}@${{ needs.imatge.outputs.digest }} \
            --namespace=preproduccio
          kubectl rollout status deployment/bibliotech -n preproduccio --timeout=5m

      - name: Proves de fum
        run: |
          BASE=https://preproduccio.bibliotech.nexussoftware.com
          curl -fsS "$BASE/actuator/health/readiness" | jq -e '.status == "UP"'
          curl -fsS "$BASE/api/materials?size=1"      | jq -e '.contingut | length >= 0'
          echo "Proves de fum correctes"

  # ---------------------------------------------------------------
  # 4. Producció: requereix aprovació manual
  # ---------------------------------------------------------------
  produccio:
    name: Desplegar a producció
    runs-on: ubuntu-latest
    needs: [imatge, preproduccio]
    if: startsWith(github.ref, 'refs/tags/v')     # només des d'una etiqueta de versió
    environment:
      name: produccio                             # amb revisors obligatoris configurats
      url: https://bibliotech.nexussoftware.com

    steps:
      - name: Desar la revisió actual, per si cal tornar
        id: actual
        run: |
          ACTUAL=$(kubectl get deployment/bibliotech -n produccio \
                   -o jsonpath='{.spec.template.spec.containers[0].image}')
          echo "imatge_anterior=$ACTUAL" >> $GITHUB_OUTPUT

      - name: Desplegar (actualització progressiva)
        run: |
          kubectl set image deployment/bibliotech \
            bibliotech=${{ env.REGISTRE }}/${{ env.IMATGE }}@${{ needs.imatge.outputs.digest }} \
            --namespace=produccio
          kubectl rollout status deployment/bibliotech -n produccio --timeout=10m

      - name: Verificar salut després del desplegament
        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 (intent $i)"; sleep 5
            else
              echo "::error::Verificacio de salut fallida"; exit 1
            fi
          done
          # Comprovar que la taxa d'errors no s'ha disparat
          ERRORS=$(curl -fsS "$BASE/actuator/metrics/http.server.requests?tag=outcome:SERVER_ERROR" \
                    | jq '.measurements[0].value // 0')
          if (( $(echo "$ERRORS > 10" | bc -l) )); then
            echo "::error::Massa errors 5xx despres del desplegament"; exit 1
          fi

      - name: Tornada enrere automàtica si alguna cosa ha fallat
        if: failure()
        run: |
          echo "::warning::Desplegament fallit; tornant a la versio anterior"
          kubectl rollout undo deployment/bibliotech -n produccio
          kubectl rollout status deployment/bibliotech -n produccio --timeout=5m

      - name: Notificar el resultat
        if: always()
        run: |
          ESTAT="${{ job.status }}"
          curl -X POST "${{ secrets.WEBHOOK_EQUIP }}" \
            -H 'Content-Type: application/json' \
            -d "{\"text\":\"Desplegament de BiblioTech ${{ github.ref_name }}: $ESTAT\"}"

Punts clau d'aquesta canonada:

Decisió Motiu
Desplegar per digest, no per etiqueta Una etiqueta es pot moure; un digest identifica bytes exactes
Anàlisi de vulnerabilitats bloquejant No publicar una imatge amb CVE crítics (12-07)
Signatura amb cosign Verificable: aquesta imatge la va construir la nostra canonada
Preproducció automàtic, producció manual Velocitat on no hi ha risc, control on sí
environment amb revisors L'aprovació és del sistema, no un missatge en un xat
Verificació de salut + tornada enrere automàtica Un desplegament fallit es reverteix en 2 minuts, sense humans
Només des d'etiqueta v* a producció Tot desplegament de producció té una versió identificable

  1. Escalat horitzontal i què exigeix de l'aplicació

Escalar verticalment és donar més recursos a una instància; escalar horitzontalment, tenir més instàncies. La segona és la que dona alta disponibilitat i escala sense límit superior, però exigeix propietats de l'aplicació.

Requisits, tots ells verificables:

Requisit Per què Estat de BiblioTech
Sense estat en memòria La petició 2 pot anar a una altra instància ✅ Res en memòria des de 12-01
Sessió compartida o sense sessió Ídem ✅ API sense estat; JWT a 12-07
Sense fitxers locals Cada instància té el seu disc ⚠️ Revisar exportacions
Tasques programades coordinades Tres instàncies = tres execucions del mateix @Scheduled ⚠️ Pendent
Memòria cau distribuïda o local coherent Les memòries cau locals divergeixen ⚠️ Revisar CatalegAmbCache
Migracions amb bloqueig Tres instàncies arrencant alhora ✅ Flyway fa servir bloqueig

Aquí es reprèn la sessió del mòdul 7. Qualsevol estat que avui visqui en memòria —el Map de sessions, la memòria cau local, la pila de desfer de la CLI— deixa de funcionar amb més d'una instància. La solució no és «no escalar», sinó treure aquest estat a un lloc compartit: base de dades, Redis, o eliminar-lo per disseny.

El problema de les tasques programades, que és el més freqüent i el que més sorprèn:

// AMB 3 INSTANCIES: aixo envia TRES avisos a cada empleat, tots els dies
@Scheduled(cron = "0 0 8 * * *")
public void enviarAvisosDiaris() {
    serveiAvisos.avisarVencimentsProxims(3);
}

Solucions, de menor a major robustesa:

// Opcio A: ShedLock — bloqueig distribuit a la base de dades
@Scheduled(cron = "0 0 8 * * *")
@SchedulerLock(name = "avisosDiaris", lockAtMostFor = "10m", lockAtLeastFor = "1m")
public void enviarAvisosDiaris() {
    serveiAvisos.avisarVencimentsProxims(3);
}
# Opció B: un CronJob de Kubernetes que invoca la CLI de 12-03.
# Avantatge: el planificador no viu a l'aplicació.
apiVersion: batch/v1
kind: CronJob
metadata:
  name: bibliotech-avisos
spec:
  schedule: "0 8 * * *"
  concurrencyPolicy: Forbid           # no encavalcar execucions
  jobTemplate:
    spec:
      backoffLimit: 3
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: cli
              image: registre.nexussoftware.com/bibliotech-cli:1.4.2
              args: ["avisos", "enviar", "--dies-antelacio=3"]
              envFrom:
                - secretRef: { name: bibliotech-secrets }

I l'escalat automàtic, que a Kubernetes és declaratiu:

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 reduir a la primera baixada: evita oscil·lació

Amb un avís important: escalar l'aplicació no escala la base de dades. Deu instàncies amb 20 connexions cadascuna són 200 connexions a PostgreSQL, que per defecte n'accepta 100. Ajusta el pool o posa un pgbouncer al davant. El coll d'ampolla es mou; no desapareix.

Errors Comuns i Consells

1. Fer servir l'etiqueta latest en producció. No saps què s'està executant, la tornada enrere no funciona i dues instàncies poden acabar amb versions diferents. Etiquetes semàntiques o digests.

2. Executar el contenidor com a root. És el valor per defecte i és una vulnerabilitat gratuïta. USER no root, sempre.

3. Ficar secrets a la imatge. Queden a l'històric de capes; docker history els revela. Variables d'entorn o gestors de secrets.

4. No limitar la memòria del contenidor. Un procés Java pot consumir tota la de l'amfitrió i tombar els veïns.

5. Limitar la memòria sense ajustar MaxRAMPercentage. El 25 % per defecte malgasta recursos, i no comptar la memòria fora del heap causa OOMKilled sense cap missatge de la JVM.

6. ddl-auto: update en producció. No esborra, no reanomena, no versiona, no és reversible i falla amb diverses instàncies arrencant alhora. Flyway.

7. Modificar una migració ja aplicada. Flyway detecta el canvi de suma de comprovació i l'aplicació no arrenca. Per corregir, un script nou.

8. No provar la tornada enrere. És el que necessites exactament el dia que tot va malament. Prova-la a preproducció, cronometrada.

9. Sondes mal configurades. Confondre liveness i readiness fa que l'aplicació es reiniciï en bucle quan la base de dades té un problema passatger, convertint una incidència menor en una caiguda total.

10. Oblidar dumb-init o exec. Sense ells, SIGTERM no arriba a la JVM i l'aturada ordenada no passa: peticions tallades a cada desplegament.

11. Tasques programades sense coordinar. Amb tres instàncies, tres correus a cada empleat. ShedLock o un CronJob extern.

12. Còpies de seguretat que mai no s'han restaurat. No són còpies de seguretat. Prova la restauració i cronometra-la.

Consell final: la millor mesura de la qualitat d'un desplegament és quant es triga a tornar enrere. Si són trenta segons, desplegaràs sovint i amb tranquil·litat. Si són dues hores, desplegaràs poc, en lots grans i amb por — que és precisament el que fa que els desplegaments surtin malament.

Exercicis

Exercici 1: Dockerfile per a la CLI

Escriu el Dockerfile multietapa del mòdul bibliotech-consola (12-03), tenint en compte que:

  • La CLI s'executa i acaba: no és un servei de llarga vida.
  • Ha d'arrencar el més ràpid possible (s'invoca des de cron).
  • No necessita port exposat ni comprovació de salut.
  • Ha d'acceptar arguments: docker run bibliotech-cli cataleg llistar --format=json.
  • Ha de ser usable com a imatge d'un CronJob de Kubernetes.
  • Optimitza les opcions de la JVM per a l'arrencada, no per al rendiment sostingut.

Inclou a més el CronJob de Kubernetes que envia els avisos diaris.

Exercici 2: migració en dues fases

BiblioTech ha de partir el camp empleats.nom (que avui conté «Marta Ruiz») en nom i cognoms, sense tall de servei i amb tornada enrere possible en tot moment.

Escriu:

  • Els scripts de Flyway de les tres fases.
  • Quina versió de l'aplicació acompanya cada fase i què fa el seu codi.
  • El pla de desplegament amb els punts on la tornada enrere és possible i on deixa de ser-ho.
  • Una prova que verifiqui que la migració és correcta amb dades reals, inclosos els casos difícils (un sol nom, cognoms compostos, noms amb partícules).

Exercici 3: canonada amb blau-verd

Escriu el flux de GitHub Actions que desplegui BiblioTech amb estratègia blau-verd a Kubernetes:

  • Determinar quin és el color actiu actualment.
  • Desplegar la versió nova al color inactiu.
  • Executar proves de fum contra el color inactiu, sense trànsit real.
  • Commutar el trànsit canviant el selector del Service.
  • Vigilar cinc minuts i tornar enrere automàticament si la taxa d'errors puja.
  • Deixar el color anterior encès una hora abans de retirar-lo.

Inclou els manifestos de Kubernetes necessaris.


Solucions

Solució 1

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

WORKDIR /build

COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY bibliotech-domini/pom.xml           bibliotech-domini/
COPY bibliotech-aplicacio/pom.xml        bibliotech-aplicacio/
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-domini/src           bibliotech-domini/src
COPY bibliotech-aplicacio/src        bibliotech-aplicacio/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 extret

# ==============================================================================
# ETAPA 2: GENERAR L'ARXIU CDS
# S'executa l'aplicació una vegada amb --help perquè carregui les classes,
# i se'n memoritza el resultat. Això retalla ~1,5 s de cada arrencada, que
# multiplicat per les execucions de cron sí que importa.
# ==============================================================================
FROM eclipse-temurin:21-jre-alpine AS cds

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

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

# ==============================================================================
# ETAPA 3: EXECUCIÓ
# ==============================================================================
FROM eclipse-temurin:21-jre-alpine AS execucio

LABEL org.opencontainers.image.title="BiblioTech CLI" \
      org.opencontainers.image.description="Eina de linia d ordres de BiblioTech" \
      org.opencontainers.image.vendor="Nexus Software"

# tzdata per a les dates; dumb-init perquè Ctrl+C i SIGTERM arribin al procés
# (la cancel·lació neta de 12-03 en depèn).
# NO s'instal·la curl: no hi ha healthcheck per fer.
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

# Opcions ORIENTADES A L'ARRENCADA, no al rendiment sostingut.
# El procés viu segons: compilar a fons amb C2 no s'amortitza mai.
#   TieredStopAtLevel=1    només C1: compilació ràpida i lleugera
#   UseSerialGC            el GC més barat d'inicialitzar (una sola tasca, poca memòria)
#   SharedArchiveFile      fa servir el CDS de l'etapa 2
#   MaxRAMPercentage=75    conscient del cgroup, com sempre
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"

# SENSE EXPOSE: la CLI no escolta en cap port.
# SENSE HEALTHCHECK: el procés acaba; no hi ha res a vigilar.

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

# CMD són els arguments PER DEFECTE, substituïbles en executar
CMD ["--help"]
docker build -f Dockerfile.cli -t bibliotech-cli:1.4.2 .

docker run --rm bibliotech-cli:1.4.2 cataleg llistar --format=json
docker run --rm -e BIBLIOTECH_DB_URL=… bibliotech-cli:1.4.2 avisos enviar --dies-antelacio=3

# Comprovar l'efecte del CDS
docker run --rm bibliotech-cli:1.4.2 --version   # ~1,1 s en lloc de ~2,6 s

L'ENTRYPOINT mereix explicació, perquè és la part on més gent s'encalla: la forma amb sh -c i "$@" permet alhora expandir $JAVA_OPTS i rebre els arguments de l'usuari. El -- final és el $0 de l'script, sense el qual el primer argument de l'usuari es perdria.

El CronJob de Kubernetes:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: bibliotech-avisos-diaris
  labels:
    app: bibliotech
    component: tasques-programades
spec:
  schedule: "0 8 * * 1-5"           # de dilluns a divendres a les 8:00
  timeZone: "Europe/Madrid"         # Kubernetes 1.27+: fonamental amb l'horari d'estiu

  concurrencyPolicy: Forbid         # si l'anterior continua corrent, NO llançar-ne una altra
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  startingDeadlineSeconds: 600      # si el clúster estava caigut, hi ha 10 min de marge

  jobTemplate:
    spec:
      backoffLimit: 2               # 2 reintents davant d'una fallada
      activeDeadlineSeconds: 900    # matar si supera els 15 minuts
      ttlSecondsAfterFinished: 86400

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

          securityContext:
            runAsNonRoot: true
            runAsUser: 1001

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

              args:
                - "avisos"
                - "enviar"
                - "--dies-antelacio=3"
                - "--silencios"        # sense decoració: la sortida va al log

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

              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: {}

I aquí és on es cobra el disseny de 12-03: els codis de sortida governen el comportament de Kubernetes. El codi 0 (correcte) i el 3 (res a enviar) marquen el Job com a reeixit; el 7 (correu no disponible) el marca com a fallit i backoffLimit: 2 reintenta automàticament. Sense aquests codis diferenciats, o es reintentaria sempre o mai.

kubectl get cronjob bibliotech-avisos-diaris
kubectl create job --from=cronjob/bibliotech-avisos-diaris prova-manual   # executar ja
kubectl logs job/prova-manual

Solució 2

Fase 1 — Expandir. Migració compatible amb l'aplicació v1.4.x, que continua fent servir nom.

-- V9__partir_nom_empleat_fase1.sql

ALTER TABLE empleats ADD COLUMN nom_pila VARCHAR(80);
ALTER TABLE empleats ADD COLUMN cognoms  VARCHAR(120);

-- Poblar amb una heurística conservadora:
-- la PRIMERA paraula és el nom; la resta, els cognoms.
-- És imperfecta per a noms compostos ("Josep Maria"), i per això
-- es conserva la columna original i es genera un informe de revisió.
UPDATE empleats
SET nom_pila = split_part(trim(nom), ' ', 1),
    cognoms  = NULLIF(trim(substring(trim(nom) from position(' ' in trim(nom)) + 1)), '')
WHERE nom_pila IS NULL;

-- Cas especial: un sol terme (sense espais) → tot és nom
UPDATE empleats
SET nom_pila = trim(nom), cognoms = NULL
WHERE position(' ' in trim(nom)) = 0;

-- Trigger de sincronia bidireccional: tant se val quina versió de l'app escrigui
CREATE OR REPLACE FUNCTION sincronitzar_nom_empleat() RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        IF NEW.nom_pila IS NULL AND NEW.nom IS NOT NULL THEN
            -- Va escriure l'app v1 (columna antiga): derivar les noves
            NEW.nom_pila := split_part(trim(NEW.nom), ' ', 1);
            NEW.cognoms  := NULLIF(trim(substring(trim(NEW.nom)
                                    from position(' ' in trim(NEW.nom)) + 1)), '');
        ELSIF NEW.nom IS NULL AND NEW.nom_pila IS NOT NULL THEN
            -- Va escriure l'app v2 (columnes noves): derivar l'antiga
            NEW.nom := trim(NEW.nom_pila || ' ' || COALESCE(NEW.cognoms, ''));
        END IF;
    ELSIF TG_OP = 'UPDATE' THEN
        IF NEW.nom IS DISTINCT FROM OLD.nom THEN
            NEW.nom_pila := split_part(trim(NEW.nom), ' ', 1);
            NEW.cognoms  := NULLIF(trim(substring(trim(NEW.nom)
                                    from position(' ' in trim(NEW.nom)) + 1)), '');
        ELSIF NEW.nom_pila IS DISTINCT FROM OLD.nom_pila
           OR NEW.cognoms  IS DISTINCT FROM OLD.cognoms THEN
            NEW.nom := trim(NEW.nom_pila || ' ' || COALESCE(NEW.cognoms, ''));
        END IF;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sincronitzar_nom
    BEFORE INSERT OR UPDATE ON empleats
    FOR EACH ROW EXECUTE FUNCTION sincronitzar_nom_empleat();

-- Vista de revisió manual: els casos que l'heurística probablement va fallar
CREATE OR REPLACE VIEW v_empleats_revisio_nom AS
SELECT id, nom, nom_pila, cognoms,
       CASE
         WHEN nom_pila IN ('Josep','Jose','Maria','Joan','Anna','Lluis','Francesc','Pere')
              AND cognoms LIKE '% %'                     THEN 'possible nom compost'
         WHEN cognoms ~ '^(de|del|dels|la|les|els|van|von|di|da) ' THEN 'cognom amb particula'
         WHEN cognoms IS NULL                            THEN 'sense cognoms'
         ELSE 'revisar'
       END AS motiu
FROM empleats
WHERE nom_pila IN ('Josep','Jose','Maria','Joan','Anna','Lluis','Francesc','Pere')
   OR cognoms ~ '^(de|del|dels|la|les|els|van|von|di|da) '
   OR cognoms IS NULL;

Fase 2 — Migrar. L'aplicació v1.5.0 fa servir les columnes noves.

@Entity
public class Empleat {

    @Column(name = "nom_pila", length = 80)
    private String nomPila;

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

    /**
     * La columna antiga continua existint i la mante el trigger.
     * insertable/updatable a false: JPA MAI no l'escriu.
     * Es conserva mapejada nomes per poder llegir-la si calgues.
     */
    @Column(name = "nom", insertable = false, updatable = false)
    private String nomCompletHeretat;

    public String nomComplet() {
        return cognoms == null ? nomPila : nomPila + " " + cognoms;
    }
}

Fase 3 — Contraure. Només quan v1.4.x ja no existeix en cap entorn.

-- V11__partir_nom_empleat_fase3.sql

-- Verificació prèvia: si alguna cosa va quedar incoherent, AVORTAR
DO $$
DECLARE incoherents INTEGER;
BEGIN
    SELECT count(*) INTO incoherents
    FROM empleats
    WHERE nom_pila IS NULL
       OR trim(nom) IS DISTINCT FROM trim(nom_pila || ' ' || COALESCE(cognoms, ''));

    IF incoherents > 0 THEN
        RAISE EXCEPTION 'Hi ha % empleats amb nom incoherent. Revisa v_empleats_revisio_nom abans de contraure.', incoherents;
    END IF;
END $$;

DROP TRIGGER IF EXISTS trg_sincronitzar_nom ON empleats;
DROP FUNCTION IF EXISTS sincronitzar_nom_empleat();
DROP VIEW IF EXISTS v_empleats_revisio_nom;

ALTER TABLE empleats ALTER COLUMN nom_pila SET NOT NULL;
ALTER TABLE empleats DROP COLUMN nom;

CREATE INDEX idx_empleat_cognoms ON empleats(cognoms, nom_pila);

Pla de desplegament amb els punts de no retorn:

Pas Acció Tornada enrere Durada
1 Còpia de seguretat completa 10 min
2 Aplicar V9 (fase 1) : eliminar columnes i trigger 2 min
3 Verificar la vista de revisió i corregir a mà 1-2 h
4 Desplegar l'app v1.5.0 : tornar a v1.4.x 5 min
5 Vigilar 48 hores 2 dies
6 Aplicar V11 (fase 3) NO. Punt de no retorn 1 min

Prova de la migració amb dades difícils:

@Tag("integracio")
class MigracioNomEmpleatIT extends ProvaAmbPostgres {

    @Test
    void parteixCorrectamentElsNomsConeguts() {
        // Estat inicial: fins a V8, amb la columna antiga
        flywayFinsA("8");
        jdbc.update("""
                insert into empleats (nom, correu, data_alta) values
                    ('Marta Ruiz',              '[email protected]',  '2024-03-01'),
                    ('Diego Alonso',            '[email protected]',  '2025-01-15'),
                    ('Nuria Vidal',             '[email protected]',  '2023-09-10'),
                    ('Josep Maria Ferre Roig',  '[email protected]',  '2022-05-20'),
                    ('Anna de la Torre',        '[email protected]',   '2021-11-02'),
                    ('Prince',                  '[email protected]', '2020-01-01')
                """);

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

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

        // Casos dificils: l'heuristica els divideix malament, i aixo es ESPERAT
        assertThat(consultar("[email protected]"))
                .containsExactly("Josep", "Maria Ferre Roig");     // requereix revisio manual
        assertThat(consultar("[email protected]"))
                .containsExactly("Anna", "de la Torre");

        // Un sol terme
        assertThat(consultar("[email protected]"))
                .containsExactly("Prince", null);

        // I tots apareixen a la vista de revisio, que es l'important:
        // la migracio no preten encertar sempre, preten NO PERDRE DADES
        // i assenyalar el que cal revisar.
        assertThat(jdbc.queryForList("select correu from v_empleats_revisio_nom", String.class))
                .contains("[email protected]", "[email protected]",
                          "[email protected]");
    }

    @Test
    void elTriggerSincronitzaEnLesDuesDireccions() {
        flywayFinsA("9");

        // L'app v1 escriu la columna antiga
        jdbc.update("insert into empleats (nom, correu, data_alta) values (?,?,?)",
                    "Carles Sanz", "[email protected]", Date.valueOf("2026-01-01"));
        assertThat(consultar("[email protected]")).containsExactly("Carles", "Sanz");

        // L'app v2 escriu les columnes noves
        jdbc.update("""
                insert into empleats (nom_pila, cognoms, correu, data_alta)
                values (?,?,?,?)""",
                "Elena", "Ferrer Rico", "[email protected]", Date.valueOf("2026-01-02"));
        assertThat(jdbc.queryForObject(
                "select nom from empleats where correu = ?", String.class,
                "[email protected]"))
                .isEqualTo("Elena Ferrer Rico");
    }

    @Test
    void laFase3AvortaSiQuedenIncoherencies() {
        flywayFinsA("9");
        // Provocar una incoherencia saltant-se el trigger
        jdbc.update("alter table empleats disable trigger trg_sincronitzar_nom");
        jdbc.update("insert into empleats (nom, correu, data_alta) values (?,?,?)",
                    "Sense Partir", "[email protected]", Date.valueOf("2026-01-03"));
        jdbc.update("alter table empleats enable trigger trg_sincronitzar_nom");

        assertThatThrownBy(() -> flywayFinsA("11"))
                .hasMessageContaining("nom incoherent");

        // I el mes important: la columna antiga CONTINUA AQUI. No s'ha perdut res.
        assertThat(existeixColumna("empleats", "nom")).isTrue();
    }
}

Solució 3

Manifestos de Kubernetes:

# k8s/blau-verd/service.yaml
# El Service apunta a UN color. Commutar = canviar el selector.
apiVersion: v1
kind: Service
metadata:
  name: bibliotech
  labels: { app: bibliotech }
spec:
  selector:
    app: bibliotech
    color: blau                  # ← això és el que canvia en la commutació
  ports:
    - port: 80
      targetPort: 8080
---
# Service auxiliar per provar el color inactiu SENSE trànsit real
apiVersion: v1
kind: Service
metadata:
  name: bibliotech-preview
spec:
  selector:
    app: bibliotech
    color: verd                  # s'ajusta abans de les proves de fum
  ports:
    - port: 80
      targetPort: 8080
---
# k8s/blau-verd/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: IMATGE
          ports: [{ name: http, containerPort: 8080 }]
          envFrom:
            - configMapRef: { name: bibliotech-config }
            - secretRef:    { name: bibliotech-secrets }
          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 flux de treball:

# .github/workflows/cd-blau-verd.yml
name: Desplegament blau-verd

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

env:
  NAMESPACE: produccio
  REGISTRE: ghcr.io
  IMATGE: ${{ github.repository }}

jobs:

  # ---------------------------------------------------------------
  # 1. Determinar els colors
  # ---------------------------------------------------------------
  colors:
    runs-on: ubuntu-latest
    outputs:
      actiu:   ${{ steps.detectar.outputs.actiu }}
      inactiu: ${{ steps.detectar.outputs.inactiu }}
    steps:
      - name: Configurar kubectl
        uses: azure/k8s-set-context@v4
        with:
          kubeconfig: ${{ secrets.KUBECONFIG }}

      - name: Detectar el color actiu
        id: detectar
        run: |
          ACTIU=$(kubectl get service bibliotech -n $NAMESPACE \
                  -o jsonpath='{.spec.selector.color}')
          if [ "$ACTIU" = "blau" ]; then INACTIU="verd"; else INACTIU="blau"; fi
          echo "actiu=$ACTIU"     >> $GITHUB_OUTPUT
          echo "inactiu=$INACTIU" >> $GITHUB_OUTPUT
          echo "::notice::Actiu: $ACTIU — es desplegara a: $INACTIU"

  # ---------------------------------------------------------------
  # 2. Desplegar al color inactiu (sense trànsit)
  # ---------------------------------------------------------------
  desplegar-inactiu:
    runs-on: ubuntu-latest
    needs: colors
    steps:
      - uses: actions/checkout@v4
      - uses: azure/k8s-set-context@v4
        with: { kubeconfig: '${{ secrets.KUBECONFIG }}' }

      - name: Generar i aplicar el Deployment del color inactiu
        run: |
          COLOR=${{ needs.colors.outputs.inactiu }}
          IMG=${{ env.REGISTRE }}/${{ env.IMATGE }}:${{ github.ref_name }}

          sed -e "s|COLOR|$COLOR|g" -e "s|IMATGE|$IMG|g" \
              k8s/blau-verd/deployment-plantilla.yaml | kubectl apply -n $NAMESPACE -f -

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

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

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

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

          BASE=http://localhost:18080

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

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

          echo "→ Cataleg"
          curl -fsS "$BASE/api/materials?size=1" | jq -e '.contingut | length >= 0'

          echo "→ Errors esperats (404 i 400)"
          test "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/materials/978-9999999999")" = "404"
          test "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/materials/no-isbn")" = "400"

          kill $PF
          echo "Proves de fum correctes"

  # ---------------------------------------------------------------
  # 4. Commutar el trànsit (amb aprovació manual)
  # ---------------------------------------------------------------
  commutar:
    runs-on: ubuntu-latest
    needs: [colors, fum]
    environment:
      name: produccio            # amb revisors obligatoris
      url: https://bibliotech.nexussoftware.com
    steps:
      - uses: azure/k8s-set-context@v4
        with: { kubeconfig: '${{ secrets.KUBECONFIG }}' }

      - name: Commutar el Service al color nou
        run: |
          kubectl patch service bibliotech -n $NAMESPACE \
            -p '{"spec":{"selector":{"app":"bibliotech","color":"${{ needs.colors.outputs.inactiu }}"}}}'
          echo "::notice::Transit commutat a ${{ needs.colors.outputs.inactiu }}"

  # ---------------------------------------------------------------
  # 5. Vigilar 5 minuts; tornar enrere si la taxa d'error puja
  # ---------------------------------------------------------------
  vigilar:
    runs-on: ubuntu-latest
    needs: [colors, commutar]
    steps:
      - uses: azure/k8s-set-context@v4
        with: { kubeconfig: '${{ secrets.KUBECONFIG }}' }

      - name: Vigilar la taxa d'errors durant 5 minuts
        id: vigilancia
        run: |
          BASE=https://bibliotech.nexussoftware.com
          LLINDAR_ERRORS=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')
            ERRORS=$(curl -fsS "$BASE/actuator/metrics/http.server.requests?tag=outcome:SERVER_ERROR" \
                      | jq '.measurements[] | select(.statistic=="COUNT") | .value // 0')

            TAXA=$(echo "scale=4; $ERRORS / ($TOTAL + 1)" | bc)
            echo "Comprovacio $i/10 — total=$TOTAL errors=$ERRORS taxa=$TAXA"

            if (( $(echo "$TAXA > $LLINDAR_ERRORS" | bc -l) )); then
              echo "::error::Taxa d errors $TAXA per sobre del llindar $LLINDAR_ERRORS"
              exit 1
            fi

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

      - name: TORNADA ENRERE automàtica
        if: failure()
        run: |
          echo "::warning::Tornant al color ${{ needs.colors.outputs.actiu }}"
          # La tornada enrere es INSTANTANIA: el color anterior continua ences i sa
          kubectl patch service bibliotech -n $NAMESPACE \
            -p '{"spec":{"selector":{"app":"bibliotech","color":"${{ needs.colors.outputs.actiu }}"}}}'

          curl -X POST "${{ secrets.WEBHOOK_EQUIP }}" \
            -H 'Content-Type: application/json' \
            -d '{"text":"🔴 BiblioTech ${{ github.ref_name }}: tornada enrere automatica a ${{ needs.colors.outputs.actiu }}"}'
          exit 1

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

      - name: Esperar una hora abans de retirar
        run: sleep 3600      # finestra de seguretat: tornada enrere instantània durant 1 hora

      - name: Reduir el color antic a zero rèpliques
        run: |
          # No s'ESBORRA el Deployment: s'escala a 0.
          # Aixi el manifest es conserva i tornar a aixecar-lo es una ordre.
          kubectl scale deployment/bibliotech-${{ needs.colors.outputs.actiu }} \
            --replicas=0 -n $NAMESPACE

          curl -X POST "${{ secrets.WEBHOOK_EQUIP }}" \
            -H 'Content-Type: application/json' \
            -d '{"text":"✅ BiblioTech ${{ github.ref_name }} estable. Color ${{ needs.colors.outputs.actiu }} retirat."}'

Avantatges de blau-verd enfront de l'actualització progressiva, que és el que avalua l'exercici:

Aspecte Progressiva Blau-verd
Tornada enrere Progressiva inversa: minuts Instantània: un patch
Convivència de versions Sí, inevitable No: tot el trànsit va a una
Provar abans d'exposar No , amb el Service de previsualització
Recursos necessaris 1× + 1 pod durant la transició
Compatibilitat d'esquema Obligatòria Recomanable igualment, per la tornada enrere

I el punt que tanca el cercle amb la secció 21: la tornada enrere instantània només funciona si la base de dades és compatible amb les dues versions. Si la versió nova va aplicar una migració destructiva, commutar el Service de tornada al color antic no arregla res: l'aplicació antiga es trobarà un esquema que no entén. Per això el patró expandir-contraure no és opcional, sinó la condició que fa que blau-verd signifiqui alguna cosa.

Conclusió

BiblioTech està en producció.

Entens què diferencia realment el teu portàtil d'un entorn real —versió de Java, configuració, esquema, memòria, conseqüències d'una fallada— i els tres principis que governen un desplegament sa: un artefacte per a tots els entorns, configuració des de fora i tot ha de poder desfer-se.

Empaquetes en jar executable, sabent per què va desplaçar el war i com està construït per dins. I fas servir el jar per capes, que no és un detall: converteix un desplegament de 60 MB en un d'1 MB, de quaranta segons a tres, i amb això canvia el comportament de l'equip — perquè un desplegament de tres segons es fa sense pensar-hi i un de dos minuts s'acumula «per al dijous». Amb construcció reproduïble i traçabilitat al commit, de manera que «quina versió hi ha en producció?» té una resposta exacta en un segon.

Contenidoritzes amb un Dockerfile multietapa que entens línia a línia: l'etapa de construcció amb Maven que no arriba a la imatge final; els POM copiats abans que el codi perquè la memòria cau de dependències funcioni; l'usuari no root; les capes ordenades per estabilitat; dumb-init com a PID 1 sense el qual SIGTERM no arriba a la JVM; i les opcions de la JVM amb MaxRAMPercentage i ExitOnOutOfMemoryError. Amb un .dockerignore que impedeix que secrets i el directori .git entrin al context, i coneixent les alternatives —Buildpacks i Jib— amb els seus avantatges reals.

Saps el que gairebé ningú no sap sobre la JVM en un contenidor: que des de Java 10 és conscient dels cgroups, que el 25 % per defecte malgasta memòria, i sobretot que la memòria de la JVM no és només el heap — metaspace, piles, memòria cau de codi, búfers directes —, que és la raó per la qual un contenidor Java mor amb OOMKilled sense que l'aplicació registri res.

Governes l'esquema amb Flyway, amb les sis raons concretes per les quals ddl-auto: update no val en producció, la convenció de versions, les regles d'or —un script aplicat mai no es modifica— i el patró expandir-contraure en tres fases, que és el que permet que una migració convisqui amb dues versions de l'aplicació i que la tornada enrere continuï sent possible.

Tries on desplegar amb criteri, sabent que systemd sobre un servidor propi continua sent una resposta perfectament vàlida i que adoptar Kubernetes per a tres serveis sense equip de plataforma és una decisió que es paga cada setmana. I si és Kubernetes, tens un Deployment amb maxUnavailable: 0, etiquetes exactes en lloc de latest, límits de recursos i PodDisruptionBudget.

Exposes sondes distingint vitalitat de disponibilitat —la diferència entre reiniciar totes les instàncies perquè la base de dades va tenir un singlot i simplement treure-les del balancejador— amb indicadors propis que marquen degradat en lloc de caigut per a les dependències no essencials. Atures de manera ordenada amb server.shutdown: graceful, @PreDestroy i l'espera del preStop que cobreix la finestra de cursa amb el balancejador. I coneixes les opcions d'arrencada ràpida —CDS, AOT, CRaC, Native Image— amb el criteri de quan compensa cadascuna.

Manejes les quatre estratègies de desplegament amb els seus costos, els marcadors de funcionalitat que separen el desplegament de l'activació, i el límit dur de la tornada enrere: el codi torna, les dades no. D'aquí la regla que ho resumeix tot: la base de dades va per davant i sempre és compatible cap enrere.

Tens la canonada de lliurament continu completa: construcció multiarquitectura, anàlisi de vulnerabilitats bloquejant, signatura de la imatge, desplegament per digest, preproducció automàtica, producció amb aprovació, verificació de salut posterior i tornada enrere automàtica. I saps què exigeix l'escalat horitzontal de l'aplicació —sense estat en memòria, tasques programades coordinades amb ShedLock o CronJob, i el recordatori que escalar l'aplicació no escala la base de dades.

BiblioTech funciona, està provat, està desplegat i es pot actualitzar sense tallar el servei. I té dos forats de la mida d'un projecte sencer.

El primer: qualsevol pot fer qualsevol cosa. No hi ha autenticació, no hi ha autorització, les contrasenyes no existeixen, l'API està oberta i ningú no ha revisat si és vulnerable a les coses que fan que les aplicacions surtin a les notícies.

El segon: quan alguna cosa falli, te n'assabentaràs per una trucada. No hi ha mètriques, no hi ha traces, no hi ha alertes, i l'únic registre és text pla en un contenidor efímer.

La lliçó final tanca tots dos, i tanca el curs: seguretat —vulnerabilitats comunes i la seva prevenció concreta, Spring Security amb BCrypt i JWT, i l'advertiment sobre el que un curs no pot substituir—, observabilitat —els tres pilars, Micrometer i Actuator, Prometheus i Grafana, traces distribuïdes i alertes útils—, i evolució —versionat de l'API, deute tècnic, actualitzacions i com fer créixer el sistema. I al final, la recapitulació del viatge complet de BiblioTech, el que saps fer ara, i per on continuar.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats