Salta al contenuto principale

110 - ML-Ops: Dockerfile multi-stage per un servizio ML, utente non root e healthcheck

Un Dockerfile multi-stage per servire un modello con FastAPI e UV: dipendenze dal lockfile, utente non root, healthcheck, cache, SIGTERM e versioni del modello.

Cosa ne pensi di questo articolo?

110 - ML-Ops: Dockerfile multi-stage per un servizio ML, utente non root e healthcheck
Machine Learning Engineerfedericocalo.dev

In Breve

Un Dockerfile multi-stage per servire un modello con FastAPI e UV: dipendenze dal lockfile, utente non root, healthcheck, cache, SIGTERM e versioni del modello.

Dockerfile per un servizio ML: multi-stage, utente non root, healthcheck

Un Dockerfile è la ricetta per costruire un'immagine: una sequenza di istruzioni, ciascuna delle quali produce un layer. Qui containerizzi un classificatore di abbandono servito con FastAPI, usando UV per installare le dipendenze dal lockfile (vedi uv e la gestione dei pacchetti). Ogni riga del Dockerfile ha una ragione, e le vediamo una per una: la separazione in due stadi, l'ordine che sfrutta la cache, l'utente non privilegiato e il controllo di salute.

Una premessa su cosa è stato verificato. Il servizio Python (app.py) l'ho eseguito davvero, con il client di test di FastAPI e un modello addestrato per l'occasione; i comandi di UV (uv lock, uv sync --locked --no-install-project) li ho eseguiti in un progetto di prova. Il Dockerfile e i comandi docker non li ho eseguiti: nel testo sono dichiarati schema da adattare o uscita dedotta, non eseguita, e dove una cifra dipende dall'immagine che costruirai (dimensioni, tempi) non ne riporto nessuna e ti dico come misurarla.

Le parole del Dockerfile

Per leggere il resto servono pochi termini, oltre a quelli dell'articolo sui container (immagine, container, layer). L'immagine di base è il punto di partenza indicato con FROM, come partire da un impasto già pronto invece che dalla farina. Il contesto di build è la cartella che docker build invia al motore: solo i file lì dentro possono essere copiati nell'immagine. Un multi-stage è un Dockerfile con più FROM, ciascuno dei quali apre uno stadio: l'immagine finale è l'ultimo, e dagli altri si prendono solo i file che servono, come la cucina di preparazione e la sala, dove i pentoloni restano in cucina. La cache di build è il riuso di un layer già costruito, se l'istruzione e i suoi file di input non sono cambiati.

Un UID è il numero che identifica un utente su Linux: lo 0 è root, mentre qui usiamo 10001, un utente comune. L'healthcheck è un comando che Docker esegue a intervalli per sapere se il servizio sta bene. Infine FastAPI è la libreria con cui scrivi le funzioni del servizio web, uvicorn il server che le espone in rete, ASGI lo standard con cui parlano.

Il passaggio fra i due stadi:

Build multi-stage: lo stadio di costruzione ha UV e produce il .venv, l'immagine finale riceve solo il .venv, il codice e il modelloStadio 1: buildpython:3.12-slim + UVuv sync --lockedStadio 2: finalepython:3.12-slimutente non rootCOPY --from=build/app/.venv+ app.py + model.joblibUV e cache restano qui: non entrano nell'immagine finale

La documentazione di Docker descrive i build multi-stage come lo strumento per ottimizzare un Dockerfile mantenendolo leggibile. Lo schema qui sopra è il caso tipico per Python: nello stadio di costruzione vive tutto ciò che serve a produrre l'ambiente, nello stadio finale solo il risultato.

L'analogia: la cucina di preparazione e il piatto servito

In un ristorante, la cucina di preparazione ha coltelli, taglieri, pentoloni e sacchi di verdure. Al tavolo arriva solo il piatto. Se portassi in sala anche i pentoloni, il cliente avrebbe una sala piena di attrezzi. Il build multi-stage fa questo: lo stadio di costruzione ha gli attrezzi (UV, strumenti di compilazione); l'immagine finale prende solo il risultato (le librerie già installate).

