Perché un’immagine Docker pesa dieci volte più dell’app

Un servizio Node con poche centinaia di kilobyte di codice sorgente finisce in un’immagine da oltre 1 GB. È il risultato normale di una build che funziona, non un errore su una riga particolare. Il peso arriva da un’immagine base che porta con sé un intero sistema operativo, una toolchain di build che l’app in esecuzione non chiama mai, e una cache del package manager che nessuno ha detto alla build di cancellare.

Lo paghi a ogni deploy: docker pull diventa più lento, e uno scanner di vulnerabilità trova centinaia di pacchetti in più su cui riportare risultati. Anche lo storage cresce: si accumula su ogni tag che hai mai pubblicato. Risolverlo significa riscrivere il Dockerfile, non l’app.

Quale immagine base scegliere: alpine, slim o distroless

La riga FROM fissa il punto di partenza. Sceglierla male lì e nient’altro nel file recupera la differenza.

Immagine baseDimensione approssimativaCosa contiene
node:261,77 GBDebian completa, compilatori, più runtime di linguaggi, documentazione
node:26-slim371 MBDebian ridotta, senza toolchain di build
node:26-alpine247 MBmusl libc, apk, circa 50 pacchetti
gcr.io/distroless/nodejs22-debian12212 MBSolo runtime Node, nessuna shell, nessun package manager, circa 10 pacchetti

Lo schema si ripete fuori da Node. debian:bookworm-slim sta intorno ai 74 MB contro i 77 MB di ubuntu:22.04. alpine:latest è intorno ai 7 MB. gcr.io/distroless/static-debian12, pensata per un binario Go o Rust compilato staticamente che non ha bisogno di altro che i certificati CA, sfiora i 2 MB.

Alpine ottiene la sua dimensione sostituendo glibc con musl libc, e quel cambio è il conto da pagare: i moduli nativi compilati contro glibc (alcuni pacchetti npm, la maggior parte dei pacchetti pip con estensioni in C) vanno in segfault o lanciano errori di simbolo mancante sotto musl. Un tag -slim evita il rischio e taglia comunque l’immagine di due terzi. Passa ad Alpine solo dopo aver verificato che le tue dipendenze non se ne curano.

Distroless va oltre. Nessuna shell, nessun package manager, niente che un attaccante possa eseguire dopo aver ottenuto esecuzione di codice, e niente sh nemmeno per te: è un costo reale la prima volta che un container si comporta male in produzione.

L’ordine dei layer, .dockerignore e i meccanismi di una multi-stage build sono trattati in Dockerfile: le Best Practice per Build Più Piccole e Veloci. Qui si parte dal presupposto che quella separazione esista già e ci si concentra su ciò che uno stage di build da solo non risolve.

Come tenere le cache del package manager fuori dall’immagine

Una multi-stage build tiene il compilatore fuori dallo stage di runtime. Non fa nulla per la cache del package manager, che finisce nel layer in cui è girata l’installazione: innocua in uno stage di build che butti via, peso morto nello stage finale se anche quello installa qualcosa.

I cache mount di BuildKit risolvono il problema senza bisogno di pulizia:

1
2
3
4
5
6
7
8
9
# syntax=docker/dockerfile:1
FROM node:26-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

--mount=type=cache dà a quel RUN una directory che persiste tra le build e non finisce mai in un layer. La cache di npm continua a velocizzare la build successiva; niente di tutto questo arriva nell’immagine. Per Python è RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt, per Go --mount=type=cache,target=/root/.cache/go-build.

Senza BuildKit, dì all’installer di non fare cache: pip install --no-cache-dir, oppure npm ci && npm cache clean --force. Entrambi i comandi vanno nella stessa RUN. Cancellare un file in un layer successivo lo nasconde dietro un whiteout marker e lascia i byte fermi nel layer sottostante, quindi la versione divisa in due RUN spedisce comunque la cache. Un servizio che Docker Compose costruisce con build: . esegue lo stesso Dockerfile con lo stesso builder, quindi i cache mount funzionano allo stesso modo.

