Aurora Libros funciona a la teva màquina, i funciona bé. Producció és una altra cosa: allà ningú no reinicia el contenidor a mà quan es penja, la configuració canvia entre entorns i una petició tallada a mitges és una comanda perduda. Aquesta lliçó converteix la teva imatge 1.3.0 en la 2.0.0: la que un orquestrador pot arrencar, matar i tornar a arrencar cent vegades al dia sense que cap client se n'assabenti.

Contingut

  1. Què distingeix una imatge de producció
  2. Els dotze factors aplicats a la configuració
  3. Validar la configuració en arrencar i fallar de pressa
  4. PID 1 i el problema dels zombis
  5. Un procés per contenidor
  6. L'aturada ordenada completa
  7. El període de gràcia i la petició més llarga
  8. Les tres sondes: liveness, readiness i startup
  9. /salut/viu i /salut/preparat a aurora-api
  10. Reproductibilitat: digests, lockfile i etiquetes OCI
  11. Triar límits de recursos amb dades reals
  12. El límit de memòria i el heap de Node
  13. El Dockerfile definitiu d'Aurora Libros
  14. El compose.prod.yaml de la versió 2.0.0
  15. Checklist de quinze punts

Advertència. Els valors de límits, terminis i sondes d'aquesta lliçó són punts de partida raonats amb dades d'Aurora Libros, no una política. Els llindars de recursos, les finestres d'aturada i el tractament de secrets en producció s'han de validar amb el responsable d'infraestructura, seguretat o compliance de la teva organització.

  1. Què distingeix una imatge de producció

No és una diferència de tecnologia: és una diferència de supòsits. En desenvolupament dones per fet que hi ha algú mirant; en producció dones per fet que no.

Aspecte Imatge de desenvolupament Imatge de producció
Base node:22 (~1,1 GB), amb git, compiladors node:22-alpine fixada per digest
Dependències npm install, inclou devDependencies npm ci --omit=dev amb lockfile
Codi Bind mount des de l'amfitrió Copiat dins de la imatge
Recàrrega nodemon, --watch Sense recàrrega: el procés es reemplaça
Configuració .env al repositori Variables d'entorn de l'orquestrador
Secrets Text pla, còmode Fitxers a /run/secrets/, patró _FILE
Usuari root (tant és) node (UID 1000), sense privilegis
Sistema de fitxers Escrivible read_only amb tmpfs mínims
Logs Acolorits, nivell debug JSON en una línia, nivell info
Errors Stack trace al client Missatge genèric; el detall, al log
Aturada Ctrl+C i prou SIGTERM capturat, drenatge ordenat
Salut Cap Tres sondes separades
Arrencada amb config invàlida Falla a la primera petició Falla en arrencar, al segon 0
Reproductibilitat "Avui funciona" El mateix digest avui i d'aquí a sis mesos

Les quatre últimes files separen una imatge que funciona d'una que es pot operar; les tres primeres ja les vas resoldre al mòdul 5.

  1. Els dotze factors aplicats a la configuració

La regla del tercer factor de la metodologia twelve-factor és taxativa: la configuració viu a l'entorn, mai a la imatge. La prova pràctica és la pregunta del codi obert: podries publicar aquesta imatge ara mateix en un registre d'imatges públic sense filtrar res? Si la resposta és no, tens configuració a dins.

D'aquí se'n deriva la propietat més valuosa de tot el mòdul: una imatge, molts entorns. El mateix digest que va passar les proves a CI és el que corre a staging i el que corre a producció; l'únic que canvia són les variables que li injectes. Si reconstruïssis la imatge per a producció, estaries desplegant un artefacte que ningú no ha provat. El que ve a continuació és com es reparteix cada cosa.

Tipus de dada On va Exemple a Aurora Libros
Constant de l'aplicació A la imatge Rutes de les vistes, versió de l'API
Configuració per entorn Variable d'entorn DB_HOST, PORT, LOG_NIVELL
Secret Fitxer muntat + patró _FILE DB_PASSWORD_FILE=/run/secrets/db_password
Estat Volum o servei extern aurora-dades, aurora-cache

  1. Validar la configuració en arrencar i fallar de pressa

La pitjor fallada de configuració és la silenciosa: el contenidor arrenca, es declara sa, rep trànsit i llavors descobreix que DB_PASSWORD estava buida. Quan te n'assabentes, el balancejador ja li ha enviat clients.

La solució és fail fast: validar-ho tot en arrencar i sortir amb un codi diferent de zero si falta res.

