La lliçó anterior va acabar amb una frase incòmoda: PM2 resol el procés, no l'entorn. Escena Viva viu supervisada, però sobre una màquina que algú va configurar a mà fa mesos, amb una versió de Node que pot no ser la que exigeix engines, amb les llibreries del sistema que calguessin per compilar bcrypt i amb les fonts tipogràfiques que es van instal·lar quan els PDF de les entrades van començar a sortir malament. Avui fem que l'entorn viatgi amb l'aplicació. El resultat serà un artefacte únic —una imatge— que conté Node 24, les dependències de producció ja instal·lades, el codi i res més; que s'executa idèntic al teu portàtil, en integració contínua i en producció; i que es desplega copiant un identificador en comptes de repetint passos d'instal·lació. Avís d'abast: al catàleg hi ha un curs de Docker complet. Aquí no estudiarem Docker: empaquetarem bé una aplicació Node, amb la teoria justa per ser autosuficient i tot el detall en les decisions que afecten una aplicació com la nostra.

Contingut

  1. «A la meva màquina funciona» i què és un contenidor
  2. Imatge, contenidor, capes i registre
  3. El Dockerfile d'Escena Viva, línia a línia
  4. .dockerignore: el fitxer que ningú escriu i tothom necessita
  5. HEALTHCHECK amb /salut/preparat
  6. Construir, mesurar, etiquetar i publicar
  7. docker compose: l'entorn complet de desenvolupament
  8. Migracions en contenidor
  9. Registres, límits de memòria i CPU
  10. Què no es fica a la imatge i com escanejar-la

  1. «A la meva màquina funciona» i què és un contenidor

El problema té nom i és vell: l'aplicació depèn de molt més que del seu codi. Depèn de la versió exacta de l'intèrpret, de les llibreries compartides del sistema operatiu, de les variables d'entorn, dels binaris que hi hagi al PATH, de les fonts tipogràfiques instal·lades i del contingut d'/etc. El teu portàtil, el servidor de CI i el VPS de producció difereixen en tot això, i les diferències només es manifesten en el pitjor moment. Un contenidor és un procés normal del sistema operatiu amfitrió a qui el nucli ha mentit sobre el món. Mitjançant namespaces veu el seu propi sistema de fitxers, la seva pròpia llista de processos (on ell és el PID 1), la seva pròpia xarxa i els seus propis usuaris; mitjançant cgroups se li limita quanta CPU i quanta memòria pot fer servir. Però és un procés més de l'amfitrió, executant el mateix nucli Linux. Si fas ps aux al servidor, allà hi ha el teu node. Aquesta és la diferència clau amb una màquina virtual, que emula maquinari complet i executa un sistema operatiu sencer amb el seu propi nucli.

Contenidor Màquina virtual
Què aïlla Processos, sistema de fitxers, xarxa Maquinari complet
Nucli El de l'amfitrió, compartit Propi, un per màquina
Arrencada Mil·lisegons Desenes de segons
Mida típica 50-300 MB 1-20 GB
Sobrecost Pràcticament nul 5-15 % de CPU i memòria
Aïllament Bo, però comparteix nucli Molt fort
Sistema operatiu Només Linux sobre nucli Linux Qualsevol

La conseqüència pràctica: pots executar vint contenidors en un portàtil, arrencar-los en un segon i llençar-los sense pensar-hi. I la conseqüència de seguretat: com que el nucli és compartit, un contenidor no és una frontera de seguretat tan forta com una VM. Per això importa l'usuari no root de l'apartat 3.

  1. Imatge, contenidor, capes i registre

Quatre conceptes i continuem:

  • Imatge: una plantilla immutable de només lectura. El sistema de fitxers complet que veurà el procés, més metadades (quina ordre executar, quin port exposa, quines variables porta). Es construeix a partir d'un Dockerfile.
  • Contenidor: una instància en execució d'una imatge, amb una capa d'escriptura efímera a sobre. Quan el contenidor mor, aquella capa desapareix. Tot el que hi escriguis es perd, i aquest detall tindrà conseqüències enormes a la lliçó 11-05.
  • Capes: cada instrucció del Dockerfile que modifica el sistema de fitxers crea una capa. Les capes es guarden a la memòria cau i es comparteixen entre imatges: si dues de les teves imatges parteixen de node:24-alpine, aquella capa es desa i es transfereix una sola vegada. Això governa el temps de construcció i el de desplegament.
  • Registre: un magatzem d'imatges (Docker Hub, GitHub Container Registry, ECR, Artifact Registry). Publiques allà i el servidor descarrega d'allà, i això és el que converteix «desplegar» en «descarregar una imatge i arrencar-la». Amb això n'hi ha prou: anem al que ens toca.

  1. El Dockerfile d'Escena Viva, línia a línia