Come rimuovere simboli di debug e file che l’app non usa mai

Le dipendenze di build sono solo metà del problema. Le dipendenze di runtime portano file che in produzione non vengono mai letti: suite di test, documentazione .md, cache .pyc compilate, source map.

Per Python, salta la cache di pip e la cache dei bytecode nel layer che installa:

1
2
3
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-compile -r requirements.txt \
    && find /usr/local/lib -name '__pycache__' -exec rm -rf {} +

Un binario compilato porta informazioni di debug che la produzione non legge mai. Un flag nello stage di build le elimina:

1
2
# Go: rimuove la symbol table e le informazioni di debug DWARF in fase di build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
1
2
# C/C++/Rust: rimuove i simboli da un binario già compilato
RUN strip /app/binary

-ldflags="-s -w" toglie in genere un 20-30% da un binario Go. Niente che noti su una piccola CLI, byte veri su qualsiasi cosa abbia un grafo di dipendenze ampio. I simboli rimossi costano solo se attacchi un debugger esattamente al binario in esecuzione in produzione, cosa che non fai su un container senza shell a cui attaccarti.

Come scoprire quale layer appesantisce l’immagine

Docker ha registrato dove sono finiti i byte. Basta leggerlo:

1
docker history myapp:latest

Una riga per layer, con l’istruzione che l’ha creato e la sua dimensione. Aggiungi --no-trunc quando più righe RUN si somigliano nella vista troncata.

Questo indica l’istruzione costosa, non i file costosi al suo interno. dive fa la seconda metà del lavoro:

1
dive myapp:latest

Apre una TUI sui layer, colora i file aggiunti, modificati ed eliminati, e riporta un punteggio di efficienza insieme ai byte totali sprecati: spazio occupato da file che un layer successivo ha sovrascritto o eliminato ma che restano comunque nell’immagine. CI=true dive myapp:latest esegue lo stesso controllo in modo non interattivo e termina con un codice diverso da zero quando l’immagine non rispetta le soglie definite in un file .dive-ci:

1
2
3
4
rules:
  lowestEfficiency: 0.95
  highestWastedBytes: 20MB
  highestUserWastedPercent: 0.10

Un’immagine che è cresciuta silenziosamente smette di essere qualcosa che un collega nota settimane dopo e diventa un check fallito sulla pull request che l’ha causata.

Quando ridurre l’immagine non conviene

Uno script che costruisci una volta e lanci sulla tua macchina non ha bisogno di una base distroless né di un binario ripulito dai simboli. L’immagine non lascia mai il tuo disco, e il lavoro costa più del gigabyte che risparmi. Vale lo stesso per l’immagine di un job CI di breve durata, ricostruita da zero a ogni run: togliere 200 MB da qualcosa che viene scaricato una volta e poi buttato via non porta nulla.

Il compromesso morde di più ai margini. Un’immagine distroless o scratch non ha shell, quindi docker exec -it myapp sh su un container che si comporta male fallisce senza appello. Debughi dai log e da una riproduzione locale, oppure tieni pronta una variante di debug proprio per questo (distroless pubblica tag -debug con una shell busybox). Alpine rinuncia a meno di questa comodità e in cambio ti dà il rischio di musl. Nessuno di questi costi è nascosto, ed è per questo che “usa sempre la base più piccola possibile” è un consiglio peggiore che scegliere la base caso per caso, in base a cosa serve davvero a ogni servizio.

Da dove partire su un’immagine che hai già

Lancia docker history su quello che spedisci oggi, prima di cambiare qualsiasi cosa. Il layer più grande è quasi sempre una correzione ovvia, una volta che sai quale sia. Poi lancia dive sulla stessa immagine con le soglie viste sopra: un run che fallisce al primo tentativo ti dà un numero da battere e un report a livello di file su dove sta lo spreco, che è meglio di indovinare sul Dockerfile in base a ciò che di solito gonfia le immagini.

Articoli correlati