// api/src/config.js — única porta d'entrada a la configuració
const fs = require('node:fs');

function llegir(nom, { obligatori = false, defecte } = {}) {
  const ruta = process.env[`${nom}_FILE`];   // patró _FILE: guanya sobre la variable directa
  if (ruta) {
    try { return fs.readFileSync(ruta, 'utf8').trim(); }
    catch (e) { throw new Error(`${nom}_FILE apunta a ${ruta}, illegible: ${e.code}`); }
  }
  const valor = process.env[nom];
  if (valor) return valor;
  if (defecte !== undefined) return defecte;
  if (obligatori) throw new Error(`Falta la variable obligatoria ${nom} (o la seva variant _FILE)`);
}

function enter(nom, defecte, { min = 1, max = 65535 } = {}) {
  const brut = llegir(nom, { defecte: String(defecte) });
  const n = Number(brut);
  if (!Number.isInteger(n) || n < min || n > max) throw new Error(`${nom}="${brut}" no es un enter entre ${min} i ${max}`);
  return n;
}

let config;
try {
  config = {
    port:    enter('PORT', 3000),
    version: llegir('APP_VERSION', { defecte: '0.0.0-dev' }),
    gracia:  enter('TERMINI_ATURADA_MS', 15000, { min: 1000, max: 120000 }),
    db: {
      host:     llegir('DB_HOST',     { obligatori: true }),
      usuari:   llegir('DB_USER',     { obligatori: true }),
      password: llegir('DB_PASSWORD', { obligatori: true }),
      base:     llegir('DB_NAME',     { obligatori: true }),
      maxPool:  enter('DB_POOL_MAX', 10, { min: 1, max: 200 }),
    },
    redis: { host: llegir('REDIS_HOST', { obligatori: true }), ttl: enter('CACHE_TTL', 60, { min: 1, max: 86400 }) },
  };
} catch (e) {
  process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), nivell: 'error',
    servei: 'aurora-api', missatge: 'configuracio invalida', detall: e.message }) + '\n');
  process.exit(78);   // EX_CONFIG de sysexits.h: "error de configuració"
}

module.exports = config;

Tres decisions importants. La primera: res de la resta del codi no llegeix process.env; tots importen config.js, de manera que hi ha un únic lloc on saber què configura l'aplicació. La segona: el _FILE guanya sobre la variable directa, així que el mateix codi serveix per a desenvolupament (variable) i per a producció (secret muntat). La tercera: el codi de sortida 78 no és decoratiu; distingir-lo de l'1 genèric permet que l'orquestrador —i tu, llegint docker inspect— sàpiga que no és una fallada transitòria i que reiniciar mil vegades no ho arreglarà. Ho comprovaràs a l'exercici 1: la fallada arriba al segon 0, amb el nom exacte de la variable que falta, en comptes d'un 502 a les tres de la matinada.

  1. PID 1 i el problema dels zombis

A 03-02 vas veure que el procés principal del contenidor és el PID 1. Producció hi afegeix un matís que en desenvolupament no molesta: a Linux, el PID 1 té dues responsabilitats especials del procés init.

  1. Adoptar orfes. Quan un procés mor deixant fills, aquests fills passen a penjar del PID 1.
  2. Recollir zombis. Un procés acabat roman en estat Z fins que el seu pare crida wait() per llegir-ne el codi de sortida. Si ningú no ho fa, l'entrada no s'allibera mai.

Node.js no fa la segona cosa: no és un init. Si la teva API llança subprocessos —una conversió d'imatge, un pg_dump—, cadascun deixa un zombi que ocupa una entrada de la taula de processos, i els comptes amb docker compose exec aurora-api ps -eo stat | grep -c Z. Amb pids_limit: 200, dos-cents zombis i el contenidor ja no pot crear cap procés més. La solució és un init mínim al davant:

Opció Com Quan
--init / init: true Docker injecta docker-init (tini) com a PID 1 Per defecte: zero canvis a la imatge
tini a la imatge ENTRYPOINT ["/sbin/tini","--"] Quan no controles el runtime (Kubernetes)
dumb-init Igual, alternativa històrica Equivalent
Cap El procés és PID 1 Si mai no llança subprocessos i captura senyals

Un detall que surt car: el PID 1 té desactivades les accions per defecte dels senyals. Si el teu procés no instal·la un gestor de SIGTERM, el senyal s'ignora, docker stop espera deu segons i te'l mata amb SIGKILL. Aquest és l'origen real de la majoria dels "el meu contenidor triga deu segons a parar".

  1. Un procés per contenidor