Aquest és el fitxer complet; després el desmuntem sencer.

# syntax=docker/dockerfile:1

# ---------- ETAPA 1: dependencies de produccio ----------
FROM node:24-alpine AS dependencies
# Eines de compilacio per als moduls natius (bcrypt).
# S'instal.len NOMES en aquesta etapa: no arriben a la imatge final.
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copiem NOMES els manifestos: canvien molt menys que el codi.
COPY package.json package-lock.json ./
# npm ci: instal.lacio reproduible des de package-lock.json (M5).
# --omit=dev: sense dependencies de desenvolupament (mocha, chai, eslint...).
RUN npm ci --omit=dev && npm cache clean --force

# ---------- ETAPA 2: imatge final ----------
FROM node:24-alpine AS produccio
# tini com a PID 1: reenvia senyals i enterra processos zombis.
RUN apk add --no-cache tini
ENV NODE_ENV=production
ENV NODE_OPTIONS="--max-old-space-size=768"
WORKDIR /app

# node_modules ja compilat, amb propietari correcte des del principi.
COPY --from=dependencies --chown=node:node /app/node_modules ./node_modules
# Codi de l'aplicacio. Va al final: es el que mes canvia.
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
COPY --chown=node:node migracions ./migracions
COPY --chown=node:node scripts ./scripts

USER node          # Sense privilegis: la imatge node ja porta 'node' (uid 1000).
EXPOSE 3000        # Documental: no publica res, pero informa les eines.

# Comprovacio de salut recolzada en la sonda de 11-02.
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD node ./scripts/comprovar-salut.js

# Forma exec (array JSON): SENSE interpret d'ordres pel mig, el senyal arriba al proces.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "src/servidor.js"]

FROM: quina imatge base

La primera decisió és la que més es copia sense pensar. Les opcions reals:

Base Mida A favor En contra
node:24 (Debian completa) ~1,1 GB Tot compila sense dolor, bona depuració Enorme; molta superfície d'atac
node:24-slim (Debian mínima) ~250 MB glibc, compatible amb gairebé tot Més gran que Alpine
node:24-alpine ~150 MB Petita, poques vulnerabilitats musl en comptes de glibc
distroless/nodejs24 ~180 MB Sense intèrpret d'ordres ni gestor de paquets: superfície mínima Impossible depurar-hi dins

Avís important sobre Alpine i els mòduls natius. Alpine fa servir musl libc en comptes de glibc. La conseqüència pràctica: els binaris precompilats que publiquen paquets com bcrypt estan fets per a glibc, així que a Alpine cal compilar-los, i per a això calen python3, make i g++. Per això la nostra primera etapa els instal·la. A més, musl té un assignador de memòria diferent que, en càrregues amb molta concurrència, pot rendir una mica pitjor que glibc. Escena Viva fa servir alpine perquè la mida i la superfície d'atac compensen, i perquè la construcció multietapa fa que les eines de compilació no arribin a la imatge final. Si tinguessis problemes estranys amb bcrypt, sharp o canvas, node:24-slim és una retirada perfectament honorable — i una alternativa a considerar és substituir bcrypt per @node-rs/argon2, que no necessita compilació. I una regla que no es negocia: mai FROM node:latest. Una construcció que avui fa servir Node 24 i demà Node 25 no és reproduïble, i engines diu <25 per alguna cosa. Per a màxima reproduïbilitat es pot fixar el digest: FROM node:24-alpine@sha256:....

Construcció multietapa

FROM ... AS dependencies i després un segon FROM creen dues imatges durant la construcció. Només l'última es conserva; de la primera copiem el que ens interessa amb COPY --from=dependencies.

El benefici és enorme: python3, make i g++ ocupen uns 200 MB i no arriben a la imatge final. Tampoc no hi arriben les capçaleres de compilació ni la memòria cau d'npm. La imatge final té el node_modules ja compilat, sense res del que va caldre per produir-lo.

