Fare il deploy (in italiano «rilascio» o «messa in servizio») significa portare il modello dal luogo in cui l'hai addestrato al luogo in cui qualcun altro, o qualche altro programma, può usarlo davvero. Quel luogo si chiama produzione: l'ambiente reale, con utenti reali, dove un errore ha conseguenze. Fin qui hai codice versionato (Git, GitHub), un ambiente riproducibile (uv), test (pytest, test dei modelli), configurazione esterna (variabili d'ambiente), un'immagine impacchettata (Dockerfile) e un modello con una versione precisa (model registry). Resta una domanda: su cosa lo faccio girare, e come lo tengo in vita? Non esiste una risposta unica; esistono quattro forme tipiche e una regola per scegliere.
I listati Python e i comandi systemd-analyze di questo articolo sono stati eseguiti in una cartella temporanea fuori dal repository, con MLflow 3.18.0, FastAPI 0.162.5, scikit-learn 1.9.1 e Python 3.12, sugli stessi modelli registrati dell'articolo precedente. L'unità systemd del servizio di prova ha superato systemd-analyze verify (nella variante con un eseguibile che esiste su questa macchina). Il servizio vero, systemctl e journalctl non sono stati eseguiti: ciò che dipende da un server è dichiarato nel testo come schema da adattare.
Le parole del deploy
Un servizio è un programma che resta acceso ad aspettare richieste, come un negozio aperto che aspetta clienti; il contrario è lo script, che parte, lavora e finisce, come un artigiano che consegna il lavoro e se ne va. Un endpoint è un indirizzo preciso del servizio a cui si può chiedere qualcosa, come lo sportello numero 3 di un ufficio: /predict è lo sportello «dammi una previsione». La latenza è il tempo fra richiesta e risposta, in millisecondi. L'inferenza è usare un modello già addestrato su dati nuovi: l'addestramento è lo studio, l'inferenza è l'esame. Un batch è l'elaborazione a lotti, molti dati in una volta a un orario stabilito, come fare il bucato una volta a settimana invece di lavare una maglietta ogni sera.
Dal lato dell'infrastruttura, un VPS (virtual private server) è un computer virtuale che affitti da un fornitore e che resta acceso giorno e notte, di cui sei tu l'amministratore. systemd è il programma che su Linux avvia gli altri all'accensione e li riavvia se si fermano, il «capo reparto» del server. Il cold start (avvio a freddo) è il tempo che un servizio impiega per essere pronto dopo essere stato spento, perché deve caricare il modello e le librerie. Le ultime tre parole sono quelle che cambiano di più la scelta tra le quattro forme, come vedremo.
L'analogia: quattro modi di vendere il pane
Immagina di dover far arrivare il pane ai clienti. Puoi cuocere tutto di notte e lasciare i sacchetti pronti alle sei del mattino (batch). Puoi tenere il forno acceso e sfornare quando il cliente entra (API su server). Puoi dare la ricetta a una catena che ha già negozi, camion e cassieri, e occuparti solo del pane (servizio gestito). Oppure puoi affittare un locale e fare tutto tu, dal forno alla serranda (VPS). Nessuna è «migliore»: dipende da quanti clienti hai, quanto in fretta vogliono il pane e quanto tempo vuoi passare a gestire il negozio.
Forma 1: batch, lo script che gira a orari fissi
La forma più semplice, e spesso quella giusta. Il modello viene usato su un insieme di dati già disponibile (tutti i clienti di ieri), non su una richiesta in attesa. Non serve server, non serve rete esposta, e un guasto non ferma nessun utente: il lotto si rilancia. Lo script legge un CSV, carica il campione del registry per alias e scrive un secondo CSV con una colonna in più:
# score_batch.py
import sys
import mlflow
import pandas as pd
from churn.data import FEATURES
mlflow.set_tracking_uri("sqlite:///mlflow.db")
model = mlflow.sklearn.load_model("models:/churn-classifier@champion")
src, dst = sys.argv[1], sys.argv[2]
df = pd.read_csv(src)
df["churn_probability"] = model.predict_proba(df[FEATURES])[:, 1].round(4)
df.to_csv(dst, index=False)
print(f"{len(df)} clienti valutati -> {dst}")
# python score_batch.py clienti.csv out.csv
# 3 clienti valutati -> out.csv
# out.csv: c1 -> 0.7993, c2 -> 0.1862, c3 -> 0.5547 (clienti.csv: c1 = 3 mesi e 4 reclami, c2 = 60 e 0, c3 = 24 e 2)
# con un file inesistente: FileNotFoundError, exit=1
Il modello si carica per alias, quindi lo script non sa quale versione sia in servizio: spostare l'alias cambia il modello alla notte successiva, senza toccare il codice. sys.argv[1] e sys.argv[2] sono i due argomenti della riga di comando, file in ingresso e in uscita. Con tre clienti di prova lo script ha scritto le probabilità 0.7993, 0.1862 e 0.5547, e due esecuzioni hanno dato lo stesso file. Con un file inesistente termina con codice d'uscita 1 e il FileNotFoundError in coda al messaggio: il fallimento è visibile, non silenzioso, e un pianificatore che guarda il codice di uscita se ne accorge.
Per farlo girare ogni notte si usa un timer di systemd o cron, il pianificatore storico di Unix. Un timer settimanale si scrive, per esempio, con OnCalendar=Mon *-*-* 03:00:00 UTC (ogni lunedì alle 03:00 UTC) e Persistent=true. Secondo la documentazione di systemd.timer significa che l'ora dell'ultima attivazione viene salvata su disco e, se durante lo spegnimento è «saltata» un'esecuzione, il servizio parte subito all'attivazione del timer. Il batch va bene quando le previsioni servono a un processo (un report, una campagna e-mail), l'aggiornamento giornaliero basta e i dati arrivano a pacchetti; non va bene quando l'utente aspetta la risposta guardando lo schermo.
Forma 2: API su server, il processo sempre acceso
È il servizio dell'articolo sul Dockerfile: un programma FastAPI che resta in ascolto e risponde a richieste HTTP in pochi millisecondi. Il modello viene caricato una volta all'avvio e riusato per ogni richiesta, per questo l'avvio è lento e la risposta veloce. Il prezzo è la responsabilità: il servizio deve restare in vita, deve dire come sta, e un suo guasto lo vede subito l'utente. Il resto dell'articolo riguarda questo.
Forma 3: servizio gestito nel cloud
Un servizio gestito è una piattaforma di un fornitore cloud a cui consegni l'immagine container (vedi registry, tag e digest) e che si occupa del resto: avvia le copie, le moltiplica se arriva più traffico, le spegne se non ne arriva, fornisce indirizzo e certificato https. Cloud Run di Google è l'esempio della categoria. Il contratto del container di Cloud Run dice che la piattaforma inietta la variabile d'ambiente PORT nel container e che l'applicazione deve ascoltare su quella porta: è la configurazione per variabile d'ambiente dell'articolo sui segreti, applicata dalla piattaforma. I dettagli commerciali (prezzi, limiti) cambiano e vanno letti sul sito del fornitore; qui interessa la forma.
| Cosa guadagni | Cosa paghi |
|---|---|
| Nessun server da amministrare né aggiornare | Un costo che cresce con l'uso, da monitorare |
| Scalabilità automatica con il traffico | Il cold start: dopo un periodo di inattività la prima richiesta è lenta, perché deve avviare il container e caricare il modello |
| https, indirizzo e logging inclusi | Dipendenza dal fornitore e dalle sue regole (lock-in) |
| Rilasci per immagine, con rollback semplice | I dati e il modello escono dal tuo controllo fisico: attenzione a privacy e normative |
La tabella ha una sola idea sotto: scambi tempo di amministrazione con controllo. Per un modello pesante la voce che pesa di più è il cold start, perché il «tempo per caricare il modello» è proprio ciò che la scalabilità a zero ripete a ogni risveglio. Se il tuo modello impiega minuti a caricarsi, chiedi al fornitore come tenere almeno una copia sempre calda, e metti quel costo nel conto.
Forma 4: un VPS con systemd
Affitti un server virtuale e ci fai girare il servizio sotto systemd. Vale la pena leggere l'unità riga per riga, perché ognuna è una decisione di operatività. Quella che segue è scritta per il servizio FastAPI dell'articolo (lo chiamo churn-api, in /opt/churn-api) ed è un modello da adattare: l'ho controllata con systemd-analyze verify, ma il servizio non è stato avviato. Il file è uno solo, e lo divido in blocchi per commentarlo. La prima sezione dice quando partire e quando smettere di riprovare:
[Unit]
Description=Churn API (FastAPI)
After=network.target
StartLimitIntervalSec=120
StartLimitBurst=3
After=network.target fa partire il servizio dopo che la rete è disponibile; se il tuo servizio dipendesse da un altro (un database, una cache) aggiungeresti anche Wants= con il nome di quell'unità, che ne desidera la presenza: è l'ordine in cui accendi le macchine in un laboratorio. StartLimitIntervalSec=120 e StartLimitBurst=3 hanno un effetto preciso, descritto in systemd.unit: un'unità avviata più di tre volte in due minuti non può più essere avviata. Evita il «ciclo di morte» che riempie i log e consuma il processore, ma ha un rovescio: dopo tre fallimenti rapidi il servizio resta fermo finché qualcuno non interviene.
La sezione [Service] decide con quale identità e con quale configurazione gira il processo:
[Service]
User=churn
Group=churn
WorkingDirectory=/opt/churn-api
EnvironmentFile=/opt/churn-api/.env.prod
Environment=MLFLOW_TRACKING_URI=sqlite:////opt/churn-api/mlflow.db
User=churn fa girare il servizio con un utente dedicato e non con root, lo stesso principio dell'utente app nel Dockerfile. EnvironmentFile= carica configurazione e segreti da un file; la documentazione di systemd.exec precisa che, se il file non esiste o non si legge, il servizio non parte. Environment= aggiunge singole variabili, qui l'indirizzo del registry di MLflow (quattro barre dopo sqlite:, perché il percorso è assoluto). La conseguenza pratica è che i segreti non stanno nell'unità, leggibile da molti, ma in un file con permessi stretti.
Il comando di avvio e le regole di arresto e riavvio sono il cuore dell'unità:
ExecStart=/opt/churn-api/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 --workers 1 --timeout-keep-alive 30
TimeoutStopSec=20
TimeoutStartSec=300
Restart=always
RestartSec=10
ExecStart usa l'uvicorn dell'ambiente virtuale del progetto (.venv/bin/uvicorn): nessun Python «di sistema» per caso. app:app dice a uvicorn di cercare l'oggetto app nel file app.py, e --workers 1 chiede un solo processo, perché ogni processo carica il proprio modello in memoria. --host 127.0.0.1 lo fa ascoltare solo sul computer stesso, tema che riprendo più sotto. TimeoutStartSec=300 concede fino a cinque minuti per l'avvio: il valore di riserva del gestore è DefaultTimeoutStartSec, che su questa macchina vale 1 min 30 s (systemctl show -p DefaultTimeoutStartUSec), e con un modello pesante da caricare un servizio ancora in avvio sarebbe considerato fallito a metà. Restart=always e RestartSec=10 fanno ripartire il processo dopo dieci secondi se si ferma per qualunque motivo: è la riga che rende un processo un servizio. All'arresto systemd invia per default SIGTERM (è il valore di KillSignal= nella documentazione di systemd.kill) e TimeoutStopSec=20 concede venti secondi per chiudere con garbo prima che systemd lo forzi: lo stesso tema del CMD in forma lista dell'articolo sul Dockerfile.
Infine i vincoli di sicurezza, l'equivalente dell'isolamento di un container ottenuto con gli strumenti di systemd:
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/opt/churn-api /var/lib/churn-api
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Il processo non può acquisire nuovi privilegi (NoNewPrivileges), vede il sistema in sola lettura (ProtectSystem=strict, ProtectHome=read-only) salvo le due cartelle elencate in ReadWritePaths, e ha una /tmp privata. Se il servizio venisse compromesso, il danno possibile è limitato a quelle cartelle. Questa unità fa ciò che faceva il Dockerfile: utente non privilegiato, configurazione da file, comando di avvio, isolamento, riavvio automatico. Cambia dove vive l'isolamento (nel kernel tramite il container, o nelle opzioni di systemd) e dove vivono le dipendenze (nell'immagine, o nell'ambiente virtuale sul server). La sezione [Install] in coda dice a quale traguardo agganciare l'unità quando la abiliti con systemctl enable.
Come scegliere: cinque domande
| Domanda | Se la risposta è... | ...tende verso |
|---|---|---|
| Qualcuno aspetta la risposta in tempo reale? | No | Batch |
| Il traffico è molto variabile o imprevedibile? | Sì | Servizio gestito (scala da solo) |
| I dati possono lasciare la tua infrastruttura? | No (privacy, normativa) | VPS o server tuo |
| Hai qualcuno che amministra un server? | No | Servizio gestito |
| Il costo fisso mensile conta più della flessibilità? | Sì | VPS (costo prevedibile) |
Le cinque domande non danno un punteggio: spingono in direzioni diverse, e la scelta nasce quando le risposte divergono. Un progetto con una macchina, una persona che la amministra, traffico contenuto e un costo fisso mensile ha l'insieme di risposte che porta al VPS, con un deploy che può restare manuale (git pull e riavvio, come mostra la sezione sotto). Un buon deploy è quello coerente con i vincoli del progetto, non il più alla moda; e un progetto con traffico molto variabile e nessun amministratore avrebbe risposto all'opposto.
Cosa deve esporre un servizio per essere gestibile
Un servizio senza strumenti per capire come sta è una scatola nera: se qualcosa va storto, scopri il problema da un utente arrabbiato. Servono quattro cose semplici. La prima è un controllo di salute, un endpoint (/health) che dice se il servizio è vivo. Un buon controllo ha un dettaglio notevole: se viene chiamato prima che il modello sia caricato risponde con "status": "starting" invece di fingere di essere pronto (l'applicazione di prova più sotto è più semplice, perché carica il modello prima di accettare richieste e risponde sempre ok). Distingue «sono vivo» da «sono pronto a lavorare», distinzione che i sistemi di orchestrazione chiamano liveness e readiness. La seconda è la versione: quale codice e quale modello stanno rispondendo, perché la prima domanda a un errore segnalato è «quale versione?». Aggiungila al controllo di salute, come nell'esempio di log sotto.
La terza sono i log. Tutto ciò che il servizio scrive sullo standard output finisce, sotto systemd, nel journal, e si legge con journalctl -u churn-api. La quarta sono le metriche, numeri nel tempo (richieste al secondo, latenza, errori): in Python si espongono, per esempio, con la libreria prometheus_client, nel formato che Prometheus raccoglie. Il lato delle metriche e del drift dei modelli è tema dell'articolo su drift detection e riaddestramento.
Il rilascio, comando per comando
Il rilascio manuale di un servizio come churn-api si può scrivere come una sola riga. Smontarla insegna che cosa significa «deploy» in concreto. Lo schema qui sotto è da adattare e non è stato eseguito, perché richiede il server:
cd /opt/churn-api \
&& git pull --ff-only \
&& uv sync --locked \
&& sudo systemctl restart churn-api
Il filo è l'operatore &&: ciascun passo parte solo se il precedente è riuscito, quindi un git pull fallito non installa e non riavvia nulla. È la prima difesa contro i deploy a metà. git pull --ff-only rifiuta di creare un commit di unione: se il ramo locale non è un semplice avanzamento di quello remoto si ferma con un errore (l'ho provato su due repository di prova, e finisce con codice 128 e il messaggio «Fast forward non possibile»), invece di fondere in silenzio. uv sync --locked installa esattamente il lockfile della lezione 05 e fallisce se non è aggiornato, quindi l'ambiente del servizio è quello dello sviluppo, e non quello che un pip install risolverebbe a ogni rilascio. systemctl restart ricarica codice, modello e configurazione. Il commit che hai appena tirato è l'identificatore del rilascio, e va annotato nel registro dei rilasci descritto in fondo.
Dopo il riavvio la riga non finisce: serve una verifica, perché «il comando è terminato» non vuol dire «il servizio è pronto». Il tempo dopo cui il controllo di salute risponde dipende dalla macchina e dal modello: misuralo sul tuo servizio, perché con un modello grande può essere molto più dei secondi che si aspetta chi viene dal web, e usalo per dimensionare i tentativi di attesa. La verifica minima è chiedere l'health e controllare che dica ciò che ti aspetti, compresa la versione:
curl -s http://127.0.0.1:8000/health
# schema da adattare, non eseguito: serve il servizio in esecuzione sul server
Se risponde "starting" aspetta e riprova; se non risponde, leggi il journal con journalctl -u churn-api. Nell'articolo successivo vedi cosa succede quando il rilascio sembra riuscito e non lo è.
Quando si cambia il modello e quando il codice
Con il registry i due rilasci si separano. Il rilascio del codice (la riga qui sopra) avviene quando cambia il servizio. Il rilascio del modello avviene quando sposti l'alias champion, e il servizio lo riprende al successivo riavvio. Regola pratica: tratta entrambi come eventi con un identificatore e una riga nel registro dei rilasci, anche solo un file di testo con data, versione del codice, versione del modello e chi lo ha fatto. Il registro è descritto in fondo all'articolo.
Le trappole più comuni
Quasi tutte nascono dalla stessa illusione, che il deploy finisca quando finisce il comando. Il servizio può essere ancora in avvio, o morto subito dopo; un timeout di avvio lasciato al default con un modello pesante lo fa uccidere a metà del caricamento e lo fa sembrare «instabile» (le due righe dell'unità qui sopra, TimeoutStartSec e StartLimit..., esistono per questo). Un solo worker con un modello grande è giusto, mentre più worker moltiplicano la memoria: misura prima di scalare. I segreti nell'unità restano leggibili nella configurazione di sistema, quindi vanno nel file di ambiente con permessi stretti. E senza un modo per sapere la versione in uso, la prima domanda di ogni incidente resta senza risposta. Un batch che fallisce in silenzio è la versione notturna dello stesso problema: se nessuno controlla l'esito, il lotto salta e nessuno se ne accorge per settimane; il codice d'uscita diverso da zero e un avviso sono parte del batch.
Dopo il rilascio comincia il lavoro vero
Un errore comune di chi viene dallo sviluppo è considerare il deploy il traguardo. In un servizio ML è il punto di partenza: da quel momento il modello incontra dati che non ha mai visto, utenti che fanno cose impreviste, e un'infrastruttura che a volte si rompe. Un servizio che non ti dice come sta è un servizio che scopri essere rotto da un cliente. La differenza tra un servizio che «gira» e uno che si può gestire comincia dal minimo: il registro di ciò che accade, la osservabilità, cioè la capacità di capire dall'esterno cosa succede dentro un sistema.
Log strutturati: righe che una macchina sa leggere
Un log è il diario del servizio. Un diario in prosa libera ("Richiesta ricevuta dal cliente 42, tutto ok") è leggibile da una persona e quasi inutilizzabile per cercare, contare, filtrare. Un log strutturato scrive una riga per evento, in un formato regolare (qui JSON), con campi fissi. Il servizio completo qui sotto aggiunge all'applicazione FastAPI un middleware, una funzione che avvolge ogni richiesta, che registra identificativo, percorso, stato, durata e versione del modello. Il modello si carica dal registry per numero di versione, preso dalla variabile MODEL_VERSION:
# app.py
import json
import logging
import os
import sys
import time
import uuid
from contextlib import asynccontextmanager
import mlflow
import pandas as pd
from fastapi import FastAPI, Request
from pydantic import BaseModel, Field
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
log = logging.getLogger("churn")
MODEL_VERSION = os.environ["MODEL_VERSION"]
state: dict = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
mlflow.set_tracking_uri(os.environ["MLFLOW_TRACKING_URI"])
state["model"] = mlflow.sklearn.load_model(f"models:/churn-classifier/{MODEL_VERSION}")
log.info(json.dumps({"evento": "avvio", "model_version": MODEL_VERSION}))
yield
app = FastAPI(lifespan=lifespan)
class Customer(BaseModel):
tenure: int = Field(ge=0, le=600)
complaints: int = Field(ge=0, le=100)
@app.middleware("http")
async def registra_richiesta(request: Request, call_next):
rid, t0 = uuid.uuid4().hex[:8], time.perf_counter()
response = await call_next(request)
log.info(json.dumps({"evento": "richiesta", "id": rid, "percorso": request.url.path,
"stato": response.status_code,
"ms": round((time.perf_counter() - t0) * 1000, 2),
"model_version": MODEL_VERSION}))
return response
@app.get("/health")
def health():
return {"status": "ok", "model_version": MODEL_VERSION}
@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)}
Il middleware prende ogni richiesta; uuid genera un identificativo breve per poterla ritrovare; time.perf_counter() misura il tempo prima e dopo; call_next(request) esegue il vero gestore e restituisce la risposta; poi si scrive una riga JSON. La versione del modello, letta dalla variabile d'ambiente, finisce in ogni riga e nel /health. L'avvio è l'unico evento scritto nel lifespan, dopo il caricamento del modello. Ho pilotato il servizio con il TestClient di FastAPI, che lo chiama in memoria senza aprire porte, con quattro richieste: un controllo di salute, due previsioni uguali e una non valida (tenure uguale a −1). Il pilota è questo, di otto righe:
from fastapi.testclient import TestClient
from app import app
with TestClient(app) as client:
client.get("/health")
for _ in range(2):
client.post("/predict", json={"tenure": 3, "complaints": 4})
client.post("/predict", json={"tenure": -1, "complaints": 4})
Il with è necessario: è lui a far partire e chiudere il lifespan, cioè a caricare il modello e a scrivere la riga di avvio. Senza, le richieste arriverebbero a un servizio senza modello. L'ho eseguito cinque volte con MODEL_VERSION=1 e MLFLOW_TRACKING_URI=sqlite:///mlflow.db, e qui ne riporto una:
# MODEL_VERSION=1 MLFLOW_TRACKING_URI=sqlite:///mlflow.db python prova_app.py
# (id e ms cambiano a ogni esecuzione)
{"evento": "avvio", "model_version": "1"}
{"evento": "richiesta", "id": "563da2c9", "percorso": "/health", "stato": 200, "ms": 7.58, "model_version": "1"}
{"evento": "richiesta", "id": "61e7127b", "percorso": "/predict", "stato": 200, "ms": 28.93, "model_version": "1"}
{"evento": "richiesta", "id": "d2e2446f", "percorso": "/predict", "stato": 200, "ms": 5.46, "model_version": "1"}
{"evento": "richiesta", "id": "d5f40531", "percorso": "/predict", "stato": 422, "ms": 0.49, "model_version": "1"}
Tre cose da leggere. Primo: ogni riga dice quale versione del modello ha risposto, quindi quando un utente segnala un problema alle 14:07, trovi la riga e sai quale modello l'ha prodotta. Secondo: la prima previsione è più lenta della seconda, perché il codice del modello viene eseguito per la prima volta (inizializzazioni, cache). In cinque esecuzioni rifatte per la verifica la prima è durata tra 10,3 e 18,7 ms e la seconda tra 1,5 e 1,8 ms; sono tempi della mia macchina e variano a ogni esecuzione. È il warm-up: un test di latenza o un controllo di salute devono tenerne conto, e un servizio con un modello pesante a volte fa una previsione fittizia all'avvio proprio per scaldarsi. Terzo: la richiesta con input non valido compare nel log con stato 422 e durata minima (0,35-0,6 ms nelle stesse cinque esecuzioni), perché è respinta prima di arrivare al modello; contare i 422 permette di accorgersi che un cliente ha cambiato formato.
Cosa non scrivere nei log: i dati di input in chiaro se contengono informazioni personali, i segreti, le intere risposte del modello se sono lunghe o sensibili. Una pratica prudente è registrare un identificativo o un riassunto (la lunghezza, un hash) e non il contenuto. Con righe JSON si possono poi estrarre i campi con strumenti come jq, e sotto systemd tutto ciò che il servizio scrive su stdout si cerca con journalctl -u nome-servizio.
Un batch pianificato, con i timer di systemd
Il batch di questo articolo ha bisogno di un orario. Con systemd si scrivono due file: l'unità di servizio, che dice cosa eseguire, e un'unità timer, che dice quando. La coppia qui sotto fa girare lo script del batch ogni notte. Il servizio è di tipo oneshot: parte, lavora e termina, a differenza del servizio sempre acceso della forma 2.
[Unit]
Description=Churn - punteggio batch notturno
[Service]
Type=oneshot
User=churn
WorkingDirectory=/opt/churn
EnvironmentFile=-/opt/churn/.env.prod
ExecStart=/opt/churn/.venv/bin/python score_batch.py clienti.csv risultati.csv
TimeoutStartSec=1800
NoNewPrivileges=true
ProtectSystem=strict
PrivateTmp=true
# file: churn-score.service
User=churn è un utente dedicato, come sempre. EnvironmentFile=-/opt/churn/.env.prod ha un trattino iniziale, che per la documentazione di systemd.exec rende il file opzionale e fa ignorare in silenzio ogni errore: comodo in sviluppo, pericoloso in produzione, dove un file di segreti mancante dovrebbe fermare il servizio e non farlo partire senza configurazione. TimeoutStartSec=1800 fissa un limite di mezz'ora: per i servizi di tipo oneshot il timeout di avvio è disattivato per default, quindi il limite è una scelta tua. Il timer dice quando:
[Unit]
Description=Churn - punteggio batch, ogni notte alle 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
Unit=churn-score.service
[Install]
WantedBy=timers.target
# file: churn-score.timer
OnCalendar usa una data nel formato di systemd, e Persistent=true fa recuperare l'esecuzione se la macchina era spenta all'ora prevista. Si attiva con systemctl enable --now churn-score.timer, comando che non ho eseguito perché modifica il sistema. Una cosa che sembra poco e vale molto: puoi verificare i file prima di installarli. Ho eseguito systemd-analyze (systemd 259) su queste due unità, su una variante con un interprete che esiste su questa macchina e su un calendario volutamente sbagliato:
systemd-analyze verify ./churn-score.service ./churn-score.timer
# churn-score.service: Command /opt/churn/.venv/bin/python is not executable: File o directory non esistente
# exit=1
# v.service = churn-score.service con ExecStart=/usr/bin/python3 ...; v.timer punta a v.service
systemd-analyze verify ./v.service ./v.timer
# exit=0
# bad.timer = v.timer con OnCalendar=*-*-* 25:99:00
systemd-analyze verify ./v.service ./bad.timer
# bad.timer:5: Failed to parse calendar specification, ignoring: *-*-* 25:99:00 (percorso abbreviato)
# bad.timer: Timer unit lacks value setting. Refusing.
# Unit bad.timer has a bad unit file setting.
# exit=1
systemd-analyze calendar "Mon *-*-* 03:00:00 UTC" # eseguito il 2026-11-12 alle 22:41, ora locale (CET)
# Normalized form: Mon *-*-* 03:00:00 UTC
# Next elapse: Mon 2026-11-16 04:00:00 CET
# (in UTC): Mon 2026-11-16 03:00:00 UTC
La prima verifica fallisce con un errore vero: sulla mia macchina non esiste /opt/churn/.venv/bin/python, e verify controlla anche che l'eseguibile esista, quindi sul server di destinazione, a installazione fatta, dovrebbe passare. La seconda, con /usr/bin/python3 al posto dell'interprete, esce con 0 senza messaggi. La terza mostra che un calendario non valido viene rifiutato prima di arrivare in produzione. Il comando calendar traduce un'espressione e dice quando scatterebbe: eseguito il 12 novembre 2026 alle 22:41 ora locale, un'espressione di esempio (lunedì alle 03:00 UTC) risulta lunedì 16 novembre alle 04:00 CET, cioè le 03:00 UTC. La riga «Next elapse» dipende dal giorno in cui lo lanci. Verificare prima di installare evita la classe di errori più sciocca e più comune dei timer: quello che parte all'ora sbagliata, per un fuso orario o una sintassi non capita.
Progettare per il guasto
Un servizio ML dipende da altre cose: una cache, un database, un servizio esterno. Prima o poi una non risponde, e il comportamento in quel momento è una decisione di progetto da prendere prima, non a caldo. Le opzioni vanno dalla più rigida alla più morbida. Si può fallire subito, con un errore chiaro, giusto quando una risposta sbagliata è peggio di nessuna risposta (una decisione finanziaria, medica). Si può degradare con grazia: restituire un risultato più povero ma onesto, segnalando il limite (un valore calcolato senza la cache, un avviso nella risposta). Oppure si può riprovare con pazienza, con attese crescenti e un numero massimo di tentativi, per i guasti transitori: esistono librerie pensate per questo, come tenacity in Python e Resilience4j in Java, che fornisce anche l'interruttore automatico di circuito (circuit breaker) per le chiamate fra servizi.
Il filo comune è decidere e documentare cosa vede l'utente quando qualcosa non va, e verificarlo con un test. Un guasto gestito è una funzionalità; uno non pensato è un incidente. Riprovare ha un limite che non va dimenticato: un guasto che dura minuti, mentre il servizio riprova, si trasforma in code di richieste in attesa; per questo i tentativi hanno un tetto e il circuito si apre.
Chi può chiamare il servizio: un principio e una riga dell'unità
Un servizio interno ha un presupposto da scrivere: chi è autorizzato a chiamarlo e dove viene verificato. Se la verifica sta in un altro servizio che gli sta davanti, il servizio interno non deve essere raggiungibile se non attraverso quello. L'unità mostrata sopra lo dice con una riga: --host 127.0.0.1, cioè il processo ascolta solo sul computer stesso, e chi lo chiama deve stare sulla stessa macchina. Cambiare quell'indirizzo in 0.0.0.0 lo aprirebbe a ogni interfaccia di rete, e a quel punto a tenerlo lontano da Internet resterebbe solo il firewall. La lezione è generale: il valore sicuro deve stare nel file versionato e non in una correzione locale, che la prossima reinstallazione dell'unità cancellerebbe senza avvisare.
Il valore giusto dipende da chi sta davanti. Con una piattaforma gestita vale l'opposto: il contratto di Cloud Run chiede che il container ascolti su 0.0.0.0, e dice che non deve ascoltare su 127.0.0.1, perché è la piattaforma a ricevere le richieste e a inoltrarle. Per te: controlla che la verifica non possa essere aggirata chiamando direttamente la porta del servizio.
Rilasciare senza fermare il servizio: l'idea blu-verde
Con un solo processo e un avvio che può durare minuti, il riavvio del servizio ML significa un'interruzione. Per molti progetti, con rilasci rari e traffico contenuto, è accettabile. Quando non lo è, esiste una tecnica concettualmente semplice, il rilascio blu-verde: si tengono due copie del servizio, su due porte diverse; il proxy invia il traffico alla copia «blu» mentre si avvia la «verde» con la nuova versione; quando la verde risponde bene al controllo di salute, si sposta il traffico su di lei e si ferma la blu. Il rollback è lo spostamento inverso, immediato, perché la vecchia copia è ancora lì. Costa il doppio della memoria durante il rilascio (due modelli caricati) e una configurazione del proxy più elaborata. Va scelto quando il costo dell'interruzione supera quello della complessità; l'articolo sul deploy manuale e la CI/CD discute il criterio con cui si decide quanta complessità di rilascio ripaga, ed è il motivo per cui un progetto con una macchina e rilasci rari può restare sul riavvio semplice.
Il registro dei rilasci: l'abitudine che manca più spesso
È un file di testo (o una tabella) in cui, a ogni rilascio, scrivi una riga:
| Data e ora | Servizio | Versione del codice | Versione del modello | Chi | Esito della verifica |
|---|---|---|---|---|---|
| 2026-11-12 18:20 | churn-api | sha-a1b2c3d | champion v1 | nome | health: versione 1, previsione di prova 0.7993 |
La riga è d'esempio: data, nome del servizio e hash sono inventati, mentre la previsione di prova (0.7993) è quella dell'articolo sul registry. Costa trenta secondi e risponde, mesi dopo, alla domanda «cosa era in produzione il giorno in cui è successo?». È ciò che trasforma il rollback da ipotesi a procedura: sai a quale versione tornare. Non richiede strumenti: un file nel repository, o una pagina di wiki, basta.
Che cosa sai fare adesso
Sai distinguere le quattro forme di deploy (batch, API su server, servizio gestito, VPS con systemd) e sceglierne una con cinque domande: attesa in tempo reale, variabilità del traffico, dove possono stare i dati, chi amministra, costo fisso o flessibile. Sai leggere un'unità systemd riga per riga come l'equivalente operativo di un Dockerfile, scrivere un timer e verificarlo con systemd-analyze prima di installarlo. Sai che un servizio gestibile espone un controllo di salute con la distinzione vivo/pronto, la versione, log strutturati e metriche, e che il comando di rilascio termina prima che il servizio sia pronto, quindi si verifica sempre. L'ultimo passo del modulo è decidere quanto automatizzare: deploy manuale o CI/CD, e perché a volte la pipeline automatica non conviene.
Nella stessa serie trovi anche ML-Ops: container, immagini e isolamento.
Fonti
- systemd.service:
Type=oneshot,TimeoutStartSec=(disattivato di default peroneshot),Restart=, consultata il 2026-11-12. - systemd.timer:
OnCalendar=ePersistent=, consultata il 2026-11-12. - systemd.exec:
EnvironmentFile=e prefisso «-»,NoNewPrivileges=,ProtectSystem=,PrivateTmp=, consultata il 2026-11-12. - systemd.unit:
StartLimitIntervalSec=eStartLimitBurst=, consultata il 2026-11-12. - Cloud Run, contratto del container: variabile d'ambiente
PORTiniettata nel container e obbligo di ascoltare su0.0.0.0, consultata il 2026-11-13. - systemd.kill:
KillSignal=vale SIGTERM per default, consultata il 2026-11-13.
Come sono stati verificati i listati: score_batch.py e l'applicazione con il middleware sono stati eseguiti con MLflow 3.18.0, FastAPI 0.162.5 (con httpx 0.28.1 per il TestClient) e Python 3.12 su un registry di prova; score_batch.py due volte con lo stesso file di uscita, l'applicazione cinque volte (id e tempi variano). systemd-analyze è di systemd 259. L'unità churn-api.service è stata controllata con systemd-analyze verify, due volte, in una variante con un eseguibile esistente. Non eseguiti, dichiarati nel testo: il rilascio su un server, curl sul servizio, systemctl e journalctl. Clienti e probabilità sono dati sintetici inventati.
Commenti
Caricamento commenti...
Accedi per commentare