El Dockerfile amb què vas tancar la lliçó anterior funciona: construeix auroralibros/aurora-api:1.0.0, arrenca Express en un contenidor i respon a curl. Però li falten les peces que separen una imatge que funciona d'una imatge que pots posar en producció sense envermellir. Ara mateix, l'API s'executa com a root dins del contenidor, ningú no sap qui la va construir ni des de quin commit, Docker no té manera de saber si el procés és viu però encallat, la versió de Node està escrita a foc al FROM, i no pots executar la imatge amb una comanda diferent sense perdre completament el seu comportament per defecte. Aquesta lliçó resol les cinc coses. Aprendràs ARG i la seva diferència crítica amb ENV, el binomi ENTRYPOINT + CMD amb les seves quatre combinacions, USER per deixar de ser root, LABEL amb l'estàndard OCI, HEALTHCHECK contra l'endpoint /salut que vas preparar a la lliçó 01-07, i les tres instruccions menors —VOLUME, STOPSIGNAL, ONBUILD i SHELL— que convé conèixer encara que les facis servir poc. Al final tindràs el Dockerfile professional d'aurora-api.

Contingut

  1. ARG davant d'ENV: àmbit i moment
  2. ARG abans del primer FROM
  3. ENTRYPOINT davant de CMD: les quatre combinacions
  4. El patró docker-entrypoint.sh i per què importa exec "$@"
  5. USER: deixar d'executar com a root
  6. LABEL i les etiquetes estàndard OCI
  7. HEALTHCHECK: que Docker vigili el teu servei
  8. VOLUME i per què declarar-lo sol ser mala idea
  9. STOPSIGNAL i la gestió de senyals
  10. ONBUILD i SHELL
  11. El Dockerfile professional d'aurora-api

  1. ARG davant d'ENV: àmbit i moment

Totes dues defineixen variables. La confusió entre elles és constant, i la diferència és senzilla d'enunciar: ARG existeix només durant la construcció; ENV existeix també dins del contenidor en execució.

Aspecte ARG ENV
Moment en què existeix Només durant docker build Durant la build i en execució
Visible dins del contenidor No Sí (printenv, process.env)
Es defineix des de fora amb --build-arg CLAU=valor -e CLAU=valor a docker run
Valor per defecte ARG CLAU=valor ENV CLAU=valor
Sobreescrivible en executar No existeix en execució Sí, amb -e
Apareix a docker image history? Sí, el seu valor Sí, el seu valor
Ús típic Versions, rutes de build, metadades de CI Configuració per defecte de l'aplicació

Un exemple que ho demostra tot. Crea /tmp/arg-env/Dockerfile:

# syntax=docker/dockerfile:1
FROM alpine:3.21

# Argument de construcció, amb valor per defecte
ARG SALUTACIO_BUILD=hola-des-de-la-build

# Variable d'entorn, amb valor per defecte
ENV SALUTACIO_RUN=hola-des-de-lexecucio

# Durant la build, TOTES DUES estan disponibles
RUN echo "BUILD veu ARG: $SALUTACIO_BUILD" && echo "BUILD veu ENV: $SALUTACIO_RUN"

CMD ["sh", "-c", "echo \"RUN veu ARG: [$SALUTACIO_BUILD]\"; echo \"RUN veu ENV: [$SALUTACIO_RUN]\""]
cd /tmp/arg-env
docker build --progress=plain --no-cache -t argenv . 2>&1 | grep "BUILD veu"
#5 0.153 BUILD veu ARG: hola-des-de-la-build
#5 0.154 BUILD veu ENV: hola-des-de-lexecucio

Durant la construcció, totes dues estan disponibles. Ara executa:

docker run --rm argenv
RUN veu ARG: []
RUN veu ENV: [hola-des-de-lexecucio]

L'ARG està buit. No és que valgui una altra cosa: no existeix. Es va evaporar quan va acabar la build. L'ENV continua sent-hi.

I així es passa un valor des de fora:

docker build --build-arg SALUTACIO_BUILD=valor-injectat --progress=plain --no-cache -t argenv . 2>&1 | grep "BUILD veu ARG"
#5 0.148 BUILD veu ARG: valor-injectat

El pont entre totes dues

El patró més útil és combinar-les: un ARG que alimenta un ENV.

ARG VERSIO_APP=1.0.0
ENV APP_VERSION=${VERSIO_APP}

Així, --build-arg VERSIO_APP=1.2.3 a la construcció acaba sent una variable d'entorn consultable dins del contenidor. És com s'injecten números de versió i hashes de commit des d'un pipeline de CI (lliçó 06-02).

L'advertència important: ARG no és un secret

Això s'ha de gravar a foc:

# ❌ NO HO FACIS MAI
ARG DB_PASSWORD
RUN echo "connectant amb $DB_PASSWORD" > /app/config.txt
docker build --build-arg DB_PASSWORD=superSecreta2026 -t filtrada .
docker image history filtrada --no-trunc | head -5
CREATED BY
|1 DB_PASSWORD=superSecreta2026 /bin/sh -c echo "connectant amb $DB_PASSWORD" > /app/config.txt

Aquí està la contrasenya, en clar, a l'historial de la imatge. Qualsevol que descarregui la imatge la pot llegir amb una sola comanda, sense arrencar res. I publicant al repositori públic auroralibros/aurora-api, a Internet.

És la mateixa regla que arrossegues des de la lliçó 01-07, ara amb una via de fuita addicional: no només ENV filtra secrets, també ARG. Per passar credencials a una build de manera segura existeixen els muntatges de secrets de BuildKit (RUN --mount=type=secret), que no deixen rastre en cap capa; s'estudien a la lliçó 05-05. Per a Aurora Libros, la regla es manté intacta: les credencials no entren a la imatge ni en construir ni en executar; s'injecten en arrencar el contenidor.

  1. ARG abans del primer FROM

ARG té una peculiaritat d'àmbit que sorprèn tothom: un ARG declarat abans del primer FROM només és visible a les línies FROM, no dins de la imatge.

# syntax=docker/dockerfile:1

# ARG "global": viu fora de qualsevol etapa. Només el veuen els FROM.
ARG NODE_VERSION=22
ARG ALPINE_VERSION=3.21

FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}

# Aquí dins, NODE_VERSION ja no existeix llevat que es torni a declarar
ARG NODE_VERSION
RUN echo "Construint sobre Node ${NODE_VERSION}"