Per què package*.json abans que el codi

Això és el que més estalvia en el dia a dia, i val la pena entendre-ho bé. Docker desa les capes a la memòria cau: si els fitxers d'entrada d'una instrucció no han canviat, reutilitza la capa i salta al pas següent. Però tan bon punt una capa s'invalida, totes les següents es reconstrueixen. Comparem:

COPY . .                                  # MALAMENT: qualsevol canvi reinstal.la TOT
RUN npm ci --omit=dev

COPY package.json package-lock.json ./    # BE: nomes si canvien els manifestos
RUN npm ci --omit=dev
COPY src ./src
Escenari Amb COPY . . primer Amb els manifestos primer
Primera construcció 95 s 95 s
Canvi en un controlador 92 s 6 s
Canvi a package.json 94 s 94 s

Com que el codi canvia desenes de vegades al dia i les dependències una vegada cada quinze, l'estalvi real ronda el 90 % del temps de construcció; en una canonada de CI que s'executa a cada push (11-06), això són hores d'espera al mes.

npm ci --omit=dev: el pagament del mòdul 5

npm ci instal·la exactament el que diu package-lock.json, sense resoldre rangs de versions: és reproduïble per definició, i la mateixa construcció avui i d'aquí a sis mesos dóna el mateix arbre de dependències. npm install, en canvi, pot resoldre una versió nova d'una transitiva i encolomar-te un canvi que ningú ha demanat. A més, npm ci falla si package.json i el lock no són coherents, cosa que atrapa l'error d'haver editat l'un sense l'altre.

--omit=dev exclou devDependencies: mocha, chai, sinon, supertest, c8, eslint, prettier, husky, pino-pretty. Són uns 200 MB i desenes de paquets que en producció no aporten res i sí superfície d'atac. Aquí es cobra la factura de la disciplina que vam imposar a 11-01: si alguna cosa que l'aplicació necessita en execució és a devDependencies, la imatge arrenca i peta amb Cannot find module. I només en producció.

USER node i per què

Per defecte, el procés dins del contenidor s'executa com a root. Root del contenidor no és root de l'amfitrió, però si algú aconsegueix escapar de l'aïllament —o si muntes un volum de l'amfitrió— la diferència entre root i un usuari normal és la diferència entre un incident i una catàstrofe. Les imatges oficials de Node ja porten un usuari node amb uid 1000. N'hi ha prou amb USER node després de copiar els fitxers, i amb --chown=node:node a cada COPY perquè els permisos siguin correctes des del principi (si fas chown -R després, dupliques la mida: crea una capa nova amb tots els fitxers). Efecte secundari que confon molta gent: com a usuari no root, el procés no pot escoltar en ports per sota del 1024. És exactament la restricció del mòdul 4, i la resposta és la mateixa: escolta al 3000 i que el servidor intermediari invers o el mapatge de ports (-p 80:3000) faci la resta.

WORKDIR, EXPOSE, ENV

WORKDIR /app fixa el directori de treball i el crea si no existeix; fes-lo servir sempre en comptes de RUN cd, perquè cada RUN és un intèrpret d'ordres nou i el cd no persisteix. EXPOSE 3000 és purament documental: no obre cap port —publicar de debò és -p 3000:3000 en executar—, i el seu valor és que docker compose i altres eines el llegeixen. I ENV NODE_ENV=production activa tot el que vam veure a 11-01: Express en mode producció, llibreries sense comprovacions de desenvolupament i el nostre propi configuracio.esProduccio.

CMD en forma d'exec: que SIGTERM arribi de debò

Aquest és l'error clàssic que trenca tota la feina d'aturada ordenada que arrosseguem des del mòdul 6.

CMD node src/servidor.js        # MALAMENT: shell. Docker executa /bin/sh -c "..."
CMD ["npm", "start"]            # MALAMENT: npm es un intermedi que no reenvia senyals
CMD ["node", "src/servidor.js"] # BE: forma exec, node rep els senyals

