Il problema: un agente non si comporta come un servizio web
Un servizio web risponde in pochi decimi di secondo, non ricorda niente tra una richiesta e l'altra e, se lo spegni, perde al massimo le richieste in volo. Un agente fa il contrario su tutti e tre i punti: un compito dura secondi o minuti perché dentro c'è un ciclo di chiamate al modello e agli strumenti, porta con sé uno stato (che cosa ha già fatto, che cosa gli manca) e, se il processo viene fermato a metà, può aver già eseguito un'azione con effetti. Mettere un agente in un container e dargli tre repliche lo fa girare, ma non lo rende gestibile.
Questo articolo prende quei tre punti uno alla volta: come si ferma un processo che sta lavorando senza perdere il compito, come si distingue «sta avviandosi» da «è bloccato» da «non può servire adesso», come si decide quante repliche servono quando la CPU non dice nulla, e che cosa misurare e registrare per accorgersi dei guai. Prima di cominciare, una dichiarazione sullo stato delle prove: i listati Python girano in locale con modelli e agenti finti a copione, due volte con uscita identica. Il Dockerfile e i manifesti Kubernetes sono invece schemi da adattare, non eseguiti: non c'è Docker né un cluster in questa macchina, e i manifesti sono stati controllati solo come sintassi YAML (due documenti validi), non applicati.
Questo articolo arriva dopo quelli sull'agente e prima di quello sui costi: l'orchestrazione ha già introdotto i tracciatori e le code, la valutazione dice come sapere se l'agente funziona, e la sicurezza come limitarne i danni. Qui si parla di tenerlo in piedi.
Il contenitore: poche righe che contano
Un'immagine per un agente Python non ha niente di speciale, con una sola eccezione: il modo in cui il processo viene avviato. Il Dockerfile qui sotto usa due stadi, perché i compilatori servono per installare certi pacchetti e non servono per eseguirli; copia nello stadio finale solo ciò che è stato installato. L'utente agent senza shell di login evita che il processo giri come amministratore dentro il container.
# --- stadio 1: compilazione ---
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# --- stadio 2: esecuzione ---
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /install /usr/local
COPY src/ ./src/
RUN useradd --create-home --shell /usr/sbin/nologin agent
USER agent
ENV PYTHONUNBUFFERED=1
EXPOSE 8080
# forma exec: python è il processo 1 e riceve SIGTERM direttamente
ENTRYPOINT ["python", "-m", "src.agent_server"]
La riga che pesa è l'ultima. La documentazione di Docker dice che la forma shell di ENTRYPOINT avvia il programma come sottoprocesso di /bin/sh -c, che non inoltra i segnali: il programma non è il processo 1 e non riceve il SIGTERM di docker stop. Con la forma exec (l'elenco tra parentesi quadre) è Python stesso a ricevere il segnale, e potrà reagire come nel listato della prossima sezione. Vale la pena scartare due abitudini comuni: non c'è nessun HEALTHCHECK, perché le sonde le dichiara il manifesto Kubernetes (esiste per Docker semplice, con intervallo e timeout predefiniti di 30 secondi), e nessuna chiave nell'immagine, perché un'immagine si copia, si pubblica e si dimentica.
Quando non serve tutto questo: per un agente che gestisce poche richieste su un solo server, un container (o perfino un'unità systemd) con riavvio automatico copre il caso. Kubernetes risolve problemi di molte repliche e molti nodi; se non li hai, ti porta in dote un cluster da mantenere.
Fermarsi bene: SIGTERM e compiti a metà
Ogni volta che aggiorni l'immagine, che il cluster sposta un pod o che l'autoscaler riduce le repliche, i processi ricevono un SIGTERM. La documentazione di Kubernetes descrive la sequenza: il kubelet chiede al runtime di fermare i container mandando TERM al processo principale, con un periodo di grazia predefinito di 30 secondi (terminationGracePeriodSeconds); scaduto quello, il processo viene ucciso. Un servizio web se la cava perché le richieste durano poco. Un agente che sta a metà di un compito di due minuti no.
Il listato mostra la soluzione minima. Il gestore del segnale non interrompe nulla: alza soltanto un indicatore, e il ciclo lo legge tra un compito e il successivo. Lo stato vive in una tabella SQLite (nel manifesto sarebbe Redis o PostgreSQL, fuori dal pod), così il pod B può riprendere dal compito 3 senza che nessuno glielo racconti. Il segnale lo spedisce il programma a se stesso dopo il secondo compito, per riprodurre senza un cluster ciò che farebbe il kubelet; l'agente è una funzione che restituisce una stringa, nessun modello è chiamato.
import os
import signal
import sqlite3
import tempfile
fermati = False
def alla_terminazione(numero, frame):
global fermati
fermati = True # il ciclo lo legge tra un compito e il successivo
signal.signal(signal.SIGTERM, alla_terminazione)
def agente_finto(compito):
return f"risposta a {compito}"
def esegui(db, nome, spedisci_sigterm_dopo=None):
fatti = 0
while not fermati:
riga = db.execute(
"SELECT id, testo FROM compiti WHERE stato = 'in_coda' ORDER BY id LIMIT 1"
).fetchone()
if riga is None:
print(f"{nome}: coda vuota")
return
id_, testo = riga
esito = agente_finto(testo)
db.execute(
"UPDATE compiti SET stato = 'fatto', esito = ? WHERE id = ?", (esito, id_)
)
db.commit()
fatti += 1
print(f"{nome}: compito {id_} completato")
if spedisci_sigterm_dopo == fatti:
os.kill(os.getpid(), signal.SIGTERM) # come farebbe il kubelet
print(f"{nome}: SIGTERM ricevuto, esco dopo {fatti} compiti")
with tempfile.TemporaryDirectory() as cartella:
db = sqlite3.connect(os.path.join(cartella, "stato.db"))
db.execute("CREATE TABLE compiti (id INTEGER PRIMARY KEY, testo TEXT, stato TEXT, esito TEXT)")
for n in range(1, 6):
db.execute("INSERT INTO compiti (testo, stato) VALUES (?, 'in_coda')", (f"domanda {n}",))
db.commit()
esegui(db, "pod A", spedisci_sigterm_dopo=2)
fermati = False # il pod B è un processo nuovo: qui lo simuliamo
esegui(db, "pod B")
print(db.execute("SELECT stato, COUNT(*) FROM compiti GROUP BY stato").fetchall())
# Output:
# pod A: compito 1 completato
# pod A: compito 2 completato
# pod A: SIGTERM ricevuto, esco dopo 2 compiti
# pod B: compito 3 completato
# pod B: compito 4 completato
# pod B: compito 5 completato
# pod B: coda vuota
# [('fatto', 5)]
Due cose da leggere nell'uscita. Primo, nessun compito è andato perso né eseguito due volte: il pod A ha finito il compito in corso e poi è uscito. Secondo, questo funziona perché ogni compito dura meno del periodo di grazia. Se un compito può durare di più, hai tre scelte: alzare terminationGracePeriodSeconds (come nel manifesto più avanti), spezzare il compito in passi con un punto di ripresa dopo ciascuno, oppure accettare che venga ripetuto. In quest'ultimo caso gli strumenti con effetti devono tollerare la ripetizione, cioè essere idempotenti: se ne parla nell'articolo sul tool calling avanzato. Un SIGKILL, che nessun codice può intercettare, arriva comunque se il periodo scade o se il nodo muore, quindi la tabella di stato resta necessaria anche con l'arresto ordinato.
Tre sonde, tre domande diverse
Kubernetes offre tre sonde, e la confusione più frequente è usarne una per rispondere a tutte e tre le domande. Ognuna ha una conseguenza diversa quando fallisce, e per questo va alimentata da un controllo diverso.
- Startup: «ha finito di avviarsi?». Se fallisce oltre la soglia, il kubelet uccide il container e applica la politica di riavvio.
- Liveness: «sta ancora facendo progressi?». Serve a rilevare situazioni come un deadlock; se fallisce oltre la tolleranza, il container viene riavviato.
- Readiness: «ha senso mandarle traffico adesso?». Se fallisce il pod esce dagli endpoint del Service, ma non viene riavviato.
La conseguenza pratica sta nella terza voce e nella seconda: se la sonda di liveness controllasse Redis, una caduta di Redis farebbe riavviare tutte le repliche insieme, che al riavvio troverebbero ancora Redis giù, in un ciclo che peggiora l'incidente. Redis giù è un problema di readiness (non mandarmi compiti) e non di liveness (non sono io il guasto). Il listato separa le tre domande in tre funzioni pure; i controlli reali (ping a Redis, ultimo giro del ciclo principale) li fornisci tu, qui sono quattro stati scritti a mano.
from dataclasses import dataclass
@dataclass
class Stato:
ultimo_giro_del_ciclo: float # secondi dall'ultimo giro del ciclo principale
avviato: bool # caricamento iniziale terminato
redis_ok: bool
chiave_presente: bool
LIMITE_BLOCCO_S = 120 # scelta d'esempio: un giro piu lungo del compito piu lungo atteso
def viva(s):
"""200 se il processo progredisce. Non guarda le dipendenze."""
return (200, "viva") if s.ultimo_giro_del_ciclo < LIMITE_BLOCCO_S else (503, "ciclo bloccato")
def pronta(s):
"""200 se ha senso mandarle traffico adesso."""
if not s.avviato:
return 503, "in avvio"
if not s.redis_ok:
return 503, "redis non raggiungibile"
if not s.chiave_presente:
return 503, "chiave del modello assente"
return 200, "pronta"
def avviata(s):
return (200, "avviata") if s.avviato else (503, "caricamento in corso")
casi = {
"tutto bene": Stato(3, True, True, True),
"redis è caduto": Stato(3, True, False, True),
"ancora in avvio": Stato(0, False, False, True),
"ciclo bloccato": Stato(400, True, True, True),
}
for nome, s in casi.items():
print(f"{nome:17} live={viva(s)[0]} ready={pronta(s)[0]} startup={avviata(s)[0]} ({pronta(s)[1]})")
# Output:
# tutto bene live=200 ready=200 startup=200 (pronta)
# redis è caduto live=200 ready=503 startup=200 (redis non raggiungibile)
# ancora in avvio live=200 ready=503 startup=503 (in avvio)
# ciclo bloccato live=503 ready=200 startup=200 (pronta)
Nell'uscita, la riga «redis è caduto» tiene viva la replica (200) ma la toglie dal traffico (503); la riga «ciclo bloccato» è l'unica che chiede un riavvio. Nota che nel quarto caso la readiness dice ancora 200: un processo bloccato continua a sembrare pronto finché la liveness non lo ferma, ed è un buon motivo per tenere la soglia del ciclo vicina alla durata del compito più lungo (qui 120 secondi, un valore d'esempio, non un consiglio). Un'altra scelta di questo schema: la sonda legge uno stato già calcolato in background, invece di aprire una connessione a ogni richiesta, per non trasformare la sonda in un carico.
Nel manifesto la sonda di avvio è quella che protegge gli avvii lenti. La documentazione di Kubernetes suggerisce di darle lo stesso controllo della liveness con un prodotto failureThreshold × periodSeconds abbastanza lungo da coprire il caso peggiore di avvio: qui 18 × 10 secondi, cioè fino a 180 secondi, scelti come esempio per un agente che carichi indici o documenti all'inizio. Il resto del manifesto fissa le richieste di CPU e memoria (il limite sulla CPU è omesso: è una scelta mia, discutibile, per non rallentare un ciclo già lento) e prende la chiave del modello da un Secret.
apiVersion: apps/v1
kind: Deployment
metadata:
name: agente
spec:
replicas: 3
selector:
matchLabels: {app: agente}
template:
metadata:
labels: {app: agente}
spec:
terminationGracePeriodSeconds: 120 # più lungo del compito più lungo che accetti di finire
containers:
- name: agente
image: registry.example.com/agente:1.0.0
ports:
- {name: http, containerPort: 8080}
resources:
requests: {cpu: 500m, memory: 1Gi}
limits: {memory: 2Gi}
env:
- name: CHIAVE_MODELLO
valueFrom:
secretKeyRef: {name: agente-segreti, key: chiave-modello}
startupProbe:
httpGet: {path: /health/startup, port: http}
periodSeconds: 10
failureThreshold: 18 # 18 x 10 s = fino a 180 s per avviarsi
livenessProbe:
httpGet: {path: /health/live, port: http}
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet: {path: /health/ready, port: http}
periodSeconds: 10
failureThreshold: 3
Il Secret non è cifrato di suo: la documentazione avverte che per impostazione predefinita Kubernetes lo conserva non cifrato nel datastore del cluster (etcd), e chi ha accesso all'API, o può creare un pod nello stesso namespace, può leggerlo. Il valore in base64 che si vede nei manifesti è una codifica, non una protezione. Per questo la chiave non sta nell'immagine né nel repository: si crea nel cluster (per esempio con kubectl create secret da un file locale) e si abilita la cifratura a riposo se la gestisci tu.
Quante repliche: la formula dell'autoscaler
Per un agente la CPU è un cattivo segnale di carico: il processo passa quasi tutto il tempo ad aspettare il modello. Un pod può avere dieci compiti in corso e la CPU al 5%. L'autoscaler orizzontale (HPA) accetta metriche personalizzate: il numero di compiti attivi per pod, o la lunghezza della coda. Prima di scrivere il manifesto conviene sapere che cosa ne fa, perché la regola è semplice: la documentazione la scrive come desiredReplicas = ceil[currentReplicas × currentMetricValue / desiredMetricValue], e salta qualunque azione quando il rapporto è abbastanza vicino a 1,0, con una tolleranza predefinita di 0,1.
Il listato riproduce solo quella formula, la tolleranza e i limiti minimo e massimo: è una versione ridotta, perché il controllore reale considera anche i pod non pronti, le metriche mancanti e le politiche di scaling. Serve a rispondere alle domande che si fanno prima di configurare qualunque cosa: quante repliche chiederà? Si stabilizza?
import math
TOLLERANZA = 0.1 # valore predefinito del controllore (documentazione Kubernetes)
def repliche_desiderate(attuali, metrica_attuale, metrica_obiettivo, minimo, massimo):
rapporto = metrica_attuale / metrica_obiettivo
if abs(rapporto - 1.0) <= TOLLERANZA:
return attuali # abbastanza vicino a 1: nessuna azione
desiderate = math.ceil(attuali * rapporto)
return max(minimo, min(massimo, desiderate))
# metrica per pod: compiti attivi, obiettivo 5 per pod
print("compiti attivi per pod, obiettivo 5")
for attuali, per_pod in [(3, 5.2), (3, 8), (3, 2), (10, 12), (10, 60)]:
d = repliche_desiderate(attuali, per_pod, 5, minimo=2, massimo=20)
print(f" {attuali:2} repliche, {per_pod:4} per pod -> {d}")
# metrica esterna: profondità della coda, obiettivo 10 compiti per replica.
# Con AverageValue la metrica per pod è totale / repliche attuali.
print("coda con 120 compiti, obiettivo 10 per replica")
for attuali in (3, 8, 12, 20):
d = repliche_desiderate(attuali, 120 / attuali, 10, minimo=2, massimo=20)
print(f" {attuali:2} repliche -> {d}")
# Output:
# compiti attivi per pod, obiettivo 5
# 3 repliche, 5.2 per pod -> 3
# 3 repliche, 8 per pod -> 5
# 3 repliche, 2 per pod -> 2
# 10 repliche, 12 per pod -> 20
# 10 repliche, 60 per pod -> 20
# coda con 120 compiti, obiettivo 10 per replica
# 3 repliche -> 12
# 8 repliche -> 12
# 12 repliche -> 12
# 20 repliche -> 12
Tre letture. Con 3 repliche e 5,2 compiti per pod l'HPA non fa nulla (rapporto 1,04, dentro la tolleranza), mentre a 8 passa a 5. Con 10 repliche e 60 compiti per pod la formula chiederebbe 120 repliche e il limite massimo lo ferma a 20: in quel momento la coda cresce e nessun autoscaler ti salva, perciò il massimo è anche una decisione di costo e di carico sui servizi a valle. Infine, con la coda da 120 compiti e un obiettivo di 10 per replica il sistema converge a 12 qualunque sia il punto di partenza, e proprio per questo conviene misurare la coda e non la CPU.
Il manifesto dell'HPA usa la prima metrica, i compiti attivi per pod, con una finestra di stabilizzazione di 300 secondi in discesa: la documentazione spiega che l'HPA guarda gli stati desiderati calcolati in quell'intervallo e usa il più alto, per non togliere pod per una pausa breve. Per salire non c'è attesa e si aggiungono al massimo 4 pod al minuto (valori d'esempio). Una metrica personalizzata non esiste da sola: serve un componente che la renda disponibile all'API delle metriche del cluster, ed è un pezzo in più da installare e tenere aggiornato.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: agente
spec:
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: agente}
minReplicas: 2
maxReplicas: 20
metrics:
- type: Pods
pods:
metric: {name: agent_active_tasks}
target: {type: AverageValue, averageValue: "5"}
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- {type: Pods, value: 4, periodSeconds: 60}
scaleDown:
stabilizationWindowSeconds: 300
Se l'agente elabora compiti presi da una coda, esiste un'alternativa: KEDA, un componente a parte che, come dice la sua documentazione, può portare il carico a zero repliche quando non ci sono messaggi e riattivarlo quando ne arriva uno, pilotando l'HPA sul resto. Scalare a zero costa un avvio a freddo al primo compito, quindi vale per lavori in differita e non per chi aspetta una risposta in chat. Anche l'HPA può arrivare a zero repliche con metriche esterne o di oggetto, non con la sola CPU, secondo la sua documentazione.
Che cosa misurare, e dove non farsi male con le etichette
Le metriche dell'infrastruttura (CPU, memoria, riavvii) dicono che il pod vive, non che l'agente lavora bene. Quelle che servono a un agente sono poche e rispondono a domande precise:
- Compiti terminati per esito: un contatore con l'etichetta
esito(ok, errore, scaduto) per calcolare la quota di fallimenti. - Durata del compito: un istogramma in secondi, per vedere la coda lunga, non solo la media.
- Compiti attivi: un indicatore che sale e scende, la stessa metrica che l'autoscaler legge.
- Chiamate al modello e agli strumenti, con token consumati: il dato che alimenterà il controllo dei costi dell'articolo successivo.
Il criterio comune è che ogni metrica risponda a una decisione: allarmare, scalare o spiegare un costo. Una metrica che nessuno legge è un costo di memoria nel server che le raccoglie. Il listato mostra le prime tre con la libreria ufficiale prometheus_client (0.26.0), in un registro privato così che l'uscita non dipenda dal processo.
from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram, generate_latest
reg = CollectorRegistry() # registro privato: l'uscita non dipende dal processo
compiti = Counter("agent_tasks", "Compiti terminati", ["esito"], registry=reg)
durata = Histogram("agent_task_duration_seconds", "Durata del compito", buckets=[1, 5, 30], registry=reg)
attivi = Gauge("agent_active_tasks", "Compiti in corso", registry=reg)
attivi.inc()
for esito, secondi in [("ok", 0.8), ("ok", 4.0), ("errore", 12.0)]:
compiti.labels(esito=esito).inc()
durata.observe(secondi)
attivi.dec()
attivi.inc()
for riga in generate_latest(reg).decode().splitlines():
if not riga.startswith("#") and "_created" not in riga:
print(riga)
# l'errore di progetto: un'etichetta con valori illimitati
reg2 = CollectorRegistry()
sbagliato = Counter("agent_tasks_by_id", "no", ["task_id"], registry=reg2)
for n in range(1000):
sbagliato.labels(task_id=f"t{n}").inc()
serie = [r for r in generate_latest(reg2).decode().splitlines() if r.startswith("agent_tasks_by_id_total")]
print("serie create con task_id come etichetta:", len(serie))
# Output:
# agent_tasks_total{esito="ok"} 2.0
# agent_tasks_total{esito="errore"} 1.0
# agent_task_duration_seconds_bucket{le="1.0"} 1.0
# agent_task_duration_seconds_bucket{le="5.0"} 2.0
# agent_task_duration_seconds_bucket{le="30.0"} 3.0
# agent_task_duration_seconds_bucket{le="+Inf"} 3.0
# agent_task_duration_seconds_count 3.0
# agent_task_duration_seconds_sum 16.8
# agent_active_tasks 1.0
# serie create con task_id come etichetta: 1000
Dall'uscita si impara qualcosa che la documentazione conferma: il contatore chiamato agent_tasks compare come agent_tasks_total, perché la libreria aggiunge il suffisso; i bucket dell'istogramma sono cumulativi (il bucket da 5 secondi conta anche quello da 1); la durata si misura in secondi, l'unità di base che le linee guida di Prometheus raccomandano. Le soglie dei bucket (1, 5, 30) sono d'esempio e vanno scelte attorno alla durata che osservi davvero. L'ultima riga è la trappola: usare un identificativo illimitato come etichetta crea una serie temporale per ogni valore, mille compiti, mille serie. Le stesse linee guida dicono di non usare etichette per dimensioni con alta cardinalità, come identificativi utente o email. L'identificativo del compito va nei log, non nelle metriche.
Log che si possono seguire
Se un compito attraversa dodici chiamate e tre pod, una riga di log che dice «errore nello strumento» non basta: devi poter filtrare tutte le righe di quel compito. La soluzione è un identificativo di traccia presente in ogni riga, e in Python il posto giusto per tenerlo è una variabile di contesto (contextvars), che resta legata al compito anche in codice asincrono senza dover passare l'identificativo da una funzione all'altra. Il formato JSON permette poi di cercare per campo. Il listato imposta l'identificativo una volta per compito e lo stampa in ogni riga.
import contextvars
import json
import logging
import sys
trace_id = contextvars.ContextVar("trace_id", default="-")
class JsonFormatter(logging.Formatter):
def format(self, record):
voce = {
"level": record.levelname,
"trace_id": trace_id.get(),
"event": record.getMessage(),
}
voce.update(getattr(record, "campi", {}))
return json.dumps(voce, ensure_ascii=False, sort_keys=True)
log = logging.getLogger("agente")
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
log.addHandler(handler)
log.setLevel(logging.INFO)
def compito(id_compito, passi):
trace_id.set(f"trace-{id_compito}") # una sola assegnazione per compito
log.info("compito_avviato", extra={"campi": {"task": id_compito}})
for n, strumento in enumerate(passi, start=1):
log.info("strumento_chiamato", extra={"campi": {"iterazione": n, "strumento": strumento}})
log.warning("compito_terminato", extra={"campi": {"iterazioni": len(passi)}})
compito("t1", ["cerca", "leggi"])
compito("t2", ["cerca"])
# Output:
# {"event": "compito_avviato", "level": "INFO", "task": "t1", "trace_id": "trace-t1"}
# {"event": "strumento_chiamato", "iterazione": 1, "level": "INFO", "strumento": "cerca", "trace_id": "trace-t1"}
# {"event": "strumento_chiamato", "iterazione": 2, "level": "INFO", "strumento": "leggi", "trace_id": "trace-t1"}
# {"event": "compito_terminato", "iterazioni": 2, "level": "WARNING", "trace_id": "trace-t1"}
# {"event": "compito_avviato", "level": "INFO", "task": "t2", "trace_id": "trace-t2"}
# {"event": "strumento_chiamato", "iterazione": 1, "level": "INFO", "strumento": "cerca", "trace_id": "trace-t2"}
# {"event": "compito_terminato", "iterazioni": 1, "level": "WARNING", "trace_id": "trace-t2"}
L'ordinamento delle chiavi (sort_keys) rende l'uscita identica a ogni esecuzione, comodo per confrontare i log nei test; l'ora è omessa solo in questo esempio, nei sistemi reali la aggiunge l'aggregatore o il formattatore. Un avvertimento: i log degli agenti contengono spesso testo degli utenti, prompt e risposte, quindi decidi che cosa registri e per quanto, e non scrivere mai le chiavi. Per le tracce a span annidati, con la gerarchia compito, iterazione e chiamata, il tracciatore minimo dell'articolo sull'orchestrazione mostra la struttura.
Domande frequenti
Serve Kubernetes per portare un agente in produzione?
No. Kubernetes risolve il problema di molte repliche su molti nodi. Per un agente con carico modesto su un solo server bastano un container o un'unità di servizio con riavvio automatico, uno stato esterno al processo e una gestione corretta di SIGTERM, che è il punto che questo articolo mostra con un listato.
Perché la liveness non deve controllare le dipendenze?
Perché riavviare un processo non ripara una dipendenza guasta. Se la liveness controlla Redis e Redis cade, tutte le repliche vengono riavviate insieme e al riavvio ritrovano il guasto. Una dipendenza assente è un caso per la readiness, che toglie la replica dal traffico senza riavviarla.
Con quale metrica scalare un agente?
Con una che segue il lavoro in attesa: i compiti attivi per pod o la lunghezza della coda. La CPU dice poco, perché l'agente aspetta il modello. L'HPA calcola le repliche come ceil(repliche attuali × metrica attuale / metrica obiettivo) e non agisce se il rapporto è entro 0,1 da 1.
Che cosa sai fare adesso
Sai scrivere un'immagine il cui processo riceve SIGTERM e finisce il compito in corso prima di uscire, tenendo lo stato fuori dal pod così che un'altra replica possa riprendere. Sai separare le tre sonde in base a ciò che succede quando falliscono, calcolare a mano che cosa chiederà l'autoscaler e perché un massimo troppo basso diventa una coda, scegliere poche metriche che motivano una decisione ed evitare l'errore delle etichette illimitate. E sai dire dove i listati finiscono: nessuna riga è stata provata su un cluster, quindi i valori dei manifesti sono punti di partenza da misurare sul tuo carico.
Per vedere un grafo con tetto e verificatore messo alla prova, c'è l'esempio costruito di assistente di ricerca; per i limiti che nessun deployment risolve, Il Futuro degli AI Agents: Capacità Emergenti e Limitazioni.
Fonti
- Pod Lifecycle (Kubernetes) - sequenza di terminazione con SIGTERM, periodo di grazia predefinito di 30 secondi, funzione delle sonde di avvio, liveness e readiness; consultata il 2026-11-12
- Configure Liveness, Readiness and Startup Probes (Kubernetes) - sonda di avvio con failureThreshold × periodSeconds per coprire l'avvio più lento; consultata il 2026-11-12
- Horizontal Pod Autoscaling (Kubernetes) - formula delle repliche desiderate, tolleranza 0,1, finestra di stabilizzazione, scalabilità a zero con metriche esterne; consultata il 2026-11-12
- Secrets (Kubernetes) - Secret conservati per impostazione predefinita non cifrati in etcd; consultata il 2026-11-12
- Dockerfile reference (Docker) - forma shell ed exec di ENTRYPOINT e segnali, opzioni e valori predefiniti di HEALTHCHECK; consultata il 2026-11-12
- Metric and label naming (Prometheus) - unità di base e divieto di etichette ad alta cardinalità; consultata il 2026-11-12
- Scaling Deployments, StatefulSets and Custom Resources (KEDA) - scalare a zero in assenza di messaggi; consultata il 2026-11-12
- Prove eseguite in locale:
python3 -Isui quattro listati con la sola libreria standard eprometheus_client0.26.0 in un ambiente virtuale fuori dal repository, ciascuno due volte con uscita identica; YAML dei manifesti letto con PyYAML 6.0.3 (sola sintassi)

Commenti
Caricamento commenti...
Accedi per commentare