← Blog
·10 blog.minutes

Docker e Kubernetes: Una Guida Pratica per Sviluppatori Moderni

Una guida senza fronzoli alla containerizzazione, alle migliori pratiche dei Dockerfile, ai fondamenti di Kubernetes e a quando hai realmente bisogno di un orchestratore.

Ogni sviluppatore incontra prima o poi lo stesso muro. Scrivi codice sulla tua macchina, funziona perfettamente, lo pubblichi in staging ed esplode con un errore criptico su una dipendenza di sistema mancante o una versione diversa di una libreria. Il classico problema "funziona sulla mia macchina" ha perseguitato lo sviluppo software per decenni, e Docker non lo ha solo risolto — ha reso la soluzione cosi' semplice che ora non c'e' piu' scusa per non usarlo.

Ma Docker risolve solo il problema del confezionamento. Una volta che hai containerizzato la tua applicazione, devi ancora eseguirla in produzione — potenzialmente su piu' server, con bilanciamento del carico, deployment a downtime zero, health check e recupero automatico. Ecco dove entra in gioco Kubernetes. Ed e' anche dove la maggior parte degli sviluppatori si perde nella complessita', perche' Kubernetes introduce un vocabolario completamente nuovo di astrazioni che richiede tempo per essere interiorizzato.

Questa guida taglia il rumore. Copre cosa sono realmente i container sotto il cofano, come scrivere Dockerfile che siano efficienti e sicuri, i concetti di Kubernetes che devi conoscere per distribuire applicazioni reali, i compromessi tra Docker Compose e Kubernetes per lo sviluppo locale e — cosa piu' importante — quando dovresti usare ogni strumento e quando dovresti lasciarlo stare.

Cosa Sono Realmente i Container

Un container non e' una macchina virtuale leggera. Questo e' l'equivoco piu' comune e porta a modelli mentali errati. Una macchina virtuale esegue un sistema operativo guest completo sopra un hypervisor, con il proprio kernel, la propria allocazione di memoria e i propri driver di dispositivo. Un container condivide il kernel host e funge da processo userspace isolato. L'isolamento e' fornito da funzionalita' del kernel Linux — namespace per l'isolamento dei processi, cgroups per i limiti delle risorse e overlay filesystem per layer di immagine efficienti.

Questa distinzione conta perche' spiega il comportamento che osserverai. I container si avviano in millisecondi perche' non c'e' un kernel da avviare. Usano meno memoria perche' non c'e' un kernel duplicato e nessun processo di sistema ridondante. Ma significa anche che un container in esecuzione su Linux non puo' eseguire una versione del kernel diversa dall'host, e un container Windows richiede un host Windows (o una VM Linux Hyper-V su versioni precedenti). Su macOS, Docker Desktop esegue container Linux all'interno di una VM leggera proprio per questa ragione.

Un'immagine e' il template di sola lettura — un'istantanea di un filesystem piu' metadati. Un container e' un'istanza in esecuzione di quell'immagine, con uno strato scrivibile sopra. Puoi costruire un'immagine una volta ed eseguire dozzine di container da essa. Questa e' l'unita' fondamentale di operazione nel mondo Docker, e comprenderla chiaramente rende tutto il resto piu' facile.

Migliori Pratiche per i Dockerfile

Un Dockerfile e' una ricetta per costruire un'immagine. Ogni istruzione crea un nuovo layer, e i layer vengono memorizzati nella cache. Questo significa che l'ordine delle istruzioni influisce direttamente sulla velocita' di build, sulla dimensione dell'immagine e sulla sicurezza. Ecco i principi che contano di piu' nei progetti reali.

Ordina i Layer per Frequenza di Modifica

Docker memorizza nella cache ogni layer dopo che e' stato costruito. Se un layer non e' cambiato dall'ultima build, Docker riutilizza la versione cache. Questo significa che dovresti mettere le istruzioni che cambiano raramente in cima e quelle che cambiano frequentemente in fondo. Le dipendenze di sistema (apt-get, apk add) non cambiano quasi mai. Le dipendenze dell'applicazione (npm install, pip install) cambiano quando aggiorni il lockfile. Il codice sorgente dell'applicazione cambia a ogni commit.

# Bad: source code before dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .                 # busts the cache for everything below
RUN npm ci                # runs on every build, even if package.json did not change
EXPOSE 3000
CMD ["node", "dist/index.js"]

# Good: stable-first layer ordering
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                # cached unless package.json changes
COPY . .                  # only the source changes bust this layer
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

La differenza e' drammatica. Il Dockerfile cattivo ricostruisce tutte le dipendenze a ogni commit. Quello buono ricostruisce le dipendenze solo quando il lockfile cambia, che tipicamente e' una volta per pull request piuttosto che a ogni commit. Su un progetto Node.js con 500 dipendenze, questo puo' risparmiare due minuti per build.

Build Multi-Stage

Le build multi-stage ti permettono di usare un unico Dockerfile per costruire la tua applicazione e produrre un'immagine runtime minima. Lo stadio di build contiene compilatori, dipendenze di sviluppo e strumenti di build. Lo stadio runtime copia solo l'output compilato. Questo mantiene le immagini di produzione piccole e riduce la superficie d'attacco.

# Build stage
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage — starts from a fresh, minimal base
FROM node:20-alpine AS runner
WORKDIR /app

# Only what is needed to run
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/package.json ./
COPY --from=builder /build/node_modules ./node_modules

EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

Lo stadio runtime non include il compilatore TypeScript, i file sorgente o le dipendenze di sviluppo. Per un'applicazione tipica, questo riduce l'immagine da 800 MB a meno di 200 MB. La sintassi COPY --from=builder e' l'intuizione chiave — preleva file da uno stadio precedente senza trasportare i layer di quello stadio.

Esegui Come Utente Non-Root

I container vengono eseguiti come root per impostazione predefinita. Questo e' un rischio di sicurezza: se un attaccante sfrutta la tua applicazione, ha accesso root all'interno del container. La soluzione e' una singola riga nel tuo Dockerfile che passa a un utente non-root. La maggior parte delle immagini base include un utente node o nobody per questo scopo.

Oltre alla sicurezza, le build multi-stage e un corretto ordinamento dei layer migliorano anche la velocita' della pipeline CI/CD. Ogni minuto risparmiato su una build di immagine e' un minuto che i tuoi sviluppatori non aspettano per un deploy. Su un team di dieci sviluppatori che distribuisce cinque volte al giorno, risparmiare due minuti per build recupera oltre sessanta ore di tempo sviluppatore all'anno.

Fondamenti di Kubernetes

Kubernetes e' un orchestratore di container. Prende un cluster di macchine (nodi), programma i container su di esse, li mantiene in esecuzione, gestisce la rete e fornisce un'API dichiarativa per descrivere lo stato desiderato del tuo sistema. Dici a Kubernetes cosa vuoi — tre repliche del tuo server API, porta 8080 esposta, strategia di aggiornamento rolling — e lui lo realizza.

La curva di apprendimento e' reale perche' Kubernetes introduce un insieme a strati di astrazioni. Le tre con cui interagirai di piu' sono Pod, Deployment e Service.

Pod

Un Pod e' l'unita' distribuibile piu' piccola in Kubernetes. Rappresenta uno o piu' container che condividono un namespace di rete e volumi di storage. In pratica, la maggior parte dei Pod esegue un singolo container. I pattern sidecar (un container principale piu' un container di logging o proxy) usano Pod multi-container, ma per il deployment applicativo quotidiano, userai un container per Pod.

Raramente crei Pod direttamente. I Pod sono effimeri — possono essere terminati e riprogrammati in qualsiasi momento. Se crei un Pod manualmente e il nodo su cui e' in esecuzione fallisce, il Pod e' perso per sempre. Ecco dove entrano in gioco i Deployment.

Deployment

Un Deployment gestisce un insieme di Pod identici (un ReplicaSet). Gestisce aggiornamenti rolling, scaling, auto-riparazione e rollback. Questa e' la risorsa che userai per distribuire applicazioni stateless.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  labels:
    app: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      containers:
        - name: api
          image: myregistry/api-server:v1.2.3
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 250m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3000
          readinessProbe:
            httpGet:
              path: /ready
              port: 3000

Questo Deployment dichiara tre repliche del server API. Kubernetes garantira' che tre Pod siano sempre in esecuzione. Se un Pod crasha, Kubernetes crea un sostituto. Durante un aggiornamento rolling (cambiando il tag dell'immagine), Kubernetes sostituisce i Pod uno alla volta, garantendo zero downtime. Il liveness probe dice a Kubernetes quando un Pod e' sano; il readiness probe dice quando un Pod e' pronto per ricevere traffico.

Service

I Pod hanno indirizzi IP dinamici. Ogni volta che un Pod viene ricreato, ottiene un nuovo IP. Un Service fornisce un endpoint di rete stabile che bilancia il traffico attraverso i Pod che corrispondono al suo selettore. E' cosi' che altre parti del tuo sistema trovano e comunicano con la tua applicazione.

apiVersion: v1
kind: Service
metadata:
  name: api-server
spec:
  selector:
    app: api-server
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP

Questo Service mappa la porta 80 su un IP stabile interno al cluster sulla porta 3000 dei Pod con l'etichetta app: api-server. Altri servizi all'interno del cluster possono raggiungerlo tramite il nome DNS api-server. Per il traffico esterno, useresti una risorsa LoadBalancer o Ingress sopra il Service.

Kubernetes non e' una piattaforma su cui distribuisci. E' una piattaforma a cui descrivi la tua distribuzione. La differenza tra imperativo e dichiarativo e' il singolo cambiamento mentale piu' importante che puoi fare.

Sviluppo Locale: Docker Compose vs Kubernetes

L'errore piu' grande che i team commettono e' presumere di aver bisogno di Kubernetes per lo sviluppo locale perche' lo usano in produzione. Docker Compose e Kubernetes servono scopi diversi, e scegliere quello sbagliato per il lavoro locale crea attrito inutile.

Docker Compose e' progettato per lo sviluppo locale. Funziona su una singola macchina, avvia i container in secondi e ha un formato YAML semplice che mappa direttamente a cio' di cui hai bisogno: un server web, un database, un'istanza Redis e forse un worker per le code. Definisci i servizi, e docker compose up mette tutto online, con i log in streaming sul tuo terminale, le porte mappate su localhost e l'hot reload funzionante subito.

version: "3.8"
services:
  api:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/app
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Questo file Compose ti da' un ambiente di sviluppo funzionante con hot reload, un database locale con storage persistente e networking appropriato tra i servizi. Richiede circa trenta righe di YAML e si avvia in meno di dieci secondi.

Minikube, Kind e k3s possono eseguire Kubernetes localmente, ma aggiungono un overhead significativo. Richiedono piu' memoria, impiegano piu' tempo ad avviarsi e introducono complessita' (ingress controller, service mesh, storage class) di cui semplicemente non hai bisogno quando stai iterando su una singola funzionalita'. Eseguire Kubernetes localmente e' utile per testare comportamenti specifici di Kubernetes — come le politiche di evacuazione dei pod, l'autoscaling orizzontale dei pod o le definizioni di risorse personalizzate — ma non e' un sostituto di Compose nello sviluppo quotidiano.

  • Usa Docker Compose per lo sviluppo locale. E' veloce, semplice e mappa direttamente ai container che esegui.
  • Usa Kubernetes (tramite Minikube o Kind) per i test di integrazione quando la tua infrastruttura di produzione usa funzionalita' Kubernetes come ConfigMap, Secret o controller personalizzati.
  • Usa un cluster di sviluppo remoto solo quando hai bisogno di accesso GPU, hardware specializzato o un ambiente di staging condiviso che rispecchi esattamente la produzione.
  • Non eseguire due cluster Kubernetes localmente solo perche' hai due ambienti. Compose gestisce questo con un singolo flag --profile.
  • Se il tuo team passa piu' tempo a fare debug di configurazioni Kubernetes che a scrivere codice applicativo, hai superato i tuoi fari. Torna a Compose e aggiungi complessita' solo quando il dolore di non averla supera il dolore di mantenerla.

Insidie Comuni e Come Evitarle

Anche dopo aver compreso i concetti, certi errori si ripetono tra i team. Ecco quelli che vale la pena memorizzare per saltare le sessioni di debug di due giorni.

Il caos dei tag delle immagini e' il problema di produzione piu' comune. Usare latest come tag dell'immagine in un Deployment Kubernetes significa che non puoi dire quale versione sia in esecuzione su un dato nodo. Kubernetes scarica un'immagine solo se non e' presente sul nodo, quindi latest su un nodo potrebbe essere una versione diversa da latest su un altro. Usa sempre tag di versione semantica o SHA di commit. Meglio ancora, usa un digest di immagine completamente qualificato — e' l'unica cosa che e' garantita essere immutabile.

Le richieste e i limiti di risorse sono frequentemente omessi o impostati arbitrariamente. Se non imposti le richieste, Kubernetes non puo' programmare i tuoi Pod in modo intelligente e i nodi diventano sovraccarichi. Se non imposti i limiti, una perdita di memoria in un Pod puo' crashare altri Pod sullo stesso nodo. Imposta entrambi e usa strumenti come Vertical Pod Autoscaler in modalita' raccomandazione per ottimizzarli in base all'uso effettivo.

ConfigMap e Secret vengono montati come variabili d'ambiente o file. Le variabili d'ambiente sono convenienti, ma qualsiasi modifica richiede un riavvio del Pod per avere effetto. I mount basati su file possono essere aggiornati senza riavviare (il Pod legge il nuovo contenuto quando il file viene acceduto), ma molte applicazioni memorizzano la configurazione nella cache all'avvio. Sapere quale pattern usa la tua applicazione e progettare il tuo approccio alla configurazione di conseguenza.

I volumi persistenti in Kubernetes non sono magici. Un PersistentVolumeClaim richiede storage, ma la classe di storage sottostante deve essere configurata per il tuo cloud provider. Le classi di storage predefinite possono usare storage collegato in rete che ha caratteristiche di performance diverse dagli SSD locali. Se le performance del tuo database contano, fai un benchmark della tua classe di storage prima di andare in produzione.

Il logging e il debug in Kubernetes e' piu' difficile che su un singolo server. I Pod sono effimeri, quindi i log scompaiono quando un Pod viene eliminato. Usa kubectl logs --tail=50 -f nome-pod per il tailing live, ma per il debug in produzione, hai bisogno di una soluzione di logging centralizzata (Loki, Elasticsearch o un servizio di logging cloud). Analogamente, entra in un Pod con kubectl exec -it nome-pod -- sh per ispezionare un container in esecuzione, ma ricorda che qualsiasi modifica apportata all'interno di un container in esecuzione viene persa al riavvio.

Hai Davvero Bisogno di Kubernetes?

Questa e' la domanda che nessuno vuole fare perche' Kubernetes ha un bell'aspetto sul curriculum di un ingegnere e segnala maturita' operativa. Ma Kubernetes e' una soluzione a un problema specifico: eseguire piu' servizi containerizzati su piu' macchine con recupero automatico, scaling e deployment rolling. Se hai uno o due servizi in esecuzione su un singolo server, Kubernetes e' eccessivo.

Ecco un quadro decisionale semplice. Se stai distribuendo una singola applicazione che serve meno di diecimila richieste al secondo, un singolo server con Docker Compose in produzione (si', Compose funziona bene in produzione per molti carichi di lavoro) ti servira' bene. Aggiungi un reverse proxy come Caddy o Nginx, imposta backup automatici e hai un sistema di produzione che un singolo sviluppatore puo' capire e mantenere nella sua interezza.

Passa a Kubernetes quando hai piu' servizi che devono essere distribuiti indipendentemente, quando hai bisogno di scaling per servizio (la tua API ha bisogno di dieci repliche ma il tuo worker solo due), quando hai bisogno di deployment a downtime zero come operazione di routine, o quando il tuo team ha almeno una persona la cui responsabilita' principale e' l'infrastruttura. Prima che queste condizioni siano soddisfatte, il costo operativo di Kubernetes — sia nella gestione del cluster che nel carico cognitivo dello sviluppatore — e' un netto negativo.

Molti team beneficiano di una via di mezzo. Usa Docker Compose per lo sviluppo locale e una piattaforma container gestita come AWS App Runner, Google Cloud Run o Fly.io per la produzione. Queste piattaforme ti danno deployment di container, HTTPS automatico e scaling senza richiederti di gestire un control plane Kubernetes. Ottieni la maggior parte dei benefici della containerizzazione con nessuna della curva di apprendimento di Kubernetes.

La migliore strategia infrastrutturale e' quella che permette al tuo team di rilasciare funzionalita'. Docker e Kubernetes sono strumenti, non identita'. Usali quando aiutano e saltali quando non lo fanno.