La temptació de ficar supervisord, systemd o un cron dins del contenidor apareix tan bon punt necessites una segona cosa. Resisteix-t'hi, per raons concretes:

  • L'estat es torna opac. Docker només veu el gestor. Si Node mor i supervisord continua viu, el contenidor està "sa" amb l'aplicació caiguda.
  • Els reinicis deixen de funcionar. restart: always i l'orquestrador reaccionen a la mort del PID 1, que ja no passa mai.
  • Els logs es barregen i perden el seu origen; docker logs deixa de ser útil.
  • L'escalat s'acobla. Si l'API necessita tres rèpliques i el cron una, no pots.
  • La imatge s'infla i la seva superfície d'atac creix.

Les tasques periòdiques d'Aurora Libros —la còpia de la base de dades de 05-02— són un servei a part amb el seu propi cicle de vida, no un cron amagat. L'única excepció legítima és el patró sidecar: dos contenidors diferents que comparteixen xarxa o volum, cadascun amb el seu PID 1.

  1. L'aturada ordenada completa

Quan l'orquestrador retira una rèplica envia SIGTERM i engega un cronòmetre. El que facis en aquests segons decideix si els clients que estaven comprant acaben la compra o veuen un error.

// api/src/server.js — aturada ordenada (fragment final)
const servidor = app.listen(config.port, () =>
  log.info('escoltant', { port: config.port, version: config.version }));

servidor.keepAliveTimeout = 5000;        // sense això, una connexió ociosa el manté obert
servidor.headersTimeout   = 6000;

let aturant = false;
estat.preparat = true;                   // a partir d'aquí, /salut/preparat respon 200

async function aturar(senyal) {
  if (aturant) return;                   // idempotent: dos SIGTERM no trenquen res
  aturant = true;
  estat.preparat = false;                // 1) fer fallar la readiness JA: no arriba trànsit nou
  log.info('aturada iniciada', { senyal, termini_ms: config.gracia });

  await new Promise(r => setTimeout(r, 5000));   // 2) marge perquè el balancejador se n'assabenti

  await new Promise((resolve) => {              // 3) tancar el listener i drenar el que hi ha en curs
    servidor.close(resolve);
    setTimeout(() => { log.warn('connexions en curs forcades'); resolve(); }, config.gracia - 7000);
  });

  // 4) tancar dependències en ordre invers al d'obertura
  try { await redis.quit(); } catch (e) { log.warn('redis.quit fallada', { detall: e.message }); }
  try { await pool.end(); }   catch (e) { log.warn('pool.end fallada',   { detall: e.message }); }
  log.info('aturada completa');
  process.exit(0);
}

process.on('SIGTERM', () => aturar('SIGTERM'));
process.on('SIGINT',  () => aturar('SIGINT'));
process.on('unhandledRejection', (e) => { log.error('promesa sense capturar', { detall: String(e) }); aturar('rebuig'); });

El pas 2 és el que gairebé tothom es salta i el que més errors evita. Entre que el teu Pod deixa d'estar preparat i que el balancejador deixa d'enviar-li trànsit passen uns quants segons: l'actualització de les taules d'encaminament no és instantània. Si tanques el listener immediatament, les peticions que ja anaven de camí s'estavellen contra un port tancat. Esperar uns segons amb el listener obert però la readiness en vermell elimina aquesta finestra.

L'ordre del pas 4 també importa: primer Redis i després PostgreSQL, a l'inrevés de com es van obrir, perquè una petició en curs pot necessitar la base de dades després de fallar a la memòria cau.

  1. El període de gràcia i la petició més llarga

La regla és aritmètica:

periode_de_gracia  >  espera_de_readiness + peticio_mes_llarga + tancament_de_dependencies

Per a Aurora Libros, amb la petició més lenta mesurada en 4 s (el llistat complet sense memòria cau), 5 s d'espera de readiness i ~1 s de tancament de connexions: 5 + 4 + 1 = 10 s, i es configuren 15 s per tenir marge.

Lloc Clau Valor a Aurora Libros
Aplicació TERMINI_ATURADA_MS 15000
Compose stop_grace_period 20s
Dockerfile STOPSIGNAL SIGTERM (per defecte)
Kubernetes terminationGracePeriodSeconds 30 (06-05)

Fixa't que el termini de la plataforma sempre és més gran que el de l'aplicació: qui ha de decidir acabar és el teu codi, no el SIGKILL. Si Docker mata el procés abans que acabi, l'aturada ordenada no haurà servit de res.

  1. Les tres sondes: liveness, readiness i startup

