Container: capire immagini, container e isolamento
Un container è un processo (o un gruppo di processi) che gira sul tuo computer ma vede un mondo suo: un filesystem proprio, una rete propria, un elenco di processi proprio. Il filesystem esce da un'immagine, un pacchetto di sola lettura che contiene tutto ciò che serve al programma: l'interprete Python, le librerie, il tuo codice. Il vantaggio riguarda la quarta dipendenza implicita del notebook, il sistema operativo e i pacchetti di sistema: se li impacchetti con l'applicazione, "funziona da me" diventa "funziona dove c'è un motore di container". In questo articolo capisci cosa sono davvero, senza leggende.
Una premessa su come sono stati verificati i comandi. I blocchi che usano Docker sono dichiarati nel testo come uscita dedotta, non eseguita: li ho scritti sulla base della documentazione ufficiale e ne spiego il comportamento, ma non li ho avviati. I blocchi che usano solo strumenti di Linux (unshare, kill, la shell) li ho invece eseguiti davvero, due volte, e mostrano gli stessi meccanismi del kernel su cui Docker si appoggia.
Le parole dei container
Sei termini che compaiono in ogni pagina di documentazione, e che conviene fissare prima dei comandi:
- Kernel: il nucleo del sistema operativo, quello che gestisce memoria, processi e dischi. Come il motore e il telaio di un autobus: sopra possono esserci carrozzerie diverse, ma il motore è uno.
- Immagine: il pacchetto di sola lettura che contiene sistema di base, Python, librerie e codice. Come il file di installazione di un gioco.
- Container: un'istanza in esecuzione di un'immagine. Come il gioco avviato dal file di installazione: puoi avviarne quanti ne vuoi dallo stesso file.
- Layer (strato): ogni istruzione di costruzione di un'immagine aggiunge uno strato di modifiche sopra il precedente, come i fogli trasparenti sovrapposti di un atlante anatomico.
- Volume: una cartella che vive fuori dal container e sopravvive a lui. Come l'armadietto in palestra: la borsa si riporta a casa, ciò che hai lasciato nell'armadietto resta.
- Namespace e cgroup: le due funzioni del kernel Linux su cui si basano i container. I namespace decidono cosa il processo vede (i propri file, i propri processi); i cgroup decidono quanto può usare (memoria, processore).
Le prime quattro voci descrivono gli oggetti che maneggi (immagine, container, strato), le ultime due i meccanismi che li rendono possibili. Se ricordi una sola coppia, ricorda immagine e container: confonderle porta agli errori più comuni, come cercare di salvare un modello dentro un container e trovarlo sparito. Dockerfile, daemon e porte compaiono più avanti, nel punto in cui servono.
La struttura di un'immagine e il container che ne nasce:
La panoramica di Docker definisce il container come un ambiente "loosely isolated", debolmente isolato: l'isolamento esiste ma è una proprietà da configurare, come vedremo.
L'analogia: il container delle navi
Prima dei container standard, caricare una nave significava gestire casse di forme diverse, ognuna con le sue regole. Il container metallico ha cambiato tutto: dentro puoi mettere qualsiasi cosa, ma fuori ha sempre la stessa forma, e gru, camion e navi lo trattano allo stesso modo. Il container software fa lo stesso: dentro c'è la tua applicazione con le sue dipendenze, fuori ha un'interfaccia uniforme (si avvia, si ferma, espone una porta). L'analogia ha un limite importante: il container della nave è una scatola, quello software non è una macchina virtuale. Lo vediamo subito.
Il perché: l'inferno delle dipendenze e l'idea di impacchettare tutto
Immagina di dover far girare sullo stesso server due progetti: uno richiede una versione di una libreria di sistema, l'altro la versione successiva, incompatibile. Con un solo sistema operativo condiviso, uno dei due deve cedere. Chi ha amministrato server conosce questa situazione, chiamata "inferno delle dipendenze" (dependency hell). Gli ambienti virtuali Python (uv e la gestione dei pacchetti) la risolvono per le librerie Python, ma non per ciò che sta sotto: le librerie di sistema, il compilatore, perfino la versione dell'interprete.
Le macchine virtuali risolvono tutto, ma a un prezzo: ogni progetto ottiene un sistema operativo intero, con il suo kernel e la sua memoria riservata. Il container nasce come via di mezzo: isolare i progetti senza duplicare il sistema operativo. Ogni programma vede un mondo suo (i propri file, i propri processi, la propria rete), ma sotto c'è un solo kernel condiviso. È come un condominio rispetto a villette separate: le pareti dividono gli appartamenti, ma le fondamenta sono comuni.
Container non è macchina virtuale
Una macchina virtuale simula un computer intero, con il proprio sistema operativo e il proprio kernel. Un container condivide il kernel dell'host e usa due funzioni del kernel Linux per dargli l'illusione di essere solo: i namespace, che la pagina di manuale namespaces(7) descrive come l'astrazione che fa apparire a un gruppo di processi di avere una propria istanza isolata di una risorsa globale, e i cgroup, che la pagina cgroups(7) descrive come il modo di organizzare i processi in gruppi di cui limitare e misurare l'uso delle risorse. La prima conseguenza si verifica con un comando Docker:
uname -r
docker run --rm python:3.12-slim sh -c 'uname -r; python -V; head -2 /etc/os-release'
# uscita dedotta, non eseguita:
# la prima riga del container è IDENTICA alla riga di `uname -r` dell'host (stesso kernel);
# Python e la distribuzione sono quelli dell'immagine, non quelli dell'host
Il comando avvia un container usa-e-getta (--rm) dall'immagine python:3.12-slim ed esegue tre comandi di seguito. Il primo, uname -r, stampa la versione del kernel: dentro il container è la stessa dell'host, perché non c'è un kernel proprio. Gli altri due mostrano invece versione di Python e nome della distribuzione, che appartengono all'immagine. Conseguenze pratiche: un container parte in frazioni di secondo (nessun sistema operativo da avviare) e pesa molto meno di una macchina virtuale, ma l'isolamento è meno forte, perché un difetto del kernel è condiviso. Un'immagine Linux su un host Linux funziona nativamente; su Windows e macOS Docker Desktop avvia di nascosto una piccola macchina virtuale Linux, che fornisce il kernel.
L'isolamento, visto con i propri occhi
"Un container vede un mondo suo" diventa concreto con i namespace, che puoi provare senza Docker con il comando unshare di util-linux (unshare(1): esegue un programma in nuovi namespace). Il blocco qui sotto è stato eseguito su Linux con util-linux 2.41.3, due volte:
echo "host: $(ls /proc | grep -cE '^[0-9]+$') processi"
unshare -r -p -f --mount-proc sh -c 'echo "namespace: $(ls /proc | grep -cE "^[0-9]+$") processi"'
unshare -r -u sh -c 'hostname prova-ns; hostname'
hostname
id -u; unshare -r sh -c 'id -u'
# host: 557 processi (566 alla seconda esecuzione: cambia in continuazione)
# namespace: 4 processi
# prova-ns
# <nome dell'host>
# 1000
# 0
Che cosa dice, comando per comando. /proc è la cartella speciale di Linux in cui ogni processo ha una sottocartella numerata; contarle equivale a contare i processi visibili. L'host ne ha circa cinquecento (il numero cambia da una macchina e da un'esecuzione all'altra), mentre il processo avviato con -p (nuovo namespace dei processi) e --mount-proc ne vede quattro: la shell e i comandi della sua pipeline, e nulla più. Con -u (nuovo namespace dell'hostname) il nome cambia solo dentro, e l'host non se ne accorge. Con -r (nuovo namespace utente, con corrispondenza dell'utente corrente a root) l'identificativo è 0 dentro e 1000 fuori: è l'idea per cui, in un container, un processo "è root" senza esserlo sull'host. Un container Docker usa gli stessi meccanismi, in più con filesystem e rete propri.
Lo stesso confronto in Docker ha questa forma:
docker run --rm python:3.12-slim sh -c 'echo "hostname: $(hostname)"; echo "processi: $(ls /proc | grep -cE "^[0-9]+$")"; ls /'
# uscita dedotta, non eseguita:
# hostname: un identificativo esadecimale (l'inizio dell'ID del container), diverso da quello dell'host
# processi: un numero piccolo, pochi processi (come nella prova con unshare)
# ls /: un filesystem proprio (bin, etc, usr, var...), quello dell'immagine
Il nome dell'host dentro un container è per impostazione predefinita l'ID abbreviato del container, e il filesystem è quello dell'immagine: per questo un container non vede i tuoi file se non glieli porti con un volume.
Immagine e container: la ricetta e il piatto
L'immagine è immutabile: una pila di layer a sola lettura, la ricetta più gli ingredienti già misurati. Il container è un'istanza in esecuzione di un'immagine, con sopra un sottile strato scrivibile: dalla stessa immagine ne puoi avviare cento. Lo strato scrivibile sparisce quando il container viene rimosso, come mostra la coppia di comandi che segue.
docker run --rm python:3.12-slim sh -c 'echo ciao > /tmp/f.txt; ls /tmp'
docker run --rm python:3.12-slim ls /tmp
# uscita dedotta, non eseguita:
# primo comando: f.txt
# secondo comando: (nessun output: /tmp è vuoto, il file stava nello strato scrivibile del primo container)
Il primo container scrive un file in /tmp e lo elenca; il secondo, avviato dalla stessa immagine, parte da zero e non lo trova. Per un servizio ML questo ha una regola d'oro: un container è usa-e-getta. Non ci salvi dati, modelli addestrati o log che vuoi tenere. Ciò che deve sopravvivere va in un volume o in un servizio esterno.
I comandi che ti servono
| Comando | Cosa fa |
|---|---|
docker run IMMAGINE | Crea e avvia un container |
-d | In background (detached) |
--rm | Rimuove il container quando termina |
-p 8000:8000 | Pubblica la porta: porta_host:porta_container |
-v CARTELLA:/dati | Monta una cartella dell'host nel container |
-e NOME=valore | Passa una variabile d'ambiente (vedi variabili d'ambiente e segreti) |
docker ps | Elenca i container in esecuzione |
docker logs NOME | Mostra l'output del container |
docker exec -it NOME sh | Apre una shell dentro un container in esecuzione |
docker stop NOME | Lo ferma |
docker images | Elenca le immagini locali |
I flag di docker run sono documentati nel riferimento del comando. Quelli che userai di più sono -d e --rm per il ciclo di vita, -p e -v per aprire passaggi verso l'esterno, -e per la configurazione.
Porte e volumi: i due buchi nel muro
Per impostazione predefinita il container è isolato: dall'esterno non si raggiunge e non vede i tuoi file. Due opzioni aprono un passaggio controllato. -p 18000:8000 collega la porta 18000 del tuo computer alla 8000 del container: l'applicazione dentro ascolta su 8000 e non sa nulla del 18000. -v "$PWD/vol:/dati" rende la cartella vol visibile come /dati nel container, e ciò che ci scrive sopravvive.
docker run --rm -v "$PWD/vol:/dati" python:3.12-slim sh -c 'echo ciao > /dati/f.txt'
ls -la vol
# uscita dedotta, non eseguita:
# -rw-r--r-- 1 root root ... f.txt (proprietario: root)
Guarda il proprietario atteso: root. Per impostazione predefinita il processo nel container gira come root (uid=0), e i file che crea nel volume sono di root anche sul tuo computer: poi non riesci a modificarli senza sudo. È una trappola classica, e uno dei motivi per cui nell'articolo sul Dockerfile il servizio gira con un utente non privilegiato. Con unshare -r hai visto sopra un utente che dentro è 0 e fuori è 1000, ma è una mappatura del namespace utente; nel caso del volume il file viene scritto sul disco dell'host con il proprietario numero 0.
Un limite che sorprende: il container è vuoto
docker run --rm python:3.12-slim python -c "import sklearn"
# uscita dedotta, non eseguita:
# ModuleNotFoundError: No module named 'sklearn'
L'immagine base ha Python e quasi nient'altro: è la variante "slim", pensata per essere piccola. Le tue librerie le aggiungi tu costruendo un'immagine tua, ed è l'oggetto dell'articolo sul Dockerfile. Il messaggio è lo stesso ModuleNotFoundError che vedresti con un ambiente virtuale vuoto: il container è un ambiente pulito, che è il suo pregio e il suo costo.
Layer e cache: perché l'ordine conta
Un'immagine si costruisce per layer, uno per istruzione. Docker riusa un layer dalla cache se l'istruzione e i file che usa non sono cambiati; appena uno cambia, tutti i layer successivi si rifanno. Regola pratica: in alto nel Dockerfile ciò che cambia raramente (le dipendenze), in basso ciò che cambia spesso (il tuo codice). Se copi il codice prima di installare le dipendenze, ogni modifica di una riga invalida anche l'installazione, e la ricostruzione ripete minuti di lavoro inutile.
L'astrazione "layer" diventa concreta con docker history, che elenca gli strati di un'immagine con la dimensione di ciascuno:
docker history python:3.12-slim --format '{{.Size}}\t{{.CreatedBy}}'
# schema dell'uscita, uscita dedotta, non eseguita (le dimensioni dipendono dal tag e dalla data):
# <decine di MB> debian.sh --arch ... <- il sistema operativo di base
# <pochi MB> RUN ... apt-get install ... <- pacchetti di sistema necessari a Python
# <decine di MB> RUN ... (compilazione e installazione di Python)
# 0B ENV PYTHON_VERSION=... (variabili: solo metadati)
# 0B CMD ["python3"]
Si legge dal basso verso l'alto, dal più vecchio al più recente. Le istruzioni che pesano 0B sono metadati (variabili d'ambiente, il comando di avvio): non aggiungono file. La dimensione di un'immagine è la somma dei suoi strati, e due immagini che condividono gli strati di base li condividono anche su disco: per questo dieci immagini costruite su python:3.12-slim non pesano dieci volte il suo peso.
Il caso reale: dove un container serve e dove no
Un esercizio onesto è guardare questo repository. I tre servizi (Spring Boot, Angular SSR, FastAPI) non hanno un Dockerfile: git ls-files trova i Dockerfile soltanto fra i prodotti digitali in vendita, non nei servizi del sito. I tre servizi girano come processi sotto systemd su un VPS, con PostgreSQL e Redis installati nativamente. I container compaiono altrove: due file docker-compose in infra/ per strumenti ausiliari (Lighthouse CI e monitoraggio) e i test d'integrazione del backend, che avviano dipendenze vere con Testcontainers (backend/pom.xml dichiara la libreria) e quindi richiedono Docker.
Non è una svista, è un calcolo. Il container risolve "stesso ambiente su molte macchine, rilasciato da persone diverse". Qui c'è una macchina, una persona che rilascia, e un ambiente costruito una volta. Il costo del container (immagini da costruire e conservare, reti, aggiornamenti dell'immagine base, un livello in più nel debug) supera il beneficio. Dove invece serve un servizio effimero e identico, come un database per un test, il container è lo strumento giusto.
| Situazione | Container? |
|---|---|
| Una macchina, un rilasciatore, servizio stabile | Non necessario |
| Più macchine o più persone che rilasciano | Molto utile |
| Servizio da spostare su un cloud gestito | Quasi sempre richiesto |
| Test che richiedono un database vero e usa-e-getta | Ideale |
| Serving di un modello con dipendenze di sistema delicate | Utile: chiude il problema delle librerie native |
La tabella è un criterio, non una regola: la colonna di destra cambia con il numero di macchine e di persone coinvolte, non con la moda del momento. Se la tua situazione è la prima riga, il container è una complicazione che puoi rimandare; se è la seconda o la terza, ti fa risparmiare più di quanto costa.
Un container vero: avviarlo, osservarlo, entrarci
docker run -d --rm --name web -p 18002:8000 python:3.12-slim python -m http.server 8000
Che cosa dice, parola per parola: -d in background; --rm lo rimuove quando termina; --name web gli dà un nome che puoi usare nei comandi successivi; -p 18002:8000 collega la porta 18002 del tuo computer alla 8000 del container; poi l'immagine e infine il comando da eseguire dentro (un server web minimale incluso in Python, in ascolto sulla 8000).
curl -s -o /dev/null -w "http=%{http_code}\n" localhost:18002/
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}'
docker logs web | tail -2
# uscita dedotta, non eseguita:
# http=200
# web python:3.12-slim 0.0.0.0:18002->8000/tcp
# una riga di log del server per la richiesta appena fatta, "GET / HTTP/1.1" 200
Il curl sulla porta del tuo computer riceve 200 perché il traffico arriva al server dentro il container. docker ps mostra nome, immagine e la mappatura delle porte. docker logs riporta ciò che il programma ha scritto sullo standard output: questo è il motivo per cui un servizio containerizzato deve scrivere i log sullo standard output e non in un file interno, perché docker logs e le piattaforme li raccolgono da lì. Per fermarlo, docker stop web: grazie a --rm il container sparisce, e docker ps -a non lo elenca più.
docker exec esegue un comando in un container già avviato, come aprire una finestra sul suo interno (con -it e sh ottieni una shell interattiva). Le immagini minime hanno meno superficie d'attacco e meno peso, ma anche meno strumenti per il debug: in una variante "slim" può mancare perfino un comando come ps, e in quel caso si ispeziona dall'host o si usa un'immagine di diagnosi. Il compromesso è reale.
I limiti: cosa succede quando il container esagera
Un container senza limiti può consumare tutta la memoria dell'host: la documentazione sui limiti di risorse dice che per impostazione predefinita un container non ha vincoli e può usare quanto lo scheduler del kernel gli concede. I cgroup permettono di fissare un tetto. Il comando seguente tenta di allocare 200 MB in un container limitato a 64 MB:
docker run --rm --memory=64m --memory-swap=64m python:3.12-slim \
python -c "x = bytearray(200*1024*1024); print('allocato')"
echo "exit=$?"
# uscita dedotta, non eseguita: nessun "allocato" sullo standard output e exit=137
--memory=64m imposta il tetto; --memory-swap uguale impedisce di sforarlo usando il disco (il riferimento di docker run lo definisce come memoria più swap). Il programma viene terminato dal sistema e il codice d'uscita è 137, che significa 128 + 9: la shell, quando un comando termina per un segnale N, restituisce 128+N (lo dice la pagina di manuale di bash), e il segnale 9 è SIGKILL. Il 137 non dimostra da solo la memoria esaurita, è il segno di un'uccisione con SIGKILL; la causa tipica in un container è appunto il tetto di memoria superato.
Il codice 137 si può riprodurre senza Docker, ed è l'unico pezzo di questo blocco che ho eseguito (due volte):
sh -c 'kill -9 $$'; echo "exit=$?"
# exit=137
La shell figlia si uccide da sola con il segnale 9 ($$ è il proprio numero di processo), e la shell padre legge 128+9. Per un servizio ML la situazione è concreta: un modello grande, caricato più volte (un worker in più) o un batch di dati troppo ampio può superare il limite, e il servizio "sparisce" senza un messaggio d'errore Python. Se vedi un container finito con codice 137, controlla la memoria prima del codice.
Si può restringere ancora: filesystem in sola lettura, rete assente
L'isolamento è una configurazione, non un tutto o niente. Due esperimenti mostrano quanto si può stringere, con le opzioni --read-only (filesystem radice in sola lettura) e --tmpfs (una cartella in memoria) del riferimento di docker run:
docker run --rm --read-only python:3.12-slim sh -c 'touch /x'
docker run --rm --read-only --tmpfs /tmp python:3.12-slim sh -c 'touch /tmp/x && echo "tmp scrivibile"'
# uscita dedotta, non eseguita:
# touch: cannot touch '/x': Read-only file system
# tmp scrivibile
--read-only rende il filesystem del container non scrivibile: se un attaccante riuscisse a eseguire comandi nel servizio, non potrebbe installare nulla né modificare i file del programma. Ma un'applicazione ha spesso bisogno di una cartella temporanea, e --tmpfs /tmp monta una piccola area in memoria solo per quello. Con la rete l'opzione è --network none:
docker run --rm --network none python:3.12-slim python -c "import urllib.request as u; u.urlopen('http://example.com', timeout=3)"
# uscita dedotta, non eseguita: urllib.error.URLError, con un errore di risoluzione del nome
# (il testo esatto dipende dalla versione di Python e dalla libreria di sistema)
--network none toglie ogni accesso alla rete: il programma non riesce nemmeno a risolvere i nomi. Per un job di batch che legge file locali e scrive file locali è un'ottima difesa: anche se compromesso, non ha modo di inviare dati altrove. Sono tre leve (utente non root, filesystem in sola lettura, rete limitata) che stringono progressivamente il perimetro. È la stessa logica delle opzioni NoNewPrivileges, ProtectSystem e PrivateTmp dell'unità systemd portfolio-ml.service di questo sito, ottenuta in un altro modo (ne parla l'articolo sul deploy).
La trappola della rete: "localhost" nel container non è il tuo localhost
Uno degli errori più frequenti dei principianti. Il servizio nel container deve parlare con un altro servizio sul tuo computer (un database, MLflow, Redis). Scrive localhost:5432, e non funziona. Il motivo: ogni container ha la propria rete, e dentro di essa localhost indica il container stesso, dove non c'è nulla in ascolto su quella porta.
# sul computer: un servizio in ascolto solo su 127.0.0.1:18077
docker run --rm python:3.12-slim python -c "import urllib.request as u; u.urlopen('http://localhost:18077')"
docker run --rm --network host python:3.12-slim python -c "import urllib.request as u; u.urlopen('http://localhost:18077')"
# uscita dedotta, non eseguita:
# primo comando: URLError (connessione rifiutata: dentro il container non c'è nulla sulla 18077)
# secondo comando: raggiunge il servizio (il container condivide la rete dell'host)
Con --network host il container condivide la rete dell'host e "localhost" torna a significare il tuo computer. Non è la soluzione da preferire in generale, perché toglie l'isolamento di rete, ma chiarisce il meccanismo. Le vie corrette dipendono dal caso. Se i due servizi sono entrambi in container, falli parlare per nome di servizio sulla stessa rete Docker (con Compose è automatico: il servizio db si raggiunge all'indirizzo db). Se il servizio sull'host deve essere raggiunto dal container, usa un indirizzo che il container sappia risolvere: la documentazione di docker run riporta la convenzione host.docker.internal, che Docker Desktop risolve da solo e che su Linux va associato con --add-host=host.docker.internal:host-gateway; il servizio sull'host deve ascoltare su un'interfaccia raggiungibile, non solo su 127.0.0.1. In ogni caso l'indirizzo va nella configurazione (vedi variabili d'ambiente), non nel codice, così cambia tra sviluppo e produzione senza modifiche.
Più container insieme: Docker Compose
Un servizio reale ha spesso più parti: il servizio di previsione, un database, MLflow, un sistema di monitoraggio. Avviare e collegare a mano più container è tedioso. Docker Compose permette di descriverli tutti in un file YAML e di avviarli insieme con un comando. Il repository di questo sito ne ha due in infra/, ed è istruttivo leggere infra/monitoring/docker-compose.monitoring.yml (189 righe, contate con wc -l). L'intestazione dice: "Avvia Prometheus + Grafana + Loki + Node Exporter + Promtail su Docker. Le app (backend, ml, frontend) girano nativamente come systemd — solo il monitoring usa Docker." Il comando documentato nello stesso file è docker compose -f infra/monitoring/docker-compose.monitoring.yml up -d. Gli elementi che il file dichiara sono quelli di questo articolo, tradotti in YAML:
services:l'elenco dei container:node-exporter,prometheus,alertmanager,loki,promtailegrafana, ciascuno con la sua immagine e le sue opzioni;networks:una rete dedicata di tipo bridge, chiamataportfolio-monitoring, dove i container si trovano per nome;volumes:tre volumi locali (prometheus_data,grafana_data,loki_data) perché i dati di monitoraggio sopravvivano al container;- le porte pubblicate, in questo file legate a
127.0.0.1(9090, 9093, 3100 e 3000), quindi raggiungibili solo dal server stesso: l'accesso dall'esterno passa dal proxy Nginx, come dice l'intestazione.
Questo è un esempio perfetto della regola "il container serve dove serve": un insieme di strumenti di terzi (Prometheus, Grafana, Loki), con installazioni complesse e dipendenze proprie, è molto più semplice da avviare come container pronti che da installare uno per uno sul server. I tre servizi del progetto, scritti da chi li gestisce, con un solo ambiente da mantenere, restano sotto systemd. La scelta non è incoerente: è lo stesso criterio applicato a due situazioni diverse. Nota anche il dettaglio sulle porte: legare la porta a 127.0.0.1:9090:9090 invece di 9090:9090 è una scelta di isolamento, perché nel secondo caso la porta sarebbe raggiungibile da qualunque interfaccia dell'host.
I container per il machine learning: cosa cambia
Quattro particolarità da tenere presenti quando il contenuto è un servizio ML. Le immagini sono grandi: le librerie ML portano con sé binari pesanti, e con librerie di deep learning e supporto GPU si arriva a molti gigabyte, con effetto su tempi di build, pubblicazione e avvio a freddo (la dimensione dipende dalle librerie e va misurata sull'immagine che costruisci). Anche i modelli sono grandi: includerli nell'immagine o montarli come volume è una scelta con compromessi tra riproducibilità e velocità di aggiornamento. Le GPU richiedono configurazione esplicita: il container non vede la scheda grafica a meno di usare il runtime del produttore e di dichiararlo, e l'immagine deve contenere librerie compatibili con i driver dell'host, che sono parte del kernel condiviso. Infine l'avvio è lento: caricare un modello richiede secondi o minuti, e un container dichiarato "non sano" prima che finisca di caricare il modello viene riavviato in loop dall'orchestratore.
L'ultimo punto ha un riscontro in questo repository: l'unità systemd del servizio ML ha TimeoutStartSec=300 (infra/systemd/portfolio-ml.service), cioè concede fino a cinque minuti all'avvio prima di considerarlo fallito. Lo stesso ragionamento vale per i controlli di salute di un container.
Il kernel condiviso: le conseguenze pratiche
Dal kernel condiviso discendono tre limiti. Un'immagine costruita per l'architettura sbagliata (per esempio ARM su un host x86) non parte, o parte lentamente emulata. Il container non può avere un kernel diverso, quindi funzioni che richiedono moduli del kernel o driver (le GPU) hanno bisogno di configurazioni esplicite. E un difetto del kernel riguarda tutti i container dell'host: per questo l'isolamento tra clienti diversi si fa con macchine virtuali, non con soli container. Il filo comune è che il container isola i processi, non l'hardware né il kernel sotto di essi.
Costruire in locale e rilasciare altrove: la promessa e il suo limite
La promessa è "costruisco una volta, ovunque funziona uguale". Regge finché host e immagine sono compatibili (stessa architettura di processore, stesso tipo di kernel) e finché l'immagine contiene davvero tutto. Ciò che resta fuori e può rompere la promessa: la configurazione (le variabili d'ambiente passate all'avvio), i dati (volumi) e le dipendenze di rete (un database, un servizio esterno). Il container impacchetta il programma, non il suo mondo.
Come ragionare quando qualcosa non funziona in un container
- Il container parte?
docker ps -amostra anche quelli terminati, con lo stato di uscita. Un'uscita con codice 137 indica un'uccisione con SIGKILL, spesso per memoria (vista sopra); 1 è di solito un errore dell'applicazione. - Cosa dice il log?
docker logs NOME, anche dopo che è terminato (finché non lo rimuovi). - Cosa vede dentro?
docker exec -it NOME shper ispezionare file e variabili;docker run --rm -it --entrypoint sh IMMAGINEper entrare in un'immagine senza avviare il servizio. - La configurazione arriva?
docker inspect NOMEmostra variabili, montaggi, rete. - Il problema è la rete? Ricorda la trappola di
localhost. - Il problema è la costruzione?
docker build --progress=plain .mostra tutto il log, e--no-cacheripete ogni passo da zero se sospetti una cache obsoleta.
L'ordine va dal più economico al più costoso: lo stato e il log si leggono in pochi secondi e risolvono la maggior parte dei casi, l'ispezione interna richiede un container vivo, la ricostruzione senza cache costa minuti. Parti dalla prima domanda e scendi solo se la risposta non basta.
Prova tu: quattro esercizi guidati
Esercizio 1: contare ciò che il processo vede
Esegui ls /proc | grep -cE '^[0-9]+$' sul tuo computer, poi lo stesso comando dentro unshare -r -p -f --mount-proc sh -c '...' e, se hai Docker, dentro docker run --rm python:3.12-slim sh -c '...'. Dovresti vedere centinaia di processi sull'host e pochissimi nel namespace o nel container, con un hostname diverso nel container. Cosa impari: l'isolamento dei processi, concretamente.
Esercizio 2: limiti e filesystem in sola lettura
Con Docker, esegui il programma che alloca 200 MB con --memory=64m --memory-swap=64m: dovresti vedere uscita con codice 137 (echo $?). Poi docker run --rm --read-only python:3.12-slim sh -c 'touch /x': Read-only file system; aggiungi --tmpfs /tmp e verifica che /tmp sia scrivibile. Cosa impari: che il 137 è il segno di un'uccisione con SIGKILL e che l'isolamento si stringe con poche opzioni.
Esercizio 3: la trappola di localhost
Avvia sul computer python3 -m http.server 18077 --bind 127.0.0.1. Da un container prova a raggiungere http://localhost:18077 con urllib: dovresti vedere un errore di connessione. Ripeti con --network host: stavolta risponde. Poi spiega, a parole tue, perché nel primo caso "localhost" non era il tuo computer. Cosa impari: la causa del più comune errore di rete dei principianti.
Esercizio 4: aprire un'immagine
Esegui docker history python:3.12-slim e docker image inspect python:3.12-slim --format '{{len .RootFS.Layers}}'. Dovresti vedere pochi strati e molte istruzioni da 0 B: conta quanti e quale pesa di più. Costruisci poi una piccola immagine tua con due RUN e osserva come compaiono nella cronologia. Cosa impari: che un'immagine è la somma dei suoi strati e che le istruzioni di soli metadati non pesano.
Pulizia consigliata a fine esercizi: docker ps -a per vedere se è rimasto qualcosa, docker image prune per le immagini senza nome, e docker system df per sapere quanto spazio occupano.
Che cosa sai fare adesso
Sai dire perché un container non è una macchina virtuale: condivide il kernel dell'host e usa namespace e cgroup per isolare ciò che il processo vede e quanto consuma. Sai distinguere immagine e container, e sai che lo strato scrivibile sparisce con il container, quindi che ciò che deve restare va in un volume. Sai ordinare un Dockerfile per sfruttare la cache, riconoscere un'uscita 137, evitare la trappola di localhost e stringere l'isolamento con utente non root, filesystem in sola lettura e rete limitata. E sai valutare se il container serve a te oggi: dove c'è una macchina e una persona, spesso no. Il passo successivo è costruire un'immagine tua: un Dockerfile per un servizio ML.
Nella stessa serie trovi anche ML-Ops: dal notebook al servizio e ML-Ops: registry di container, tag, digest e versioni che non cambiano sotto i piedi.
Fonti
- Docker Docs, panoramica: definizione di container come ambiente debolmente isolato, consultata il 2026-11-12.
- Docker Docs, docker container run: opzioni
--read-only,--tmpfs,--network,--memory-swapehost.docker.internal, consultata il 2026-11-12. - Docker Docs, limiti di risorse dei container: un container non ha limiti per impostazione predefinita, consultata il 2026-11-12.
- namespaces(7) e cgroups(7): che cosa fanno namespace e cgroup, consultate il 2026-11-12.
- unshare(1): esecuzione di un programma in nuovi namespace, consultata il 2026-11-12.
- bash(1): stato di uscita 128+N per un comando terminato dal segnale N, consultata il 2026-11-12.
- Docker Compose: descrizione di applicazioni multi-container in un file YAML, consultata il 2026-11-12.
Come sono stati verificati i listati: i blocchi con unshare e con kill -9 sono stati eseguiti due volte in bash su Linux (util-linux 2.41.3) con la stessa uscita, tranne il numero di processi dell'host, che cambia (557 e 566 nelle due esecuzioni). I blocchi con docker sono dichiarati nel testo come uscita dedotta e non eseguiti, e dove l'uscita dipende da dimensioni o versioni è riportato solo lo schema. I fatti sul repository (assenza di Dockerfile nei tre servizi, i due file Compose, rete, volumi e porte del monitoraggio, TimeoutStartSec=300) vengono da git ls-files, wc -l e grep sui file del repository, il 2026-11-12.
Commenti
Caricamento commenti...
Accedi per commentare