Amb la forma shell, el PID 1 del contenidor és /bin/sh i Node és el seu fill. Quan fas docker stop, Docker envia SIGTERM al PID 1. L'intèrpret el rep, no el reenvia als seus fills i no fa res. Node no s'assabenta mai que s'ha d'aturar. Deu segons després, Docker perd la paciència i envia SIGKILL, que mata el procés a l'instant: connexions tallades a mitja resposta, transaccions obertes, cap de les compres en vol acabada. Amb la forma exec (l'array JSON), Node és el PID 1 i rep SIGTERM directament. El nostre gestor de src/servidor.js es dispara, /salut/preparat comença a retornar 503, es tanquen les connexions ocioses i es drena el que queda. Exactament el que vam construir fa cinc mòduls. El mateix s'aplica a ENTRYPOINT. I mai npm start com a ordre: npm afegeix un procés intermedi amb el mateix problema, i a més reescriu els codis de sortida.

El problema del PID 1 i tini

Ser el PID 1 comporta responsabilitats que Node no va ser escrit per assumir:

  1. Enterrar processos zombis. Quan un procés mor, el seu pare n'ha de recollir el codi de sortida. Si el pare no hi és, el PID 1 hereta l'orfe. Node no ho fa, i els zombis s'acumulen a la taula de processos.
  2. Gestionar senyals sense comportament per defecte. El PID 1 no té els gestors per defecte del nucli: si no gestiones un senyal explícitament, s'ignora.

Escena Viva bifurca processos de veritat (els worker threads del pool de PDF i, potencialment, subprocessos), així que el problema és real. La solució és un init mínim: tini, que ocupa uns pocs kilobytes. Es resol amb les tres línies que ja hi ha al Dockerfile: RUN apk add --no-cache tini i ENTRYPOINT ["/sbin/tini", "--"] davant del CMD. Ara tini és el PID 1, enterra zombis i reenvia tots els senyals a Node. L'alternativa sense tocar el Dockerfile és docker run --init, que injecta un init propi; funciona igual de bé, però depèn que qui executi el contenidor se'n recordi, de l'indicador. Prefereixo que la imatge sigui correcta per si sola.

  1. .dockerignore: el fitxer que ningú escriu i tothom necessita

Abans de construir, Docker envia el context de construcció —el directori sencer— al motor. Sense .dockerignore, això inclou el node_modules local, .git amb tot l'historial i, atenció, el teu .env. Els tres desastres, per ordre de gravetat:

  1. El .env acaba dins de la imatge. Amb COPY . ., els teus secrets queden gravats en una capa, i les capes es publiquen en un registre: qualsevol amb accés a la imatge els extreu amb docker history. Això ha filtrat credencials d'empreses grans més d'una vegada.
  2. El node_modules local es copia a dins. A més de ser lent, si el teu portàtil és macOS o Windows, els binaris compilats de bcrypt són d'una altra plataforma i no funcionen a Linux. Errors incomprensibles garantits.
  3. .git engreixa el context i pot contenir branques, credencials antigues i fitxers esborrats que continuen a l'historial.
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.github
.env
.env.*
!.env.example
test
coverage
.nyc_output
*.md
!README.md
.vscode
.idea
.DS_Store
Dockerfile
docker-compose*.yml
informes

Nota sobre informes: és el directori on el mòdul 3 escrivia els PDF generats. No ha d'entrar a la imatge ni de bon tros, i a la lliçó següent veurem que a més no ha d'existir en producció.

  1. HEALTHCHECK amb /salut/preparat

HEALTHCHECK diu a Docker com comprovar si el contenidor està sa. Docker marca el contenidor com a healthy o unhealthy, i els orquestradors fan servir aquell estat per retirar-li trànsit o reemplaçar-lo.

Aquí reutilitzem la sonda de disponibilitat de 11-02. Com que la imatge Alpine no porta curl ni wget complet —i no volem afegir-los només per a això—, la comprovació es fa amb Node, que ja hi és a dins:

// scripts/comprovar-salut.js — sense dependencies ni config: ha de funcionar sempre.
'use strict';

const http = require('node:http');
const port = process.env.PORT || 3000;
const opcions = { host: '127.0.0.1', port, path: '/salut/preparat', timeout: 2000 };

// Codi de sortida 0 = sa; qualsevol altre = malalt.
const peticio = http.request(opcions, (r) => process.exit(r.statusCode === 200 ? 0 : 1));
peticio.on('error', () => process.exit(1));
peticio.on('timeout', () => { peticio.destroy(); process.exit(1); });
peticio.end();

Els paràmetres del HEALTHCHECK mereixen atenció:

