Un Dockerfile si scrive in dieci minuti, ma la differenza tra uno scritto in fretta e uno scritto bene si vede per mesi: immagini da un gigabyte invece che da cento megabyte, build che impiegano cinque minuti a ogni modifica, container che girano come amministratore senza motivo. Le buone pratiche non sono molte e si applicano a quasi ogni linguaggio.
Scegliere l'immagine di partenza
La prima riga, FROM, decide gran parte del risultato. Le opzioni tipiche sono tre:
- immagini complete (per esempio
python:3.12): comode, contengono compilatori e strumenti, ma sono grandi; - immagini slim (
python:3.12-slim): basate su Debian ridotto, sono un buon compromesso per la maggior parte delle applicazioni; - immagini minime o distroless: contengono quasi solo il runtime, sono piccole e offrono meno superficie d'attacco, ma non hanno shell né strumenti di diagnosi.
Le immagini basate su Alpine sono molto piccole, ma usano la libreria C musl invece di glibc. Con alcuni linguaggi, Python in particolare, questo può costringere a compilare pacchetti che altrimenti arriverebbero già pronti, allungando le build. Vanno scelte con consapevolezza, non per abitudine.
Indica sempre una versione precisa. Il tag latest cambia nel tempo, e una build che funziona oggi può rompersi tra un mese senza che tu abbia toccato nulla.
L'ordine delle istruzioni e la cache
Docker riutilizza un layer se l'istruzione e i file da cui dipende non sono cambiati. Appena un layer cambia, tutti quelli successivi vengono ricostruiti. La regola quindi è: ciò che cambia raramente va in alto, ciò che cambia spesso va in basso.
L'errore più comune è copiare tutto il codice e poi installare le dipendenze. In questo modo ogni modifica a un file sorgente invalida la cache e costringe a reinstallare tutte le librerie. L'ordine corretto è copiare prima solo il file delle dipendenze, installarle, e solo dopo copiare il resto del codice.
Le build multi-stage
Molte applicazioni hanno bisogno di strumenti per essere compilate, ma non per girare. Un progetto Go o Rust richiede il compilatore; un frontend JavaScript richiede Node per generare i file statici. Portarsi questi strumenti in produzione è uno spreco e un rischio.
La build multi-stage risolve il problema: si definiscono più fasi nello stesso Dockerfile, e l'immagine finale copia dalla fase di build solo ciò che serve.
# Fase 1: build
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Fase 2: esecuzione
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /install /usr/local
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
In questo esempio le dipendenze vengono installate in una cartella separata nella prima fase, poi copiate nella seconda. Con linguaggi compilati il guadagno è ancora maggiore: l'immagine finale può contenere un singolo eseguibile.
Non girare come root
Per impostazione predefinita il processo dentro un container gira come utente root. Se un attaccante sfrutta una vulnerabilità dell'applicazione, si ritrova con i massimi privilegi all'interno del container, e un passo più vicino all'host. Creare un utente dedicato e passare a lui con l'istruzione USER costa due righe e riduce molto il danno possibile.
Di conseguenza, l'applicazione deve ascoltare su una porta superiore alla 1024 e scrivere solo nelle cartelle di cui l'utente è proprietario.
Il file .dockerignore
Quando lanci una build, Docker invia al motore tutto il contenuto della cartella, detto contesto. Senza un file .dockerignore, finiscono nel contesto la cartella .git, le dipendenze locali, i file temporanei e, nel caso peggiore, file .env con password reali. Un esempio minimo da cui partire:
.git
node_modules
__pycache__
*.log
.env
Le build diventano più veloci e si evita di incorporare segreti nell'immagine, dove chiunque possa scaricarla potrebbe leggerli.
Altre accortezze utili
- Unisci i comandi correlati: installare pacchetti e pulire la cache dello stesso gestore nello stesso
RUNevita che i file temporanei restino in un layer. - Usa la forma JSON per
CMDedENTRYPOINT: così il processo riceve correttamente i segnali di arresto e il container si ferma in modo pulito. - Non mettere segreti in
ENVoARG: restano visibili nella cronologia dell'immagine. Vanno passati a runtime. - Aggiungi un HEALTHCHECK quando l'orchestratore non ne prevede uno proprio, per sapere se l'applicazione risponde davvero e non solo se il processo è vivo.
- Etichetta le immagini con la versione o l'identificativo del commit, non solo con
latest, così sai sempre cosa sta girando.
Verificare il risultato
Dopo la build controlla la dimensione con docker images e i layer con docker history: un layer inaspettatamente grande di solito indica un file copiato per errore o una cache non ripulita. Uno scanner di vulnerabilità, come Trivy o gli strumenti integrati nei registry, segnala le librerie della base che richiedono aggiornamento. Ricostruire periodicamente l'immagine, anche senza modifiche al codice, serve a incorporare le correzioni di sicurezza della base.
In sintesi
- Parti da un'immagine base piccola e con versione fissata, mai da
latest. - Ordina le istruzioni da quelle stabili a quelle che cambiano spesso per sfruttare la cache.
- Usa build multi-stage per lasciare fuori dall'immagine finale compilatori e strumenti.
- Esegui l'applicazione con un utente non root e tieni i segreti fuori dall'immagine.
- Controlla dimensione e vulnerabilità, e ricostruisci periodicamente per applicare gli aggiornamenti.