Aquesta doble declaració (ARG NODE_VERSION sense valor, dins de l'etapa) és la manera de "reimportar" l'argument global. Sense ella, la variable estaria buida dins del RUN.

La utilitat pràctica és enorme: parametritzar la versió base sense tocar el Dockerfile.

# Build per defecte: Node 22
docker build -t aurora-api:node22 .

# Provar l'API amb Node 23 sense editar res
docker build --build-arg NODE_VERSION=23 -t aurora-api:node23 .

Casos d'ús reals per a Aurora Libros:

Escenari Comanda
Provar la propera versió major de Node abans d'adoptar-la --build-arg NODE_VERSION=23
Reproduir un bug a la versió antiga --build-arg NODE_VERSION=20
Matriu de compatibilitat a CI Un job per versió, mateix Dockerfile

Un avís: parametritzar la base és còmode, però el valor per defecte ha de ser el de producció. Si l'ARG no té valor per defecte i algú construeix sense --build-arg, obtindrà node:-alpine, que no existeix, i un error críptic de manifest.

  1. ENTRYPOINT davant de CMD: les quatre combinacions

CMD, que ja coneixes, defineix què s'executa en arrencar el contenidor i es descarta completament si passes una comanda a docker run. ENTRYPOINT defineix un executable que no es descarta: els arguments de docker run se li afegeixen darrere.

La combinació de totes dues produeix quatre escenaris. Aquesta taula és la referència que convé tenir a mà:

# Dockerfile docker run imatge executa docker run imatge extra executa
1 Només CMD ["node","server.js"] node server.js extra (el CMD es descarta)
2 Només ENTRYPOINT ["node","server.js"] node server.js node server.js extra
3 ENTRYPOINT ["node"] + CMD ["server.js"] node server.js node extra (el CMD se substitueix)
4 ENTRYPOINT ["node","server.js"] + CMD ["--port=3000"] node server.js --port=3000 node server.js extra

La regla que resumeix les quatre files: ENTRYPOINT és l'executable fix; CMD són els arguments per defecte, i són l'única cosa que l'usuari pot reemplaçar.

Comprova-ho amb la imatge que ja tens:

# Cas 1: només CMD (la teva imatge 1.0.0)
docker run --rm auroralibros/aurora-api:1.0.0 node --version
v22.14.0

El CMD ["node","server.js"] ha desaparegut: el servidor no arrenca.

# Cas 3: ENTRYPOINT + CMD
printf 'FROM auroralibros/aurora-api:1.0.0\nENTRYPOINT ["node"]\nCMD ["server.js"]\n' > /tmp/Dockerfile.ep
docker build -q -t aurora-ep -f /tmp/Dockerfile.ep /tmp

docker run --rm aurora-ep --version
v22.14.0

Aquí --version ha substituït server.js, però node continua sent-hi: s'ha executat node --version. L'executable és intocable.

El patró recomanat

ENTRYPOINT ["node"]
CMD ["server.js"]

Avantatges davant de només CMD:

  • La imatge té una identitat clara. És "la imatge que executa node", no "una imatge genèrica".
  • És autodocumentada. docker run lasevaimatge --help funciona sense més.
  • Evita accidents. Ningú no arrenca per descuit un contenidor que no fa el que la imatge promet.

I el seu inconvenient principal: depurar costa més. Amb només CMD, un docker run --rm -it lasevaimatge sh et dona un intèrpret d'ordres. Amb ENTRYPOINT ["node"], aquella comanda intenta executar node sh i falla. Per a això hi ha --entrypoint:

docker run --rm -it --entrypoint sh aurora-ep
/app #

--entrypoint substitueix l'executable fix. Compte amb un detall que despista: --entrypoint accepta un sol valor, i tot el que vingui després del nom de la imatge se li passa com a arguments:

docker run --rm --entrypoint sh aurora-ep -c "ls /app && node --version"
node_modules
package-lock.json
package.json
server.js
v22.14.0

Formes shell i exec, un altre cop

Tot el que has après a la lliçó 02-03 sobre CMD s'aplica igual a ENTRYPOINT, agreujat:

ENTRYPOINT ["node", "server.js"]     # ✅ Forma exec
ENTRYPOINT node server.js            # ❌ Forma shell

Amb ENTRYPOINT en forma shell passen dues coses dolentes, no una:

  1. El PID 1 és sh i no reenvia SIGTERM: els 10 segons d'espera i el SIGKILL de la lliçó anterior.
  2. CMD s'ignora completament, i els arguments de docker run també. L'ENTRYPOINT en forma shell s'empassa tot el mecanisme d'arguments.

Amb ENTRYPOINT, la forma exec no és una recomanació: és obligatòria.

  1. El patró docker-entrypoint.sh i per què importa exec "$@"

Quan l'arrencada necessita lògica —esperar una dependència, generar un fitxer de configuració, aplicar migracions—, l'ENTRYPOINT apunta a un script.

Crea ~/aurora-libros/api/docker-entrypoint.sh:

#!/bin/sh
# Script d'arrencada d'aurora-api
# S'executa abans del procés principal i li cedeix el control amb exec "$@"

set -e   # Avorta davant del primer error: millor no arrencar que arrencar malament

echo "[entrypoint] Iniciant aurora-api en mode ${NODE_ENV:-development}"

# Validació de configuració obligatòria: fallar aviat i amb un missatge clar
if [ -z "$DB_HOST" ]; then
  echo "[entrypoint] AVÍS: DB_HOST no definida; es farà servir localhost"
fi

# Espera activa que la base de dades accepti connexions.
# Evita el clàssic ECONNREFUSED quan l'API arrenca abans que PostgreSQL.
if [ -n "$DB_HOST" ] && [ "$ESPERAR_DB" = "true" ]; then
  echo "[entrypoint] Esperant ${DB_HOST}:${DB_PORT:-5432}..."
  intents=0
  until nc -z "$DB_HOST" "${DB_PORT:-5432}" 2>/dev/null; do
    intents=$((intents + 1))
    if [ "$intents" -ge 30 ]; then
      echo "[entrypoint] ERROR: la base de dades no respon després de 30 intents"
      exit 1
    fi
    sleep 1
  done
  echo "[entrypoint] Base de dades disponible després de ${intents}s"
fi

echo "[entrypoint] Cedint el control a: $@"

# LA LÍNIA CLAU
exec "$@"

I el Dockerfile l'incorpora així:

COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

El flux complet:

sequenceDiagram
    participant D as docker run
    participant E as docker-entrypoint.sh (PID 1)
    participant N as node server.js

    D->>E: Arrenca ENTRYPOINT amb CMD com a arguments ($@)
    E->>E: set -e, comprova variables
    E->>E: Espera que DB_HOST respongui
    E->>N: exec "$@" · REEMPLAÇA el procés
    Note over E,N: node hereta el PID 1;<br/>l'script deixa d'existir
    D->>N: docker stop → SIGTERM arriba DIRECTAMENT a node
    N->>N: Tancament net en 0,3 s

Per què exec "$@" i no simplement "$@"

És la part que s'ha d'entendre de debò.

  • "$@" són tots els arguments rebuts, cadascun entre cometes per separat. Com que l'ENTRYPOINT és l'script i el CMD és ["node","server.js"], "$@" val node server.js. Les cometes són imprescindibles: sense elles, un argument amb espais es partiria en dos.
  • exec és l'essencial. Sense exec, l'intèrpret d'ordres llança un fill i es queda esperant: el PID 1 continua sent l'script, i estàs exactament a l'escenari de la forma shell de la lliçó 02-03 —SIGTERM a l'script, que no el reenvia, deu segons i SIGKILL—. Amb exec, l'intèrpret es reemplaça a si mateix per node: mateix PID, mateixos descriptors de fitxer, l'script desapareix de l'arbre de processos i node passa a ser el PID 1.

Comprova-ho amb l'script en marxa:

docker run -d --name t-entry auroralibros/aurora-api:1.1.0
docker exec t-entry ps -o pid,comm
PID   COMMAND
    1 node

El PID 1 és node, no sh. L'script es va executar, va fer la seva feina i es va apartar. I per tant:

time docker stop t-entry
real    0m0.291s

Si en traguessis l'exec, aquesta xifra seria de 10 segons. Un caràcter de diferència, quatre lletres, i tot el comportament d'aturada del contenidor canvia.

Nota pràctica: l'script fa servir nc (netcat), que a node:22-alpine no ve instal·lat. Caldria afegir-hi RUN apk add --no-cache netcat-openbsd. A la versió final d'aquesta lliçó s'opta per no incloure l'espera activa, perquè Docker Compose resol les dependències d'arrencada de manera més neta amb depends_on i condicions de salut (lliçó 04-02). El patró, però, convé conèixer-lo: te'l trobaràs a les imatges oficials de PostgreSQL i MySQL, que el fan servir justament així.

  1. USER: deixar d'executar com a root

Ara mateix la teva API s'executa com a root dins del contenidor. Comprova-ho:

docker run --rm auroralibros/aurora-api:1.0.0 id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon)...

