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
- Què distingeix una imatge de producció
- Els dotze factors aplicats a la configuració
- Validar la configuració en arrencar i fallar de pressa
- PID 1 i el problema dels zombis
- Un procés per contenidor
- L'aturada ordenada completa
- El període de gràcia i la petició més llarga
- Les tres sondes: liveness, readiness i startup
/salut/viui/salut/preparataaurora-api- Reproductibilitat: digests, lockfile i etiquetes OCI
- Triar límits de recursos amb dades reals
- El límit de memòria i el heap de Node
- El
Dockerfiledefinitiu d'Aurora Libros - El
compose.prod.yamlde la versió 2.0.0 - 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ó.
- 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.
- 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 |
- 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.
- 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.
- Adoptar orfes. Quan un procés mor deixant fills, aquests fills passen a penjar del PID 1.
- Recollir zombis. Un procés acabat roman en estat
Zfins que el seu pare cridawait()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".
- 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
supervisordcontinua viu, el contenidor està "sa" amb l'aplicació caiguda. - Els reinicis deixen de funcionar.
restart: alwaysi l'orquestrador reaccionen a la mort del PID 1, que ja no passa mai. - Els logs es barregen i perden el seu origen;
docker logsdeixa de ser útil. - L'escalat s'acobla. Si l'API necessita tres rèpliques i el
cronuna, 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.
- 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.
- El període de gràcia i la petició més llarga
La regla és aritmètica:
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.
- 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:
- La readiness falla a les tres rèpliques: correcte, no té sentit enviar-los trànsit.
- La liveness també falla, perquè és el mateix endpoint.
- L'orquestrador reinicia les tres rèpliques de l'API.
- La base de dades torna, però les rèpliques estan arrencant de zero.
- 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.
/salut/viu i /salut/preparat a aurora-api
/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.
- 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
- 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-dbrep 2 CPU perquè unVACUUMo unpg_dumphan de poder córrer. aurora-cacheamb--maxmemory 200mbnecessita 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
requestsper sota delslimitsa Kubernetes (06-05): lesrequestsreserven i decideixen on cap el Pod; elslimitstallen.
- 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.
- El
Dockerfile definitiu d'Aurora Libros
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ó)
- El
compose.prod.yaml de la versió 2.0.0
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.
- 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 -u → 1000 |
| 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
stagingi 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
502d'un desplegament. CMD npm start.npmes converteix en PID 1, no reenviaSIGTERMa Node i afegeix una capa inútil. Fes servir sempreCMD ["node", "src/server.js"].- Confiar en els valors per defecte de la configuració. Un
DB_HOSTamb defectelocalhostno falla: es connecta al lloc equivocat. El que és obligatori no té valor per defecte. stop_grace_periodmenor que el termini intern. Docker enviaSIGKILLa mitges del drenatge i tota la feina d'aturada ordenada va a la brossa. Ilatesten producció impedeix saber què corre i fa impossible el rollback: etiqueta amb SemVer i, millor encara, desplega per digest.- Consell: prova l'aturada.
docker stopambheyenviant 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_maxiversionestalvia 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: 78printf '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 responsesAra 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| 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| Sonda | Resposta | Què faria l'orquestrador | Correcte? |
|---|---|---|---|
/salut/viu |
200 | Res: la rèplica continua viva | Sí |
/salut/preparat |
503 | Li treu el trànsit, sense reiniciar | Sí |
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
- Què és Docker?
- Instal·lant Docker
- Arquitectura de Docker
- Comandes Bàsiques de Docker
- Entenent les Imatges de Docker
- Creant el teu Primer Contenidor Docker
- El Projecte del Curs: la Plataforma Aurora Libros
Mòdul 2: Treballant amb Imatges Docker
- Docker Hub i Repositoris
- Construint Imatges Docker
- Conceptes Bàsics de Dockerfile
- Instruccions Avançades del Dockerfile
- Gestionant Imatges Docker
- Etiquetatge i Publicació d'Imatges
Mòdul 3: Contenidors Docker
- Executant Contenidors
- Cicle de Vida del Contenidor
- Gestionant Contenidors
- Inspecció i Depuració de Contenidors
- Xarxes a Docker
- Persistència de Dades amb Volums
- Límits de Recursos i Polítiques de Reinici
Mòdul 4: Docker Compose
- Introducció a Docker Compose
- Definint Serveis a Docker Compose
- Comandes de Docker Compose
- Aplicacions Multi-Contenidor
- Variables d'Entorn a Docker Compose
- Perfils, Overrides i Múltiples Entorns
- Desenvolupament Local amb Docker Compose
Mòdul 5: Conceptes Avançats de Docker
- Aprofundiment en Xarxes Docker
- Opcions d'Emmagatzematge Docker
- Millors Pràctiques de Seguretat a Docker
- Optimitzant Imatges Docker
- Builds Avançades amb BuildKit i Buildx
- Registre i Monitoratge a Docker
- El Runtime per Dins: Namespaces, Cgroups i Capes
Mòdul 6: Docker en Producció
- Preparar una Imatge per a Producció
- CI/CD amb Docker
- Orquestrant Contenidors amb Docker Swarm
- Introducció a Kubernetes
- Desplegant Contenidors Docker a Kubernetes
- Escalat i Balanceig de Càrrega
- Estratègies de Desplegament i Rollback