Paràmetre Per què
--interval=30s Suficient per detectar; no sobrecarrega
--timeout=3s Més gran que el límit intern de la sonda (1 s per dependència)
--start-period=20s Migracions i connexió a la BD triguen; les fallades aquí no compten
--retries=3 Una fallada aïllada no ha de marcar el contenidor com a malalt

--start-period és el que més s'oblida: sense ell, una arrencada lenta compta com a fallada i el contenidor es marca malalt abans d'haver tingut oportunitat d'estar sa.

  1. Construir, mesurar, etiquetar i publicar

# Dues etiquetes: la versio (per a humans) i el hash del commit (la veritat).
docker build -t escena-viva/api:1.4.0 -t escena-viva/api:$(git rev-parse --short HEAD) .
docker images escena-viva/api                                    # Mida real
docker run --rm -p 3000:3000 --env-file .env escena-viva/api:1.4.0  # Provar en local
docker push escena-viva/api:1.4.0                                # Publicar

Sobre l'etiquetatge: latest no és una versió, és un àlies mutable que apunta a l'últim que has publicat. Desplegar latest significa no saber què estàs desplegant ni poder revertir. Etiqueta sempre amb la versió semàntica i amb el hash curt del commit; la primera és per a humans i la segona és la veritat.

La mida, mesurada

Aquests són els números reals d'Escena Viva a cada pas d'optimització:

Versió Mida Què va canviar
node:24 + COPY . . + npm install 1.410 MB Punt de partida ingenu
node:24-slim / node:24-alpine 546 / 412 MB Base Debian mínima; base Alpine
+ npm ci --omit=dev 268 MB Sense dependències de desenvolupament
+ multietapa (sense compiladors) 97 MB Sense python3, make, g++ ni memòria cau d'npm

D'1,4 GB a menys de 100 MB: catorze vegades menys, i no és només estètica. Menys mida significa desplegaments més ràpids —cada instància descarrega la imatge—, menys cost d'emmagatzematge i transferència al registre, i moltíssima menys superfície d'atac: cada binari que no és a la imatge és un binari que no pot tenir una vulnerabilitat ni servir un atacant.

  1. docker compose: l'entorn complet de desenvolupament

Aquí arriba el que portes volent des del mòdul 7. Escena Viva necessita PostgreSQL, MongoDB i Redis; instal·lar-los a mà a cada portàtil de l'equip és una tarda perduda per persona i una font inesgotable de diferències de versió.

docker compose aixeca tot l'entorn amb una ordre.

# docker-compose.yml
name: escena-viva

# Ancores YAML: l'API i el consumidor comparteixen imatge i connexions.
x-comu: &comu
  build: { context: ., target: produccio }
  env_file: ['.env.docker']        # Nomes els secrets; NO versionat.
  restart: unless-stopped
  environment: &entorn
    NODE_ENV: development
    NIVELL_REGISTRE: debug
    # Els noms de servei son noms DNS dins de la xarxa de compose.
    URL_POSTGRES: postgres://escena:escena@postgres:5432/escena_viva
    URL_MONGO: mongodb://mongo:27017/escena_viva
    URL_REDIS: redis://redis:6379

x-sa: &sa { condition: service_healthy }

services:
  api:
    <<: *comu
    ports: ['3000:3000']
    init: true                     # Init de Docker; redundant amb tini, inofensiu.
    depends_on: { postgres: *sa, mongo: *sa, redis: *sa }

  consumidor:                      # El consumidor de la cua del M10.
    <<: *comu
    command: ['node', 'src/processos/consumidor-entrades.js']
    environment:
      <<: *entorn
      CONCURRENCIA_CUA: 2
    depends_on: { postgres: *sa, redis: *sa }

  postgres:
    image: postgres:17-alpine
    environment: { POSTGRES_USER: escena, POSTGRES_PASSWORD: escena, POSTGRES_DB: escena_viva }
    ports: ['5432:5432']           # Exposat nomes per a un client local.
    volumes: ['dades-postgres:/var/lib/postgresql/data']
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U escena -d escena_viva']
      interval: 5s
      retries: 10

  mongo:
    image: mongo:8
    ports: ['27017:27017']
    volumes: ['dades-mongo:/data/db']
    healthcheck:
      test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
      interval: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    command: ['redis-server', '--appendonly', 'yes']
    ports: ['6379:6379']
    volumes: ['dades-redis:/data']
    healthcheck: { test: ['CMD', 'redis-cli', 'ping'], interval: 5s, retries: 10 }