uid=0 és root. Un contenidor està aïllat, però aquell aïllament no és una muralla infranquejable: si apareix una vulnerabilitat d'escapament del runtime o muntes malament un volum, el root del contenidor pot convertir-se en root de l'amfitrió. Executar com a usuari sense privilegis elimina tota aquesta classe de problemes per un cost de tres línies.

La instrucció és USER, i afecta totes les instruccions posteriors del Dockerfile i el procés del contenidor:

USER node
USER 1000
USER node:node
USER 1000:1000    # La forma més portable: numèrica

Crear l'usuari a Alpine

node:22-alpine ja porta un usuari node (uid 1000), així que n'hi hauria prou amb USER node. Però convé saber crear-lo, perquè en altres bases no existeix:

RUN addgroup -g 1001 -S aurora && \
    adduser  -u 1001 -S aurora -G aurora

Opcions de les eines d'Alpine (BusyBox), diferents de les de Debian:

Opció Significat
-g 1001 / -u 1001 GID i UID explícits. Fixar-los importa perquè els permisos de volums muntats coincideixin
-S System: compte de sistema, sense contrasenya ni caducitat
-G aurora Grup primari de l'usuari
-D (a adduser) Sense contrasenya

A Debian/Ubuntu l'equivalent seria groupadd -g 1001 aurora && useradd -u 1001 -g aurora -m -s /bin/sh aurora.

L'ordre correcte

Aquest és el punt on falla tothom:

# ❌ MALAMENT: canviar a USER abans d'instal·lar
USER node
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev      # EACCES: permission denied
# ✅ BÉ: instal·lar com a root, canviar d'usuari al final
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

La seqüència correcta és: instal·lar i copiar com a root (que pot escriure on vulgui), assignar la propietat en la mateixa operació de còpia amb --chown, i posar USER just abans del CMD. Com vas veure a 02-03, --chown al COPY evita un RUN chown -R posterior que duplicaria la mida de la capa pel copy-on-write.

Comprova el resultat a la imatge final:

docker run --rm auroralibros/aurora-api:1.1.0 id
uid=1000(node) gid=1000(node) groups=1000(node)

La conseqüència immediata: ports privilegiats

Un usuari sense privilegis no pot escoltar en ports per sota del 1024. Si la teva aplicació fes servir el port 80, deixaria d'arrencar:

Error: listen EACCES: permission denied 0.0.0.0:80

Per això aurora-api escolta al 3000, i per això el contenidor de Nginx del mòdul 4 requerirà cura. La solució mai no és tornar a root: és escoltar en un port alt i publicar-lo on calgui amb -p 80:3000, ja que el mapatge el fa Docker a l'amfitrió, no el procés.

L'enduriment complet de la seguretat en execució —capabilities, --read-only, no-new-privileges, perfils seccomp, user namespaces— és la lliçó 05-03. Aquí et quedes amb l'essencial: un contenidor de producció no s'executa com a root.

  1. LABEL i les etiquetes estàndard OCI

LABEL afegeix metadades arbitràries a la imatge en forma de parells clau-valor. No canvia el comportament, no pesa res, i respon a preguntes que en producció són urgents: de quin commit va sortir aquesta imatge? qui la manté? de quina versió és?

LABEL clau="valor"
LABEL clau1="valor1" clau2="valor2"

Agrupa sempre diverses etiquetes en una sola instrucció: cada LABEL crea una capa de metadades.

L'estàndard OCI

Perquè les eines puguin llegir aquestes dades existeix un vocabulari estàndard, org.opencontainers.image.*, definit per l'Open Container Initiative (la mateixa de la lliçó 01-03):

Etiqueta Contingut
org.opencontainers.image.title Nom llegible del component
org.opencontainers.image.description Descripció breu
org.opencontainers.image.version Versió del programari empaquetat
org.opencontainers.image.authors Responsables, amb contacte
org.opencontainers.image.vendor Organització propietària
org.opencontainers.image.licenses Llicència en format SPDX
org.opencontainers.image.source URL del repositori de codi
org.opencontainers.image.documentation URL de la documentació
org.opencontainers.image.revision Hash del commit exacte
org.opencontainers.image.created Data de construcció (RFC 3339)
org.opencontainers.image.base.name Imatge base utilitzada

Aplicades a Aurora Libros, combinant ARG per al que varia a cada build:

ARG VERSION=1.1.0
ARG REVISION=desconeguda
ARG CREATED=desconeguda

LABEL org.opencontainers.image.title="aurora-api" \
      org.opencontainers.image.description="API REST del catàleg d'Aurora Libros S.L." \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.authors="[email protected]" \
      org.opencontainers.image.vendor="Aurora Libros S.L." \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
      org.opencontainers.image.documentation="https://github.com/auroralibros/aurora-libros/blob/main/README.md" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}" \
      org.opencontainers.image.base.name="docker.io/library/node:22-alpine"

Els tres primers ARG s'omplen a la build, típicament des del pipeline:

docker build \
  --build-arg VERSION=1.1.0 \
  --build-arg REVISION="$(git rev-parse --short HEAD)" \
  --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t auroralibros/aurora-api:1.1.0 .

I així es llegeixen després:

docker image inspect auroralibros/aurora-api:1.1.0 \
  --format '{{range $k, $v := .Config.Labels}}{{$k}} = {{$v}}
{{end}}'
org.opencontainers.image.authors = [email protected]
org.opencontainers.image.base.name = docker.io/library/node:22-alpine
org.opencontainers.image.created = 2026-08-04T09:14:22Z
org.opencontainers.image.description = API REST del catàleg d'Aurora Libros S.L.
org.opencontainers.image.licenses = MIT
org.opencontainers.image.revision = 7a3f912
org.opencontainers.image.source = https://github.com/auroralibros/aurora-libros
org.opencontainers.image.title = aurora-api
org.opencontainers.image.vendor = Aurora Libros S.L.
org.opencontainers.image.version = 1.1.0

El valor real d'això es veu a les tres de la matinada d'un dimarts: producció falla, tens un contenidor corrent i necessites saber exactament quin codi porta a dins. Un docker inspect et dona el commit 7a3f912, i amb ell el git log t'ho explica tot. Sense aquella etiqueta, comença l'arqueologia.

Les etiquetes també serveixen per filtrar, amb la sintaxi de la lliçó 01-04:

docker image ls --filter "label=org.opencontainers.image.vendor=Aurora Libros S.L."

  1. HEALTHCHECK: que Docker vigili el teu servei

Un contenidor pot estar Up i ser completament inútil: el procés viu però el bucle d'esdeveniments està bloquejat, el pool de connexions esgotat o l'aplicació retornant 500 a tot. docker ps diria Up 3 hours tan tranquil. HEALTHCHECK dona a Docker una manera de comprovar la salut real.

HEALTHCHECK [opcions] CMD <comanda>
Opció Per defecte Què controla
--interval 30s Cada quant s'executa la comprovació
--timeout 30s Quant s'espera que respongui abans de donar-la per fallida
--start-period 0s Període de gràcia inicial: les fallades d'aquí no compten
--start-interval 5s Interval durant el període de gràcia (versions recents)
--retries 3 Fallades consecutives necessàries per declarar unhealthy

La comanda determina l'estat pel seu codi de sortida: 0 = sa, 1 = malalt. Qualsevol altre valor es tracta com a error.

--start-period mereix atenció especial. Sense ell, una aplicació que triga 20 segons a arrencar seria marcada unhealthy durant aquella arrencada legítima, i en un orquestrador la matarien i reiniciarien en un bucle etern. El període de gràcia diu: "durant els primers N segons, si falla, no ho comptis".

El healthcheck d'aurora-api

Aurora Libros ja té l'endpoint /salut des de la lliçó 01-07, dissenyat precisament per a això: retorna 200 si la base de dades i la memòria cau responen, i 503 si no.

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/salut || exit 1

Detalls de l'elecció de la comanda:

  • wget i no curl: node:22-alpine porta wget (de BusyBox) però no curl. Fer servir curl obligaria a un apk add curl que afegeix uns 4 MB. Si prefereixes curl, l'opció és curl -f http://localhost:3000/salut || exit 1, on -f fa que retorni codi d'error davant d'un HTTP 4xx/5xx.
  • --spider: no descarrega el cos, només comprova que el recurs respon.
  • --tries=1: sense reintents interns; ja reintenta Docker amb --retries.
  • || exit 1: normalitza qualsevol fallada al codi 1, que és el que Docker espera.
  • localhost: la comprovació s'executa dins del contenidor, així que localhost és el servei mateix. No necessita ports publicats.
  • Els temps: cada 30 s, amb 3 s de paciència, 10 s de gràcia en arrencar i 3 fallades seguides abans de declarar-lo malalt. En el pitjor cas, un servei caigut es detecta en uns 90 segons.

Veure-ho funcionar

docker run -d --name aurora-hc -p 3000:3000 auroralibros/aurora-api:1.1.0
docker ps --filter name=aurora-hc --format "table {{.Names}}\t{{.Status}}"

Acabat d'arrencar:

NAMES        STATUS
aurora-hc    Up 3 seconds (health: starting)

health: starting és el període de gràcia. Espera i torna a mirar:

sleep 40 && docker ps --filter name=aurora-hc --format "table {{.Names}}\t{{.Status}}"
NAMES        STATUS
aurora-hc    Up 43 seconds (unhealthy)

unhealthy, i amb raó: /salut retorna 503 perquè no hi ha PostgreSQL ni Redis, exactament com a les lliçons anteriors. El healthcheck està fent la seva feina amb precisió total: el procés viu, però el servei no està operatiu. Justament la distinció que Up no sap fer.

L'historial complet és a les metadades:

docker inspect aurora-hc --format '{{json .State.Health}}' | python3 -m json.tool
{
    "Status": "unhealthy",
    "FailingStreak": 3,
    "Log": [
        {
            "Start": "2026-08-04T09:32:11.442Z",
            "End": "2026-08-04T09:32:11.503Z",
            "ExitCode": 1,
            "Output": ""
        }
    ]
}

FailingStreak: 3 són les tres fallades consecutives que van disparar el canvi d'estat. El camp Log desa les últimes comprovacions amb la seva sortida, que és on mires quan un healthcheck falla i no saps per què.

Els estats possibles:

Estat Significat
starting Dins del --start-period; les fallades no compten
healthy L'última comprovació va sortir amb codi 0
unhealthy S'han assolit --retries fallades consecutives

I per què importa més enllà de la columna de docker ps:

  • Docker Compose pot esperar que un servei estigui healthy abans d'arrencar-ne un altre amb depends_on: condition: service_healthy (lliçó 04-02). És la solució neta al problema que intentava resoldre a mà l'script de l'apartat 4.
  • Docker Swarm reemplaça automàticament les rèpliques unhealthy (lliçó 06-03).
  • Kubernetes té el seu propi mecanisme equivalent, les probes (lliçó 06-05).

Neteja:

docker rm -f aurora-hc

HEALTHCHECK NONE

Si la teva imatge base defineix un healthcheck que no et serveix, el pots desactivar:

HEALTHCHECK NONE

És rar, però apareix en heretar d'imatges corporatives amb comprovacions que no s'apliquen al teu cas.

  1. VOLUME i per què declarar-lo sol ser mala idea

VOLUME declara que un directori de la imatge s'ha de muntar com a volum:

VOLUME /var/lib/postgresql/data
VOLUME ["/dades", "/registres"]

Quan arrenquis un contenidor d'aquella imatge sense especificar res, Docker crearà automàticament un volum anònim per a aquella ruta.

Sona útil, i tanmateix la recomanació general és no posar-lo. Quatre raons:

  1. Genera volums anònims orfes. Cada docker run sense -v explícit crea un volum amb nom aleatori que no s'esborra en eliminar el contenidor (llevat de docker rm -v). En una màquina de desenvolupament s'acumulen desenes de gigabytes de volums amb noms com f3a9c2e1b8... que ningú no sap si són importants. Els veuràs a docker system df a la lliçó 02-05.
  2. No es pot desfer. Un cop una imatge declara VOLUME /dades, qui la fa servir no ho pot treure. Li imposa una decisió d'emmagatzematge que potser no li convé.
  3. Trenca el COPY posterior. Tot el que escriguis en aquella ruta després del VOLUME al Dockerfile es perd silenciosament, sense cap avís. És un error desesperant de diagnosticar.
  4. És una decisió d'execució, no d'imatge. Qui munta què i on ho decideix qui desplega, amb -v o amb la secció volumes: de Compose.

Demostració del punt 3:

FROM alpine:3.21
VOLUME /dades
RUN echo "hola" > /dades/fitxer.txt    # Es perd
docker run --rm aquesta-imatge ls /dades
(buit)

El fitxer no hi és. Es va escriure en una capa que el muntatge del volum tapa.

Per a Aurora Libros no es fa servir VOLUME en cap Dockerfile. L'API no desa estat —llegeix de PostgreSQL i posa a la memòria cau amb Redis—, i la persistència del catàleg es resol muntant un volum en executar el contenidor d'aurora-db, que és el tema de la lliçó 03-06. Curiosament, la imatge oficial de PostgreSQL sí que declara VOLUME /var/lib/postgresql/data, i és justament el motiu que apareguin volums anònims orfes així que hi experimentes sense -v.

  1. STOPSIGNAL i la gestió de senyals

STOPSIGNAL canvia el senyal que Docker envia al PID 1 en executar docker stop:

STOPSIGNAL SIGTERM     # El valor per defecte
STOPSIGNAL SIGQUIT     # El que necessita Nginx
STOPSIGNAL SIGINT
STOPSIGNAL 15          # També per número

El comportament per defecte ja el coneixes de la lliçó 02-03: docker stop envia SIGTERM, espera 10 segons i envia SIGKILL, que no es pot capturar ni ignorar.

El problema és que no tots els programes interpreten SIGTERM igual:

Programa SIGTERM Senyal per a aturada ordenada
Node.js Acaba immediatament SIGTERM (amb gestor propi)
Nginx Aturada ràpida: talla les connexions en curs SIGQUIT: aturada ordenada
PostgreSQL Smart shutdown: espera els clients SIGINT per a fast shutdown
Apache Aturada immediata SIGWINCH per a l'ordenada

El cas de Nginx és el que afecta Aurora Libros: el contenidor aurora-web del mòdul 4 voldrà STOPSIGNAL SIGQUIT per no tallar peticions a mig servir durant un desplegament.

Per a l'API, SIGTERM (el valor per defecte) és correcte, però convé que server.js el capturi per tancar netament. El patró, que es reprendrà a la lliçó 06-01:

// Aturada ordenada: deixa d'acceptar connexions noves, acaba les
// en curs i tanca el pool de PostgreSQL i el client de Redis.
const servidor = app.listen(PORT, '0.0.0.0', () => { /* ... */ });

async function aturar(senyal) {
  console.log(`[aurora-api] rebut ${senyal}, tancant ordenadament...`);
  servidor.close(async () => {
    await pool.end();
    await cache.quit();
    console.log('[aurora-api] tancat netament');
    process.exit(0);
  });
  // Xarxa de seguretat: si alguna cosa s'encalla, sortir abans del SIGKILL
  setTimeout(() => process.exit(1), 8000).unref();
}

process.on('SIGTERM', () => aturar('SIGTERM'));
process.on('SIGINT', () => aturar('SIGINT'));

Fixa't en el temporitzador de 8 segons: és deliberadament inferior als 10 de docker stop, per forçar la sortida abans que arribi el SIGKILL. I recorda que res d'això no serveix si el CMD està en forma shell: el senyal no arribaria mai al procés de Node.

  1. ONBUILD i SHELL

Dues instruccions d'ús minoritari que convé reconèixer.

ONBUILD

Registra una instrucció que no s'executa ara, sinó quan algú faci servir aquesta imatge com a base amb un FROM.

# A la imatge "aurora-base-node"
FROM node:22-alpine
WORKDIR /app
ONBUILD COPY package*.json ./
ONBUILD RUN npm ci --omit=dev && npm cache clean --force
ONBUILD COPY . .
CMD ["node", "server.js"]

Qui la faci servir només escriu:

FROM auroralibros/aurora-base-node:1

I en construir, es disparen automàticament els tres passos registrats. És una plantilla per estandarditzar microserveis d'una organització.

Per què es fa servir poc: la màgia oculta. Qui llegeix aquell Dockerfile d'una línia no veu el que passarà; ha d'anar a inspeccionar la base. Depurar una fallada en un ONBUILD és especialment frustrant, perquè l'error assenyala un fitxer que no conté la instrucció culpable. Les mateixes imatges oficials de Node van retirar les seves variants onbuild fa anys per aquest motiu. Si necessites estandarditzar, avui és preferible una plantilla de Dockerfile compartida o un fitxer base ben documentat.

SHELL

Canvia l'intèrpret que fa servir la forma shell de RUN, CMD i ENTRYPOINT:

SHELL ["/bin/bash", "-c"]
SHELL ["powershell", "-Command"]    # Contenidors Windows

Per defecte és ["/bin/sh", "-c"] a Linux. El cas d'ús més comú i legítim a Linux és activar el mode estricte de bash en imatges amb molts RUN encadenats:

SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -s https://exemple.com/llista.txt | grep aurora > /app/llista.txt

Sense pipefail, aquell RUN té èxit encara que el curl falli, perquè el codi de sortida d'una canonada és el de l'última comanda, i grep sí que funcionaria. Amb pipefail, la fallada de qualsevol baula fa fallar la instrucció sencera. És una fallada silenciosa clàssica que produeix imatges amb fitxers buits.

Nota per a Alpine: node:22-alpine no porta bash, només ash de BusyBox. Fer servir SHELL ["/bin/bash", …] allà requeriria RUN apk add --no-cache bash. Al Dockerfile d'aurora-api no apareix cap SHELL: no hi ha canonades als RUN.

  1. El Dockerfile professional d'aurora-api

Tot junt. Desa això a ~/aurora-libros/api/Dockerfile:

# syntax=docker/dockerfile:1
# =============================================================================
# aurora-api · API REST del catàleg d'Aurora Libros S.L.
#
# Construcció estàndard:
#   docker build -t auroralibros/aurora-api:1.1.0 .
#
# Construcció amb metadades completes (el que farà el pipeline a 06-02):
#   docker build \
#     --build-arg VERSION=1.1.0 \
#     --build-arg REVISION="$(git rev-parse --short HEAD)" \
#     --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
#     -t auroralibros/aurora-api:1.1.0 .
# =============================================================================

# --- Arguments globals: només els veuen les instruccions FROM ----------------
# Permeten provar una altra versió de Node sense tocar el fitxer:
#   docker build --build-arg NODE_VERSION=23 -t aurora-api:node23 .
ARG NODE_VERSION=22
ARG ALPINE_VERSION=3.21

FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}

# --- Arguments de l'etapa: metadades de traçabilitat ------------------------
# Reimportem NODE_VERSION perquè els ARG globals no travessen el FROM.
ARG NODE_VERSION
ARG VERSION=1.1.0
ARG REVISION=desconeguda
ARG CREATED=desconeguda

# --- Metadades OCI ----------------------------------------------------------
# Costen zero bytes i responen a "quin commit porta dins això?" a les 3 AM.
LABEL org.opencontainers.image.title="aurora-api" \
      org.opencontainers.image.description="API REST del catàleg d'Aurora Libros S.L." \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.authors="[email protected]" \
      org.opencontainers.image.vendor="Aurora Libros S.L." \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}" \
      org.opencontainers.image.base.name="docker.io/library/node:${NODE_VERSION}-alpine"

WORKDIR /app

# --- Dependències abans que codi (memòria cau, lliçó 02-02) -----------------
# --chown al COPY mateix: evita un RUN chown -R posterior que duplicaria
# la mida de la capa per copy-on-write.
COPY --chown=node:node package*.json ./

RUN npm ci --omit=dev && npm cache clean --force

# --- Codi de l'aplicació ----------------------------------------------------
COPY --chown=node:node . .

# --- Configuració per defecte, sobreescrivible amb -e -----------------------
# CAP SECRET AQUÍ: quedaria a les metadades i a docker image history.
ENV NODE_ENV=production \
    PORT=3000 \
    APP_VERSION=${VERSION}

# --- Usuari sense privilegis ------------------------------------------------
# Va DESPRÉS d'instal·lar i copiar (root necessitava escriure) i ABANS del CMD.
# node:22-alpine ja inclou l'usuari "node" amb uid 1000.
USER node

# --- Port: documentació, no publicació --------------------------------------
# 3000 i no 80 perquè un usuari sense privilegis no pot fer servir ports <1024.
EXPOSE 3000

# --- Comprovació de salut ---------------------------------------------------
# wget ve amb BusyBox a Alpine; curl no hi és i afegiria ~4 MB.
# start-period de 10 s per no marcar unhealthy durant una arrencada legítima.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/salut || exit 1

# --- Procés principal -------------------------------------------------------
# ENTRYPOINT fixa l'executable; CMD són els arguments reemplaçables.
# Tots dos en forma exec: node és PID 1 i rep SIGTERM directament.
ENTRYPOINT ["node"]
CMD ["server.js"]

Construeix la versió nova:

cd ~/aurora-libros/api
docker build \
  --build-arg VERSION=1.1.0 \
  --build-arg REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo sense-git)" \
  --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t auroralibros/aurora-api:1.1.0 .
[+] Building 10.8s (13/13) FINISHED
 => [1/5] FROM docker.io/library/node:22-alpine3.21@sha256:9f2c...        0.0s
 => [2/5] WORKDIR /app                                                    0.1s
 => [3/5] COPY --chown=node:node package*.json ./                         0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                8.7s
 => [5/5] COPY --chown=node:node . .                                      0.1s
 => exporting to image                                                    0.7s
 => => naming to docker.io/auroralibros/aurora-api:1.1.0                  0.0s

Verificació completa de tot el que has afegit:

# 1. L'usuari ja no és root
docker run --rm auroralibros/aurora-api:1.1.0 --eval "console.log(process.getuid())"
1000

Fixa't en el detall: amb ENTRYPOINT ["node"], l'argument --eval "..." ha substituït el CMD ["server.js"] i s'ha executat node --eval .... El cas 3 de la taula de l'apartat 3, en directe.

# 2. ENTRYPOINT i CMD a les metadades
docker image inspect auroralibros/aurora-api:1.1.0 \
  --format 'Entrypoint: {{json .Config.Entrypoint}}
Cmd:        {{json .Config.Cmd}}
User:       {{.Config.User}}
Health:     {{json .Config.Healthcheck.Test}}'
Entrypoint: ["node"]
Cmd:        ["server.js"]
User:       node
Health:     ["CMD-SHELL","wget --quiet --tries=1 --spider http://localhost:3000/salut || exit 1"]
# 3. Arrencada, healthcheck i aturada neta
docker run -d --name aurora-pro -p 3000:3000 auroralibros/aurora-api:1.1.0
sleep 5 && docker ps --filter name=aurora-pro --format "{{.Names}}: {{.Status}}"
sleep 40 && docker ps --filter name=aurora-pro --format "{{.Names}}: {{.Status}}"
time docker stop aurora-pro
docker rm aurora-pro
aurora-pro: Up 5 seconds (health: starting)
aurora-pro: Up 45 seconds (unhealthy)
aurora-pro
real    0m0.304s

Les tres coses confirmades: període de gràcia, detecció correcta que el servei no està operatiu (falta PostgreSQL, mòdul 3) i aturada en 0,3 segons gràcies a la forma exec.

Compara amb la versió anterior:

Aspecte 1.0.0 (lliçó 02-03) 1.1.0 (aquesta lliçó)
Usuari d'execució root (uid 0) node (uid 1000)
Versió de Node Fixa al FROM Parametritzable amb --build-arg
Traçabilitat Cap 11 etiquetes OCI, amb commit i data
Salut del servei Només "el procés viu" healthy/unhealthy real contra /salut
Executable Reemplaçable del tot Fix (node), amb arguments reemplaçables
Mida 167 MB 167 MB (les metadades no pesen)

Tota aquesta millora ha costat zero bytes.

Errors Habituals i Consells

  • Fer servir ARG per a secrets. Queden a docker image history en clar. Per a credencials a la build existeixen els secrets de BuildKit (lliçó 05-05); per a credencials en execució, -e i fitxers d'entorn (lliçó 04-05).
  • Esperar que un ARG global estigui disponible dins de l'etapa. Els ARG anteriors al primer FROM només els veuen els FROM. Cal tornar a declarar-los, sense valor, dins de l'etapa.
  • ENTRYPOINT en forma shell. Trenca dues coses: el PID 1 passa a ser sh (10 s d'aturada) i els arguments de docker run i el CMD s'ignoren completament. Forma exec sempre.
  • Un script d'entrypoint sense exec. L'script es queda com a PID 1, no reenvia SIGTERM i tornes als 10 segons i el SIGKILL. L'última línia ha de ser exec "$@", amb cometes.
  • USER massa amunt. Si canvies d'usuari abans de npm ci, el pas falla amb EACCES. Instal·la com a root, copia amb --chown i posa USER just abans del CMD.
  • RUN chown -R després de copiar. Duplica la mida d'aquella capa pel copy-on-write. Fes servir COPY --chown.
  • Un usuari sense privilegis escoltant al port 80. EACCES: permission denied. Escolta en un port alt i publica amb -p 80:3000.
  • HEALTHCHECK sense --start-period. El contenidor es marca unhealthy durant la seva arrencada legítima i l'orquestrador entra en un bucle de reinicis.
  • Un healthcheck que consulta dependències externes i no distingeix. Si /salut retorna 503 perquè la base de dades està caiguda, l'orquestrador reiniciarà l'API, que no en té cap culpa. Per a Aurora Libros és correcte en desenvolupament; en producció convé separar liveness (viu el procés?) de readiness (pot atendre?), i això es veu a la lliçó 06-05.
  • VOLUME al Dockerfile. Genera volums anònims orfes, no es pot desfer i fa desaparèixer silenciosament el que escriguis després en aquella ruta. Decideix l'emmagatzematge en executar.
  • Consell: posa els LABEL en una sola instrucció, amb \ per partir línies. Cada LABEL és una capa de metadades.
  • Consell: prova la comanda del healthcheck a mà amb docker exec abans de ficar-la al Dockerfile. Estalvia cicles de build sencers.

Exercicis

Exercici 1: demostra la diferència entre ARG i ENV

Construeix una imatge basada en alpine:3.21 que:

  1. Rebi un ARG ENTORN_BUILD amb valor per defecte local.
  2. Defineixi un ENV ENTORN_RUN a partir d'aquell ARG.
  3. Imprimeixi tots dos valors durant la construcció amb un RUN.
  4. Imprimeixi tots dos en executar el contenidor.

Després:

  • Construeix-la sense --build-arg i executa-la. Què imprimeix cada variable?
  • Construeix-la amb --build-arg ENTORN_BUILD=produccio i executa-la. Què canvia?
  • Executa la imatge amb -e ENTORN_RUN=sobreescrit. Es pot fer el mateix amb l'ARG?
  • Comprova amb docker image history si el valor de l'ARG és visible. Explica què implica això per a les contrasenyes.

Exercici 2: recorre les quatre combinacions d'ENTRYPOINT i CMD

Crea quatre imatges basades en alpine:3.21, una per fila de la taula de l'apartat 3, fent servir echo com a executable. Per a cadascuna, executa docker run --rm <imatge> i docker run --rm <imatge> adeu i anota la sortida real. Després:

  1. Omple la taula amb el que has observat i compara-la amb la teòrica.
  2. Explica en una frase la regla que governa les quatre files.
  3. Fes servir --entrypoint per executar ls / a la imatge de la quarta fila.

Exercici 3: healthcheck que sí que distingeix

L'HEALTHCHECK actual d'aurora-api marca el contenidor unhealthy quan falla PostgreSQL, encara que l'API funcioni perfectament. Dissenya'n una alternativa:

  1. Explica per què això és problemàtic en un orquestrador que reinicia els contenidors malalts.
  2. Proposa dos endpoints diferents i què hauria de comprovar cadascun.
  3. Escriu l'HEALTHCHECK que faries servir al Dockerfile i justifica'n l'elecció.
  4. Demostra amb un contenidor real la diferència entre comprovar /salut (que retorna 503 sense base de dades) i comprovar només que el port respon.

Solucions

Solució a l'exercici 1

mkdir -p /tmp/ex-argenv && cd /tmp/ex-argenv

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21

ARG ENTORN_BUILD=local
ENV ENTORN_RUN=${ENTORN_BUILD}

RUN echo "[build] ARG=${ENTORN_BUILD} ENV=${ENTORN_RUN}"

CMD ["sh", "-c", "echo \"[run] ARG=[${ENTORN_BUILD}] ENV=[${ENTORN_RUN}]\""]
EOF

docker build --no-cache --progress=plain -t ex:defecte . 2>&1 | grep "\[build\]"
docker run --rm ex:defecte
#5 0.142 [build] ARG=local ENV=local
[run] ARG=[] ENV=[local]

Amb --build-arg:

docker build --no-cache --progress=plain --build-arg ENTORN_BUILD=produccio -t ex:prod . 2>&1 | grep "\[build\]"
docker run --rm ex:prod
#5 0.139 [build] ARG=produccio ENV=produccio
[run] ARG=[] ENV=[produccio]

Anàlisi:

  • A la build, totes dues existeixen. L'ENV pren el valor de l'ARG: és el patró pont.
  • En execució, l'ARG està sempre buit. Mai no va arribar al contenidor.
  • L'ENV conserva el valor congelat a la build.

Sobreescriptura en execució:

docker run --rm -e ENTORN_RUN=sobreescrit ex:prod
[run] ARG=[] ENV=[sobreescrit]

L'ENV sí que se sobreescriu amb -e. L'ARG no, perquè no existeix en execució: -e ENTORN_BUILD=x crearia una variable d'entorn nova sense cap relació amb l'ARG de la build.

L'historial:

docker image history ex:prod --no-trunc --format "{{.CreatedBy}}" | head -4
CMD ["sh" "-c" "echo \"[run] ARG=[${ENTORN_BUILD}] ENV=[${ENTORN_RUN}]\""]
|1 ENTORN_BUILD=produccio /bin/sh -c echo "[build] ARG=${ENTORN_BUILD} ENV=${ENTORN_RUN}"
ENV ENTORN_RUN=produccio
ARG ENTORN_BUILD=local

El valor produccio apareix en clar a la línia del RUN. Si en lloc de produccio hagués estat superSecreta2026, qualsevol que descarregués la imatge la llegiria amb una sola comanda i sense arrencar res. Aquesta és exactament la raó per la qual ARG no serveix per a secrets, per molt que "desaparegui" en execució: desapareix de l'entorn, no de l'historial.

Solució a l'exercici 2

mkdir -p /tmp/ex-ep && cd /tmp/ex-ep

printf 'FROM alpine:3.21\nCMD ["echo", "hola-cmd"]\n' > D1
printf 'FROM alpine:3.21\nENTRYPOINT ["echo", "hola-entry"]\n' > D2
printf 'FROM alpine:3.21\nENTRYPOINT ["echo"]\nCMD ["hola-combi"]\n' > D3
printf 'FROM alpine:3.21\nENTRYPOINT ["echo", "prefix"]\nCMD ["sufix"]\n' > D4

for n in 1 2 3 4; do docker build -q -t ep:$n -f D$n . ; done

for n in 1 2 3 4; do
  echo "--- Cas $n ---"
  echo -n "sense arguments: "; docker run --rm ep:$n
  echo -n "amb 'adeu':      "; docker run --rm ep:$n adeu
done
--- Cas 1 ---
sense arguments: hola-cmd
amb 'adeu':      /bin/sh: adeu: not found
--- Cas 2 ---
sense arguments: hola-entry
amb 'adeu':      hola-entry adeu
--- Cas 3 ---
sense arguments: hola-combi
amb 'adeu':      adeu
--- Cas 4 ---
sense arguments: prefix sufix
amb 'adeu':      prefix adeu

1. La taula observada coincideix amb la teòrica. El cas 1 amb argument és especialment il·lustratiu: adeu va reemplaçar el CMD sencer i Docker va intentar executar un programa anomenat adeu, que no existeix. Al cas 2, adeu es va afegir a l'ENTRYPOINT. Al 3 i al 4, adeu va substituir el CMD però l'ENTRYPOINT va romandre.

2. La regla: ENTRYPOINT és l'executable fix i CMD són els seus arguments per defecte; el que escrius després del nom de la imatge a docker run reemplaça únicament el CMD.

3. Amb --entrypoint:

docker run --rm --entrypoint ls ep:4 /
bin
dev
etc
home
lib
...

--entrypoint ls substitueix echo, i la / posterior al nom de la imatge substitueix el CMD, i queda ls /. És la comanda que necessitaràs cada vegada que vulguis inspeccionar una imatge amb ENTRYPOINT propi:

docker run --rm -it --entrypoint sh auroralibros/aurora-api:1.1.0

Solució a l'exercici 3

1. El problema. El healthcheck actual comprova /salut, que retorna 503 si PostgreSQL o Redis no responen. A Swarm o Kubernetes, un contenidor unhealthy es reinicia o es reemplaça. Si la base de dades cau cinc minuts, totes les rèpliques de l'API es marcaran malaltes i entraran en un cicle de reinicis tot i estar perfectament sanes. Pitjor encara: quan PostgreSQL torni, es trobarà amb una allau de reconnexions de rèpliques acabades d'arrencar, amb les memòries cau buides. S'ha convertit una fallada d'una dependència en una caiguda total i en una tempesta de reintents.

2. Dos endpoints amb responsabilitats diferents:

Endpoint Pregunta que respon Comprova Acció si falla
/salut/viu (liveness) El procés funciona? Només que Express respon. Sense dependències Reiniciar: el procés està trencat
/salut/preparat (readiness) Pot atendre peticions? Base de dades i memòria cau accessibles Treure del balanceig, sense reiniciar

La distinció és la clau: reiniciar arregla un procés encallat, però no arregla una base de dades caiguda. Davant d'una fallada de dependència, el correcte és deixar d'enviar trànsit a aquella rèplica i esperar, no matar-la.

3. L'HEALTHCHECK del Dockerfile:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/salut/viu || exit 1

Justificació: l'HEALTHCHECK de Docker té un únic estat, i la seva conseqüència natural és el reinici o el reemplaçament. Per tant ha de reflectir la liveness. La readiness la consulta el balancejador o l'orquestrador pel seu compte —a Compose, amb depends_on: condition: service_healthy (lliçó 04-02); a Kubernetes, amb una readinessProbe separada (lliçó 06-05)—.

L'endpoint nou a server.js seria tan simple com:

// Liveness: no toca cap dependència externa a propòsit
app.get('/salut/viu', (req, res) => res.status(200).json({ estat: 'viu' }));

4. La demostració. Amb el healthcheck actual, sense base de dades:

docker run -d --name hc-salut auroralibros/aurora-api:1.1.0
sleep 45
docker ps --filter name=hc-salut --format "{{.Names}}: {{.Status}}"
hc-salut: Up 45 seconds (unhealthy)

Ara sobreescrivint el healthcheck en temps d'execució per comprovar només que el port respon:

docker run -d --name hc-port \
  --health-cmd="wget -q --tries=1 --spider http://localhost:3000/llibres/abc || exit 1" \
  --health-interval=10s --health-start-period=10s --health-retries=3 \
  auroralibros/aurora-api:1.1.0
sleep 45
docker ps --filter name=hc-port --format "{{.Names}}: {{.Status}}"
hc-port: Up 45 seconds (healthy)

El mateix contenidor, amb la mateixa base de dades absent, és unhealthy amb una comprovació i healthy amb l'altra. La petició a /llibres/abc retorna un 400 de validació —sense tocar PostgreSQL, perquè l'identificador no és un enter—, cosa que demostra que Express és viu i encaminant correctament. És exactament el senyal de liveness que es busca.

Fixa't de passada en les opcions --health-* de docker run: permeten sobreescriure el healthcheck de la imatge sense reconstruir-la, una cosa utilíssima per experimentar.

docker rm -f hc-salut hc-port

Conclusió

La teva imatge ha passat de "funciona" a "és professional", i sense guanyar ni un sol byte. Saps distingir ARG d'ENV pel seu àmbit i el seu moment —el primer s'evapora en acabar la build, el segon viatja dins del contenidor—, i saps que cap dels dos no serveix per a secrets, perquè un --build-arg amb una contrasenya queda escrit en clar a docker image history. Has fet servir ARG abans del FROM per parametritzar la versió de Node i poder provar Node 23 sense editar ni una línia.

Domines el binomi ENTRYPOINT + CMD i les seves quatre combinacions, amb la regla que les resumeix: l'ENTRYPOINT és l'executable fix, el CMD són els arguments reemplaçables, i --entrypoint és la teva porta d'entrada quan necessites depurar. Has vist per què un script docker-entrypoint.sh ha d'acabar en exec "$@": sense aquell exec, l'script es queda com a PID 1, no reenvia SIGTERM i tornes als deu segons d'espera i el SIGKILL. Amb USER node has deixat d'executar l'API com a root —uid=1000 confirmat— instal·lant primer com a root i copiant amb --chown per no duplicar capes, i entens per què això obliga a escoltar al 3000 i no al 80.

Amb onze etiquetes OCI la imatge ja diu qui la va fer, de quin commit surt i quan es va construir, que és el que necessites a les tres de la matinada. Amb HEALTHCHECK Docker vigila l'endpoint /salut que vas preparar a la lliçó 01-07, i has vist el cicle complet startingunhealthy a docker ps, amb l'historial de fallades a docker inspect. I saps per què VOLUME al Dockerfile sol ser mala idea —volums orfes, decisió irreversible imposada a l'usuari i escriptures posteriors que desapareixen sense avís—, per a què serveix STOPSIGNAL (Nginx necessita SIGQUIT) i què fan ONBUILD i SHELL en els pocs casos en què valen la pena.

auroralibros/aurora-api:1.1.0 és una imatge que s'executa com a usuari sense privilegis, s'identifica, s'autodiagnostica i s'atura netament en 0,3 segons. Ja saps construir-la; ara convé saber administrar-la. A la lliçó següent, Gestionant Imatges Docker, t'ocuparàs del magatzem local: llistar i filtrar amb plantilles, extreure camps concrets amb inspect --format, auditar amb history quina capa s'ha menjat 80 MB, esborrar imatges amb contenidors que les fan servir, entendre d'on surten aquelles misterioses imatges <none>:<none> que s'acumulen build rere build, netejar amb la família prune sense destruir el que no toca, diagnosticar l'espai amb docker system df i endur-te una imatge a una altra màquina sense cap registre, amb docker save i docker load.

Docker: De Principiant a Avançat

Mòdul 1: Introducció a Docker

Mòdul 2: Treballant amb Imatges Docker

Mòdul 3: Contenidors Docker

Mòdul 4: Docker Compose

Mòdul 5: Conceptes Avançats de Docker

Mòdul 6: Docker en Producció

Mòdul 7: Ecosistema i Eines de Docker

© Copyright 2026. Tots els drets reservats