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
- «A la meva màquina funciona» i què és un contenidor
- Imatge, contenidor, capes i registre
- El
Dockerfiled'Escena Viva, línia a línia .dockerignore: el fitxer que ningú escriu i tothom necessitaHEALTHCHECKamb/salut/preparat- Construir, mesurar, etiquetar i publicar
docker compose: l'entorn complet de desenvolupament- Migracions en contenidor
- Registres, límits de memòria i CPU
- Què no es fica a la imatge i com escanejar-la
- «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.
- 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
Dockerfileque 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 denode: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.
- El
Dockerfile d'Escena Viva, línia a línia
Dockerfile d'Escena Viva, línia a líniaAquest é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 senyalsAmb 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:
- 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.
- 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.
.dockerignore: el fitxer que ningú escriu i tothom necessita
.dockerignore: el fitxer que ningú escriu i tothom necessitaAbans 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:
- El
.envacaba dins de la imatge. AmbCOPY . ., 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 ambdocker history. Això ha filtrat credencials d'empreses grans més d'una vegada. - El
node_moduleslocal es copia a dins. A més de ser lent, si el teu portàtil és macOS o Windows, els binaris compilats debcryptsón d'una altra plataforma i no funcionen a Linux. Errors incomprensibles garantits. .gitengreixa 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
informesNota 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ó.
HEALTHCHECK amb /salut/preparat
HEALTHCHECK amb /salut/preparatHEALTHCHECK 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.
- 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 # PublicarSobre 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.
docker compose: l'entorn complet de desenvolupament
docker compose: l'entorn complet de desenvolupamentAquí 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_onambcondition: service_healthy. Eldepends_onsimple 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_onnomé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_fileseparat. Les variables no secretes van aenvironment(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
Dockerfileper a dos serveis.apiiconsumidorcomparteixen imatge i només difereixen encommand: 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.
- Migracions en contenidor
Temptació evident: posar npm run migrar && node src/servidor.js al CMD. És un error, per tres raons.
- 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 TABLEconcurrent produeixen resultats imprevisibles. - Trenca la forma exec. Aquell
&&obliga a un intèrpret d'ordres, i ja hem vist què li passa aSIGTERMamb un intèrpret pel mig. - 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.
- 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:
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.
- Què no es fica a la imatge i com escanejar-la
Mai a la imatge:
- Secrets. Ni amb
ENV, ni ambARG, ni copiant un.env. Tot queda a l'historial de capes i es recupera ambdocker 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 servirRUN --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
CMDen forma shell onpm start.SIGTERMno arriba a Node,SIGKILLtalla a mitja resposta i l'aturada ordenada del mòdul 6 no serveix de res. SempreCMD ["node", "src/servidor.js"].- Oblidar el
.dockerignore. Copies.envinode_modulesa 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, oFROM 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-sizeamb 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 shi mira-hi a dins: comprova quenode_modulesno té mocha, que no hi ha cap.envi que l'usuari ésnode. 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
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
