Ridurre la Dimensione delle Immagini Docker: Una Guida Pratica per Build Più Veloci
Rendi le immagini Docker più piccole e le build più veloci con build multi-stadio, Dockerfile cache-friendly e contesti di build puliti.
Ridurre la Dimensione delle Immagini Docker: Una Guida Pratica per Build Più Veloci
Ridurre la dimensione delle immagini Docker non significa inseguire il numero più piccolo in docker image ls. L'obiettivo utile è una build veloce, ripetibile, facile da aggiornare e abbastanza piccola da non rallentare CI, deployment o sviluppo locale.
Un'immagine gonfiata di solito deriva da alcune abitudini comuni: copiare l'intero repository troppo presto, installare strumenti di build nell'immagine runtime, lasciare le cache dei gestori di pacchetti, usare tag latest e inviare un contesto di build enorme a Docker. Correggi queste abitudini e la maggior parte dei team vedrà build più pulite senza fare nulla di esotico.
Inizia misurando l'immagine corrente:
docker image ls my-app
docker history --no-trunc my-app:latest
docker history mostra quali istruzioni del Dockerfile hanno aggiunto dimensione. Se un singolo passo RUN aggiunge una grande quantità, ispeziona quel passo prima di cambiare tutto il resto. Per un'analisi più approfondita, strumenti come dive possono mostrare i file aggiunti e rimossi in ogni layer, ma i comandi integrati sono sufficienti per una prima analisi.
Scegli un'immagine base prima per compatibilità, poi per dimensione
La scelta dell'immagine base è importante, ma l'immagine più piccola non è sempre la migliore. Alpine è piccola perché usa musl libc e un userspace minimale. Funziona bene per molte immagini Go, Node, Python e utility, ma può creare attriti con estensioni native, binari precompilati, comportamento DNS o pacchetti che si aspettano glibc.
Un ordine pratico è:
- Usa un'immagine ufficiale per il tuo runtime.
- Preferisci un tag versionato come
python:3.12-slimapython:latest. - Prova
slimprima di passare ad Alpine se la tua app ha dipendenze native. - Usa distroless o
scratchquando sai già esattamente di cosa ha bisogno il runtime.
Ad esempio, un servizio Python con psycopg, librerie di immagini o pacchetti di crittografia potrebbe essere più facile da mantenere su python:3.12-slim che su python:3.12-alpine. L'immagine Alpine potrebbe essere più piccola all'inizio, ma se aggiungi compilatori e pacchetti di compatibilità per far funzionare le dipendenze, il risultato finale potrebbe non essere più semplice o molto più piccolo.
Per i servizi Go, scratch o distroless possono essere eccellenti perché un binario compilato staticamente potrebbe aver bisogno di pochissimo in fase di esecuzione. Anche in questo caso, ricorda i certificati TLS, i dati del fuso orario e gli utenti non root. Un'immagine minuscola che non può effettuare richieste HTTPS non è un'immagine di produzione utile.
Usa build multi-stadio per tenere gli strumenti di build fuori dalla produzione
Le build multi-stadio sono il modo più affidabile per ridurre la dimensione delle immagini Docker per app compilate e build frontend. Lo stadio builder può essere grande. Può contenere compilatori, gestori di pacchetti, strumenti di test e file sorgente. Lo stadio finale dovrebbe contenere solo ciò di cui l'app ha bisogno per funzionare.
Un esempio Go:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
La parte importante non è l'immagine base esatta. La parte importante è il confine: il codice sorgente, la cache dei moduli e il compilatore rimangono in builder; l'immagine finale ottiene il binario.
Un'app Node ha una forma simile, ma devi separare le dipendenze di sviluppo da quelle di produzione:
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Per asset frontend serviti da nginx, l'immagine finale non ha affatto bisogno di Node:
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
Questo pattern rimuove node_modules, l'albero dei sorgenti e gli strumenti di build dall'immagine runtime.
Fai funzionare la cache Docker per te
L'invalidazione della cache Docker è semplice ma spietata. Se un'istruzione cambia, Docker ricostruisce quel layer e ogni layer successivo. Se copi l'intero progetto prima di installare le dipendenze, ogni modifica al codice può forzare una reinstallazione delle dipendenze.
Questo è lento:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
Questo è di solito più veloce:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Il layer delle dipendenze si ricostruisce solo quando requirements.txt cambia. Le modifiche al codice ricostruiscono solo il layer successivo COPY . ..
La stessa regola si applica a Maven, Gradle, npm, pnpm, Cargo e moduli Go. Copia prima i manifest delle dipendenze, installa o scarica le dipendenze, poi copia il resto del codice sorgente.
Mantieni il contesto di build piccolo con .dockerignore
Docker invia il contesto di build al demone prima di iniziare la build. Se il tuo contesto include .git, node_modules locali, report di copertura, video, screenshot, database di test e artefatti di build, la tua build è più lenta prima ancora che il Dockerfile venga eseguito.
Un .dockerignore di base potrebbe assomigliare a questo:
.git
.gitignore
.env
.env.*
node_modules
coverage
dist
build
target
.pytest_cache
__pycache__
*.log
.DS_Store
Fai attenzione con le esclusioni ampie. Se ignori dist ma il tuo Dockerfile si aspetta di copiare una dist precompilata, la build fallirà. Se ignori .env, di solito è buono per i segreti, ma la tua build non dovrebbe dipendere da esso. La build dovrebbe ricevere i valori richiesti tramite build args espliciti o configurazione CI.
Puoi vedere la dimensione del contesto all'inizio dell'output classico della build Docker, e l'output di BuildKit rende visibili anche i grandi trasferimenti di contesto. Se il primo passo richiede molto tempo prima che venga eseguita qualsiasi istruzione del Dockerfile, controlla .dockerignore.
Pulisci le cache dei gestori di pacchetti nello stesso layer
Un errore frequente è installare pacchetti in un layer ed eliminare le cache in un layer successivo. I file scompaiono dalla vista finale del filesystem, ma potrebbero ancora esistere nella cronologia dell'immagine.
Per immagini Debian e Ubuntu:
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
Per Alpine:
RUN apk add --no-cache ca-certificates curl
Per Python:
RUN pip install --no-cache-dir -r requirements.txt
Per npm:
RUN npm ci --omit=dev && npm cache clean --force
Non copiare ciecamente i comandi di pulizia. Usa la pulizia che corrisponde al gestore di pacchetti e all'immagine base. Evita anche apt-get upgrade nella maggior parte dei Dockerfile delle applicazioni. Rende le build meno prevedibili e può introdurre più modifiche di quanto intendessi. Applica patch ricostruendo regolarmente da immagini base mantenute e fissando la famiglia di immagini che ti aspetti.
Preferisci COPY a meno che non ti serva ADD
COPY copia i file dal contesto di build. ADD ha un comportamento extra: può decomprimere archivi tar locali e recuperare URL remoti in alcuni casi. Questo comportamento extra può sorprendere i manutentori e rendere più difficile ragionare sul comportamento della cache.
Usa COPY per i file normali dell'applicazione:
COPY ./src ./src
Usa ADD solo quando vuoi intenzionalmente il suo comportamento di estrazione degli archivi. Per download remoti, preferisci curl o wget in un passo RUN dove puoi verificare i checksum e fallire chiaramente.
Riduci ciò che installi, non solo ciò che elimini
La migliore pulizia della cache è non installare file non necessari in primo luogo. Usa --no-install-recommends con apt-get. Evita di installare editor, shell, gestori di pacchetti e strumenti di debug nelle immagini di produzione a meno che non tu abbia un motivo.
Detto questo, non rimuovere ogni strumento se rende le operazioni più difficili. Un'immagine di produzione può essere minima pur rimanendo osservabile. Per alcuni team, un'immagine slim con una shell è un compromesso migliore di un'immagine distroless perché il debug in chiamata è più semplice. Per altri team con logging, tracing e contenitori di debug effimeri robusti, distroless è una buona scelta.
La risposta onesta è specifica del carico di lavoro. Le immagini più piccole di solito si scaricano più velocemente ed espongono meno pacchetti da aggiornare, ma la manutenibilità conta ancora.
Usa i mount della cache di BuildKit per i download delle dipendenze
BuildKit può mantenere le cache dei pacchetti al di fuori dell'immagine finale mentre le riutilizza tra le build. Questo è diverso dal lasciare le cache all'interno dell'immagine.
Per npm:
# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
Per apt, i mount della cache possono aiutare in ambienti CI dove le build ripetute avvengono sullo stesso builder, anche se molti sistemi CI usano worker freschi a meno che non configuri una cache persistente. Le funzionalità di BuildKit sono potenti, ma mantieni il Dockerfile leggibile per il team che deve mantenerlo.
Controlla il contenuto finale dell'immagine
Dopo le modifiche, dimostra cosa è migliorato:
docker build -t my-app:optimized .
docker image ls my-app
docker history my-app:optimized
Quindi esegui l'immagine nello stesso modo in cui la produzione la esegue. Un'immagine più piccola che fallisce perché manca di certificati, dati locali, file del fuso orario o una directory scrivibile non è un miglioramento.
I test di fumo utili includono:
docker run --rm my-app:optimized --version
docker run --rm -p 8080:8080 my-app:optimized
curl -f http://localhost:8080/health
Se l'app scrive file, testa quel percorso. Se chiama servizi HTTPS, testa TLS. Se viene eseguita come non root, testa i permessi.
Un ordine pratico di ottimizzazione
Quando esamino un'immagine Docker sovradimensionata, uso questo ordine:
- Aggiungi o correggi
.dockerignore. - Riordina il Dockerfile in modo che l'installazione delle dipendenze possa essere memorizzata nella cache.
- Introduci build multi-stadio.
- Passa da immagini base complete a
slim, Alpine, distroless oscratchdove compatibile. - Rimuovi le cache dei gestori di pacchetti e le dipendenze di sviluppo nello stesso layer in cui vengono create.
- Ispeziona con
docker historyodivee ripeti solo dove l'evidenza lo indica.
Questo ordine evita astuzie premature. Mantiene anche il Dockerfile comprensibile, il che conta più che spremere gli ultimi pochi megabyte.