El HEALTHCHECK de 02-04 responia a una sola pregunta. En producció n'hi ha tres, i confondre-les provoca caigudes espectaculars.

Sonda Pregunta Si falla Ha de comprovar NO ha de comprovar
Liveness El procés continua viu i sa? Es reinicia el contenidor Que el bucle d'esdeveniments respon Dependències externes
Readiness Pot atendre peticions ara? Se li treu el trànsit, sense reiniciar La BD, la memòria cau, l'escalfament Res car ni lent
Startup Ha acabat d'arrencar? Es reinicia (després de molts intents) El mateix que la liveness

L'error clàssic és fer servir el mateix endpoint per a les tres, i la conseqüència és una caiguda total en cascada. Imagina't que /salut comprova PostgreSQL, com el teu del mòdul 5, i que la base de dades cau trenta segons per un failover:

  1. La readiness falla a les tres rèpliques: correcte, no té sentit enviar-los trànsit.
  2. La liveness també falla, perquè és el mateix endpoint.
  3. L'orquestrador reinicia les tres rèpliques de l'API.
  4. La base de dades torna, però les rèpliques estan arrencant de zero.
  5. Les tres es reconnecten alhora, saturen el pool i la liveness torna a fallar.

Has convertit una incidència de 30 segons en un bucle de reinicis. La regla que ho evita és simple: la liveness no comprova dependències. Reiniciar el teu procés no arregla una base de dades caiguda; l'únic que fa és empitjorar-ho.

La sonda de startup resol un problema diferent: si arrencar triga 40 s (migracions, escalfament de memòria cau) i la liveness té un llindar de 10 s, el contenidor es reinicia eternament sense arribar mai a arrencar. La startup suspèn les altres dues fins que passa per primera vegada.

  1. /salut/viu i /salut/preparat a aurora-api

// api/src/salut.js — tres endpoints, tres semàntiques
const estat = { preparat: false, arrencatEl: Date.now() };

function muntar(app, { pool, redis, config }) {
  // LIVENESS: no toca res extern. Si Node respon, Node és viu.
  app.get('/salut/viu', (req, res) =>
    res.json({ estat: 'viu', version: config.version, uptime_s: Math.round(process.uptime()) }));

  // READINESS: sí que comprova dependències, amb timeout curt i sense efectes
  app.get('/salut/preparat', async (req, res) => {
    if (!estat.preparat) return res.status(503).json({ estat: 'aturant' });
    const limit = (p, ms) => Promise.race([p, new Promise((_, k) => setTimeout(() => k(new Error('timeout')), ms))]);
    const control = {};
    try { await limit(pool.query('SELECT 1'), 2000); control.db = 'ok'; } catch (e) { control.db = `error: ${e.message}`; }
    try { await limit(redis.ping(), 1000); control.cache = 'ok'; }        catch (e) { control.cache = `error: ${e.message}`; }
    // La memòria cau degradada NO impedeix servir: se serveix des de la BD, més lent però correcte
    const sa = control.db === 'ok';
    res.status(sa ? 200 : 503).json({ estat: sa ? 'preparat' : 'degradat', control });
  });

  // STARTUP: preparat quan l'arrencada ha acabat; l'orquestrador deixa d'esperar
  app.get('/salut/arrencat', (req, res) =>
    res.status(estat.preparat ? 200 : 503).json({ estat: estat.preparat ? 'arrencat' : 'arrencant' }));
}
module.exports = { estat, muntar };

La decisió de negoci és a la penúltima línia: Redis caigut no treu la rèplica del balanceig, perquè el cache-aside d'Aurora Libros degrada a origen: db i continua venent llibres, més a poc a poc. PostgreSQL caigut sí, perquè sense catàleg no hi ha res a servir. Aquesta distinció entre dependència dura i tova és teva, no de Docker, i convé escriure-la al codi al costat de la sonda.

  1. Reproductibilitat: digests, lockfile i etiquetes OCI

Que la imatge d'avui i la d'aquí a sis mesos siguin la mateixa no és purisme: és l'única manera que un rollback serveixi d'alguna cosa.

Font de deriva Solució Verificació
La base canvia FROM node:22-alpine@sha256:... docker buildx imagetools inspect
Un paquet npm puja de versió npm ci (mai npm install) package-lock.json al repositori
Paquets del sistema Versió fixada a apk add Reconstruir i comparar digest
No saber quin commit corre Etiquetes OCI amb el SHA docker inspect --format '{{json .Config.Labels}}'
Marques de temps SOURCE_DATE_EPOCH Dues builds, mateix digest
docker buildx imagetools inspect node:22-alpine --format '{{.Manifest.Digest}}'   # es fixa al FROM
docker inspect auroralibros/aurora-api:2.0.0 \
  --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'          # quin commit corre

  1. Triar límits de recursos amb dades reals

Posar memory: 2G "per si de cas" té dos costos: l'orquestrador reserva memòria que ningú no fa servir i en cada node hi cap menys. Posar-ne poca en té un altre: OOM kill amb codi 137 en hora punta. Es mesura amb quinze minuts de mostreig sota càrrega representativa, abocant docker stats --no-stream --format '{{.Name}};{{.MemUsage}};{{.CPUPerc}}' en bucle a un CSV.

Servei Memòria en repòs Memòria pic CPU mitjana CPU pic Límit triat
aurora-api 78 MiB 214 MiB 4 % 96 % 512M / 1.0 CPU
aurora-db 96 MiB 380 MiB 6 % 140 % 1G / 2.0 CPU
aurora-cache 12 MiB 208 MiB 1 % 15 % 256M / 0.5 CPU
aurora-web 6 MiB 22 MiB 1 % 30 % 64M / 0.5 CPU

Les regles que apliquen aquests números:

  • Memòria: pic observat × 2 aproximadament, arrodonint a una potència còmoda. La memòria no és compressible: passar-se del límit vol dir mort, no lentitud.
  • CPU: no ofeguis els pics. La CPU sí que és compressible; un límit baix només produeix latència. aurora-db rep 2 CPU perquè un VACUUM o un pg_dump han de poder córrer.
  • aurora-cache amb --maxmemory 200mb necessita un límit de contenidor per sobre d'aquests 200 MB: Redis fa servir memòria a més de la de les dades. 256M és el mínim defensable.
  • Deixa sempre les requests per sota dels limits a Kubernetes (06-05): les requests reserven i decideixen on cap el Pod; els limits tallen.

  1. El límit de memòria i el heap de Node

Aquí hi ha un parany concret. El heap de V8 té el seu propi màxim, independent del cgroup. Si Node es pensa que pot fer servir 4 GB i el contenidor talla a 512 MB, el procés mor per OOM sense llançar cap excepció: el kernel el mata abans que el recol·lector d'escombraries decideixi que hi ha pressió. Comprova-ho amb docker run --rm --memory 512m node:22-alpine node -p 'v8.getHeapStatistics().heap_size_limit/1048576'.

Node 22 llegeix el cgroup i ajusta el límit raonablement, però convé ser explícit, sobretot perquè el heap no és tota la memòria del procés: els buffers, el codi natiu i la pila viuen fora d'ell. La regla pràctica és deixar al heap el 75 % del límit del contenidor, que és d'on surt l'ENV NODE_OPTIONS="--max-old-space-size=384" del Dockerfile. Amb aquesta configuració, quan l'aplicació s'acosti al sostre, V8 recol·lectarà agressivament i, si de debò no hi cap, llançarà un heap out of memory que pots registrar en comptes d'un 137 mut.

  1. El Dockerfile definitiu d'Aurora Libros

# syntax=docker/dockerfile:1.7
# api/Dockerfile — Aurora Libros 2.0.0
ARG NODE_DIGEST=sha256:9c8f1b1e0c9d2a3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c

FROM node:22-alpine@${NODE_DIGEST} AS deps             # 1. només dependències de producció
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci --omit=dev

FROM node:22-alpine@${NODE_DIGEST} AS deps-dev         # 2. dependències completes
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci

FROM deps-dev AS proves                                # 3. etapa objectiu del pipeline (06-02)
COPY . .
RUN npm run lint && npm test -- --run

FROM node:22-alpine@${NODE_DIGEST} AS runtime          # 4. imatge final
ARG VERSION=2.0.0
ARG REVISION=desconeguda
ARG DATA_BUILD
LABEL org.opencontainers.image.title="aurora-api" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${DATA_BUILD}" \
      org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
      org.opencontainers.image.base.name="docker.io/library/node:22-alpine"
ENV NODE_ENV=production APP_VERSION=${VERSION} PORT=3000 \
    NODE_OPTIONS="--max-old-space-size=384"
WORKDIR /app
# --chown al COPY evita una capa extra de RUN chown que duplicaria els fitxers
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src

USER node
EXPOSE 3000
STOPSIGNAL SIGTERM

# La liveness també dins de la imatge: serveix fora d'un orquestrador
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
  CMD node -e "require('http').get('http://127.0.0.1:3000/salut/viu',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"

# Sense shell: node és PID 1 i rep SIGTERM directament
CMD ["node", "src/server.js"]
docker build -t auroralibros/aurora-api:2.0.0 --build-arg REVISION=$(git rev-parse --short HEAD) \
  --build-arg DATA_BUILD=$(date -u +%Y-%m-%dT%H:%M:%SZ) api/
docker image inspect auroralibros/aurora-api:2.0.0 --format '{{.Size}}' | numfmt --to=iec
# 104M   (2 MB més que la 1.3.0: el preu de les tres sondes i la validació)

  1. El compose.prod.yaml de la versió 2.0.0

# compose.prod.yaml — només el servei de l'API; la resta segueix com a 05-03
services:
  aurora-api:
    image: auroralibros/aurora-api:2.0.0
    init: true                          # tini com a PID 1: recull zombis
    restart: unless-stopped
    stop_grace_period: 20s              # > TERMINI_ATURADA_MS (15 s)
    read_only: true
    tmpfs: [/tmp:size=32m,noexec,nosuid]
    user: "1000:1000"
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    pids_limit: 200
    environment:
      DB_HOST: aurora-db
      DB_USER: aurora
      DB_NAME: aurora_llibres
      DB_PASSWORD_FILE: /run/secrets/db_password
      DB_POOL_MAX: "10"
      REDIS_HOST: aurora-cache
      CACHE_TTL: "60"
      TERMINI_ATURADA_MS: "15000"
      LOG_NIVELL: info
    secrets: [db_password]
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/salut/preparat',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:       { memory: 512M, cpus: "1.0" }
        reservations: { memory: 128M, cpus: "0.1" }
    networks: [frontal, posterior]
    depends_on:
      aurora-db:    { condition: service_healthy }
      aurora-cache: { condition: service_healthy }

El healthcheck de Compose fa servir /salut/preparat i el HEALTHCHECK de la imatge fa servir /salut/viu, expressament: aquí la salut governa depends_on, és a dir, "et puc enviar trànsit?", que és exactament la readiness.

  1. Checklist de quinze punts

# Control Comprovació
1 Base fixada per digest grep 'FROM.*@sha256' api/Dockerfile
2 npm ci amb lockfile al repositori git ls-files package-lock.json
3 Sense devDependencies a la final docker run --rm IMG ls node_modules | wc -l
4 Corre com a usuari sense privilegis docker run --rm IMG id -u1000
5 Zero configuració dins de la imatge docker history --no-trunc IMG | grep -i pass
6 Secrets per fitxer amb patró _FILE DB_PASSWORD_FILE a l'entorn
7 Falla en arrencar si falta configuració Arrencar sense DB_PASSWORD → codi 78
8 PID 1 correcte (init: true o tini) docker exec IMG ps -eo pid,comm | head -2
9 Un sol procés principal Sense supervisord ni cron a la imatge
10 SIGTERM capturat i aturada ordenada time docker stop C → menys de 15 s
11 Tres sondes separades i liveness sense dependències curl /salut/viu amb la BD parada → 200
12 stop_grace_period > termini de l'aplicació 20 s davant de 15 s
13 Límits mesurats, no inventats Taula de docker stats documentada
14 Heap de Node coherent amb el límit NODE_OPTIONS al 75 % de memory
15 Etiquetes OCI amb versió i commit docker inspect --format '{{json .Config.Labels}}'

Errors Habituals i Consells

  • Reconstruir la imatge per a cada entorn. Si staging i producció tenen imatges diferents, no has provat allò que desplegues. Un artefacte, un digest, molts entorns.
  • La liveness comprova la base de dades. És l'error més car d'aquesta lliçó: converteix una caiguda de la BD en un bucle de reinicis de tota la flota. La liveness només respon "el procés respon".
  • Tancar el listener just en rebre SIGTERM. Sense l'espera prèvia amb la readiness en vermell, les peticions ja encaminades es perden. Cinc segons de marge eliminen gairebé tots els 502 d'un desplegament.
  • CMD npm start. npm es converteix en PID 1, no reenvia SIGTERM a Node i afegeix una capa inútil. Fes servir sempre CMD ["node", "src/server.js"].
  • Confiar en els valors per defecte de la configuració. Un DB_HOST amb defecte localhost no falla: es connecta al lloc equivocat. El que és obligatori no té valor per defecte.
  • stop_grace_period menor que el termini intern. Docker envia SIGKILL a mitges del drenatge i tota la feina d'aturada ordenada va a la brossa. I latest en producció impedeix saber què corre i fa impossible el rollback: etiqueta amb SemVer i, millor encara, desplega per digest.
  • Consell: prova l'aturada. docker stop amb hey enviant càrrega i compta quantes peticions fallen. Si no és zero, la teva aturada ordenada no ho és.
  • Consell: registra la configuració efectiva en arrencar, amb els secrets ocults. Un log d'arrencada amb db_host, pool_max i version estalvia hores de diagnòstic.

