Ottimizza le Immagini Docker con Build Multi-Stage: Una Guida Completa

Padroneggia i build multi-stage di Docker per ridurre drasticamente le dimensioni delle tue immagini, accelerare i deployment e migliorare la sicurezza. Questa guida completa fornisce istruzioni passo-passo, esempi pratici per Go e Node.js e le migliori pratiche essenziali. Impara come ottimizzare i tuoi Dockerfile separando le dipendenze di build, assicurando che solo i componenti necessari raggiungano l'immagine runtime finale. Lettura essenziale per chiunque voglia creare applicazioni containerizzate efficienti e sicure.

Ottimizza le Immagini Docker con Build Multi-Stage: Una Guida Completa

I build multi-stage risolvono un problema molto comune di Docker: gli strumenti necessari per costruire un'applicazione di solito non sono gli stessi necessari per eseguirla.

Un compilatore Go, la cache dei pacchetti Node, il repository Maven, il framework di test e gli header di build sono utili durante la build dell'immagine. Sono peso morto nell'immagine runtime. Rallentano i pull, aumentano la quantità di software da aggiornare e rendono più difficile capire cosa sta effettivamente girando in produzione.

Con un Dockerfile multi-stage, costruisci in uno stadio e copi solo l'artefatto finito in uno stadio runtime più piccolo. L'immagine finale non eredita lo stadio di build a meno che tu non copi esplicitamente file da esso.

Il Problema con le Immagini a Stadio Singolo

Considera una tipica applicazione Go. Hai bisogno del toolchain Go per compilarla. Una volta che hai un binario Linux, il compilatore non è più necessario. Un'immagine a stadio singolo lo mantiene comunque:

FROM golang:1.21-alpine

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

EXPOSE 8080
CMD ["./myapp"]

Questo funziona, ma l'immagine finale contiene ancora il toolchain Go e la cache di build. Lo stesso schema si presenta con Node, Java, Rust, pacchetti Python con estensioni native e build frontend.

Il costo è pratico:

  • Aumento delle Dimensioni dell'Immagine: Più layer, più dati da scaricare e archiviare.
  • Tempi di Deployment Estesi: Immagini più grandi richiedono più tempo per il trasferimento.
  • Introduzione di Rischi per la Sicurezza: Una superficie d'attacco più ampia con software non necessario.
  • Oscuramento dell'Ambiente Runtime: Rende più difficile capire cosa è veramente necessario.

Immagini più piccole non sono automaticamente più veloci in runtime, ma sono più veloci da spostare attraverso CI, registry e sistemi di deployment. Rendono anche la revisione della sicurezza meno rumorosa.

Cosa Fanno i Build Multi-Stage

Ogni istruzione FROM avvia un nuovo stadio. Puoi nominare uno stadio e copiare file da esso in seguito:

FROM golang:1.21-alpine AS builder
# costruisci file qui

FROM alpine:3.20
COPY --from=builder /app/myapp /app/myapp

Il secondo stadio parte da zero. Non contiene /usr/local/go, file sorgente, cache di pacchetti o strumenti di build dal primo stadio a meno che tu non li copi.

Un Esempio Pulito in Go

Ecco una piccola applicazione:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Ciao dall'immagine Docker ottimizzata!")
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("Server in avvio su :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Il Dockerfile multi-stage:

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/myapp /app/myapp

EXPOSE 8080
CMD ["/app/myapp"]

I file go.mod e go.sum vengono copiati prima dell'intero albero dei sorgenti in modo che Docker possa riutilizzare il layer di download delle dipendenze quando cambia solo il codice dell'applicazione. CGO_ENABLED=0 è utile quando si vuole un binario statico. Se la tua applicazione dipende da librerie C, potresti aver bisogno di un'immagine runtime che includa quelle librerie invece di forzare build statiche.

Costruisci e confronta:

docker build -t go-app:multi-stage .
docker images go-app:multi-stage
docker history go-app:multi-stage

Non fare affidamento sulla dimensione dell'esempio di un blog. Controlla la tua immagine. Le scelte delle dipendenze, le versioni delle immagini di base, i simboli di debug, i certificati, i dati del fuso orario e le librerie native influenzano tutte il risultato.

Scelte dell'Immagine di Base Runtime

alpine è popolare perché è piccola, ma piccola non è sempre uguale a compatibile. Alpine usa musl libc, mentre molte distribuzioni Linux comuni usano glibc. La maggior parte dei binari Go statici funziona bene. Alcuni pacchetti Python, Node, Java o nativi si comportano diversamente.

Opzioni runtime comuni:

Base runtime Buona adattabilità Compromesso
alpine Immagini piccole, binari semplici Differenze di compatibilità musl
debian:bookworm-slim Ampia compatibilità Linux Più grande di Alpine
Immagini Distroless Runtime di produzione con meno strumenti Più difficile eseguire il debug all'interno del contenitore
scratch Solo binari statici Nessuna shell, certificati CA o gestore pacchetti a meno che non vengano copiati

Se l'app chiama endpoint HTTPS, assicurati che l'immagine finale includa i certificati CA. Un'immagine scratch senza certificati può fallire in un modo che sembra un problema di rete.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/myapp"]

Build Multi-Stage per Altri Linguaggi/Framework

La stessa idea funziona ovunque ci sia un passaggio di build.

Per un frontend Node:

FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Per un'API Node, non copiare node_modules da un'installazione di sviluppo se include dipendenze di sviluppo:

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

Per Java:

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

Anche la Cache di Build è Importante

I build multi-stage riducono la dimensione finale dell'immagine, ma l'ordine del Dockerfile controlla ancora il comportamento della cache. Metti i file di dipendenza stabili prima dei file sorgente volatili. Usa npm ci invece di npm install in build riproducibili. Fissa le versioni delle immagini di base invece di fare affidamento su latest in produzione.

Con BuildKit, i mount della cache possono velocizzare i download dei pacchetti senza incorporare le cache nell'immagine finale:

# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -o myapp

Quella cache è per la macchina di build, non per l'immagine runtime.

Cosa Copiare e Cosa Non Copiare

Copia il set runtime completo più piccolo. Per un servizio compilato, potrebbe essere un binario più modelli di configurazione e certificati CA. Per un frontend, potrebbe essere una directory dist. Per Java, potrebbe essere un jar più una JRE.

Non copiare codice sorgente, cache del gestore pacchetti, fixture di test, file .env locali, chiavi SSH o output di build che non esegui. Usa un file .dockerignore in modo che questi file non entrino nel contesto di build in primo luogo:

.git
node_modules
coverage
dist
*.log
.env

Il file .dockerignore non sostituisce istruzioni COPY attente, ma previene il gonfiamento accidentale del contesto e le fughe di segreti.

Debug dei Build Multi-Stage

Dai un nome ai tuoi stadi. Uno stadio con nome è più facile da targetizzare:

docker build --target builder -t app-builder .
docker run --rm -it app-builder sh

Questo è utile quando la build riesce ma l'immagine finale fallisce perché un file è stato copiato nel percorso sbagliato o manca una libreria runtime.

Puoi anche ispezionare i file copiati nell'immagine finale:

docker run --rm -it --entrypoint sh my-image

Se l'immagine non ha shell, passa temporaneamente lo stadio finale a una base adatta al debug durante la diagnosi, poi rimetti la base di produzione.

Una Regola Pratica

Usa uno stadio per ogni lavoro distinto: dipendenze, build, test, runtime. Mantieni lo stadio runtime noioso. Se qualcuno apre lo stadio finale del Dockerfile, dovrebbe essere in grado di rispondere rapidamente a una domanda: di quali file ha bisogno questo contenitore per funzionare?

Questo è il vero valore dei build multi-stage. Immagini più piccole sono belle. Confini runtime chiari sono migliori.