volumes: { dades-postgres: , dades-mongo: , dades-redis: }

El que cal entendre d'aquest fitxer:

  • Xarxa i DNS. Compose crea una xarxa pròpia on cada servei és resoluble pel seu nom: per això l'URL és postgres://escena:escena@postgres:5432/..., amb el nom del servei i el port intern (5432), no el mapat.
  • depends_on amb condition: service_healthy. El depends_on simple només espera que el contenidor arrenqui, no que el servei estigui preparat; PostgreSQL triga uns segons a acceptar connexions, i l'API arrencaria abans i fallaria. Tot i així, l'aplicació ha de tolerar que la base de dades desaparegui en calent: depends_on només cobreix l'arrencada.
  • Volums amb nom. Les dades viuen en volums gestionats per Docker i sobreviuen a docker compose down. Per començar de zero, docker compose down -v.
  • env_file separat. Les variables no secretes van a environment (visibles i versionades); els secrets a .env.docker, que és al .gitignore. És la mateixa separació que vam aplicar al fitxer d'ecosistema de PM2 a 11-03.
  • Un sol Dockerfile per a dos serveis. api i consumidor comparteixen imatge i només difereixen en command: els dos processos que PM2 gestionava com dues aplicacions són aquí dos contenidors del mateix artefacte.

I el flux de treball diari, que és el veritable regal d'aquesta lliçó:

docker compose up -d              # Aixeca els cinc serveis
docker compose logs -f api        # Segueix els registres de l'API
docker compose exec api sh        # Interpret d'ordres dins del contenidor
docker compose down               # Atura tot, conservant les dades (-v les esborra)

Algú que s'incorpori a l'equip clona el repositori, copia .env.example a .env.docker, executa docker compose up i en dos minuts té l'entorn sencer. Sense instal·lar PostgreSQL, ni MongoDB, ni Redis, ni tan sols Node.

  1. Migracions en contenidor

Temptació evident: posar npm run migrar && node src/servidor.js al CMD. És un error, per tres raons.

  1. S'executarien N vegades en paral·lel. Amb quatre instàncies arrencant alhora, quatre processos migren el mateix esquema simultàniament, i les condicions de cursa en un ALTER TABLE concurrent produeixen resultats imprevisibles.
  2. Trenca la forma exec. Aquell && obliga a un intèrpret d'ordres, i ja hem vist què li passa a SIGTERM amb un intèrpret pel mig.
  3. Una migració fallida deixa el contenidor en bucle de reinici en comptes de fallar de manera visible i aturar el desplegament.

Les migracions són un pas a part i únic:

# docker-compose.yml, servei addicional
  migrar:
    <<: *comu
    command: ['npm', 'run', 'migrar']
    depends_on: { postgres: *sa }
    restart: 'no'      # Es una tasca puntual, no un servei: no es reinicia.

S'invoca a part, abans d'aixecar els processos: docker compose run --rm migrar i després docker compose up -d api consumidor. Fixa't en restart: 'no': és una tasca que acaba, no un servei. Aquí npm run sí que és acceptable perquè el procés és efímer i els senyals no importen. En producció això té un nom i una lliçó pròpia: és la fase d'alliberament de la qual parlarem a 11-05, i el seu ordre respecte al desplegament és un tema en si mateix.

  1. Registres, límits de memòria i CPU

Registres a stdout, ara obligatori

A 11-02 vam dir que escriure a stdout és el recomanable. En contenidors és l'única cosa sensata: el sistema de fitxers del contenidor és efímer, així que un registre escrit a fitxer desapareix amb el contenidor — justament quan més el necessites, perquè el contenidor va morir per alguna cosa. Docker captura stdout i stderr i els passa al seu controlador de registre, que pot ser el fitxer JSON local, journald, o un servei remot. L'aplicació no ho sap ni li importa. Amb pino escrivint JSON a stdout, la cadena està completa.

Convé a més afegir a cada servei logging: { driver: json-file, options: { max-size: '20m', max-file: '5' } }, que evita el problema clàssic que els registres omplin el disc de l'amfitrió: per defecte, el controlador json-file no té límit.

Límits i la seva relació amb Node

Els contenidors es poden limitar amb cgroups:

    deploy:
      resources:
        limits: { cpus: '2.0', memory: 1G }
        reservations: { memory: 512M }

