Quanto costa un Dockerfile scritto male

1
2
3
4
5
6
FROM node:latest

COPY . .
RUN npm install

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

Quattro righe, e ognuna ti costa qualcosa. docker build riesegue npm install a ogni modifica al codice, perché COPY . . rompe la cache dei layer prima ancora che lo step di install possa essere riutilizzato. L’immagine finale porta con sé l’intera base Debian, l’intero albero di node_modules comprese le dipendenze di sviluppo e qualsiasi tool di build che npm install ha scaricato per compilare moduli nativi. Niente viene scartato. Il container gira come root, perché nulla gli dice di non farlo, e node:latest significa che l’immagine base può cambiare sotto di te tra una build e l’altra, senza che resti traccia di cosa hai effettivamente distribuito.

Niente di tutto questo compare come errore. L’immagine si costruisce, il container parte, l’app risponde. Il costo arriva dopo: una build CI da cinque minuti perché ogni step riparte da zero, e un’immagine da 1,1 GB per un’app il cui codice pesa poche centinaia di kilobyte. Ogni correzione qui sotto è piccola. Insieme cambiano quanto ti costa un Dockerfile ogni giorno.

Come la cache dei layer decide cosa ricostruire

Docker costruisce un’immagine un’istruzione alla volta e mette in cache il risultato di ciascuna come layer. Alla build successiva rilegge il Dockerfile e riusa un layer in cache finché l’istruzione resta identica e i layer che la precedono nella catena non sono cambiati. Appena un’istruzione manca la cache, anche tutte quelle successive vengono rieseguite. I cache hit estendono la catena solo dall’alto. Se immagini, layer e container sono ancora concetti poco chiari, cos’è Docker e come funzionano i container copre le basi che questo articolo dà per scontate.

COPY e ADD invalidano in base al contenuto, non solo al testo dell’istruzione: se un file copiato è cambiato, la cache di quel layer sparisce, e con essa tutto ciò che viene dopo. COPY . . copia l’intero contesto di build, quindi invalida a ogni modifica in qualsiasi punto del progetto, fino a un commento riscritto in un file che non ha nulla a che fare con le dipendenze. Metti RUN npm install subito dopo quel COPY e riparte a ogni singola build.

Come ordinare le istruzioni del Dockerfile per sfruttare la cache

Copia solo ciò che serve a un’istruzione, nell’ordine in cui le cose cambiano davvero: i manifest delle dipendenze raramente, il codice sorgente di continuo.

1
2
3
4
5
6
7
8
9
FROM node:24-slim
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

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

package.json e package-lock.json cambiano quando aggiungi o aggiorni una dipendenza. RUN npm ci ora va in cache in base solo a questi due file. Modifica un file sorgente e ricostruisci: Docker riusa il layer di install in cache e riesegue solo il COPY finale e ciò che segue. Lo step più lento della build gira solo quando serve davvero.

npm ci al posto di npm install è una scelta deliberata. Installa esattamente quello che c’è nel lockfile e fallisce se lockfile e package.json sono in disaccordo, invece di risolvere silenziosamente nuove versioni dentro quella che doveva essere una build riproducibile.

Lo stesso ordine vale fuori da Node: un progetto Python copia requirements.txt ed esegue pip install prima del resto del sorgente, un progetto Go copia go.mod/go.sum ed esegue go mod download per primo.

Come i multi-stage build riducono l’immagine finale

Riordinare risolve la cache. Non fa nulla per un’immagine che continua a portarsi dietro dipendenze di sviluppo, tool di build e tutto ciò che npm ci ha scaricato per compilare moduli nativi, nulla di cui l’app ha bisogno per girare. Un multi-stage build separa cosa serve per costruire l’app da cosa serve per farla girare.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# syntax=docker/dockerfile:1
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist

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

Due righe FROM, due stage. Il primo, chiamato build, installa tutte le dipendenze (comprese quelle solo di sviluppo, come un bundler o TypeScript) e produce dist/. Il secondo riparte da zero dalla stessa immagine base, installa solo le dipendenze di produzione e prende una directory dal primo stage con COPY --from=build. Il compilatore TypeScript, i file sorgente .ts, la cache di npm: niente di tutto questo arriva nell’immagine finale, perché lo stage di build viene buttato via appena la build finisce.

La differenza di dimensione è il punto. node:latest, l’immagine completa basata su Debian, viaggia vicino a 1,1 GB prima ancora di aggiungere una sola dipendenza. node:24-slim sta sotto i 300 MB e node:24-alpine sotto i 200 MB se le tue dipendenze non richiedono glibc. Moltiplica questo per ogni servizio che gestisci e ogni runner CI che scarica l’immagine, e il divario diventa storage reale e minuti reali.

L’effetto è più marcato per un linguaggio compilato, dove il binario non ha bisogno di nient’altro che se stesso.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Nessuna shell, nessun package manager, nessuna toolchain Go nell’immagine finale: solo il binario statico più i certificati CA e i dati dei fusi orari che distroless/static fornisce. Un attaccante che ottiene esecuzione in quel container non ha una sh da lanciare né un apt per installarne una.

Cosa mettere nel .dockerignore

COPY . . invia l’intero contesto di build al daemon Docker prima che la build inizi, e quel contesto include file che non volevi mai spedire: .git, un node_modules locale, file .env con credenziali vere, configurazioni dell’editor. Un .dockerignore accanto al Dockerfile li esclude, usando la stessa sintassi di pattern di .gitignore.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.git
.gitignore
node_modules
npm-debug.log
.env
.env.*
Dockerfile
.dockerignore
dist
*.md

Escludere node_modules non riguarda solo la dimensione. Se un’installazione locale sulla tua macchina ha moduli nativi compilati per macOS e arm64, COPY . . spedisce quei binari dentro un container Linux dove non si caricheranno. Lascia che sia il container a installare le proprie dipendenze.

Come far girare un container come utente non-root

Niente in un Dockerfile semplice impedisce a un container di girare come root, quindi di default succede proprio questo. Se un attaccante ottiene esecuzione di codice al suo interno, tramite una vulnerabilità in una dipendenza o un bug di deserializzazione, basta un bug del kernel o un mount configurato male perché quel root nel container diventi root sull’host. La maggior parte delle immagini ufficiali fornisce già un utente a cui puoi passare.

1
2
3
4
5
6
7
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

L’immagine node crea un utente node, quindi USER node è l’unica riga che aggiungi. Quando un’immagine base non ne fornisce uno, creane uno prima di passare:

1
2
RUN groupadd -r app && useradd -r -g app app
USER app

Il --chown conta quanto lo USER. Senza, i file finiscono di proprietà di root mentre il processo che li legge gira come app, e qualsiasi cosa l’app scriva a runtime fallisce con un errore di permessi che scopri in produzione. Lo stesso vale per un volume Docker montato nel container: il suo contenuto deve essere scrivibile dallo UID a cui sei passato, o la prima scrittura muore all’avvio.

Come fissare il tag o il digest dell’immagine base

FROM node:latest si risolve in quello a cui punta latest nel giorno in cui costruisci. Ricostruisci lo stesso Dockerfile il mese prossimo e puoi finire su una major diversa, una release Debian diversa, pacchetti di sistema diversi, senza nessun diff nel tuo repository che mostri cosa è cambiato. Fissa la versione:

1
FROM node:24.9.0-slim

Per una build che deve essere riproducibile byte per byte, fissa anche il digest. Questo lega la build a un’unica immagine immutabile qualsiasi cosa succeda al tag:

1
FROM node:24.9.0-slim@sha256:9c1f9c1a...

Ottieni il digest con docker pull node:24.9.0-slim seguito da docker inspect --format='{{index .RepoDigests 0}}' node:24.9.0-slim. Fissare il tag copre la maggior parte dei progetti. Fissare il digest serve per pipeline dove “cosa abbiamo spedito esattamente” deve avere una risposta anche mesi dopo.

Quando unire più RUN in un solo layer

Ogni RUN produce un layer, e un layer cresce soltanto. Cancellare un file in un layer successivo non riduce l’immagine, nasconde il file dietro un whiteout marker mentre il layer precedente porta ancora con sé i byte. Ecco perché dividere install e cleanup su due RUN separati non fa risparmiare nulla:

1
2
3
4
# Sbagliato: la cache di apt del primo RUN finisce
# in un layer che il secondo RUN non può rimuovere dall'immagine
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

Metti il cleanup nello stesso layer dell’install:

1
2
3
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

Questo vale per le install che lasciano file dietro di sé: cache dei package manager, archivi scaricati, artefatti di build che hai già copiato altrove. Non è un argomento per unire ogni RUN del file. Una manciata di layer leggibili si debugga meglio di una riga da 400 caratteri, e il numero di layer da solo non è ciò che ti costa dimensione.

Quando queste pratiche non valgono lo sforzo

Uno script usa e getta che lanci in locale con docker build -t scratch . && docker run --rm scratch non ha bisogno né di un multi-stage build né di un digest fissato. La cerimonia costa più del rischio che evita.

Il footprint più piccolo di Alpine viene dalla musl libc al posto della glibc, che rompe i moduli nativi Node e Python compilati contro glibc. Se dopo essere passato a -alpine ottieni segfault o errori di simbolo mancante, la causa è quasi sempre questa. Usa -slim quando non sei sicuro.

E fissare le versioni è una scelta con un costo: un’immagine base non fissata raccoglie le patch di sicurezza al prossimo docker build --pull senza che tu faccia nulla. Se vuoi questo, prendilo deliberatamente, sapendo che una build può iniziare a comportarsi diversamente per motivi che non sono nel tuo diff. Lasciare il tag libero per sbaglio non è la stessa decisione.

Come misurare dimensione dell’immagine e tempo di build

Costruisci entrambe le versioni della stessa app e confronta:

1
2
3
4
docker build -t app:before -f Dockerfile.before .
docker build -t app:after -f Dockerfile.after .
docker images app --format "table {{.Tag}}\t{{.Size}}"
docker history app:after

docker images mostra il divario di dimensione. docker history mostra quale istruzione ha prodotto quale layer e quanto pesa, il modo più veloce per trovare il RUN che ancora si trascina dietro qualcosa che non dovrebbe. Niente di tutto questo cambia se la tua app viene costruita da Docker Compose: un servizio con build: . usa lo stesso Dockerfile, la stessa cache e lo stesso contesto di build, quindi docker compose build ottiene gli stessi vantaggi.

Hai ereditato un Dockerfile che precede tutto questo? Costruiscilo una volta così com’è, lancia docker history, e correggi qualunque layer sia la sorpresa più grande. Quel singolo layer di solito è la maggior parte del divario.

Articoli correlati