Il perché: dallo script di installazione all'immagine

Prima dei container, rendere "installabile" un servizio voleva dire scrivere uno script di installazione ("installa Python, installa queste librerie, copia i file, configura il servizio") da eseguire sul server. Lo script ha due difetti strutturali. Primo, parte da uno stato sconosciuto: se il server ha già una versione di qualcosa, o un file residuo di un tentativo fallito, il risultato dipende da quello. Secondo, non è ripetibile nel tempo: eseguito oggi e tra un anno, scarica versioni diverse. Un Dockerfile corregge entrambi, perché la costruzione parte sempre da una base nota, ed è eseguita una volta producendo un artefatto (l'immagine) che poi si sposta ovunque senza ricostruirlo. Il Dockerfile è la ricetta; l'immagine, il piatto già pronto, identico ovunque (ne parla l'articolo su registry, tag e digest: un'immagine si identifica con un digest).

Il servizio da impacchettare

Un file app.py con FastAPI: carica il modello all'avvio, espone /health e /predict, e valida l'input. Per eseguirlo ho usato il generatore di dati e la funzione train dell'articolo sul testare i modelli con pytest: 800 clienti sintetici con seme 0, due variabili (tenure, mesi di anzianità, e complaints, reclami) e una regressione logistica di scikit-learn 1.8.0, salvata con joblib.dump(model, "model.joblib"). L'AUC sull'insieme di test è 0,7483, lo stesso valore di quell'articolo. I dati sono inventati e il modello non ha alcun valore predittivo reale: serve solo a far girare il servizio.

import os
from contextlib import asynccontextmanager

import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field

MODEL_PATH = os.environ.get("MODEL_PATH", "model.joblib")
state: dict = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    state["model"] = joblib.load(MODEL_PATH)      # fallisce subito se il file manca
    yield

app = FastAPI(lifespan=lifespan)

class Customer(BaseModel):
    tenure: int = Field(ge=0, le=600)
    complaints: int = Field(ge=0, le=100)

@app.get("/health")
def health():
    return {"status": "ok", "model_loaded": "model" in state}

@app.post("/predict")
def predict(c: Customer):
    p = state["model"].predict_proba(pd.DataFrame([c.model_dump()]))[0, 1]
    return {"churn_probability": round(float(p), 4)}

Due scelte da notare. Il percorso del modello arriva da una variabile d'ambiente (come nell'articolo sulle variabili d'ambiente e i segreti), con un default. E la validazione dei limiti (ge, le) sta nello schema di input: un valore assurdo viene respinto con un errore 422 prima di arrivare al modello. Il caricamento sta nel lifespan, cioè all'avvio: se il file non c'è, il servizio non parte, e questo è voluto (vedi sotto, sull'healthcheck).

Ho interrogato il servizio senza aprire porte, con il TestClient di FastAPI, che chiama l'applicazione direttamente in memoria:

from fastapi.testclient import TestClient
from app import app

with TestClient(app) as client:
    print(client.get("/health").json())
    print(client.post("/predict", json={"tenure": 3, "complaints": 4}).json())
    r = client.post("/predict", json={"tenure": -1, "complaints": 4})
    print(r.status_code, r.json()["detail"][0]["type"], "|", r.json()["detail"][0]["msg"])

# {'status': 'ok', 'model_loaded': True}
# {'churn_probability': 0.9524}
# 422 greater_than_equal | Input should be greater than or equal to 0

Il with TestClient(app) esegue anche il lifespan, quindi il modello viene caricato prima della prima richiesta. Il cliente con 3 mesi di anzianità e 4 reclami ha probabilità 0,9524 sul mio modello sintetico: il numero dipende dai dati inventati e non ha altro significato. La terza richiesta, con tenure uguale a -1, non arriva al modello: lo schema la respinge con il codice 422. Eseguito due volte con Python 3.14.4, FastAPI 0.136.3 e pydantic 2.13.4, l'esito è identico. Con MODEL_PATH=/nope.joblib l'avvio solleva FileNotFoundError, cioè il fallimento rumoroso che vogliamo.