I ara la part que enganya moltíssima gent. Node no sempre respecta el límit de memòria del contenidor. V8 calcula la mida màxima del munt a partir de la memòria que veu, i en versions i configuracions on veu la de l'amfitrió, un contenidor limitat a 1 GB pot tenir un munt objectiu de diversos GB. Conseqüència? El procés creix, el cgroup el mata sense contemplacions (OOMKilled, codi 137) i el recol·lector d'escombraries no va arribar mai a considerar que hi hagués pressió de memòria. No hi ha traça, no hi ha error: el contenidor simplement desapareix. La solució és dir-l'hi explícitament, deixant marge per al que no és munt (memòries intermèdies, pila, el mateix Node): per això el Dockerfile inclou ENV NODE_OPTIONS="--max-old-space-size=768". Amb un límit de contenidor d'1 GB, un munt de 768 MB deixa uns 256 MB de marge. La regla pràctica: --max-old-space-size al voltant del 75 % del límit del contenidor. La mateixa aritmètica amb la CPU i el mòdul 10. Si limites el contenidor a 2 CPU però a dins arrenques el clúster amb instances: 0 (tants com nuclis), Node veurà els 16 nuclis de l'amfitrió i arrencarà 16 treballadors que es barallaran per 2 CPU. Pitjor rendiment que amb un de sol, més memòria i més connexions a la base de dades. En contenidors la doctrina és diferent de la de PM2: un procés per contenidor, i l'escalat es fa amb més contenidors, no amb més processos a dins. És l'orquestrador qui reparteix. Per això el Dockerfile apunta a src/servidor.js i no a src/cluster.js.

  1. Què no es fica a la imatge i com escanejar-la

Mai a la imatge:

  • Secrets. Ni amb ENV, ni amb ARG, ni copiant un .env. Tot queda a l'historial de capes i es recupera amb docker history --no-trunc. Els secrets s'injecten en executar, no en construir. Si necessites un secret durant la construcció (per exemple, un token per a un registre npm privat), fes servir RUN --mount=type=secret, que no deixa rastre en cap capa.
  • Dades: bases de dades, fitxers pujats, PDF generats. Van a volums o a emmagatzematge extern, perquè la imatge és codi, no estat.
  • Credencials de núvol, claus SSH, certificats privats i eines de desenvolupament (compiladors, git, editors): cada binari extra és superfície d'atac.

I escanejar la imatge és un pas barat i molt rendible. Eines com Trivy, Grype o docker scout comparen els paquets instal·lats amb bases de dades públiques de vulnerabilitats: trivy image --severity HIGH,CRITICAL escena-viva/api:1.4.0. Detecta tant vulnerabilitats del sistema base (OpenSSL, musl) com de les dependències npm, complementant el npm audit del mòdul 5. Aquest pas entra a la canonada de CI a la lliçó 11-06, i és una raó més perquè la imatge sigui petita: menys paquets, menys troballes per revisar.

Errors Comuns i Consells

  • CMD en forma shell o npm start. SIGTERM no arriba a Node, SIGKILL talla a mitja resposta i l'aturada ordenada del mòdul 6 no serveix de res. Sempre CMD ["node", "src/servidor.js"].
  • Oblidar el .dockerignore. Copies .env i node_modules a dins. Secrets publicats i binaris d'una altra plataforma.
  • COPY . . abans d'npm ci, que fa que cada canvi d'una línia reinstal·li totes les dependències, o FROM node:latest, que impedeix construccions reproduïbles.
  • Executar com a root, que és el valor per defecte i cal canviar-lo expressament.
  • No fixar --max-old-space-size amb límit de memòria. El contenidor mor amb codi 137 i sense cap pista.
  • Arrencar el clúster dins d'un contenidor limitat: Node veu els nuclis de l'amfitrió i arrenca massa treballadors.
  • Migracions al CMD, que s'executen en paral·lel per cada instància, o registres a fitxer dins del contenidor, que es perden justament quan calen.
  • Consell: executa docker run --rm -it escena-viva/api:1.4.0 sh i mira-hi a dins: comprova que node_modules no té mocha, que no hi ha cap .env i que l'usuari és node. Es descobreixen coses sorprenents. I construeix sempre la imatge a CI, mai al servidor de producció: el servidor només descarrega i executa.

