A la lliçó anterior vas construir auroralibros/aurora-api:0.1.0 amb un Dockerfile que se't va donar fet i se't va explicar per sobre. Funciona, arrenca l'API dins d'un contenidor i respon a curl. Però el vas escriure gairebé a cegues: saps què fa cada línia, no per què està escrita exactament així. Aquesta lliçó salda aquell deute. Recorreràs el llenguatge del Dockerfile instrucció per instrucció, amb el nivell de detall que a 02-02 va quedar pendent: per què WORKDIR és millor que RUN cd, en què es diferencien COPY i ADD i per què gairebé sempre guanya COPY, què signifiquen exactament les dues formes de RUN i de CMD, per què encadenar comandes amb && produeix imatges més petites, per què EXPOSE no exposa res, i què li passa al teu contenidor quan el pares segons com hagis escrit el CMD. Al final construiràs el Dockerfile definitiu d'aurora-api, comentat línia a línia i amb cada decisió justificada.

Contingut

  1. Estructura d'un Dockerfile, comentaris i la directiva # syntax
  2. Taula resum de les instruccions bàsiques
  3. FROM: d'on parteixes
  4. WORKDIR: on treballes
  5. COPY: què portes del context
  6. COPY davant d'ADD
  7. RUN: què executes durant la construcció
  8. ENV: configuració que sobreviu a la construcció
  9. EXPOSE: documentació, no publicació
  10. CMD: quin procés arrenca el contenidor
  11. El Dockerfile definitiu d'aurora-api

  1. Estructura d'un Dockerfile, comentaris i la directiva # syntax

Un Dockerfile és un fitxer de text pla, sense extensió, amb una instrucció per línia:

# syntax=docker/dockerfile:1

# Comentari: BuildKit l'ignora completament
FROM node:22-alpine

WORKDIR /app

RUN echo "una instrucció" && \
    echo "pot continuar a la línia següent"

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

Regles de sintaxi:

  • Les instruccions s'escriuen en MAJÚSCULES per convenció. from node:22-alpine funciona igual, però ningú no ho escriu així: en majúscules es distingeix d'un cop d'ull la instrucció dels seus arguments.
  • Una instrucció per línia. Per partir una línia llarga es fa servir la barra invertida \ al final, sense res després (ni tan sols un espai: un espai després de la barra n'inverteix l'efecte i produeix errors desconcertants).
  • Les línies que comencen per # són comentaris, llevat de les directives de l'apartat següent. Com vas comprovar a l'exercici 2 de la lliçó 02-02, els comentaris no afecten la memòria cau.
  • L'ordre importa, i no només per la memòria cau: cada instrucció s'executa sobre l'estat que va deixar l'anterior.
  • Les línies buides s'ignoren. Fes-les servir per agrupar blocs lògics; un Dockerfile ben espaiat es llegeix moltíssim millor.

La directiva # syntax

La primera línia del fitxer mereix un apartat propi:

# syntax=docker/dockerfile:1

No és un comentari: és una directiva de l'intèrpret. Li diu a BuildKit quina versió del frontend de Dockerfile ha de fer servir per interpretar la resta del fitxer. BuildKit descarrega aquella imatge del registre i la fa servir com a analitzador sintàctic.

Per què convé posar-la sempre:

  • Et dona les funcionalitats més recents sense actualitzar Docker. docker/dockerfile:1 és una etiqueta mòbil que apunta a l'última versió estable de la sèrie 1. Coses com COPY --link, els muntatges de memòria cau o els secrets de build (lliçó 05-05) en depenen.
  • Fa el fitxer portable. El mateix Dockerfile s'interpreta igual al teu portàtil i en un agent de CI amb una altra versió de Docker.
  • És compatible cap enrere. La sèrie 1 garanteix que no trencarà els Dockerfiles existents.

Les variants que veuràs:

Directiva Què obtens
# syntax=docker/dockerfile:1 Última estable de la sèrie 1. La recomanada
# syntax=docker/dockerfile:1.12 Fixada a una versió menor concreta, per a màxima reproductibilitat
(sense directiva) El frontend que porti la teva versió de Docker Engine. Funciona, però perds funcionalitats

Ha de ser la primera línia del fitxer (abans de qualsevol instrucció, fins i tot abans d'altres comentaris) perquè faci efecte.

  1. Taula resum de les instruccions bàsiques

Aquestes són les instruccions que cobreix aquesta lliçó, amb l'essencial de cadascuna:

Instrucció Què fa Crea capa? Quan actua?
FROM Estableix la imatge base Hereta les de la base Construcció
WORKDIR Fixa el directori de treball Sí (metadada, mida ~0) Construcció i execució
COPY Copia fitxers del context a la imatge Construcció
ADD Com COPY, més URL i descompressió automàtica Construcció
RUN Executa una comanda i congela el resultat Construcció
ENV Defineix variables d'entorn Sí (metadada) Construcció i execució
EXPOSE Documenta el port que fa servir el servei Sí (metadada) Només documentació
CMD Defineix el procés per defecte del contenidor Sí (metadada) Execució

La columna "Crea capa?" explica una observació de la lliçó 01-05: no totes les instruccions engreixen la imatge. COPY, ADD i RUN modifiquen el sistema de fitxers i generen capes amb contingut real; les altres només escriuen metadades al manifest, i la seva capa pesa zero bytes.

La columna "Quan actua?" és la que evita més confusió. RUN s'executa en construir i el seu resultat queda congelat; CMD no s'executa en construir en absolut, només descriu què farà el contenidor en arrencar. Confondre-les és l'error conceptual número u de qui comença.

Hi ha més instruccions —ARG, ENTRYPOINT, USER, LABEL, HEALTHCHECK, VOLUME, STOPSIGNAL, ONBUILD, SHELL—, i totes es veuen a la lliçó següent, la 02-04.

  1. FROM: d'on parteixes

FROM node:22-alpine

FROM és obligatòria i ha de ser la primera instrucció (llevat de directives i comentaris). Estableix el sistema de fitxers i les metadades de partida: tot el que vingui després es construeix a sobre d'aquelles capes.

Sintaxi completa:

FROM [--platform=<plataforma>] <imatge>[:<etiqueta>|@<digest>] [AS <nom>]

Formes que veuràs, aplicades a Aurora Libros:

FROM node                              # Perillós: latest, versió impredictible
FROM node:22                           # Millor: fixa la major, però la imatge és de ~1,1 GB
FROM node:22-alpine                    # La triada: lleugera i amb la major fixada
FROM node:22.14-alpine3.21             # Màxim control: fixa menor i versió d'Alpine
FROM node:22-alpine@sha256:9f2c1a...   # Reproductibilitat absoluta: digest immutable

El compromís entre reproductibilitat i manteniment és real i no té una resposta única:

Forma Reproductibilitat Pedaços de seguretat Recomanat per a
node (= latest) Nul·la Automàtics, però pot canviar de versió major sense avisar Mai
node:22 Mitjana Automàtics dins de la major 22 Desenvolupament
node:22-alpine Mitjana Automàtics dins de la major 22 Aurora Libros: bon equilibri
node:22.14-alpine3.21 Alta Manuals Entorns regulats
@sha256:… Total Manuals Producció crítica, cadena de subministrament auditada

Aurora Libros fa servir node:22-alpine, decidit a la lliçó 01-07 per tres motius que ara pots justificar del tot: compleix l'engines: node >=22.0.0 del package.json, és imatge oficial (espai library/, primer criteri de confiança de la lliçó 02-01) i pesa ~142 MB davant dels ~1,1 GB de node:22. La contrapartida és que Alpine fa servir musl en lloc de glibc com a biblioteca C; les tres dependències del projecte (express, pg, redis) són JavaScript pur o porten binaris compatibles, així que no suposa cap problema.

La clàusula AS <nom> dona nom a una etapa de construcció. Serveix per a les builds multietapa, la tècnica que permet compilar en una imatge i endur-se'n només el resultat a una altra molt més petita. S'estudia a la lliçó 05-04; menciona-la mentalment i continua.

  1. WORKDIR: on treballes

WORKDIR /app

WORKDIR fixa el directori de treball per a totes les instruccions posteriors que el facin servir: RUN, COPY, ADD i CMD. Si no existeix, el crea, inclosos els directoris intermedis.

Per què no RUN cd

És la pregunta obligada. Compara:

# ❌ MALAMENT: no funciona com esperes
RUN cd /app
RUN npm ci
# ✅ BÉ
WORKDIR /app
RUN npm ci

En el primer cas, npm ci s'executa a /, no a /app, i falla amb ENOENT: no such file or directory, open '/package.json'. La raó és fonamental: cada RUN s'executa en un contenidor temporal nou. El cd canvia el directori de l'intèrpret d'ordres d'aquell contenidor efímer, que mor així que acaba la instrucció; el RUN següent arrenca de zero, al directori de treball heretat de la imatge.

L'única manera que cd serveixi és encadenar-lo dins del mateix RUN:

RUN cd /app && npm ci      # Funciona, però és pitjor que WORKDIR

Tot i així, WORKDIR guanya per quatre raons:

  1. Persisteix en temps d'execució. És el directori on entres amb docker exec -it aurora-api sh i on s'executa el CMD. Un cd dins d'un RUN no deixa rastre.
  2. Crea el directori si no existeix, sense necessitat de mkdir -p.
  3. És una metadada visible. docker image inspect et diu quin és; un cd enterrat en un RUN s'ha d'anar a buscar.
  4. Es llegeix millor. Declara la intenció en lloc d'amagar-la dins d'una cadena de comandes.

Detalls addicionals:

WORKDIR /app          # Absoluta: sempre preferible
WORKDIR api           # Relativa a l'anterior: acaba a /app/api
WORKDIR $DIRECTORI    # Admet variables definides amb ENV o ARG

Fes servir sempre rutes absolutes. Les relatives encadenades obliguen a portar el compte mental d'on ets i són una font clàssica d'errors en Dockerfiles llargs.

Per a Aurora Libros, /app és la convenció habitual en imatges de Node. Qualsevol ruta serviria (/srv/aurora, /usr/src/app), però /app és curta, inequívoca i no col·lisiona amb el sistema de fitxers d'Alpine.

  1. COPY: què portes del context

COPY package.json package-lock.json ./

COPY porta fitxers i directoris des del context de construcció fins al sistema de fitxers de la imatge.

COPY [--chown=<usuari>:<grup>] [--chmod=<permisos>] <origen>... <destinació>

Regles que cal tenir clares:

  • L'origen és sempre relatiu a l'arrel del context, mai al teu directori actual ni a la ubicació del Dockerfile. I mai no pot sortir del context: és la restricció que va provocar l'error "/db/init.sql": not found de la lliçó 02-02.
  • La destinació és relativa al WORKDIR si no és absoluta. Amb WORKDIR /app, la destinació ./ significa /app/.
  • Si la destinació acaba en /, es tracta com a directori; si no, i hi ha un sol origen, s'interpreta com el nom del fitxer de destinació. Posar sempre la barra final evita sorpreses.
  • Amb diversos orígens, la destinació ha de ser un directori i acabar en /.
  • COPY copia el contingut d'un directori, no el directori. COPY web/ /public/ deixa el contingut de web/ directament a /public/, no a /public/web/. És la font número u de rutes que "no apareixen" dins de la imatge.

Exemples amb Aurora Libros:

# Dos fitxers concrets al WORKDIR
COPY package.json package-lock.json ./

# Comodins: tot el que comenci per "package" i acabi en ".json"
COPY package*.json ./

# Tot el context (ja filtrat pel .dockerignore)
COPY . .

# Reanomenant a la destinació
COPY server.js /app/main.js

# Un directori complet a una ruta absoluta
COPY web/ /usr/share/nginx/html/

Sobre COPY package*.json ./: el comodí cobreix package.json i package-lock.json d'una sola vegada. Té un avantatge pràctic sobre anomenar-los per separat: no falla si package-lock.json no existeix, perquè el patró simplement coincideix amb menys fitxers. En canvi, COPY package.json package-lock.json ./ dona error si falta el lock. Totes dues formes són defensables: l'explícita detecta abans l'oblit del lock (que trencaria el npm ci), la del comodí és més tolerant. Aurora Libros farà servir la del comodí, que és la convenció més estesa a l'ecosistema Node.

--chown i --chmod

Per defecte, tot el que es copia pertany a root:root. --chown canvia el propietari en la mateixa operació:

COPY --chown=node:node . .

La imatge node:22-alpine ja inclou un usuari node sense privilegis. Copiar directament amb la seva propietat evita un RUN chown -R node:node /app posterior, que duplicaria la mida d'aquella capa: canviar els permisos d'un fitxer el marca com a modificat i el copy-on-write de la lliçó 01-05 obliga a escriure una còpia sencera a la capa nova. Un chown recursiu sobre node_modules pot afegir desenes de megues a la imatge.

--chmod fa el mateix amb els permisos:

COPY --chmod=755 entrypoint.sh /usr/local/bin/

L'ús d'USER per executar el contenidor sense privilegis es veu a la lliçó 02-04, i el perquè en profunditat, a la 05-03.

  1. COPY davant d'ADD

ADD existeix des del primer dia de Docker i fa tot el que fa COPY, més dues coses extra. Precisament per això convé evitar-la.

Aspecte COPY ADD
Copiar fitxers locals del context
Copiar directoris
--chown / --chmod
Descomprimir automàticament un .tar, .tar.gz, .tar.bz2, .tar.xz local No
Descarregar des d'una URL No Sí (però desaconsellat)
Clonar un repositori Git (--keep-git-dir) No Sí, versions recents
Comportament predictible Total Depèn del tipus de fitxer
Recomanació oficial Fer servir aquesta Només en casos concrets

El problema d'ADD és que el seu comportament depèn del que li passis:

ADD dades.tar.gz /app/     # Descomprimeix el tar dins de /app/
ADD dades.zip /app/        # NO descomprimeix: els zip no entren a la regla
ADD fitxer.txt /app/       # Còpia normal

Tres comportaments diferents amb la mateixa instrucció. Qui llegeix el Dockerfile ha de saber de memòria quins formats es descomprimeixen per predir-ne el resultat. COPY sempre fa exactament el mateix: copiar.

I el cas de la URL és pitjor:

# ❌ MALAMENT: descarrega un fitxer remot en una capa
ADD https://exemple.com/eina.tar.gz /tmp/
RUN tar -xzf /tmp/eina.tar.gz -C /opt && rm /tmp/eina.tar.gz

Quatre problemes acumulats: el .tar.gz queda per sempre a la capa de l'ADD encara que després l'esborris (copy-on-write, lliçó 01-05), no pots verificar la suma de comprovació abans de fer-lo servir, ADD no descomprimeix el que ve d'una URL (només el local), i no controles capçaleres ni autenticació. La forma correcta, tot en un únic RUN:

# ✅ BÉ: descarrega, verifica, extreu i esborra a la MATEIXA capa
RUN wget -q https://exemple.com/eina.tar.gz -O /tmp/e.tar.gz && \
    echo "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b  /tmp/e.tar.gz" | sha256sum -c - && \
    tar -xzf /tmp/e.tar.gz -C /opt && \
    rm /tmp/e.tar.gz

Aquí sí que verifiques el hash, i l'esborrat passa dins de la mateixa capa, així que el fitxer temporal no engreixa la imatge.

La regla pràctica: fes servir COPY sempre. L'únic cas on ADD aporta alguna cosa genuïna és descomprimir un tarball local del context, que estalvia haver de tenir tar a la imatge:

ADD cataleg-inicial.tar.gz /dades/    # Ús legítim i explícit

Al Dockerfile d'aurora-api no apareix cap ADD: no hi ha tarballs ni descàrregues.

  1. RUN: què executes durant la construcció

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

RUN executa una comanda en el moment de construir la imatge, dins d'un contenidor temporal basat en les capes anteriors, i congela el sistema de fitxers resultant en una capa nova. És la instrucció que instal·la paquets, compila, genera fitxers… i la que més pes afegeix.

Forma shell i forma exec

# Forma shell: s'executa a través de /bin/sh -c
RUN npm ci --omit=dev

# Forma exec: s'executa directament, sense intèrpret d'ordres
RUN ["npm", "ci", "--omit=dev"]
Aspecte Forma shell Forma exec
Sintaxi RUN comanda args RUN ["executable", "arg1", "arg2"]
Executa a través de /bin/sh -c Directament
Variables d'entorn ($VAR) S'expandeixen No s'expandeixen
Canonades, &&, >, * Funcionen No funcionen
Requereix intèrpret d'ordres a la imatge No
Ús habitual a RUN El normal Rar

Per a RUN, la forma shell és l'habitual, perquè gairebé sempre vols encadenar comandes o expandir variables. La forma exec només cal en imatges sense intèrpret d'ordres (scratch, distroless) o quan un argument conté caràcters que l'intèrpret interpretaria malament.

Compte amb les cometes: la forma exec és JSON, així que exigeix cometes dobles. RUN ['npm', 'ci'] amb cometes simples no és JSON vàlid i BuildKit el tractarà com a forma shell, amb resultats desconcertants.

Per què encadenar amb && i \

Aquesta és la part que de debò importa. Cada RUN crea una capa. Compara:

# ❌ MALAMENT: quatre capes, i la brossa hi queda dins per sempre
RUN apk update
RUN apk add --no-cache curl
RUN apk add --no-cache tzdata
RUN rm -rf /var/cache/apk/*
# ✅ BÉ: una sola capa, la neteja sí que fa efecte
RUN apk update && \
    apk add --no-cache curl tzdata && \
    rm -rf /var/cache/apk/*

Dos problemes a la versió dolenta, i el segon és el greu:

  1. Quatre capes on n'hi hauria prou amb una. Més metadades, més entrades al manifest, més lentitud en muntar la imatge.
  2. El rm -rf del final no allibera res. Recorda de la lliçó 01-05: les capes són diffs i només s'apilen. Esborrar un fitxer a la capa 4 no l'elimina de la capa 2; només escriu un marcador d'esborrat que l'oculta. El fitxer continua viatjant dins de la imatge, ocupant el seu espai a cada descàrrega.

Comprova-ho tu mateix:

# Versió amb la neteja en un RUN a part
printf 'FROM alpine:3.21\nRUN apk add --no-cache python3\nRUN rm -rf /usr/lib/python3.12\n' > /tmp/Dockerfile.mal
docker build -q -t prova:mal -f /tmp/Dockerfile.mal /tmp

# Versió amb tot a la mateixa capa
printf 'FROM alpine:3.21\nRUN apk add --no-cache python3 && rm -rf /usr/lib/python3.12\n' > /tmp/Dockerfile.be
docker build -q -t prova:be -f /tmp/Dockerfile.be /tmp

docker image ls prova --format "table {{.Tag}}\t{{.Size}}"
TAG    SIZE
mal    62.4MB
be     14.1MB

48 MB de diferència per moure un rm d'una línia a una altra. El fitxer esborrat "existeix" igualment dins de la imatge mal, invisible però descarregable.

D'aquí les tres regles d'or de RUN:

  1. Agrupa les operacions relacionades en un sol RUN amb && i \.
  2. Neteja a la mateixa capa on embrutes. Memòries cau de gestors de paquets, fitxers temporals, codi font que ja has compilat.
  3. Però no ho agrupis tot. Si fiques en un únic RUN la instal·lació de dependències i l'arrencada de l'aplicació, perds granularitat de memòria cau. L'equilibri: un RUN per propòsit.

Neteja segons el gestor de paquets

Base Comanda Nota
Alpine apk add --no-cache paquet --no-cache evita escriure l'índex: no cal cap rm posterior
Debian/Ubuntu apt-get update && apt-get install -y --no-install-recommends paquet && rm -rf /var/lib/apt/lists/* El rm és imprescindible i ha d'anar al mateix RUN
Node npm ci --omit=dev && npm cache clean --force La memòria cau d'npm pot ocupar centenars de MB

Un avís sobre Debian: apt-get update i apt-get install han d'anar sempre al mateix RUN. Si els separes, la memòria cau pot reutilitzar un update de fa setmanes mentre executa un install nou, i acabaràs instal·lant versions que ja no són als repositoris. És el clàssic error conegut com a cache busting d'apt.

RUN a Aurora Libros

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

Desmenuçat:

  • npm ci en lloc de npm install. ci significa clean install: esborra node_modules si existeix i instal·la exactament les versions fixades a package-lock.json, sense resoldre rangs. Si el lock i el package.json no concorden, falla en lloc d'improvisar. Això és justament el que vols en una imatge: dues builds del mateix commit produeixen els mateixos bytes. npm install, en canvi, pot resoldre ^4.21.2 com a 4.21.2 avui i com a 4.22.0 el mes que ve, produint imatges diferents del mateix codi.
  • --omit=dev exclou les devDependencies. A Aurora Libros avui no n'hi ha cap, però així que entrin linters o frameworks de test, aquesta opció evitarà que viatgin a producció. Substitueix l'antiga --production, ara desaconsellada.
  • npm cache clean --force esborra la memòria cau que npm deixa a ~/.npm, que pot voltar els 50 MB. Va al mateix RUN per tot l'explicat més amunt: en un RUN a part no estalviaria ni un sol byte.

  1. ENV: configuració que sobreviu a la construcció

ENV NODE_ENV=production
ENV PORT=3000

ENV defineix variables d'entorn que existeixen durant la resta de la construcció i també dins del contenidor en execució. Aquesta doble vida és el seu tret distintiu, i és el que la diferencia d'ARG (lliçó 02-04).

Sintaxi:

ENV CLAU=valor
ENV CLAU1=valor1 CLAU2=valor2        # Diverses en una instrucció, una sola capa
ENV CLAU valor                       # Forma antiga, sense '=': desaconsellada

Fes servir sempre la forma amb =. L'antiga és ambigua amb valors que contenen espais i està en desús.

Les variables definides es poden fer servir en instruccions posteriors:

ENV APP_DIR=/app
WORKDIR $APP_DIR
COPY . $APP_DIR/

I persisteixen en execució. Comprova-ho amb la imatge que ja tens:

docker run --rm auroralibros/aurora-api:0.1.0 sh -c 'echo "NODE_ENV=$NODE_ENV PORT=$PORT"'
NODE_ENV=production PORT=3000

L'important és que són valors per defecte sobreescrivibles en arrencar el contenidor:

docker run --rm -e PORT=8080 auroralibros/aurora-api:0.1.0 sh -c 'echo "PORT=$PORT"'
PORT=8080

Aquell -e té prioritat sobre l'ENV de la imatge. És exactament el mecanisme que fa que el server.js de la lliçó 01-07 —que llegeix tota la seva configuració de process.env— serveixi sense canvis al teu portàtil i en producció.

Què posar i què NO posar en un ENV

Variable A l'ENV del Dockerfile? Per què
NODE_ENV=production No és secret i és el valor correcte per defecte per a la imatge
PORT=3000 Valor per defecte sensat, sobreescrivible amb -e
DB_HOST No Depèn de l'entorn; s'injecta en executar
DB_USER No Depèn de l'entorn
DB_PASSWORD MAI És un secret. Queda gravat en una capa i visible amb docker image history

L'última fila és la regla que arrossegues des de la lliçó 01-07 i que ara pots demostrar. Si algú escrivís ENV DB_PASSWORD=superSecreta2026, qualsevol persona amb accés a la imatge la veuria:

docker image inspect auroralibros/aurora-api:0.1.0 --format '{{json .Config.Env}}'
["PATH=/usr/local/sbin:...","NODE_VERSION=22.14.0","YARN_VERSION=1.22.22","NODE_ENV=production","PORT=3000"]

Aquí hi ha tot, en clar, sense ni tan sols haver d'arrencar el contenidor. I publicant la imatge al repositori públic auroralibros/aurora-api, a Internet.

NODE_ENV=production mereix un comentari perquè a Node no és decoratiu: Express desactiva vistes de depuració i posa les plantilles a la memòria cau, moltes biblioteques redueixen el registre i npm install ometria les devDependencies. És un canvi de comportament real, no una etiqueta.

  1. EXPOSE: documentació, no publicació

EXPOSE 3000

Aquí hi ha el parany que atrapa tothom. Llegint "expose" qualsevol entén "obre aquest port a l'exterior". EXPOSE no obre res, no publica res i no canvia el comportament de la xarxa. És exclusivament documentació en forma de metadada.

El que sí que fa:

  • Deixa constància a les metadades de la imatge de quin port fa servir el servei, perquè qui l'executi ho sàpiga sense llegir el codi.
  • Apareix a docker image inspect i a la columna PORTS de docker ps.
  • Habilita l'opció docker run -P (majúscula), que publica tots els ports declarats amb EXPOSE en ports aleatoris alts de l'amfitrió.
  • Docker Compose i alguns orquestradors el llegeixen com a pista.

El que no fa: publicar el port. Això continua sent feina exclusiva de -p a docker run, tal com vas aprendre a la lliçó 01-06.

Demostra-ho. La teva imatge 0.1.0EXPOSE 3000. Arrenca-la sense -p:

docker run -d --name prova-expose auroralibros/aurora-api:0.1.0
docker ps --filter name=prova-expose --format "table {{.Names}}\t{{.Ports}}"
NAMES           PORTS
prova-expose    3000/tcp

Fixa't en la columna PORTS: hi diu 3000/tcp, sense cap fletxa ->. Això significa "el contenidor declara aquest port", no "està publicat". Comprova-ho:

curl -s --max-time 3 http://localhost:3000/salut || echo "SENSE RESPOSTA"
SENSE RESPOSTA

Ara amb -P majúscula, que sí que publica el que està declarat:

docker rm -f prova-expose
docker run -d --name prova-expose -P auroralibros/aurora-api:0.1.0
docker ps --filter name=prova-expose --format "table {{.Names}}\t{{.Ports}}"
NAMES           PORTS
prova-expose    0.0.0.0:32768->3000/tcp

Ara sí que hi ha fletxa: el port 3000 del contenidor està publicat al 32768 de l'amfitrió, triat a l'atzar. Neteja:

docker rm -f prova-expose

Aleshores, per què molestar-se a posar EXPOSE? Per tres raons sòlides:

  1. Documenta la interfície de la imatge. Qui la rebi sap quin port mapar sense llegir server.js.
  2. És el contracte amb l'orquestrador. Compose (mòdul 4), Swarm i Kubernetes (mòdul 6) el fan servir com a referència.
  3. Costa zero. És una metadada: no afegeix ni un sol byte a la imatge.

Sintaxi completa:

EXPOSE 3000            # TCP per defecte
EXPOSE 3000/tcp        # Explícit
EXPOSE 53/udp          # UDP
EXPOSE 3000 9229       # Diversos ports

  1. CMD: quin procés arrenca el contenidor

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

CMD defineix la comanda per defecte que s'executa en arrencar un contenidor a partir de la imatge. No s'executa durant la construcció: només es desa com a metadada.

I hi ha una regla que governa tota la resta, ja vista a la lliçó 01-06: el contenidor viu mentre visqui el seu procés principal. Quan el procés del CMD acaba, el contenidor s'atura. Per això CMD ["node", "server.js"] manté el contenidor viu (el servidor no acaba) i CMD ["echo", "hola"] produeix un contenidor que mor a l'instant.

Les dues formes, i per què importen de debò

# Forma exec (JSON) — LA CORRECTA
CMD ["node", "server.js"]

# Forma shell — problemàtica
CMD node server.js

Semblen equivalents, i en condicions normals ho són. La diferència apareix en aturar el contenidor, i és prou important com per veure-la amb detall.

Amb la forma exec, Docker executa node server.js directament. El procés node és el PID 1 dins del contenidor.

Amb la forma shell, Docker executa /bin/sh -c "node server.js". El PID 1 és sh, i node és un procés fill:

flowchart LR
    subgraph EXEC["Forma exec: CMD [node, server.js]"]
        E1["PID 1: node server.js"]
    end
    subgraph SHELL["Forma shell: CMD node server.js"]
        S1["PID 1: /bin/sh -c"] --> S2["PID 7: node server.js"]
    end

    SIG1["docker stop<br/>SIGTERM"] --> E1
    SIG2["docker stop<br/>SIGTERM"] --> S1

    E1 -.->|"tanca connexions<br/>i acaba netament"| OK["Aturada en ~0,2 s ✅"]
    S1 -.->|"sh no reenvia el senyal"| KO["10 s d'espera<br/>i SIGKILL ❌"]

Quan executes docker stop, Docker envia SIGTERM al PID 1 i espera 10 segons abans d'enviar SIGKILL. Amb la forma exec, node rep el SIGTERM i pot tancar connexions, buidar memòries intermèdies i acabar. Amb la forma shell, el SIGTERM el rep sh, que no el reenvia als seus fills: node no se n'assabenta, continua treballant, i als 10 segons el mata un SIGKILL que no admet neteja. Peticions tallades per la meitat i transaccions a mig fer.

Mesura-ho:

# Amb forma exec (la teva imatge actual)
docker run -d --name t-exec auroralibros/aurora-api:0.1.0
time docker stop t-exec
real    0m0.312s
# Amb forma shell
printf 'FROM auroralibros/aurora-api:0.1.0\nCMD node server.js\n' > /tmp/Dockerfile.shell
docker build -q -t aurora-shell -f /tmp/Dockerfile.shell /tmp
docker run -d --name t-shell aurora-shell
time docker stop t-shell
real    0m10.244s

0,3 segons davant de 10,2. Deu segons de diferència per uns claudàtors, multiplicats per cada contenidor a cada desplegament. En un desplegament continu amb vint rèpliques (mòdul 6), aquesta diferència és la que separa un rollout net d'un amb errors per als usuaris.

Neteja:

docker rm t-exec t-shell && docker image rm aurora-shell

Regla: fes servir sempre la forma exec, amb claudàtors i cometes dobles. És JSON: les cometes simples no valen.

Altres propietats de CMD

  • Només l'últim compta. Si escrius diversos CMD, els anteriors es descarten sense avís.
  • Se sobreescriu des de la línia de comandes. Tot el que posis després del nom de la imatge reemplaça el CMD:
docker run --rm auroralibros/aurora-api:0.1.0 node --version
v22.14.0

No ha arrencat el servidor: has substituït el CMD per node --version. Aquesta flexibilitat és útil per depurar (docker run --rm -it lasevaimatge sh et dona un intèrpret d'ordres dins de la imatge) i és la base del patró ENTRYPOINT + CMD de la lliçó 02-04.

  • Si la imatge base té un ENTRYPOINT, el CMD es converteix en els seus arguments. És justament el tema de 02-04.
  • No facis servir CMD per a diversos processos. CMD ["sh", "-c", "node server.js & nginx"] és un antipatró: un contenidor, un procés. Aurora Libros té quatre serveis perquè tindrà quatre contenidors.

  1. El Dockerfile definitiu d'aurora-api

Ja pots justificar cada línia. Aquest és el Dockerfile final del mòdul, que substitueix el mínim de la lliçó 02-02. Desa'l a ~/aurora-libros/api/Dockerfile:

# syntax=docker/dockerfile:1
# -----------------------------------------------------------------------------
# Imatge d'aurora-api · API REST del catàleg d'Aurora Libros S.L.
# Construir amb:  docker build -t auroralibros/aurora-api:1.0.0 .
# -----------------------------------------------------------------------------

# 1. Imatge base oficial: Node 22 (exigit per engines) sobre Alpine (~142 MB
#    davant dels ~1,1 GB de node:22). Etiqueta amb la versió major fixada per
#    rebre pedaços sense salts de versió inesperats.
FROM node:22-alpine

# 2. Directori de treball. Es crea si no existeix i persisteix en execució:
#    és on aterra 'docker exec -it aurora-api sh'.
WORKDIR /app

# 3. Només els manifests de dependències. En anar ABANS del codi, la capa del
#    npm ci es reutilitza mentre package.json i el lock no canviïn.
#    El comodí cobreix package.json i package-lock.json en una sola instrucció.
COPY package*.json ./

# 4. Instal·lació reproduïble:
#      npm ci          -> versions EXACTES del lock; falla si no concorda
#      --omit=dev      -> fora les dependències de desenvolupament
#      npm cache clean -> allibera ~50 MB de memòria cau A LA MATEIXA CAPA, que és
#                         l'única manera que l'esborrat estalviï espai de debò
RUN npm ci --omit=dev && npm cache clean --force

# 5. Ara el codi de l'aplicació, el més volàtil. El .dockerignore ja ha deixat
#    fora node_modules/, .git/ i .env.
COPY . .

# 6. Configuració per defecte, sobreescrivible amb -e en executar.
#    NODE_ENV=production activa optimitzacions reals a Express.
#    AQUÍ NO HI VA CAP SECRET: quedaria gravat a les metadades de la imatge.
ENV NODE_ENV=production \
    PORT=3000

# 7. Documenta que el servei escolta al 3000. NO publica el port:
#    per a això continua calent -p 3000:3000 a docker run.
EXPOSE 3000

# 8. Procés principal, en forma exec perquè node sigui el PID 1 i rebi
#    SIGTERM directament: aturada en 0,3 s en lloc de 10 s.
CMD ["node", "server.js"]

Construeix la versió 1.0.0, que és la que acompanya el version del package.json:

cd ~/aurora-libros/api
docker build -t auroralibros/aurora-api:1.0.0 .
[+] Building 11.9s (11/11) FINISHED
 => [1/5] FROM docker.io/library/node:22-alpine@sha256:9f2c...            0.0s
 => [internal] load build context                                         0.0s
 => => transferring context: 47.83kB                                      0.0s
 => [2/5] WORKDIR /app                                                    0.1s
 => [3/5] COPY package*.json ./                                           0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                8.9s
 => [5/5] COPY . .                                                        0.1s
 => exporting to image                                                    0.6s
 => => naming to docker.io/auroralibros/aurora-api:1.0.0                  0.0s

La demostració de l'encert de memòria cau

Canvia el codi i reconstrueix, que és el que faràs cinquanta vegades al dia:

echo "// versió 1.0.0 llesta" >> server.js
docker build -t auroralibros/aurora-api:1.0.0 .
[+] Building 1.2s (11/11) FINISHED
 => CACHED [2/5] WORKDIR /app                                             0.0s
 => CACHED [3/5] COPY package*.json ./                                    0.0s
 => CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force         0.0s
 => [5/5] COPY . .                                                        0.1s
 => exporting to image                                                    0.5s

1,2 segons, amb el npm ci intacte a la memòria cau. L'ordre de les instruccions —dependències a dalt, codi a baix— fa exactament el que es va dissenyar.

Verifica la imatge acabada:

docker image ls auroralibros/aurora-api
docker image inspect auroralibros/aurora-api:1.0.0 \
  --format 'WorkingDir: {{.Config.WorkingDir}}
Cmd:        {{json .Config.Cmd}}
Env:        {{json .Config.Env}}
Ports:      {{json .Config.ExposedPorts}}'
REPOSITORY                 TAG       IMAGE ID       CREATED          SIZE
auroralibros/aurora-api    1.0.0     8c1e4a7f2b9d   4 seconds ago    167MB
auroralibros/aurora-api    0.1.0     6b4d2f8e1a3c   22 minutes ago   167MB

WorkingDir: /app
Cmd:        ["node","server.js"]
Env:        ["PATH=...","NODE_VERSION=22.14.0","YARN_VERSION=1.22.22","NODE_ENV=production","PORT=3000"]
Ports:      {"3000/tcp":{}}

Totes les metadades que has anat declarant hi són, llegibles. I cap credencial entre elles, com marca la regla del projecte. Última comprovació funcional:

docker run -d --name aurora-api-v1 -p 3000:3000 auroralibros/aurora-api:1.0.0
curl -s http://localhost:3000/salut
docker rm -f aurora-api-v1
{"servei":"aurora-api","version":"1.0.0","db":"ko","cache":"ko",
 "errorDb":"connect ECONNREFUSED 127.0.0.1:5432","errorCache":"connect ECONNREFUSED 127.0.0.1:6379"}

Igual que a 02-02: el servei viu i respon; les dependències continuen sense existir, i això és el mòdul 3.

Errors Habituals i Consells

  • RUN cd /app esperant que persisteixi. Cada RUN corre en un contenidor temporal diferent. Fes servir WORKDIR.
  • CMD en forma shell. Deu segons extra a cada aturada i senyals que no arriben mai al teu procés. Fes servir sempre CMD ["executable", "arg"] amb cometes dobles: és JSON, no Python.
  • Creure que EXPOSE publica el port. No publica res. EXPOSE documenta; -p publica. Si el teu servei "no respon" i a docker ps no veus la fletxa -> a PORTS, aquest és el problema.
  • Netejar en un RUN diferent del que embruta. L'esborrat no allibera res: la capa inferior conserva els fitxers. Encadena amb && a la mateixa instrucció.
  • apt-get update i apt-get install en RUN separats. La memòria cau reutilitza un índex vell i l'install falla o instal·la versions obsoletes. Sempre junts.
  • npm install en lloc de npm ci. Trenca la reproductibilitat: el mateix commit pot produir imatges diferents. I no oblidis --omit=dev.
  • ADD per costum. El seu comportament depèn del tipus de fitxer i amaga descàrregues remotes sense verificar. Fes servir COPY llevat que necessitis descomprimir un tarball local.
  • Secrets a ENV. Queden a les metadades, visibles amb docker image inspect sense ni tan sols arrencar el contenidor, i viatgen amb la imatge allà on vagi. Mai.
  • COPY . . sense .dockerignore. Ja ho vas veure a 02-02: context inflat, memòria cau trencada i risc de filtrar un .env.
  • Consell: comenta el per què, no el què. # Instal·la dependències sobra: es veu. # El cache clean va aquí perquè alliberi espai de debò és el que agrairà qui ho llegeixi d'aquí a sis mesos.
  • Consell: llegeix Dockerfiles aliens. Els de les imatges oficials són a GitHub (lliçó 02-01) i són una escola excel·lent.

Exercicis

Exercici 1: demostra que la neteja ha d'anar a la mateixa capa

Construeix dues imatges basades en node:22-alpine que instal·lin el paquet git amb apk:

  • Versió A: apk add git en un RUN i rm -rf /var/cache/apk/* en un altre RUN posterior.
  • Versió B: tot encadenat amb && en un únic RUN, fent servir apk add --no-cache.

Compara les mides amb docker image ls i fes servir docker image history per localitzar la capa culpable de la diferència. Explica el resultat en termes de capes i copy-on-write.

Exercici 2: mesura l'impacte de la forma del CMD

  1. Escriu dos Dockerfiles idèntics llevat del CMD: un en forma exec i un altre en forma shell, tots dos arrencant node server.js des de la imatge d'Aurora Libros.
  2. Arrenca un contenidor de cadascun.
  3. Amb docker exec <contenidor> ps -o pid,comm, comprova quin procés és el PID 1 en cada cas.
  4. Cronometra docker stop en tots dos amb time.
  5. Explica per què la diferència és d'exactament uns 10 segons i no d'un valor arbitrari, i quina opció de docker stop permetria canviar aquesta espera.

Exercici 3: audita i corregeix un Dockerfile

Aquest Dockerfile de l'API d'Aurora Libros té set problemes relacionats amb el que s'ha vist. Identifica'ls, explica la conseqüència de cadascun i escriu la versió corregida.

FROM node:latest

ADD . /app

RUN cd /app
RUN npm install
RUN npm cache clean --force

ENV DB_PASSWORD=aurora2026
ENV NODE_ENV production

EXPOSE 3000

CMD node /app/server.js

Solucions

Solució a l'exercici 1

mkdir -p /tmp/ex1 && cd /tmp/ex1

cat > Dockerfile.a <<'EOF'
FROM node:22-alpine
RUN apk add git
RUN rm -rf /var/cache/apk/*
EOF

cat > Dockerfile.b <<'EOF'
FROM node:22-alpine
RUN apk add --no-cache git
EOF

docker build -q -t ex1:a -f Dockerfile.a .
docker build -q -t ex1:b -f Dockerfile.b .
docker image ls ex1 --format "table {{.Tag}}\t{{.Size}}"
TAG   SIZE
a     165MB
b     161MB

Quatre megues de diferència (amb paquets més grans, la diferència arriba a desenes o centenars). Localitza la capa culpable:

docker image history ex1:a --format "table {{.Size}}\t{{.CreatedBy}}" | head -4
SIZE      CREATED BY
0B        RUN /bin/sh -c rm -rf /var/cache/apk/* # buildkit
19.2MB    RUN /bin/sh -c apk add git # buildkit

La lectura és concloent: la capa del rm -rf pesa 0 B. No ha alliberat res. Només ha escrit marcadors d'esborrat (whiteouts) que oculten els fitxers de la capa inferior, però aquella capa de 19,2 MB continua a la imatge i es descarrega sencera a cada docker pull.

A la versió B, --no-cache fa que apk no escrigui mai l'índex al disc, així que no hi ha res a esborrar. És l'aplicació directa del copy-on-write de la lliçó 01-05: una capa només pot afegir contingut o ocultar-lo, mai reduir la mida de les capes anteriors. D'aquí la regla: embruta i neteja a la mateixa instrucció.

Solució a l'exercici 2

cd /tmp/ex1
printf 'FROM auroralibros/aurora-api:1.0.0\nCMD ["node", "server.js"]\n' > Dockerfile.exec
printf 'FROM auroralibros/aurora-api:1.0.0\nCMD node server.js\n' > Dockerfile.shell

docker build -q -t cmd:exec -f Dockerfile.exec .
docker build -q -t cmd:shell -f Dockerfile.shell .

docker run -d --name c-exec cmd:exec
docker run -d --name c-shell cmd:shell

3. Els processos:

docker exec c-exec ps -o pid,comm
docker exec c-shell ps -o pid,comm
PID   COMMAND
    1 node

PID   COMMAND
    1 sh
    7 node

Confirmat: en la forma exec node és el PID 1; en la forma shell el PID 1 és sh i node és el seu fill amb PID 7.

4. Els temps:

time docker stop c-exec
time docker stop c-shell
c-exec
real    0m0.298s

c-shell
real    0m10.216s

5. La diferència és de ~10 segons exactes perquè aquest és el valor del temporitzador de gràcia de docker stop: envia SIGTERM al PID 1, espera 10 segons i, si el contenidor continua viu, envia SIGKILL.

  • En la forma exec, node rep el SIGTERM. Encara que aquest server.js no instal·li un gestor explícit, el comportament per defecte de Node davant de SIGTERM és acabar immediatament: 0,3 s.
  • En la forma shell, sh rep el SIGTERM i no el reenvia als seus fills (un intèrpret d'ordres POSIX mínim no fa reenviament de senyals). node no se n'assabenta mai. S'esgoten els 10 segons i arriba el SIGKILL, que el procés no pot capturar: tancament abrupte, connexions tallades, sense oportunitat de buidar memòries intermèdies ni tancar el pool de PostgreSQL.

El termini es pot ajustar amb docker stop -t <segons>:

docker rm -f c-shell 2>/dev/null; docker run -d --name c-shell cmd:shell
time docker stop -t 2 c-shell    # ~2 s: només escurça l'agonia, no arregla la causa

Abaixar el termini no resol res: el procés continua sense rebre el senyal. La solució és la forma exec, o un ENTRYPOINT amb un script que faci servir exec "$@" (lliçó 02-04). Neteja:

docker rm -f c-exec c-shell; docker image rm cmd:exec cmd:shell

Solució a l'exercici 3

Els set problemes:

# Línia Problema Conseqüència
1 FROM node:latest Etiqueta latest i variant completa Versió major impredictible (pot saltar a Node 24 sense avisar) i imatge de ~1,1 GB en lloc de ~142 MB
2 ADD . /app ADD on n'hi ha prou amb COPY Comportament depenent del tipus de fitxer; sense justificació aquí
3 RUN cd /app cd en un RUN propi No té cap efecte: el npm install següent s'executa a / i falla
4 RUN npm install Sense ci ni --omit=dev, i després de l'ADD . No reproduïble, inclou dependències de desenvolupament i la memòria cau s'invalida a cada canvi de codi
5 RUN npm cache clean en línia a part Neteja en una altra capa No allibera ni un sol byte
6 ENV DB_PASSWORD=aurora2026 Secret a la imatge Visible amb docker image inspect; catastròfic en un repositori públic
7 CMD node /app/server.js Forma shell PID 1 = sh, SIGTERM no arriba a node, 10 s d'espera i SIGKILL a cada aturada

Un vuitè detall menor: ENV NODE_ENV production fa servir la sintaxi antiga sense =, desaconsellada per ambigua.

Versió corregida:

# syntax=docker/dockerfile:1

# (1) Base oficial lleugera amb la versió major fixada
FROM node:22-alpine

# (3) WORKDIR en lloc de RUN cd: persisteix i crea el directori
WORKDIR /app

# (4) Dependències abans que codi, per preservar la memòria cau
COPY package*.json ./

# (4)(5) Instal·lació reproduïble i neteja A LA MATEIXA CAPA
RUN npm ci --omit=dev && npm cache clean --force

# (2) COPY en lloc d'ADD, i després de les dependències
COPY . .

# (6) Sense secrets. (8) Sintaxi amb '='
ENV NODE_ENV=production \
    PORT=3000

EXPOSE 3000

# (7) Forma exec: node és PID 1 i rep SIGTERM
CMD ["node", "server.js"]

La contrasenya s'injecta en executar, mai en construir:

docker run -d --name aurora-api \
  -p 3000:3000 \
  -e DB_HOST=aurora-db \
  -e DB_PASSWORD=aurora2026 \
  auroralibros/aurora-api:1.0.0

I al mòdul 4 ni tan sols anirà a la línia de comandes, sinó en un fitxer .env fora del control de versions (lliçó 04-05) o en un secret gestionat (lliçó 05-03).

Conclusió

Ja domines el llenguatge del Dockerfile. Saps que la directiva # syntax=docker/dockerfile:1 et dona l'intèrpret més recent sense actualitzar Docker, i que de les vuit instruccions bàsiques només COPY, ADD i RUN engreixen la imatge: les altres escriuen metadades que no pesen res. FROM fixa el punt de partida i el compromís entre reproductibilitat i pedaços automàtics. WORKDIR substitueix un RUN cd que no funcionaria mai, perquè cada RUN viu en un contenidor temporal diferent, i a més persisteix en execució. COPY resol les seves rutes contra el context i copia el contingut dels directoris; ADD hi afegeix descompressió i descàrregues amb un comportament variable que la fa pitjor opció llevat que sigui per a tarballs locals.

Amb RUN has vist la regla més rendible de totes: neteja a la mateixa capa on embrutes, perquè una capa només pot afegir o ocultar, mai aprimar les anteriors —48 MB de diferència a la demostració amb python3—. Amb ENV has distingit el que és un valor per defecte legítim (NODE_ENV, PORT) del que mai no ha d'entrar en una imatge (DB_PASSWORD), i ho has comprovat llegint les metadades amb docker image inspect. Amb EXPOSE has desmuntat el parany del nom: documenta, no publica, i la prova és l'absència de fletxa -> a docker ps. I amb CMD has mesurat en carn pròpia que la forma exec i la forma shell no són estils alternatius: són 0,3 segons davant de 10,2 en aturar el contenidor, perquè el PID 1 és node o és sh, i sh no reenvia SIGTERM.

El resultat és auroralibros/aurora-api:1.0.0: 167 MB, comentada línia a línia, amb cada decisió justificada, que es reconstrueix en 1,2 segons després d'un canvi de codi i que no porta ni un sol secret a dins. És un Dockerfile que funciona.

A la lliçó següent, Instruccions Avançades del Dockerfile, el convertiràs en un de professional. Aprendràs a parametritzar la versió base amb ARG i a distingir-lo d'ENV, a separar l'executable fix dels seus arguments amb ENTRYPOINT i CMD combinats, a deixar d'executar l'API com a root amb USER, a descriure la imatge amb etiquetes OCI mitjançant LABEL, i a fer que Docker vigili pel seu compte l'endpoint /salut amb HEALTHCHECK perquè docker ps mostri healthy o unhealthy. Aquest és el salt entre una imatge que arrenca i una imatge que pots posar en producció sense envermellir.

Docker: De Principiant a Avançat

Mòdul 1: Introducció a Docker

Mòdul 2: Treballant amb Imatges Docker

Mòdul 3: Contenidors Docker

Mòdul 4: Docker Compose

Mòdul 5: Conceptes Avançats de Docker

Mòdul 6: Docker en Producció

Mòdul 7: Ecosistema i Eines de Docker

© Copyright 2026. Tots els drets reservats