Il Dockerfile, riga per riga

Il Dockerfile qui sotto è schema da adattare, non eseguito (richiede Docker): ogni riga è motivata nelle sezioni che seguono, e i comandi di UV che contiene li ho verificati a parte, come mostrato più avanti.

FROM python:3.12-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /usr/local/bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project

FROM python:3.12-slim
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=build /app/.venv /app/.venv
COPY app.py model.joblib ./
ENV PATH="/app/.venv/bin:$PATH" MODEL_PATH=/app/model.joblib
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health')"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

Esistono i tag python:3.12-slim (Docker Hub) e ghcr.io/astral-sh/uv:0.12 (ghcr.io): li ho verificati interrogando i rispettivi registry il 2026-11-12. La guida di UV per Docker conferma il formato dei tag {major}.{minor}, che punta all'ultima patch di quella serie.

Stadio 1: costruire l'ambiente

La prima riga parte da python:3.12-slim, la stessa versione di Python dello sviluppo, nella variante "slim" che evita pacchetti di sistema non necessari. La seconda prende il binario di UV da un'immagine ufficiale, con la versione fissata a 0.12 e non a latest: perché, lo spiega l'articolo su registry e tag. Le due variabili ENV sono quelle che la guida di UV descrive: UV_COMPILE_BYTECODE=1 precompila i file Python in .pyc, mentre UV_LINK_MODE=copy evita gli avvisi sul collegamento dei file quando cache e destinazione stanno su filesystem diversi.

La riga che sfrutta la cache è COPY pyproject.toml uv.lock ./ prima del codice: finché questi due file non cambiano, il layer successivo viene riusato. Poi RUN uv sync --locked --no-install-project installa esattamente il lockfile. Il flag --locked fa fallire il comando se il lock non è allineato al pyproject.toml, e --no-install-project installa le dipendenze ma non il progetto stesso, che cambia più spesso (anche questo è descritto nella guida di UV). Ho verificato i due comportamenti in un progetto di prova con UV 0.12.21:

# pyproject.toml di prova: dependencies = ["idna>=3"]
uv lock
uv sync --locked --no-install-project
#  + idna==3.20                      (le dipendenze sono installate, il progetto no)

# aggiungo "certifi" alle dipendenze SENZA rigenerare il lock, poi:
uv sync --locked --no-install-project
# error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
# exit=1

Nel primo caso UV risolve e installa solo la dipendenza (idna), non il pacchetto churn-api stesso. Nel secondo, con il pyproject.toml modificato ma il lock invariato, il comando termina con codice 1 e l'avviso che il lock va aggiornato: in un Dockerfile questo significa che il build si ferma invece di installare versioni non registrate. La versione di idna è quella disponibile il 2026-11-12 e cambia nel tempo.

Stadio 2: l'immagine finale

Lo stadio finale ricomincia con FROM python:3.12-slim: UV e la cache del build non ci finiscono. RUN useradd --create-home --uid 10001 app e USER app fanno girare il servizio senza privilegi: se un difetto permettesse a un attaccante di eseguire comandi, lo farebbe come un utente comune dentro il container, non come root. La documentazione di USER precisa che l'istruzione imposta utente e gruppo predefiniti per il resto dello stadio. COPY --from=build /app/.venv /app/.venv porta solo l'ambiente già pronto; COPY app.py model.joblib ./ aggiunge codice e modello per ultimi, perché cambiano più spesso. Con ENV PATH=... MODEL_PATH=... si fissa l'ambiente di default, sovrascrivibile con docker run -e.

L'HEALTHCHECK chiede a /health se il servizio risponde. Dalla documentazione: l'intervallo di default è 30 secondi, il timeout di default 30 secondi (qui lo abbiamo portato a 3) e il periodo di avvio di default 0. Il comando esce con 0 se sta bene e con 1 se non sta bene: urlopen solleva un'eccezione se la connessione fallisce o se il server risponde con un errore HTTP, quindi Python termina con codice diverso da 0 e Docker marca il container come unhealthy. Infine CMD [...] è il comando di avvio in forma lista, senza shell di mezzo: il perché è nel "difetto 6" più sotto.

Il file .dockerignore

Quando lanci il build, Docker invia la cartella corrente al motore (il contesto di build). Senza filtro invii anche l'ambiente virtuale, i database di esperimenti e i .env con i segreti. Il .dockerignore lo impedisce:

.venv
__pycache__
mlruns
mlflow.db
.env*
.git

Non è cosmetica: un COPY . . distratto senza questo file porta i segreti nei layer dell'immagine, dove chi scarica l'immagine li può leggere (vedi l'articolo sui segreti). La guida di UV dice la stessa cosa per la sola cartella .venv: va esclusa perché dipende dalla piattaforma locale e nell'immagine deve essere creata da zero.

Costruire, avviare, verificare

Con il servizio e il Dockerfile nella stessa cartella, la sequenza è la seguente. È uscita dedotta, non eseguita: le risposte di /health e /predict sono quelle che il servizio ha dato nella prova con il TestClient qui sopra, perché il codice è lo stesso.

docker build -t churn-api:0.1.0 .
docker run -d --rm --name churn -p 18000:8000 churn-api:0.1.0
curl -s localhost:18000/health
curl -s -X POST localhost:18000/predict -H 'content-type: application/json' \
     -d '{"tenure":3,"complaints":4}'
# {"status":"ok","model_loaded":true}
# {"churn_probability":0.9524}      (il numero dipende dal tuo modello)
docker run --rm churn-api:0.1.0 id -u
# 10001

Il build crea l'immagine con il tag churn-api:0.1.0; il run la avvia in background con la porta 8000 del container pubblicata sulla 18000 del tuo computer; i due curl interrogano il servizio da fuori. L'ultimo comando, id -u, restituisce 10001: il processo non è root. Per sapere quanto pesa l'immagine e quale layer pesa di più usa docker images churn-api e docker history churn-api:0.1.0: con scikit-learn, pandas e SciPy dentro, il layer dell'ambiente virtuale è di gran lunga il più grande, e c'è poco da risparmiare senza cambiare dipendenze.

La trappola: il modello e il servizio devono parlare la stessa versione

Un file .joblib è la serializzazione dell'oggetto Python del modello (pickle): si ricarica correttamente solo con versioni compatibili della libreria che l'ha prodotto. La pagina di scikit-learn sulla persistenza dice due cose che qui contano: quando carichi uno stimatore salvato con una versione diversa, viene sollevato un InconsistentVersionWarning; e i formati basati su pickle vanno caricati solo da fonti di cui ti fidi completamente, perché il caricamento può eseguire codice arbitrario. Il secondo punto è il motivo per cui il modello entra nell'immagine da un percorso del tuo build, non da un indirizzo qualunque.

Ho riprodotto l'avviso fingendo un salvataggio fatto da una versione diversa (modificando la versione che scikit-learn scrive nel file, in un listato di prova):

import warnings
import joblib
import sklearn
import sklearn.base
from sklearn.exceptions import InconsistentVersionWarning

m = joblib.load("model.joblib")
vera = sklearn.base.__version__
sklearn.base.__version__ = "1.7.0"            # finge un salvataggio fatto da un'altra versione
joblib.dump(m, "vecchio.joblib")
sklearn.base.__version__ = vera

with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    joblib.load("vecchio.joblib")
for x in w:
    if issubclass(x.category, InconsistentVersionWarning):
        print(x.category.__name__, "| salvato con", x.message.original_sklearn_version, "| in uso", x.message.current_sklearn_version)

# InconsistentVersionWarning | salvato con 1.7.0 | in uso 1.8.0

Il listato è artificiale: scrive volutamente "1.7.0" dentro il file per simulare un modello addestrato altrove. Il risultato mostra che il caricamento non fallisce, ma segnala l'incoerenza con un avviso che l'applicazione può intercettare. Il rischio reale è che l'avviso finisca nei log e nessuno lo legga. Succede quando addestramento e servizio hanno due lockfile separati, risolti in giorni diversi: il giorno in cui esce una nuova versione, divergono senza che nessuno se ne accorga.

Le difese, in ordine di robustezza: addestrare e servire nello stesso progetto con lo stesso uv.lock; registrare la versione delle librerie insieme al modello (MLflow lo fa, vedi MLflow: tracciare gli esperimenti); aggiungere un test che carica il modello salvato e controlla una predizione nota (vedi testare i modelli con pytest). Il test d'integrazione più prezioso in ML-Ops è proprio questo: l'artefatto che rilasci si carica e risponde. In più, puoi trasformare l'avviso in errore con warnings.simplefilter("error", InconsistentVersionWarning) nel caricamento, così un'incoerenza ferma il servizio all'avvio invece di passare in silenzio.

Il modello nell'immagine o fuori?

Qui il modello è copiato dentro l'immagine (nella mia prova il file pesa 1.135 byte: un esempio minuscolo). È semplice e rende l'immagine autosufficiente e riproducibile: una versione di immagine = una versione di modello. Ma se il modello pesa gigabyte o cambia spesso, ricostruire e ripubblicare l'intera immagine a ogni riaddestramento è un costo. L'alternativa è tenere il modello fuori (un volume, un archivio di oggetti, il registry del modello di cui parla l'articolo sul model registry) e passarne l'indirizzo con MODEL_PATH o un URI. Il compromesso è tra autosufficienza e tracciabilità da un lato, velocità di aggiornamento dall'altro.

Le istruzioni del Dockerfile, in una tabella

IstruzioneCosa faNel nostro file
FROMSceglie l'immagine di partenza (e apre uno stadio)python:3.12-slim
COPYCopia file dal contesto di build (o da un altro stadio con --from) nell'immaginelock e dipendenze, poi codice e modello
RUNEsegue un comando durante la costruzione e salva il risultato come layeruv sync --locked, useradd
ENVImposta una variabile d'ambiente (resta nell'immagine, visibile)PATH, MODEL_PATH (mai segreti)
WORKDIRImposta la cartella di lavoro (e la crea)/app
USERDa qui in poi il processo gira con questo utenteapp (UID 10001)
EXPOSEDocumenta la porta su cui il servizio ascolta (non la pubblica)8000
HEALTHCHECKDefinisce come verificare che il servizio stia benerichiesta a /health
CMDIl comando che parte quando si avvia il containeruvicorn app:app ...

Due distinzioni da non confondere. RUN contro CMD: RUN agisce una volta, alla costruzione (installa le librerie); CMD agisce a ogni avvio del container (accende il servizio). EXPOSE contro -p: secondo il riferimento del Dockerfile, EXPOSE funziona come documentazione tra chi costruisce l'immagine e chi la esegue e non pubblica la porta; per raggiungere il servizio da fuori serve ancora docker run -p 18000:8000.

Costruire il Dockerfile per passi: ogni riga ripara un problema

Il Dockerfile può sembrare un blocco di istruzioni da imparare a memoria. Funziona meglio capirlo come l'esito di sei correzioni successive di un primo tentativo ingenuo. Partiamo dal tentativo ingenuo (schema da adattare, non eseguito):

FROM python:3.12
COPY . .
RUN pip install -r requirements.txt
CMD uvicorn app:app --host 0.0.0.0 --port 8000

Funziona. E ha almeno sei difetti: la base è enorme, ogni modifica reinstalla le librerie, le versioni non sono bloccate, gli attrezzi di costruzione finiscono in produzione, il servizio gira come root e lo spegnimento è brusco. Li ripariamo uno alla volta.

Difetto 1: l'immagine di base è enorme

python:3.12 contiene un sistema operativo completo con compilatori e strumenti. La variante -slim toglie ciò che non serve. Correzione: FROM python:3.12-slim. Di quanto cambi la dimensione lo vedi confrontando i due tag con docker images dopo averli scaricati; dipende dalla data del tag.

Difetto 2: ogni modifica al codice reinstalla le librerie

COPY . . viene prima dell'installazione, quindi la cache di Docker si invalida a ogni modifica di qualsiasi file e le librerie vengono riscaricate. Correzione: copiare prima i file di dipendenze, installare, e solo dopo copiare il codice. Ciò che cambia raramente sta sopra, ciò che cambia spesso sotto, come descrive la documentazione sulla cache di build. Per misurarlo, ricostruisci l'immagine dopo aver aggiunto una riga a app.py con docker build --progress=plain .: i passi di installazione devono risultare CACHED.

Difetto 3: le versioni non sono bloccate

pip install -r requirements.txt risolve le versioni oggi. Correzione: uv sync --locked dal lockfile, che, come hai visto sopra, fallisce se il lock non coincide con pyproject.toml. Con questo, l'immagine costruita oggi e quella di tra sei mesi installano le stesse versioni.

Difetto 4: gli attrezzi di costruzione finiscono nell'immagine di produzione

Un'immagine che contiene UV, la cache dei pacchetti e i file di progetto non necessari è più grande e ha più superficie d'attacco. Correzione: il multi-stage, che costruisce in un primo stadio e porta nell'immagine finale solo l'ambiente pronto. Attenzione a una promessa che si sente spesso, "multi-stage = immagine più piccola": non è una legge. Dipende da cosa lasci fuori; ad esempio UV_COMPILE_BYTECODE=1 aggiunge i file .pyc all'ambiente e quindi lo ingrandisce, in cambio di un avvio che non compila i moduli la prima volta. Confronta con docker images le varianti con e senza quella variabile sul tuo servizio, prima di decidere. Il vantaggio sicuro del multi-stage è la pulizia dell'immagine finale, non il peso.

Difetto 5: il servizio gira come root

Per impostazione predefinita il processo nel container è root. Correzione: useradd e USER app. È anche una trappola da conoscere: l'utente app non può scrivere in /app, che appartiene a root, mentre può scrivere nella sua cartella personale /home/app (creata da --create-home). Se l'applicazione ha bisogno di scrivere (una cache di modelli, file temporanei), devi preparare esplicitamente una cartella scrivibile per quell'utente (per esempio con RUN mkdir /cache && chown app /cache prima di USER app) o montare un volume. L'errore "Permission denied" all'avvio di un servizio che "funzionava come root" è uno dei più comuni dopo questa correzione. Puoi provarlo con docker run --rm churn-api:0.1.0 sh -c 'touch /app/x; touch /home/app/ok && echo scrivibile': il primo touch deve dare Permission denied.

Difetto 6: lo spegnimento è lento e brusco

La forma CMD uvicorn app:app ... (senza parentesi quadre) si chiama forma shell: Docker la esegue come /bin/sh -c "uvicorn ...". Il riferimento del Dockerfile avverte che con la forma shell l'eseguibile non riceve i segnali Unix: quindi non riceve il SIGTERM che docker stop invia. Se il processo non risponde entro il tempo di tolleranza (10 secondi per impostazione predefinita), Docker lo uccide con SIGKILL. Con la forma exec (la lista) uvicorn è il processo 1, riceve SIGTERM, può chiudere con garbo le richieste in corso e termina subito. Per un servizio ML significa riavvii e rilasci più rapidi, e richieste in corso che non si perdono. Correzione: sempre la forma a lista per CMD e ENTRYPOINT. Non riporto tempi di docker stop: li misuri tu nell'esercizio 3.

Tracciare da dove viene un'immagine: etichette e argomenti di build

Un'immagine in produzione dovrebbe dire da quale commit è nata. Con un argomento di build e un'etichetta standard:

ARG GIT_SHA=unknown
LABEL org.opencontainers.image.revision=$GIT_SHA

# docker build --build-arg GIT_SHA=$(git rev-parse --short HEAD) -t churn-api:0.1.1 .
# docker image inspect churn-api:0.1.1 --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'
# uscita dedotta, non eseguita: l'identificativo breve del commit passato al build

ARG dichiara un valore che si passa alla costruzione con --build-arg; LABEL scrive i metadati nell'immagine. Il nome org.opencontainers.image.revision è una chiave standard della specifica delle annotazioni OCI, definita come l'identificativo della revisione di controllo versione del software impacchettato. Il comando inspect restituisce il commit, così da un'immagine in esecuzione sai quale codice è, senza indovinare dal tag. Attenzione: gli ARG compaiono nella cronologia dell'immagine; non passarci segreti.

Il controllo di salute, progettato bene

L'HEALTHCHECK chiede a /health a intervalli regolari. Due dettagli da progettare. Cosa verifica: se /health risponde sempre 200 anche con il modello non caricato, nasconde il guasto; il nostro riporta model_loaded e l'avvio fallisce se il file manca, ma in un servizio che carica il modello in modo pigro il controllo deve verificare il caricamento. Quando parte: un modello pesante richiede tempo per caricarsi, e un controllo che inizia subito segnala "non sano" un servizio che sta solo avviandosi. L'opzione --start-period concede un periodo iniziale (di default 0 secondi); durante quel periodo i controlli girano alla frequenza --start-interval (di default 5 secondi), come dice il riferimento del Dockerfile.

Il servizio ML di questo sito segue lo stesso principio in un altro modo: il suo /health, chiamato prima che l'avvio sia completato, risponde con stato "starting" invece di fingere di essere pronto (ml-service/app/main.py), e l'unità systemd concede TimeoutStartSec=300 per avviarsi (infra/systemd/portfolio-ml.service). Un dettaglio da ricordare: una risposta con stato "starting" ma codice HTTP 200 supera ancora il controllo del nostro HEALTHCHECK, che guarda solo se la richiesta riesce; se vuoi che "non pronto" conti come guasto, il controllo deve leggere il corpo della risposta o l'endpoint deve rispondere 503.

Perché l'applicazione scrive i log sullo standard output

Nel container non si accede a un file di log interno: si guarda docker logs. Per questo uvicorn scrive su stdout/stderr per impostazione predefinita, e il tuo codice dovrebbe fare lo stesso (la libreria logging con un gestore di flusso, non un file). Sul VPS, dove i servizi di questo sito girano sotto systemd, lo stesso flusso viene raccolto nel journal (vedi l'articolo sul deploy): il principio è identico, cambia chi lo raccoglie.

Il percorso verso la produzione: una lista di controllo per un'immagine

  • Riproducibile: base fissata (tag o digest), dipendenze dal lock, nessun latest.
  • Piccola quanto serve: base slim, multi-stage, niente strumenti di costruzione nello stadio finale; misura, non presumere.
  • Non privilegiata: utente non root, cartelle scrivibili dichiarate, filesystem in sola lettura se possibile.
  • Pulita: .dockerignore, nessun segreto nei layer né negli ARG, nessun file di sviluppo.
  • Si spegne bene: CMD in forma exec, il servizio gestisce SIGTERM.
  • Dichiara cosa è: etichetta con il commit, versione del modello visibile in /health.
  • Si controlla: healthcheck che dice qualcosa di vero, log su stdout.
  • È stata provata: avviata, interrogata, fermata, con test che girano sull'artefatto reale e non solo sul codice.

Le otto voci non hanno un ordine di importanza, ma tre sono a prova di errore e due richiedono scelte tue. Riproducibilità, utente non root e .dockerignore costano poche righe e si applicano a qualunque servizio. Dimensione e modello dentro o fuori l'immagine dipendono invece dal servizio e vanno misurati. L'ultima voce, la prova sull'artefatto reale, è quella che molti saltano e che intercetta più errori delle altre.

Quando il Dockerfile non basta: scansione e aggiornamento

Costruire l'immagine non finisce il lavoro: dentro ci sono decine di pacchetti di sistema e di Python, ciascuno con una storia di vulnerabilità. Esistono strumenti di scansione delle immagini che li confrontano con i database pubblici, spesso integrati nel registry o nella CI. La regola generale è la stessa dell'articolo sulle licenze e il software open source: sapere cosa contiene ciò che rilasci, ricostruire l'immagine periodicamente per prendere le correzioni e rieseguire i test sull'immagine nuova prima di rilasciarla.

Prova tu: quattro esercizi guidati

Esercizio 1: costruire e provare l'immagine

Con il servizio di questo articolo (app.py, pyproject.toml, uv.lock, model.joblib) costruisci l'immagine, avviala e interrogala: docker build -t churn-api:0.1.0 ., docker run -d --rm --name churn -p 18000:8000 churn-api:0.1.0, poi curl -s localhost:18000/health e una richiesta a /predict. Dovresti vedere {"status":"ok","model_loaded":true} e una probabilità. Leggi lo stato dell'healthcheck con docker inspect churn --format '{{.State.Health.Status}}': prima starting, poi healthy. Cosa impari: che "costruita" non è "funzionante" e che il controllo di salute ha un tempo di avvio.

Esercizio 2: misurare le varianti

Costruisci la variante a stadio singolo (COPY . ., UV nell'immagine finale) accanto a quella multi-stage, ed eventualmente una terza senza UV_COMPILE_BYTECODE=1. Confronta con docker images churn-api --format '{{.Tag}} {{.Size}}' e controlla l'utente con docker run --rm IMMAGINE id -u. Dovresti vedere dimensioni diverse (annotale: sono tue misure) e 0 contro 10001 come utente. Cosa impari: a non fidarti di "multi-stage = più piccolo" senza misurare, e a riconoscere i compromessi (bytecode: più peso per un avvio più rapido).

Esercizio 3: la forma di CMD

Avvia due container, uno con CMD ["uvicorn", ...] e uno con la forma shell CMD uvicorn .... Per ciascuno, dopo qualche secondo, misura il tempo di docker stop (puoi usare date +%s.%N prima e dopo, e bc per la differenza). Con docker exec NOME sh -c 'tr "\0" " " < /proc/1/cmdline' guarda quale sia il processo 1. Dovresti vedere uvicorn come processo 1 nella forma exec e /bin/sh -c ... nella forma shell, con tempi di arresto più lunghi nella seconda (fino al tempo di tolleranza di Docker). Cosa impari: perché la forma a lista non è una questione di stile ma di gestione dei segnali.

Esercizio 4: il permesso negato

Dentro l'immagine, come utente non root, prova a scrivere in /app e in /home/app: docker run --rm churn-api:0.1.0 sh -c 'touch /app/x; touch /home/app/ok && echo scrivibile'. Dovresti vedere Permission denied per la prima e "scrivibile" per la seconda. Poi modifica il Dockerfile per creare una cartella /cache scrivibile dall'utente app e verifica che la scrittura vi riesca. Cosa impari: l'errore più comune dopo avere tolto i privilegi di root, e come risolverlo senza ridarglieli.

Pulizia: docker stop churn e docker image prune; le immagini di prova si tolgono con docker rmi.

Che cosa sai fare adesso

Sai scrivere un Dockerfile in due stadi per un servizio ML: dipendenze dal lockfile con uv sync --locked nello stadio di costruzione, immagine finale con solo l'ambiente, il codice e il modello, utente non root, .dockerignore e healthcheck che dice qualcosa di vero. Sai perché il lock va copiato prima del codice, perché CMD vuole la forma a lista, e come accorgerti che modello e libreria che lo serve non hanno la stessa versione. Sai anche di quali cifre non fidarti in un articolo: le dimensioni e i tempi si misurano sulla tua immagine. Il passo successivo è dove pubblicarla: registry, tag e digest.

Fonti

Come sono stati verificati i listati: app.py, il test con TestClient e il listato sull'avviso di versione sono stati eseguiti due volte con Python 3.14.4, FastAPI 0.136.3, pydantic 2.13.4, scikit-learn 1.8.0 e joblib 1.5.3, con la stessa uscita. I comandi di UV (0.12.21) sono stati eseguiti in un progetto di prova. Il Dockerfile e tutti i comandi docker sono dichiarati nel testo come schema da adattare o uscita dedotta, non eseguiti. L'esistenza dei tag python:3.12-slim e ghcr.io/astral-sh/uv:0.12 è stata controllata interrogando i registry. Il fatto sul /health del servizio ML del sito viene da ml-service/app/main.py. Clienti, anzianità, reclami e probabilità sono dati sintetici inventati.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti