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
- Què significa desplegar
- Empaquetatge: jar executable enfront de war
- El jar per capes i per què accelera les imatges
- Construcció reproduïble i traçabilitat
- Contenidors: imatge i contenidor
- El
Dockerfilede BiblioTech, línia a línia .dockerignore- Alternatives sense
Dockerfile: Buildpacks i Jib - Elecció d'imatge base i mida
- La JVM dins d'un contenidor
docker-composeper a l'entorn local- Configuració i secrets al desplegament
- Migracions de base de dades amb Flyway
- Desplegament en dues fases per a canvis d'esquema
- On es desplega: comparativa de plataformes
- Kubernetes: un
Deploymentmínim - Arrencada i salut: sondes d'Actuator
- Aturada ordenada
- Temps d'arrencada: CDS i Native Image
- Estratègies de desplegament
- Tornada enrere i el límit de la base de dades
- Canonada de lliurament continu
- Escalat horitzontal i què exigeix de l'aplicació
- Errors Comuns i Consells
- Exercicis
- Conclusió
- 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çó:
- 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.
- Configuració des de fora. Res específic de l'entorn dins de l'artefacte.
- Tot ha de poder desfer-se. Un desplegament sense tornada enrere és una aposta.
- 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 | Sí |
| 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>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
- 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>
- 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 |
- 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ó.
- El
Dockerfile de BiblioTech, línia a línia
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.JarLauncherConstrucció 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/healthEls cinc punts del Dockerfile que més importen, per si cal recordar-ne només cinc:
- 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.
- Els POM abans que el codi: la memòria cau de dependències es conserva entre construccions.
- Usuari no root: mitigació bàsica i obligatòria.
- Capes ordenades per estabilitat: desplegaments d'1 MB en lloc de 60 MB.
dumb-init+exec: sense ells,SIGTERMno arriba a la JVM i l'aturada ordenada no passa.
.dockerignore
.dockerignoreSense 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.yamlComprovació 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.
- Alternatives sense
Dockerfile: Buildpacks i Jib
Dockerfile: Buildpacks i JibCloud 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 localComparativa:
| Criteri | Dockerfile |
Buildpacks | Jib |
|---|---|---|---|
| Control total | Sí | No | Parcial |
| Requereix Docker per construir | Sí | Sí | 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.
- 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 ... shper 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
- 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 MBLes 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 summaryRegles 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.
docker-compose per a l'entorn local
docker-compose per a l'entorn localA 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 dadesdepends_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.
- 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.2va 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:
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
Secretde 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
- 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 | Sí |
none |
No fa res | Sí |
Per què update no val, amb precisió:
- No elimina res. Columnes i taules esborrades del model es queden per sempre.
- No reanomena. Canviar
data_vencadata_vencimentcrea una columna nova i deixa les dades a la vella. - No versiona. No hi ha manera de saber quin esquema té un entorn ni de reproduir-lo.
- No és reversible. No hi ha tornada enrere.
- Depèn de l'ordre d'arrencada. Amb diverses instàncies arrencant alhora, poden generar DDL simultani i bloquejar-se.
- No fa migració de dades. Partir
nom_completennomicognomsé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'últimaConvenció 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());
}
- 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:
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.
- 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.targetsudo systemctl daemon-reload
sudo systemctl enable --now bibliotech
sudo systemctl status bibliotech
sudo journalctl -u bibliotech -fCriteris 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.
- Kubernetes: un
Deployment mínim
Deployment mínimapiVersion: 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: bibliotechkubectl 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 immediataDos detalls que mereixen atenció especial:
maxUnavailable: 0garanteix 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. Amblatestno saps què s'està executant, la tornada enrere no funciona i dos pods poden acabar amb versions diferents.
- 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.
- 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: 30sLa 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-seI 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.
- 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.
- Estratègies de desplegament
| Estratègia | Com funciona | Tall | Cost | Tornada enrere | Quan |
|---|---|---|---|---|---|
| Recreació | Aturar tot, arrencar el nou | Sí | 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: falseAmb 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.
- 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 bibliotechTornar enrere la base de dades gairebé mai no ho és, i aquesta és la raó:
| Canvi | És reversible? | Per què |
|---|---|---|
ADD COLUMN (nullable) |
Sí | La versió antiga la ignora |
CREATE TABLE |
Sí | Ningú no la fa servir |
CREATE INDEX |
Sí | 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).dumpNota. 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ó.
- 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 |
- 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
CronJobde 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 sL'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-manualSolució 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) | Sí: eliminar columnes i trigger | 2 min |
| 3 | Verificar la vista de revisió i corregir a mà | Sí | 1-2 h |
| 4 | Desplegar l'app v1.5.0 | Sí: tornar a v1.4.x | 5 min |
| 5 | Vigilar 48 hores | Sí | 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: 5El 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 | Sí, amb el Service de previsualització |
| Recursos necessaris | 1× + 1 pod | 2× 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
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