Exercicis

Exercici 1 — Demostrar el problema del senyal

Construeix dues variants de la imatge: una amb CMD node src/servidor.js (forma shell) i una altra amb CMD ["node", "src/servidor.js"]. Arrenca cadascuna, executa docker stop i mesura quant triga a aturar-se. Explica el resultat a partir dels registres d'aturada.

Exercici 2 — Reduir la imatge

Parteix d'un Dockerfile ingenu (FROM node:24, COPY . ., npm install) i aplica les quatre optimitzacions d'aquesta lliçó una a una, mesurant la mida amb docker images després de cada pas. Comprova també el temps de reconstrucció després de canviar una línia d'un controlador.

Exercici 3 — Entorn complet i llavor

Aixeca l'entorn amb docker compose up -d, executa les migracions en un contenidor a part, llança scripts/llavor.js per carregar les tres sales i els tres esdeveniments, i verifica que /salut/preparat retorna 200 amb les tres dependències a true.

Solucions

Exercici 1.

Construint les dues variants i cronometrant docker stop, la variant shell triga uns 10 segons (el temps de gràcia de Docker abans de SIGKILL) i a docker logs no apareix cap línia d'aturada: el gestor de SIGTERM no es va executar mai. La variant exec s'atura en menys d'un segon i deixa els registres esperats:

{"level":30,"msg":"senyal SIGTERM rebuda, iniciant aturada ordenada"}
{"level":30,"msg":"servidor tancat; connexions drenades"}

Aquells deu segons de diferència són, en producció, deu segons de peticions tallades a cada desplegament.

Exercici 2. Les mides esperades són les de la taula de l'apartat 6. Sobre el temps de reconstrucció, amb COPY . . primer canviar una línia d'un controlador costa uns 90 segons; amb els manifestos copiats abans, uns 6, i la sortida de Docker ho confirma amb un => CACHED [dependencies 4/4] RUN npm ci --omit=dev: la paraula CACHED al pas d'instal·lació és la prova que l'ordre funciona.

Exercici 3.

docker compose up -d postgres mongo redis
docker compose run --rm migrar
docker compose run --rm api node scripts/llavor.js
docker compose up -d api consumidor
curl -s http://localhost:3000/salut/preparat
# {"estat":"preparat","detall":{"postgres":true,"mongo":true,"redis":true}}

L'ordre és deliberat: primer les bases de dades, després l'esquema, després les dades i finalment els processos d'aplicació. És exactament l'ordre que reproduirà la canonada de desplegament de 11-06. Si redis aparegués com a false, revisa que l'URL faci servir el nom del servei (redis://redis:6379) i no localhost: dins d'un contenidor, localhost és el mateix contenidor.

Conclusió

Escena Viva ja no és codi que s'executa sobre una màquina desconeguda: és un artefacte de 97 MB que porta el seu propi entorn a dins. Un Dockerfile multietapa que compila els mòduls natius en una etapa llencívola, instal·la només les dependències de producció amb npm ci --omit=dev, copia els manifestos abans que el codi per aprofitar la memòria cau de capes, s'executa com a usuari node sense privilegis i arrenca amb CMD en forma d'exec sota tini, de manera que SIGTERM arriba de debò al procés i l'aturada ordenada del mòdul 6 per fi funciona també en contenidor. Amb HEALTHCHECK recolzat en /salut/preparat, un .dockerignore que impedeix que els secrets entrin en una capa publicada, i un docker compose que aixeca l'API, el consumidor, PostgreSQL, MongoDB i Redis amb una sola ordre: l'entorn de desenvolupament complet que faltava des del mòdul 7. El que queda és respondre on s'executa aquell contenidor quan algú de debò vol comprar una entrada. A la propera lliçó, Desplegant a Heroku i Altres PaaS, posem Escena Viva a internet: els models de desplegament comparats, el Procfile amb els seus processos web i worker, el port que assigna la plataforma a process.env.PORT, complements gestionats de PostgreSQL i Redis amb els seus límits de connexions, migracions a la fase d'alliberament i canvis d'esquema sense parada, el sistema de fitxers efímer i què fer llavors amb els PDF del mòdul 3, HTTPS i trust proxy —la promesa pendent des del mòdul 6—, escalat, desplegament blau-verd i canari, i com revertir un desplegament, que és el primer que cal saber fer.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats