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

  1. Per què conteniritzar
  2. Els conceptes mínims de Docker
  3. El Dockerfile ingenu i per què està malament
  4. El JAR d'Spring Boot per capes
  5. El Dockerfile multietapa de CicloUrbana
  6. Triar la imatge base
  7. Buildpacks: spring-boot:build-image
  8. La JVM dins d'un contenidor
  9. El docker-compose.yml de la xarxa de Ribalta
  10. Configurar l'aplicació al contenidor
  11. spring-boot-docker-compose per a desenvolupament
  12. Imatges natives amb GraalVM
  13. Seguretat de la imatge
  14. Publicar en un registre i etiquetar
  15. Errors Comuns i Consells
  16. Exercicis

  1. 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).

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

  1. El Dockerfile ingenu i per què està malament

Aquest é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.

  1. 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 extret

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

  1. El 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.xml abans que COPY src. És el truc central: mentre el POM no canviï, la capa de dependency:go-offline surt 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.
  • -DskipTests aquí é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 --layers produeix els quatre directoris de l'apartat anterior.
  • Usuari sense privilegis. addgroup/adduser és la sintaxi d'Alpine; en imatges basades en Debian seria groupadd/useradd. El --chown a cada COPY evita una capa extra només per canviar permisos.
  • Les quatre COPY en aquest ordre exacte. És on es materialitza tota l'optimització: un canvi a LloguerService només invalida l'última.
  • EXPOSE 8080 8081 documenta el port de l'API i el de gestió de 07-01. És informatiu, no obre res per si sol.
  • TZ=Europe/Madrid evita el problema de zones horàries de 07-03: sense això, un contenidor s'executa en UTC i el cron nocturn es desplaça.
  • ENTRYPOINT en forma de llista, no com a cadena. La forma de cadena arrenca el procés sota un sh -c, que es queda com a PID 1 i no reenvia els senyals: el SIGTERM de 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

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

  1. Buildpacks: spring-boot:build-image

Spring Boot pot construir la imatge sense cap Dockerfile, fent servir Cloud Native Buildpacks:

./mvnw spring-boot:build-image -DskipTests

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.

  1. 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:

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
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ó.

  1. El docker-compose.yml de la xarxa de Ribalta

A 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: bridge

Els punts que cal entendre:

  • depends_on amb condition: service_healthy fa que l'aplicació no arrenqui fins que PostgreSQL respongui a pg_isready. Sense aquesta condició, depends_on nomé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 que docker compose up falli 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 a localhost. Dins de la xarxa del Compose, cada servei és accessible pel seu nom gràcies al DNS intern. localhost dins del contenidor de l'aplicació és el mateix contenidor.
  • 127.0.0.1:8081:8081 publica 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 healthcheck de 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: 45s dona 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 a MaxRAMPercentage=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.0
# .gitignore
.env

Un .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).

  1. 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.0

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

volumes:
  - ./config/application-prod.yml:/app/config/application-prod.yml:ro

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

stop_grace_period: 45s

  1. spring-boot-docker-compose per a desenvolupament

A 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>
spring:
  docker:
    compose:
      enabled: true
      file: compose-dev.yml
      lifecycle-management: start-and-stop

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

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

  1. 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
# .dockerignore
.git
.env
target/
*.log
.idea/
**/application-local.yml

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

docker scout cves ciclourbana:2.4.0
trivy image --severity HIGH,CRITICAL ciclourbana:2.4.0

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.

  1. 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.0

La 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.jar

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

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

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

  1. S'estan executant les proves? Sense -DskipTests, mvn package executa 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.
  2. Hi ha .dockerignore? Sense ell, el context de construcció inclou .git i target/, i transferir-los al dimoni abans de començar pot portar desenes de segons.
  3. La memòria cau s'està invalidant per una altra via? Un COPY d'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.
  4. 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 .m2 mitjançant RUN --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

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats