Architettura di dati e sistemi, dalle fondamenta Lezione 54 / 80

Container: Docker per i lavori sui dati

Pattern di Dockerfile, multi-stage build, la base image giusta, gli image registry. I fondamentali sui container che ogni data engineer dovrebbe conoscere.

Un lavoro sui dati nel 2026 quasi sicuramente gira dentro un container. L’executor di Spark su Kubernetes, il task di Airflow che fa il lavoro pesante, la materializzazione di un asset Dagster, la dbt run lanciata da una pipeline CI: sono tutti container, partiti da immagini, schedulati da qualcosa. Anche le piattaforme gestite (Databricks, Snowpark di Snowflake, AWS Glue) sotto sono container; semplicemente non vedi le cuciture.

Questa lezione raccoglie i fondamentali sui container che ogni data engineer dovrebbe conoscere. Il modello mentale, i pattern di Dockerfile che contano, le cose che vanno storte sulla strada verso un’immagine piccola e riproducibile, i registry dove le immagini vivono, e i casi d’uso specifici per i dati che mettono insieme tutto il quadro.

I container e cosa non sono

Un container non è una virtual machine. La distinzione conta perché modella sia i costi sia i modi di rompersi.

Una virtual machine ha il suo kernel, il suo processo di boot, la sua allocazione di memoria, spesso gigabyte di disco. L’hypervisor finge che la VM sia un computer vero. Avviare una VM richiede minuti; far girare dieci VM su una macchina costa dieci volte l’overhead del kernel.

Un container condivide il kernel dell’host. È un processo (o un piccolo gruppo di processi) che gira in namespace isolati, con un filesystem tutto suo e un limite di CPU/memoria imposto dai cgroup. Avviare un container richiede millisecondi. Far girare cinquanta container su una macchina è normale. L’isolamento è più debole di quello di una VM (un exploit del kernel scappa da qualunque container; nella VM resta dentro), ma per il caso d’uso “voglio far girare il mio codice in un ambiente riproducibile”, i container sono la primitiva giusta.

I due concetti chiave da tenere distinti sono l’image e il container. L’image è l’artefatto immutabile: un filesystem a layer con un file di metadati che descrive come avviare un processo al suo interno. Il container è un’istanza in esecuzione di un’image, con sopra il suo layer scrivibile. Le image si costruiscono. I container si eseguono. Un nuovo container partito dalla stessa image parte dallo stesso stato pulito.

Docker è l’implementazione originale di queste idee, dominante dal 2013. La Open Container Initiative ha standardizzato i formati in modo che le image costruite con Docker girino ovunque si parli OCI: containerd, podman, CRI-O, ogni servizio container del cloud. Quando i data engineer dicono “Docker image” di solito intendono “OCI image”; i formati sono gli stessi.

Il Dockerfile

Un Dockerfile è la ricetta per costruire un’image. Ogni istruzione crea un layer nel filesystem dell’image. I layer sono in cache, quindi se cambi un’istruzione, solo i layer da quel punto in giù vengono ricostruiti.

Un Dockerfile ingenuo per un lavoro Python sui dati:

FROM python:3.13
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "job.py"]

Funziona, ed è sbagliato in tre modi diversi. La base image è più grande del necessario. L’ordine dei layer fa sì che un cambio di codice invalidi il layer di installazione delle dipendenze. Non c’è alcun pinning di versione. Sistemeremo tutti questi problemi.

Buone pratiche che si ripagano

Usa base image specifiche e minimali. python:3.13-slim invece di python:latest. Le varianti slim rimuovono i build tool e la maggior parte dei pacchetti di sistema, tenendo solo quello che serve a far girare un interprete Python. Il tag latest è un bersaglio mobile; fissa una versione specifica così una rebuild fra mesi produce lo stesso artefatto. Per image ancora più piccole esistono varianti distroless o Alpine, con il compromesso che alcune wheel Python non hanno binari precompilati per loro, e ti tocca ricadere sulla compilazione dai sorgenti.

Il costo di scegliere una base image più piccola si compone. Un’image da 50 MB si scarica più in fretta, si scansiona per vulnerabilità più in fretta, ha una superficie d’attacco più piccola, e costa meno in storage del registry. Per un job che gira su un centinaio di executor Spark, quei 50 MB vengono moltiplicati per cento ogni volta che il cluster si avvia.

Ordina i layer per frequenza di cambiamento. Le cose che cambiano raramente (pacchetti dell’OS, dipendenze di sistema, interprete Python) vanno verso l’alto del Dockerfile. Le cose che cambiano spesso (il tuo codice) vanno verso il basso. La build cache di Docker invalida un layer e tutto quello che sta sotto quando cambiano i suoi input. Se copi il codice prima di installare le dipendenze, ogni cambio di codice invalida l’install delle dipendenze, e la tua build impiega dieci minuti quando potrebbe impiegarne dieci secondi.

L’ordine convenzionale per Python:

FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "job.py"]

La copia di requirements.txt è uno step a sé; il pip install è uno step a sé. La copia del codice viene dopo. Modificare un file Python ricostruisce solo gli ultimi due layer, in millisecondi.

Pin delle versioni delle dipendenze. requirements.txt con gli hash, oppure un lockfile di uv, oppure il poetry.lock di Poetry, oppure pip-tools. Qualunque sia lo strumento, il lockfile è il contratto: questo esatto albero di dipendenze, ogni volta. Un requests non pinnato dentro le tue requirements diventa una versione diversa il mese dopo, e la tua image è improvvisamente diversa in modi che nessuno ha chiesto.

Non girare come root. Il default nella maggior parte delle base image è root, ed è un piede su cui spararsi. Un processo compromesso che gira come root dentro un container ha probabilità molto maggiori di scappare o muoversi lateralmente rispetto allo stesso processo che gira come utente non privilegiato. Le due righe extra:

RUN useradd --create-home --shell /bin/bash appuser
USER appuser

vicino alla fine del Dockerfile spostano il processo in esecuzione su un utente non-root. Per la maggior parte dei lavori sui dati questo non costa nulla e rimuove un’intera categoria di rischio.

Usa .dockerignore. Il build context è tutto quello che sta nella directory da cui hai lanciato docker build; senza un .dockerignore, una node_modules, una .venv o una directory data/ smarrita possono gonfiare il build context fino ai gigabyte. Il file funziona come .gitignore. Aggiungi le voci ovvie: .git, __pycache__, *.pyc, .venv, node_modules, qualunque directory locale di dati.

Multi-stage build

Il singolo miglioramento più grande per le dimensioni dell’image è il multi-stage build. L’idea: un solo Dockerfile definisce due (o più) image, dove gli stage successivi copiano artefatti da quelli precedenti, e solo lo stage finale diventa l’image pubblicata.

Un pattern tipico per un job Python che ha uno step di build (compilazione di estensioni C, generazione di file):

FROM python:3.13 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.13-slim AS runtime
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl
COPY . .
RUN useradd --create-home appuser
USER appuser
CMD ["python", "job.py"]

Lo stage builder ha l’image Python piena con i compilatori. Costruisce le wheel. Lo stage runtime riparte fresco da python:3.13-slim e copia dentro solo le wheel. L’image finale non ha compilatori, non ha build tool, niente cache. Più piccola, più veloce da scaricare, meno cose da scansionare.

Il multi-stage brilla per i linguaggi con step di build espliciti: Go, Java, Rust, TypeScript che compila a JavaScript. Per il lavoro pure-Python il risparmio è più piccolo ma reale, perché un pip install da wheel precompilate nello stage di runtime è più veloce e leggero di un’installazione completa.

Image registry

Un’image deve vivere da qualche parte perché il cluster la scarichi. Un registry è il livello di storage e distribuzione: Docker Hub, GitHub Container Registry, GitLab Registry, AWS ECR, Google Artifact Registry, Azure ACR, JFrog, Harbor.

La scelta dipende per lo più da dove vive il resto dell’infrastruttura. I workload AWS scaricano da ECR. Quelli GCP da Artifact Registry. I team che gravitano intorno a GitHub usano GitHub Container Registry, che si autentica con gli stessi token del resto di GitHub. Docker Hub va bene per le image open-source ma ha rate limit che colpiscono in fretta i cluster di produzione; per la produzione usa un registry cloud specifico.

La strategia di tagging conta più di quanto la gente non realizzi. Il tag latest è mutabile; non usarlo in produzione. Usa tag immutabili: una SHA di commit git, una semver, un build number. Il cluster scarica un tag specifico, il cluster esegue un’image specifica, il deploy è riproducibile. Se mai dovessi fare rollback, ridispieghi il tag precedente e sai esattamente cosa gira.

Firma le tue image e scansionale. Cosign per la firma, Trivy o Grype per la scansione. La firma dà al cluster un modo per rifiutare image che non sono uscite dalla tua CI; lo scanner ti dà una lista di vulnerabilità note nelle tue dipendenze. Entrambi dovrebbero girare in CI prima che l’image venga pushata.

Cosa significa per i lavori sui dati

Docker compare nella data engineering in tre posti principali.

Spark su Kubernetes è l’esempio più pulito. Il driver Spark e gli executor girano come pod, partiti da un’image custom che contiene il tuo codice PySpark, le tue dipendenze, e la distribuzione di Spark. L’image viene costruita in CI, pushata su un registry, referenziata nel CR SparkApplication o nel comando spark-submit. La stessa image gira in dev, in CI, e in prod, con configurazioni diverse. L’image è l’unità di deploy. La lezione 55 copre in dettaglio il lato Kubernetes.

Gli operator di Airflow che eseguono codice utente in container sono il secondo posto. Il KubernetesPodOperator è l’esempio canonico: lo scheduler di Airflow chiede a Kubernetes di lanciare un pod da un’image specifica, con argomenti specifici, e aspetta che finisca. Il DockerOperator fa la stessa cosa per Docker puro. Il vantaggio è l’isolamento totale: le dipendenze di un task non interferiscono con quelle di un altro. Il costo è il piccolo overhead per-task di avviare un container.

Lo sviluppo locale riproducibile è il terzo posto, ed è quello più sotto-utilizzato. La stessa image che fai girare in produzione può girare sul portatile di uno sviluppatore, con la stessa versione di Python, le stesse dipendenze, lo stesso codice. I bug che spuntano solo in produzione non capitano più, perché l’ambiente di dev è l’ambiente di produzione. Un comando docker run che monta la directory del codice e avvia il job in locale è il tipo di piccola cosa che cambia il modo in cui un team lavora.

Il ciclo di build-and-deploy lega tutto insieme:

flowchart TB
    CODE[Dockerfile plus code] --> BUILD[docker build]
    BUILD --> SCAN[Scan and sign]
    SCAN --> PUSH[Push to registry]
    PUSH --> PULL[Cluster pulls image]
    PULL --> RUN[Job runs]
    RUN --> METRICS[Metrics and logs]

Una piccola checklist per il Dockerfile

Prima di mergiare un Dockerfile, le cose da cercare:

  • Base image specifica e slim con versione pinnata.
  • Multi-stage build se ci sono step di compilazione.
  • Layer ordinati da quelli che cambiano raramente a quelli che cambiano spesso.
  • Lockfile delle dipendenze copiato prima del codice, installato prima che il codice sia copiato.
  • Utente non-root per il processo in esecuzione.
  • .dockerignore che esclude la spazzatura ovvia.
  • Tag immutabile nel nome pubblicato.
  • Health check o entrypoint conosciuto-buono.
  • Nessun secret cotto dentro l’image (variabili d’ambiente a runtime, non build args).

Ogni voce è piccola. Insieme sono la differenza fra un’image che pesa quaranta megabyte e si scarica in un secondo e un’image che pesa due gigabyte e ci mette un minuto, moltiplicata per quante copie il tuo cluster sta facendo girare.

Cosa significa in pratica

I container sono l’unità di deploy. Il Dockerfile è il contratto fra il codice che scrivi e il runtime che lo esegue. Un Dockerfile piccolo e ben fatto è uno dei pezzi di codice ad alta leva nel repo di un team dati: ogni job ne eredita le proprietà.

La buona notizia è che i pattern sono stabili. Un Dockerfile scritto oggi per un job Python continuerà a funzionare fra cinque anni; il cloud sotto cambierà, l’orchestratore cambierà, i registry cambieranno, ma il Dockerfile e l’OCI image restano gli stessi. Di tutti i layer del moderno data stack, questo è uno dei più noiosi, nel senso migliore.

La lezione 55 ci costruisce sopra con Kubernetes, il runtime su cui ormai la maggior parte dei team fa girare i container. La lezione 56 chiude il modulo con la storia di deployment di Stripe, che intreccia CI, CD, IaC, e container in un unico quadro operativo.

Riferimenti

  • Docker documentation, “Best practices for writing Dockerfiles” (https://docs.docker.com/build/building/best-practices/, consultato 2026-05-01).
  • Open Container Initiative specifications (https://opencontainers.org/, consultato 2026-05-01).
  • “Multi-stage builds” nella documentazione Docker (https://docs.docker.com/build/building/multi-stage/, consultato 2026-05-01).
  • Spark on Kubernetes documentation (https://spark.apache.org/docs/latest/running-on-kubernetes.html, consultato 2026-05-01).
  • Cosign project (https://github.com/sigstore/cosign, consultato 2026-05-01).
Cerca