Exercicis

Exercici 1. Demostra la fallada ràpida: arrenca aurora-api:2.0.0 sense DB_PASSWORD, comprova que surt amb codi 78 en menys d'un segon amb un missatge que anomena la variable, i verifica que amb DB_PASSWORD_FILE apuntant a un secret sí que arrenca.

Exercici 2. Comprova que l'aturada ordenada funciona: genera càrrega contra /llibres, executa docker stop enmig de la càrrega i mesura quantes peticions han fallat. Després desactiva el gestor de SIGTERM i repeteix-ho, comparant el temps de parada i els errors.

Exercici 3. Demostra per què la liveness no ha de tocar la base de dades: para aurora-db i comprova què responen /salut/viu i /salut/preparat. Explica què faria un orquestrador amb cada resposta.

Solucions

Solució 1.

time docker run --rm -e DB_HOST=aurora-db -e DB_USER=aurora -e DB_NAME=aurora_llibres \
  -e REDIS_HOST=aurora-cache auroralibros/aurora-api:2.0.0
echo "codi: $?"
{"ts":"2026-08-05T09:12:44.118Z","nivell":"error","servei":"aurora-api",
 "missatge":"configuracio invalida",
 "detall":"Falta la variable obligatoria DB_PASSWORD (o la seva variant _FILE)"}
real  0m0.421s
codi: 78
printf 'clau-ficticia-aurora' > /tmp/db_password
docker run --rm -d --name prova-cfg -v /tmp/db_password:/run/secrets/db_password:ro \
  -e DB_HOST=aurora-db -e DB_USER=aurora -e DB_NAME=aurora_llibres -e REDIS_HOST=aurora-cache \
  -e DB_PASSWORD_FILE=/run/secrets/db_password --network aurora-libros_posterior \
  auroralibros/aurora-api:2.0.0 && docker logs prova-cfg | head -1
# {"ts":"...","nivell":"info","missatge":"escoltant","port":3000,"version":"2.0.0"}

Queden demostrades tres coses. La fallada triga 0,4 segons: el contenidor no arriba mai a existir com a servei, així que cap balancejador no li pot enviar trànsit. El missatge anomena la variable exacta, amb la qual cosa el diagnòstic és immediat fins i tot per a qui no coneix el codi. I el codi 78 és distingible: en un orquestrador amb reinici automàtic, veure exit 78 repetit vol dir "no insisteixis, arregla la configuració", mentre que un exit 1 podria ser qualsevol cosa.

A la segona comanda el secret entra com a fitxer de només lectura: no apareix mai a docker inspect ni a l'historial de l'intèrpret d'ordres, i el codi el llegeix una sola vegada en arrencar.

Solució 2.

docker run --rm --network aurora-libros_frontal ghcr.io/rakyll/hey \
  -z 30s -c 20 http://aurora-api:3000/llibres > /tmp/amb-gestor.txt &
sleep 8; time docker compose -f compose.prod.yaml stop aurora-api
wait; grep -E 'responses|error' /tmp/amb-gestor.txt
# real  0m6.104s
# Status code distribution:
#   [200] 4127 responses

Ara l'escenari contrari, sobreescrivint l'arrencada perquè el gestor no existeixi:

docker run -d --name sense-gestor --network aurora-libros_frontal \
  auroralibros/aurora-api:2.0.0 \
  node -e "require('http').createServer((q,s)=>setTimeout(()=>s.end('ok'),300)).listen(3000)"
# ... la mateixa càrrega amb hey ...
time docker stop sense-gestor
real  0m10.213s
Status code distribution:
  [200] 3811 responses
  [error] 152 connection reset by peer
Escenari Temps d'stop Peticions fallides
Amb aturada ordenada ~6 s 0
Sense gestor de SIGTERM 10,2 s 152

Els dos números expliquen la mateixa història. 10,2 segons és la signatura inconfusible del problema: el procés va ignorar SIGTERM —recorda, el PID 1 no té acció per defecte—, Docker va esperar els 10 segons complets de --time i el va matar amb SIGKILL. Un SIGKILL no es pot capturar: les 152 connexions obertes es van tallar a mitges, i cadascuna és un client veient un error.

