CicloUrbana és observable, sap en quin entorn viu i treballa pel seu compte. I continua sent un JAR que algú ha d'arrencar a mà en una màquina amb Java 21 instal·lat, la zona horària correcta i les variables d'entorn ben posades. Aquest «algú» i aquestes condicions són l'última baula artesanal del projecte: la causa que funcioni en un servidor i no en un altre, i que el desplegament depengui d'una llista de passos al cap d'una persona.
Aquesta lliçó ho elimina empaquetant l'aplicació, el seu JRE i la seva configuració en una imatge de contenidor reproduïble. Veurem per què el Dockerfile que tothom escriu primer està malament, com aprofitar les capes del JAR d'Spring Boot perquè reconstruir després de canviar una línia trigui segons, quina imatge base triar, l'alternativa dels buildpacks, com es comporta la JVM dins d'un contenidor, el docker-compose.yml complet de la xarxa de Ribalta amb PostgreSQL i les seves comprovacions de salut enganxades a les sondes de 07-01, i les pràctiques de seguretat que eviten publicar una imatge amb secrets a dins.
Contingut
- Per què conteniritzar
- Els conceptes mínims de Docker
- El
Dockerfileingenu i per què està malament - El JAR d'Spring Boot per capes
- El
Dockerfilemultietapa de CicloUrbana - Triar la imatge base
- Buildpacks:
spring-boot:build-image - La JVM dins d'un contenidor
- El
docker-compose.ymlde la xarxa de Ribalta - Configurar l'aplicació al contenidor
spring-boot-docker-composeper a desenvolupament- Imatges natives amb GraalVM
- Seguretat de la imatge
- Publicar en un registre i etiquetar
- Errors Comuns i Consells
- Exercicis
- Per què conteniritzar
Un contenidor empaqueta l'aplicació i tot allò que necessita per executar-se —el JRE, les biblioteques del sistema, la configuració per defecte, la zona horària— en un artefacte únic i immutable. El que resol, en concret:
| Problema sense contenidor | Com ho resol la imatge |
|---|---|
| «A la meva màquina funciona» | L'entorn d'execució viatja dins de l'artefacte |
| Instal·lar Java 21 a cada servidor i mantenir-lo | El JRE és una capa de la imatge |
| Un servidor amb la zona horària mal posada | Es fixa a la imatge i és idèntica a tot arreu |
| Desplegament com a llista de passos manuals | docker run o un manifest declaratiu |
| Tornar a la versió anterior | Arrencar l'etiqueta anterior de la imatge |
| Executar dues versions alhora per migrar | Dos contenidors, sense conflicte de dependències |
I una conseqüència estratègica: la imatge és la unitat que entenen Kubernetes (08-04), els serveis gestionats d'AWS (08-03) i les canalitzacions de lliurament continu (08-05). Conteniritzar no és un fi en si mateix; és el requisit per a tot el que ve després.
Convé ser honest amb allò que no resol. Un contenidor no aïlla com una màquina virtual —comparteix el nucli de l'amfitrió—, no arregla una aplicació amb estat a disc local, i no fa que una configuració dolenta sigui bona: si el perfil prod no s'activa, s'activarà malament dins del contenidor exactament igual que fora (07-02).
- Els conceptes mínims de Docker
| Concepte | Què és | A CicloUrbana |
|---|---|---|
| Imatge | Plantilla immutable de només lectura amb un sistema de fitxers | ciclourbana:2.4.0 |
| Capa | Cada instrucció del Dockerfile produeix una capa apilada i cauejable |
La capa del JRE, la de dependències, la del codi |
| Contenidor | Una instància en execució d'una imatge, amb una capa escrivible a sobre | El procés que atén el port 8080 |
Dockerfile |
Recepta de construcció d'una imatge | A l'arrel del repositori |
| Registre | Magatzem d'imatges publicades | Docker Hub, GHCR, ECR |
| Etiqueta (tag) | Nom de versió d'una imatge | 2.4.0, latest, sha-9f3a2b1 |
| Volum | Emmagatzematge persistent fora del cicle del contenidor | Les dades de PostgreSQL |
| Xarxa | Espai on els contenidors es veuen pel nom | app arriba a postgres per DNS |
La idea clau de tot el capítol són les capes. Una imatge és una pila de capes de només lectura; en reconstruir, Docker reutilitza de la memòria cau totes les capes anteriors al primer canvi i refà només les posteriors. I en publicar, només es transfereixen les capes que el registre no té. Tota l'optimització de l'apartat 4 consisteix a posar allò que canvia poc a baix i allò que canvia molt a dalt.
- El
Dockerfile ingenu i per què està malament
Dockerfile ingenu i per què està malamentAquest és el fitxer que gairebé tothom escriu primer:
FROM eclipse-temurin:21-jre
COPY target/ciclourbana-2.4.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]Funciona. I té cinc problemes seriosos:
| Problema | Conseqüència |
|---|---|
| El JAR sencer és una sola capa | Canviar una línia d'EstacioService invalida els 60 MB complets: es reconstrueix i es puja tot |
| Requereix haver compilat abans | La construcció depèn del Maven local; ningú no garanteix que sigui el mateix que el d'integració contínua |
S'executa com a root |
Una vulnerabilitat de l'aplicació s'executa amb l'usuari més privilegiat del contenidor |
| Imatge base completa | 21-jre sense sufix arrossega desenes de paquets del sistema que no es fan servir mai: superfície d'atac i pes |
ENTRYPOINT amb java -jar |
Spring Boot ha de descomprimir i resoldre el JAR a cada arrencada, i els senyals arriben pitjor al procés |
El primer és el que més fa mal en el dia a dia. En un cicle de desenvolupament normal les dependències canvien una vegada al mes i el codi canvia vint vegades al dia; amb un JAR monolític, cadascun d'aquests vint canvis reconstrueix i transfereix Spring Framework, Hibernate, Jackson i el driver de PostgreSQL sencers.
- El JAR d'Spring Boot per capes
Spring Boot resol el problema publicant el JAR ja dividit en capes ordenades de menys a més volàtil:
| Capa | Contingut | Freqüència de canvi |
|---|---|---|
dependencies |
Dependències de versió estable | Molt baixa |
spring-boot-loader |
El carregador del JAR executable | Gairebé mai |
snapshot-dependencies |
Dependències -SNAPSHOT |
Mitjana |
application |
El teu codi i els teus recursos | Altíssima |
S'inspecciona i s'extreu amb el jarmode del mateix JAR. A Spring Boot 3.3 i posteriors:
java -Djarmode=tools -jar target/ciclourbana.jar list-layers
java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extretEn versions anteriors el mode s'anomenava layertools (java -Djarmode=layertools -jar app.jar extract); el concepte és idèntic i val la pena conèixer tots dos noms, perquè la documentació i els exemples que circulen barregen els dos.
El resultat és un arbre de directoris, un per capa, que es copien a la imatge en ordre. Com que la capa application pesa uns pocs centenars de kilobytes i va l'última, un canvi al codi reconstrueix només aquesta: el cicle de construcció i publicació passa de minuts a segons.
- El
Dockerfile multietapa de CicloUrbana
Dockerfile multietapa de CicloUrbana# ---------- Etapa 1: construcció ----------
FROM maven:3.9-eclipse-temurin-21 AS construccio
WORKDIR /construccio
# 1. Només el POM: aquesta capa es cauja mentre no canviïn les dependències
COPY pom.xml .
RUN mvn -B dependency:go-offline
# 2. Ara el codi: canvia a diari, però les dependències ja estan descarregades
COPY src ./src
RUN mvn -B clean package -DskipTests
# 3. Descompondre el JAR resultant en les seves capes
RUN java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extret
# ---------- Etapa 2: execució ----------
FROM eclipse-temurin:21-jre-alpine AS execucio
# Usuari sense privilegis: no executar mai com a root
RUN addgroup -S ciclo && adduser -S ciclo -G ciclo
WORKDIR /app
# Capes de menys a més volàtil: aprofita la memòria cau a cada reconstrucció
COPY --from=construccio --chown=ciclo:ciclo /construccio/extret/dependencies/ ./
COPY --from=construccio --chown=ciclo:ciclo /construccio/extret/spring-boot-loader/ ./
COPY --from=construccio --chown=ciclo:ciclo /construccio/extret/snapshot-dependencies/ ./
COPY --from=construccio --chown=ciclo:ciclo /construccio/extret/application/ ./
USER ciclo
EXPOSE 8080 8081
ENV TZ=Europe/Madrid \
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["java", "-jar", "app.jar"]Les decisions, una a una:
- Dues etapes. La primera porta Maven i el JDK complet; la segona parteix d'una imatge amb només el JRE. La imatge final no conté Maven, ni el JDK, ni el codi font, ni el repositori
.m2: passa d'uns 800 MB a uns 200. COPY pom.xmlabans queCOPY src. És el truc central: mentre el POM no canviï, la capa dedependency:go-offlinesurt de la memòria cau i la descàrrega de dependències se salta del tot. Copiar-ho tot junt faria que qualsevol canvi de codi tornés a descarregar Internet sencer.-DskipTestsaquí és correcte, encara que soni a heretgia després del mòdul 6: les proves ja s'han executat a la canalització (08-05) abans de construir la imatge. Executar-les una altra vegada dins del contenidor duplica el temps i, amb Testcontainers, exigiria Docker dins de Docker.extract --layersprodueix els quatre directoris de l'apartat anterior.- Usuari sense privilegis.
addgroup/adduserés la sintaxi d'Alpine; en imatges basades en Debian seriagroupadd/useradd. El--chowna cadaCOPYevita una capa extra només per canviar permisos. - Les quatre
COPYen aquest ordre exacte. És on es materialitza tota l'optimització: un canvi aLloguerServicenomés invalida l'última. EXPOSE 8080 8081documenta el port de l'API i el de gestió de 07-01. És informatiu, no obre res per si sol.TZ=Europe/Madridevita el problema de zones horàries de 07-03: sense això, un contenidor s'executa en UTC i el cron nocturn es desplaça.ENTRYPOINTen forma de llista, no com a cadena. La forma de cadena arrenca el procés sota unsh -c, que es queda com a PID 1 i no reenvia els senyals: elSIGTERMde l'aturada ordenada (01-05, 07-03) no arriba mai a la JVM i el contenidor mor de cop passats deu segons.
Construir i executar:
docker build -t ciclourbana:2.4.0 .
docker run --rm -p 8080:8080 -e SPRING_PROFILES_ACTIVE=dev ciclourbana:2.4.0
- Triar la imatge base
| Imatge base | Mida aproximada | Notes |
|---|---|---|
eclipse-temurin:21-jre |
~270 MB | Debian complet; l'opció segura i més ben documentada |
eclipse-temurin:21-jre-alpine |
~180 MB | Alpine; molt usada, però compte amb musl |
amazoncorretto:21-alpine |
~190 MB | Distribució d'Amazon, suport llarg; natural a AWS (08-03) |
bellsoft/liberica-openjre-debian:21 |
~200 MB | La que fan servir els buildpacks d'Spring per defecte |
gcr.io/distroless/java21-debian12 |
~230 MB | Sense shell ni gestor de paquets: superfície mínima |
L'avís sobre Alpine. Alpine fa servir musl com a biblioteca de C en lloc de glibc. La majoria d'aplicacions Java funcionen sense problema, però hi ha dues àrees conflictives: les biblioteques amb codi natiu (alguns clients criptogràfics, compressors, netty-tcnative) poden fallar o rendir pitjor, i certs escenaris de DNS i de resolució de noms es comporten de manera diferent. Si la imatge alpine funciona amb la suite de proves de CicloUrbana executada dins del contenidor, endavant; si apareix un error natiu estrany, la primera hipòtesi ha de ser aquesta.
Sobre distroless: no té shell, així que docker exec -it ... sh no funciona. Això és exactament el que la fa segura —un atacant que aconsegueixi execució tampoc no té shell— i el que la fa incòmoda de depurar. És l'elecció correcta quan el diagnòstic es fa per Actuator i per logs centralitzats (09-05), que és justament cap on va aquest curs.
- Buildpacks:
spring-boot:build-image
spring-boot:build-imageSpring Boot pot construir la imatge sense cap Dockerfile, fent servir Cloud Native Buildpacks:
El plugin inspecciona el projecte, detecta que és una aplicació Java, tria un JRE adequat, aplica capes pel seu compte, crea un usuari sense privilegis i afegeix ajustos de memòria calculats. El resultat és una imatge OCI llesta per executar.
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>ghcr.io/ajuntament-ribalta/ciclourbana:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
<BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:MaxRAMPercentage=75</BPE_APPEND_JAVA_TOOL_OPTIONS>
</env>
<publish>true</publish>
</image>
<docker>
<publishRegistry>
<username>${env.REGISTRY_USER}</username>
<password>${env.REGISTRY_TOKEN}</password>
</publishRegistry>
</docker>
</configuration>
</plugin>| Criteri | Dockerfile propi |
Buildpacks |
|---|---|---|
| Control sobre el contingut | Total | Limitat a les opcions del constructor |
| Coneixement de Docker necessari | Mitjà | Gairebé cap |
| Actualitzacions de seguretat de la base | Manuals: cal canviar el FROM |
Es refan sense recompilar amb pack rebase |
| Bones pràctiques per defecte | Les que escriguis tu | Usuari no root, capes, SBOM, memòria ajustada |
| Reproduïbilitat | Alta si fixes versions | Molt alta |
| Depuració de la construcció | Directa | Més opaca quan alguna cosa falla |
| Mida de la imatge | Menor si la cuides | Una mica més gran |
La recomanació honesta: si l'equip no té experiència amb Docker i vol bones pràctiques per defecte, buildpacks. Si necessita control fi —una biblioteca nativa, una imatge base corporativa aprovada, una anàlisi de vulnerabilitats concreta— el Dockerfile de l'apartat 5. Tots dos camins són legítims i tots dos produeixen imatges correctes; el que no és legítim és el Dockerfile ingenu de l'apartat 3.
- La JVM dins d'un contenidor
Hi va haver una època en què la JVM no veia els límits del contenidor: llegia la memòria de la màquina amfitriona, dimensionava el heap amb la quarta part d'aquests 64 GB i l'orquestrador matava el procés per excedir el seu límit de 512 MB. Això està resolt des de Java 10: UseContainerSupport està actiu per defecte i la JVM llegeix els cgroups. No cal activar-ho.
El que sí que cal entendre és el repartiment de la memòria. -Xmx fixa un número absolut que cal revisar cada vegada que canvia el límit del contenidor; MaxRAMPercentage fixa una proporció del límit i s'adapta sola:
| Ajust | Efecte |
|---|---|
-XX:MaxRAMPercentage=75 |
El heap fa servir com a màxim el 75 % del límit del contenidor |
-XX:+ExitOnOutOfMemoryError |
Davant d'un OutOfMemoryError, el procés acaba en lloc de quedar-se mig viu |
-XX:ActiveProcessorCount=N |
Força el nombre de nuclis que veu la JVM quan la quota confon el càlcul |
Per què 75 i no 100. Una JVM no consumeix només heap: hi ha metaespai, piles de fils, memòries intermèdies directes, codi compilat i el mateix sistema operatiu. Deixar un 25 % de marge evita que l'orquestrador mati el contenidor per OOMKilled —una fallada que, a més, és difícil de diagnosticar perquè no deixa rastre al log de l'aplicació—.
JAVA_TOOL_OPTIONS és la variable a fer servir, i no posar les opcions a l'ENTRYPOINT, per dues raons: la JVM la llegeix automàticament, i es pot sobreescriure en arrencar el contenidor sense reconstruir la imatge. La mateixa arrencada en deixa constància al log (Picked up JAVA_TOOL_OPTIONS: ...), cosa que serveix de confirmació.
- El
docker-compose.yml de la xarxa de Ribalta
docker-compose.yml de la xarxa de RibaltaA 04-02 vam crear un docker-compose.yml amb només PostgreSQL. Ara es completa amb l'aplicació:
services:
postgres:
image: postgres:16-alpine
container_name: ciclourbana-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ciclourbana
POSTGRES_USER: ciclourbana
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?falta POSTGRES_PASSWORD}
TZ: Europe/Madrid
volumes:
- postgres-dades:/var/lib/postgresql/data
networks: [xarxa-ciclourbana]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ciclourbana -d ciclourbana"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
app:
build: .
image: ciclourbana:${VERSION:-2.4.0}
container_name: ciclourbana-app
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ciclourbana
SPRING_DATASOURCE_USERNAME: ciclourbana
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET:?falta JWT_SECRET}
JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75"
ports:
- "8080:8080"
- "127.0.0.1:8081:8081" # gestió: només accessible des de l'amfitrió
networks: [xarxa-ciclourbana]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health/readiness"]
interval: 15s
timeout: 3s
retries: 3
start_period: 45s
deploy:
resources:
limits:
memory: 1g
volumes:
postgres-dades:
networks:
xarxa-ciclourbana:
driver: bridgeEls punts que cal entendre:
depends_onambcondition: service_healthyfa que l'aplicació no arrenqui fins que PostgreSQL respongui apg_isready. Sense aquesta condició,depends_onnomés garanteix l'ordre d'inici, no que el servei estigui llest, i CicloUrbana fallaria en connectar i entraria en un bucle de reinicis.${POSTGRES_PASSWORD:?falta POSTGRES_PASSWORD}fa quedocker compose upfalli immediatament si la variable no està definida, en lloc d'arrencar amb una cadena buida. És la mateixa filosofia de fallada primerenca de 07-02.- La URL apunta a
postgres, no alocalhost. Dins de la xarxa del Compose, cada servei és accessible pel seu nom gràcies al DNS intern.localhostdins del contenidor de l'aplicació és el mateix contenidor. 127.0.0.1:8081:8081publica el port de gestió només a l'amfitrió, no a totes les interfícies. És la traducció a Docker de la pràctica de 07-01: Actuator mai accessible des de fora.- El
healthcheckde l'aplicació consulta/actuator/health/readiness, la sonda que vam escriure a 07-01. Aquí es veu per què s'anomenava «la peça que necessita l'orquestrador»: és exactament el mateix mecanisme que farà servir Kubernetes a 08-04. start_period: 45sdona marge a l'arrencada d'Spring —context, Flyway, pool— sense que les fallades d'aquest període comptin com a reintents.limits.memory: 1gés el que dona sentit aMaxRAMPercentage=75: sense un límit declarat, el percentatge es calcula sobre la memòria de l'amfitrió.
Els secrets van en un fitxer .env al costat del docker-compose.yml i fora de Git:
# .env — MAI es versiona
POSTGRES_PASSWORD=una-contrasenya-llarga-i-aleatoria
JWT_SECRET=un-altre-secret-de-com-a-minim-32-caracters
VERSION=2.4.0Un .env versionat és el mateix error que un application-prod.yml amb credencials (07-02), amb l'agreujant que sembla un fitxer d'infraestructura inofensiu. I convé saber que les variables d'entorn d'un contenidor són visibles amb docker inspect per a qualsevol que pugui parlar amb el dimoni de Docker: per a secrets de debò, en producció es fan servir els secrets de l'orquestrador muntats com a fitxers i llegits amb spring.config.import: optional:configtree:/run/secrets/ (07-02).
- Configurar l'aplicació al contenidor
La regla és la del factor III de 12-Factor App, ja aplicada a 07-02: la configuració ve de l'entorn. Dins d'un contenidor això significa que la imatge és idèntica a tots els entorns i l'única cosa que canvia són les variables:
docker run --rm -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://bd-ribalta:5432/ciclourbana \
-e SPRING_DATASOURCE_PASSWORD="$POSTGRES_PASSWORD" \
-e JWT_SECRET="$JWT_SECRET" \
ghcr.io/ajuntament-ribalta/ciclourbana:2.4.0Mai no es cou el perfil a la imatge. Escriure ENV SPRING_PROFILES_ACTIVE=prod al Dockerfile produeix una imatge que només serveix per a producció i trenca el principi de 07-02: la imatge que es prova a preproducció deixaria de ser la que es desplega. El perfil es decideix en executar.
Quan la configuració és massa extensa per a variables, es munta un fitxer:
Spring el troba sol, perquè ./config/ al costat del JAR és una de les ubicacions de la cadena de 07-02, i el :ro el munta de només lectura.
I sobre l'aturada: Docker envia SIGTERM i espera deu segons abans del SIGKILL. Com que l'aplicació té aturada ordenada amb fins a quaranta segons d'espera (07-03), cal ampliar aquest termini o el contenidor morirà a mitja petició:
spring-boot-docker-compose per a desenvolupament
spring-boot-docker-compose per a desenvolupamentA 06-05 va aparèixer el mòdul que arrenca els serveis del docker-compose.yml juntament amb l'aplicació en desenvolupament:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>En executar ./mvnw spring-boot:run, Spring aixeca els serveis declarats, detecta PostgreSQL i configura sol spring.datasource.* amb el port assignat —igual que feia @ServiceConnection a les proves— i els atura en parar l'aplicació. Qui cloni CicloUrbana no necessita instal·lar res ni recordar cap ordre prèvia.
Dues precaucions. El fitxer apuntat ha de ser de desenvolupament (compose-dev.yml, només amb PostgreSQL), no el docker-compose.yml de producció que a més construeix i inicia la mateixa aplicació: això produiria dues CicloUrbana barallant-se pel port 8080. I lifecycle-management: start-only és preferible si molesta que els contenidors s'aturin cada vegada, a canvi d'haver-los de parar a mà.
- Imatges natives amb GraalVM
GraalVM compila l'aplicació a un executable natiu per endavant, sense JVM en temps d'execució:
./mvnw -Pnative native:compile # binari local, requereix GraalVM instal·lat
./mvnw spring-boot:build-image -Pnative # imatge de contenidor, sense instal·lar res| Aspecte | JVM tradicional | Imatge nativa |
|---|---|---|
| Temps d'arrencada | 2-4 segons | 40-90 mil·lisegons |
| Memòria resident | 300-500 MB | 80-150 MB |
| Rendiment sostingut | Millor (el JIT optimitza amb el temps) | Una mica pitjor en càrregues llargues |
| Temps de compilació | ~30 segons | 5-15 minuts |
| Reflexió i proxies dinàmics | Sense restriccions | S'han de declarar per endavant |
| Eines de diagnòstic | Completes | Limitades |
On brilla: funcions sense servidor, escalat a zero, arrencades molt freqüents, entorns amb memòria cara. On no compensa: una aplicació com CicloUrbana, que arrenca una vegada i s'executa durant setmanes, guanya poc i paga una compilació de deu minuts a cada construcció.
L'obstacle tècnic és la reflexió: la compilació anticipada necessita conèixer en temps de construcció totes les classes que s'instanciaran dinàmicament. Spring Boot 3 genera automàticament gran part d'aquestes metadades, però el codi propi que faci servir reflexió l'ha de declarar amb RuntimeHints:
@Component
public class PistesNatives implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader cl) {
hints.reflection().registerType(ResumXarxa.class, MemberCategory.values());
}
}Convé conèixer-ho i saber que existeix; per a CicloUrbana, la imatge amb JRE de l'apartat 5 és l'elecció correcta avui.
- Seguretat de la imatge
Una imatge és un artefacte que es publica i es distribueix. Tot el que hi entra, hi entra per sempre: esborrar un fitxer en una capa posterior no l'elimina de l'anterior, hi continua i es pot extreure.
| Pràctica | Per què |
|---|---|
Usuari no root (USER ciclo) |
Limita el dany d'una vulnerabilitat de l'aplicació |
| Imatge base mínima | Menys paquets, menys CVE per apedaçar |
| Sense secrets a les capes | Un ARG amb una contrasenya queda a l'historial de la imatge |
.dockerignore |
Evita copiar .git, .env, target/ o claus al context de construcció |
| Versions fixades | eclipse-temurin:21-jre-alpine, mai latest |
| Escaneig periòdic | docker scout cves o Trivy a la canalització (08-05) |
| Actualitzar la base | Les vulnerabilitats apareixen després de publicar: cal reconstruir |
El .dockerignore és més important del que sembla. Sense ell, COPY . . fica el directori .git complet dins de la imatge —amb tot l'historial, inclosos els secrets que algú va pujar i va esborrar després— i també el .env que tanta cura vam posar a no versionar. A més, el context de construcció sencer es transfereix al dimoni de Docker, cosa que alenteix cada construcció.
I un avís sobre els secrets a la construcció: ARG TOKEN seguit d'un RUN que el faci servir deixa el valor a les metadades de la imatge, visible amb docker history. Per a credencials durant la construcció existeix RUN --mount=type=secret, que no persisteix res a les capes.
- Publicar en un registre i etiquetar
docker tag ciclourbana:2.4.0 ghcr.io/ajuntament-ribalta/ciclourbana:2.4.0
docker push ghcr.io/ajuntament-ribalta/ciclourbana:2.4.0La política d'etiquetatge decideix si un desplegament és reproduïble:
| Etiqueta | Ús | Risc |
|---|---|---|
2.4.0 |
Versió semàntica, immutable | Cap: és la que es desplega |
sha-9f3a2b1 |
El commit exacte | Cap; casa amb /actuator/info de 07-01 |
latest |
Comoditat en desenvolupament | Alt: ningú no sap què conté ni es pot reproduir |
2.4 o 2 |
Àlies mòbils | Mitjà: canvien sota els peus |
La regla: desplegar sempre per versió o per commit, mai per latest. Amb l'etiqueta sha-9f3a2b1 i l'/actuator/info de 07-01 retornant aquest mateix hash, la pregunta «què hi ha executant-se a Ribalta?» té resposta exacta i verificable. A 08-05 aquest etiquetatge el genera la canalització automàticament.
Errors Comuns i Consells
COPY . . abans de resoldre les dependències. Cada canvi de codi torna a descarregar el repositori Maven sencer. Primer el pom.xml, després el src.
El JAR com una sola capa. Canviar una línia invalida 60 MB. Extreu les capes amb el jarmode.
Executar com a root. És el valor per defecte i cal canviar-lo explícitament.
ENTRYPOINT en forma de cadena. El sh -c intermedi es menja el SIGTERM i l'aturada ordenada no passa mai.
depends_on sense condition: service_healthy. Garanteix l'ordre d'arrencada, no que la base de dades estigui llesta.
Fer servir localhost a la URL de la base de dades dins del contenidor. Cada contenidor té el seu propi localhost; cal fer servir el nom del servei.
No declarar un límit de memòria. MaxRAMPercentage es calcula sobre la memòria de l'amfitrió i el contenidor acaba OOMKilled.
Coure SPRING_PROFILES_ACTIVE=prod a la imatge. La imatge deixa de ser la mateixa a tots els entorns i es trenca el principi de 07-02.
Versionar el .env o no tenir .dockerignore. Tots dos fiquen secrets on no han d'estar: a Git o dins de les capes de la imatge.
Consell: fixa les versions de totes les imatges base, i programa reconstruccions periòdiques per incorporar els pedaços de seguretat de la base.
Consell: prova la imatge, no només el JAR. Arrencar el contenidor i consultar /actuator/health/readiness a la canalització detecta problemes de zona horària, permisos i variables que cap prova de JVM veu.
Consell: mesura el temps de reconstrucció. Si canviar una línia triga més de trenta segons a produir una imatge nova, l'ordre de les capes està malament.
Exercicis
Exercici 1: revisar un Dockerfile real
Troba tots els problemes d'aquest fitxer, explica la conseqüència de cadascun i escriu la versió corregida.
FROM openjdk:latest
WORKDIR /app
COPY . .
RUN mvn clean package
ARG DB_PASSWORD
ENV SPRING_DATASOURCE_PASSWORD=$DB_PASSWORD
ENV SPRING_PROFILES_ACTIVE=prod
EXPOSE 8080
ENTRYPOINT java -jar target/ciclourbana-2.4.0.jarExercici 2: entorn complet amb Compose
Escriu un compose-pre.yml per a l'entorn de preproducció de l'ajuntament amb: PostgreSQL 16 amb volum i comprovació de salut; CicloUrbana amb el perfil pre, límit d'1 GB, esperant que la base de dades estigui sana, amb el port de gestió accessible només des de l'amfitrió i la seva pròpia comprovació de salut contra readiness; secrets des de .env; i un temps de gràcia d'aturada coherent amb els 40 segons de l'apartat 13 de 07-03. Explica en quin ordre arrenca tot i què passa si PostgreSQL triga un minut a estar llest.
Exercici 3: la imatge que triga quatre minuts
L'equip es queixa que canviar una línia a EstacioService i veure el resultat en un contenidor triga quatre minuts. El Dockerfile és multietapa i correcte tret de l'etapa de construcció:
FROM maven:3.9-eclipse-temurin-21 AS construccio
WORKDIR /construccio
COPY . .
RUN mvn -B clean packageDiagnostica el problema, proposa'n la correcció i calcula aproximadament quant hauria de trigar després. Indica a més què més caldria revisar si després de la correcció continués trigant més d'un minut.
Solucions
Solució 1
Nou problemes:
| # | Problema | Conseqüència |
|---|---|---|
| 1 | FROM openjdk:latest |
Imatge no fixada i a més openjdk està descontinuat; cada construcció pot donar una JVM diferent |
| 2 | Una sola etapa amb Maven | La imatge final arrossega el JDK, Maven i el repositori .m2: ~800 MB per executar 60 MB |
| 3 | COPY . . abans de les dependències |
Sense memòria cau útil: qualsevol canvi de codi ho torna a descarregar tot |
| 4 | Sense .dockerignore |
Copia .git, .env i target/ dins de la imatge |
| 5 | ARG DB_PASSWORD + ENV |
La contrasenya queda a l'historial de la imatge, visible amb docker history |
| 6 | ENV SPRING_PROFILES_ACTIVE=prod |
La imatge només serveix per a producció; es trenca «un artefacte, molts entorns» |
| 7 | S'executa com a root |
Tota la superfície del contenidor amb el màxim privilegi |
| 8 | ENTRYPOINT en forma de cadena |
sh -c com a PID 1: el SIGTERM no arriba a la JVM i no hi ha aturada ordenada |
| 9 | Sense capes del JAR ni ajustos de JVM | Reconstruccions lentes i risc d'OOMKilled |
La correcció és el Dockerfile de l'apartat 5, amb dos matisos sobre l'enunciat. La contrasenya no es passa en la construcció de cap manera: és configuració d'execució i s'injecta com a variable en arrencar el contenidor; si de debò calgués una credencial durant la construcció —per exemple, per a un repositori Maven privat— la forma correcta és RUN --mount=type=secret,id=maven, que no deixa rastre a les capes. I el perfil desapareix del Dockerfile del tot, passant a docker run -e SPRING_PROFILES_ACTIVE=... o al Compose.
Solució 2
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ciclourbana
POSTGRES_USER: ciclourbana
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?falta POSTGRES_PASSWORD}
TZ: Europe/Madrid
volumes:
- postgres-pre:/var/lib/postgresql/data
networks: [xarxa-pre]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ciclourbana -d ciclourbana"]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s
app:
image: ghcr.io/ajuntament-ribalta/ciclourbana:${VERSION:?falta VERSION}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: pre
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ciclourbana
SPRING_DATASOURCE_USERNAME: ciclourbana
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET:?falta JWT_SECRET}
JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
TZ: Europe/Madrid
ports:
- "8080:8080"
- "127.0.0.1:8081:8081"
networks: [xarxa-pre]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health/readiness"]
interval: 15s
timeout: 3s
retries: 3
start_period: 45s
stop_grace_period: 50s
deploy:
resources:
limits:
memory: 1g
volumes:
postgres-pre:
networks:
xarxa-pre:Ordre d'arrencada. Compose crea la xarxa i el volum; arrenca postgres; durant els primers 20 segons (start_period) les fallades de pg_isready no compten; quan respon, el servei passa a healthy; només llavors arrenca app, que resol postgres per DNS intern, aplica les migracions de Flyway i aixeca el context; durant 45 segons el seu propi healthcheck no penalitza, i en respondre /actuator/health/readiness amb 200 el servei queda healthy i pot rebre trànsit.
Si PostgreSQL triga un minut, el comportament continua sent correcte: amb interval: 10s, retries: 6 i start_period: 20s, hi ha marge suficient i app simplement espera. Si superés aquest marge, PostgreSQL es marcaria com a unhealthy, app no arrencaria en absolut —que és el comportament desitjat: millor no arrencar que arrencar sense base de dades— i docker compose ps mostraria clarament quin és el servei que falla.
stop_grace_period: 50s és la peça que fa coherent la cadena d'aturada: l'aplicació necessita fins a 40 segons per acabar peticions i buidar els executors (07-03), així que donar-li només els 10 segons per defecte de Docker tallaria l'aturada ordenada just a la meitat. La regla general és stop_grace_period > timeout-per-shutdown-phase > await-termination-period.
Solució 3
El diagnòstic. COPY . . copia el codi abans de resoldre les dependències, així que la capa que executa mvn package s'invalida amb qualsevol canvi de fitxer. Docker no pot reutilitzar res: cada construcció torna a descarregar tot l'arbre de dependències de Maven —Spring Boot, Hibernate, Jackson, Spring Security, JJWT, MapStruct, els drivers— des de zero. Els quatre minuts són, gairebé sencers, descàrregues repetides d'artefactes que ja es van descarregar ahir.
La correcció és separar la còpia en dos passos, amb la resolució de dependències entre tots dos:
FROM maven:3.9-eclipse-temurin-21 AS construccio
WORKDIR /construccio
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
RUN java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extretAra un canvi a EstacioService invalida la capa de COPY src i les posteriors, però la de les dependències surt de la memòria cau. El temps baixa al que trigui la compilació més l'empaquetatge: entre vint i quaranta segons en un projecte de la mida de CicloUrbana. I com que les capes dependencies i spring-boot-loader de la imatge final tampoc no canvien, la publicació al registre transfereix només uns centenars de kilobytes en lloc de seixanta megabytes.
Si després de la correcció continués trigant més d'un minut, hi ha quatre coses a revisar, en aquest ordre:
- S'estan executant les proves? Sense
-DskipTests,mvn packageexecuta tota la suite del mòdul 6, inclosos els Testcontainers, que necessitarien Docker dins de Docker. Les proves van a la canalització, abans de construir la imatge. - Hi ha
.dockerignore? Sense ell, el context de construcció inclou.gititarget/, i transferir-los al dimoni abans de començar pot portar desenes de segons. - La memòria cau s'està invalidant per una altra via? Un
COPYd'un fitxer que canvia sempre —una marca de temps, un fitxer generat— per damunt de l'etapa de dependències té el mateix efecte que el problema original. - La memòria cau existeix? En integració contínua, cada execució sol començar en una màquina neta sense memòria cau de capes; allà la solució és una memòria cau de capes del mateix sistema (
--cache-from,cache-to) o un volum persistent per al repositori.m2mitjançantRUN --mount=type=cache,target=/root/.m2, que és la forma moderna i la que millor funciona en canalitzacions (08-05).
Conclusió
CicloUrbana ja no és un JAR que algú arrenca a mà: és una imatge reproduïble que s'executa igual al portàtil d'un desenvolupador, al servidor de preproducció i a la infraestructura de l'ajuntament de Ribalta. Saps què resol un contenidor i què no, i manegues els conceptes que el sostenen —imatge, capa, contenidor, registre, etiqueta, volum, xarxa—, amb la idea central que les capes es cauegen i que per això allò que canvia poc va a baix i allò que canvia molt va a dalt.
Saps per què el Dockerfile de tres línies està malament —capa monolítica, dependència del Maven local, root, imatge grassa, senyals que no arriben— i has escrit el que sí que està bé: multietapa, amb el pom.xml copiat abans que el src perquè les dependències surtin de la memòria cau, amb el JAR descompost en les seves quatre capes pel jarmode tools, amb usuari sense privilegis, zona horària fixada, ajustos de memòria per JAVA_TOOL_OPTIONS i un ENTRYPOINT en forma de llista que sí que deixa arribar el SIGTERM a l'aturada ordenada. Coneixes les imatges base i els seus compromisos, inclòs l'advertiment sobre musl a Alpine i el preu de distroless, i saps que existeix un camí sense Dockerfile —els buildpacks d'spring-boot:build-image, amb pack rebase per apedaçar la base sense recompilar—, amb criteris clars per triar entre tots dos.
Entens com es comporta la JVM dins d'un contenidor: UseContainerSupport ja no cal activar-lo, però MaxRAMPercentage=75 sí, i el 25 % de marge és el que evita l'OOMKilled silenciós. Tens el docker-compose.yml complet de la xarxa de Ribalta, amb PostgreSQL 16, volum amb nom, xarxa pròpia, depends_on: condition: service_healthy, el port de gestió publicat només a 127.0.0.1, secrets en un .env fora de Git i —la peça que enllaça aquesta lliçó amb la primera del mòdul— un HEALTHCHECK que consulta /actuator/health/readiness. Saps configurar l'aplicació des de l'entorn sense coure mai el perfil a la imatge, muntar un YAML extern de només lectura i ampliar stop_grace_period perquè l'aturada ordenada hi càpiga. I tens spring-boot-docker-compose aixecant la base de dades en desenvolupament, la visió general de GraalVM amb els seus temps reals i els seus RuntimeHints, la llista de pràctiques de seguretat de la imatge —usuari no root, base mínima, zero secrets a les capes, .dockerignore, escaneig amb docker scout o Trivy— i una política d'etiquetatge que fa verificable quina versió s'executa a Ribalta.
Fins aquí, tot el que hem construït és una sola aplicació: un monòlit ben fet, provat, observable, configurable i ara conteniritzat. És una arquitectura perfectament respectable i, per a una xarxa municipal de bicicletes, probablement la correcta. Però tard o d'hora algú planteja la pregunta: i si el mòdul de facturació tingués el seu propi cicle de vida? I si l'equip d'estacions desplegués sense coordinar-se amb el de lloguers? I si calgués escalar només la part que consulta la disponibilitat, que rep cent vegades més trànsit que la resta? La lliçó següent, Spring Boot i Microserveis, respon a aquestes preguntes amb honestedat: quin problema resolen realment els microserveis, què es paga per ells, com es decideix on tallar, què es trenca quan cada servei té la seva pròpia base de dades, i per què la primera resposta correcta gairebé sempre és modularitzar el monòlit.
Curs de Spring Boot
Mòdul 1: Introducció a Spring Boot
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