Amb el gestor, la parada triga menys (6 s: els 5 de marge de readiness més el drenatge real) i cap petició no falla. La lliçó operativa és que un desplegament de vint rèpliques sense aturada ordenada són milers d'errors per versió publicada, i cap no apareixerà als teus logs d'aplicació, perquè el procés ja era mort quan van passar.

Solució 3.

docker compose -f compose.prod.yaml stop aurora-db
sleep 3
curl -s -o /dev/null -w 'viu:      %{http_code}\n' http://localhost:8080/salut/viu
curl -s -w '\npreparat: %{http_code}\n' http://localhost:8080/salut/preparat
viu:      200
{"estat":"degradat","control":{"db":"error: timeout","cache":"ok"}}
preparat: 503
Sonda Resposta Què faria l'orquestrador Correcte?
/salut/viu 200 Res: la rèplica continua viva
/salut/preparat 503 Li treu el trànsit, sense reiniciar

Aquest parell de respostes és exactament el comportament desitjat. El procés de Node està perfectament sa —el seu bucle d'esdeveniments respon en mil·lisegons—, així que reiniciar-lo no arreglaria res; el que passa és que no pot servir, i per això deixa de rebre trànsit i prou.

Si /salut fos alhora liveness i readiness, com a la versió 1.3.0, aquest mateix 503 hauria produït: reinici de les tres rèpliques → 40 s sense servei per l'arrencada → reconnexió simultània de tres pools contra una base de dades que acaba de tornar → possible nova fallada → segon reinici. Una incidència de 30 segons de la base de dades convertida en uns quants minuts de caiguda total, causada íntegrament per la sonda mal dissenyada.

I fixa't en la tercera línia de la resposta: cache: ok amb db: error retorna 503, però el cas invers —db: ok amb cache: error— retorna 200, perquè el cache-aside degrada a origen: db. Aquesta asimetria és una decisió de producte codificada a la sonda: Aurora Libros prefereix vendre llibres a poc a poc que no pas no vendre'n.

Conclusió

Has convertit una imatge que funciona en una imatge que es pot operar. La configuració va sortir del tot de la imatge i va entrar per l'entorn, amb un únic config.js que la valida en arrencar i falla en 0,4 segons amb codi 78 si falta res obligatori, en comptes de rebentar a la primera petició real; el patró _FILE deixa el mateix codi servint per al teu portàtil i per a un clúster amb secrets muntats. Has resolt el PID 1 amb init: true perquè els zombis no esgotin el teu pids_limit, i has entès per què ficar un supervisord a dins trenca alhora els reinicis, els logs i l'escalat.

L'aturada ordenada ja és completa i, sobretot, està mesurada: zero peticions perdudes davant de 152, amb l'espera de cinc segons amb la readiness en vermell abans de tancar el listener —el pas que gairebé ningú no implementa— i el tancament del client de Redis i del pool de PostgreSQL en ordre invers al d'obertura. Saps calcular el període de gràcia a partir de la petició més llarga i per què stop_grace_period ha de ser més gran que el termini intern. Has separat les tres sondes amb les seves tres semàntiques i n'has vist en directe la raó: amb PostgreSQL parat, /salut/viu respon 200 i /salut/preparat respon 503, així que la rèplica perd el trànsit però no es reinicia; l'endpoint únic hauria convertit trenta segons d'incidència en uns quants minuts de caiguda total. I has fixat la base per digest, npm ci amb lockfile, etiquetes OCI amb el commit, límits triats amb quinze minuts de docker stats en comptes de a ull, i un heap de V8 al 75 % del límit del cgroup perquè un desbordament sigui una excepció registrable i no un 137 mut. Tot plegat cristal·litzat al Dockerfile i al compose.prod.yaml d'aurora-api:2.0.0 i en una checklist de quinze controls verificables un a un.

Ara ja tens l'artefacte correcte, però el continues construint tu, a mà, al teu portàtil. A la lliçó següent, CI/CD amb Docker, aquesta feina passa a fer-la una màquina: muntaràs el pipeline que, a cada git push, executa el lint i les proves dins de contenidors, construeix la imatge multiarquitectura reutilitzant la memòria cau remota de BuildKit, l'escaneja amb Trivy trencant la build davant de vulnerabilitats crítiques, la signa amb Cosign, en genera l'SBOM i la publica a ghcr.io etiquetada automàticament des de la teva etiqueta de Git.

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