Skip to content
Appunti personali
3 min di lettura

I container muoiono quando capita; due ore di lavoro non devono morire con loro

Quarto articolo della serie sul sandbox per agenti. Si sposta l’avanzamento dalla memoria del container a un disco condiviso, poi si elimina di proposito il container che sta lavorando — un altro riprende da pagina 17. Compresa la parte onesta: questo «ripristino automatico» richiede ancora che qualcuno prema qualcosa.

Tre articoli più tardi: i task sono isolati fra loro e l’attesa è passata da 45 secondi a una frazione. Ma una cosa non è cambiata dal primo articolo — l’avanzamento vive ancora nella memoria del container.

Prima significava «se crasha, si perde». Ora è peggio: i container di un pool caldo vengono sostituiti per routine. Li sostituiscono gli aggiornamenti, i guasti delle macchine, la pressione sulle risorse. Sono materiale di consumo per progetto.

La domanda cambia forma: perché mai affidare a un materiale di consumo due ore del proprio lavoro?

Scriverlo altrove: la lavagna del cantiere, non la testa dell’operaio

Immagina un cantiere. L’avanzamento tenuto nella testa di un operaio finisce col cambio turno; l’avanzamento scritto col gesso sulla lavagna appesa al muro lo legge chiunque arrivi dopo.

Il codice fa esattamente questo — appende la lavagna al muro:

# si richiede un disco condiviso da 1GB
kind: PersistentVolumeClaim
spec:
  resources:
    requests:
      storage: 1Gi
---
# lo si monta in ogni container del pool
volumeMounts:
  - name: workspace-volume
    mountPath: /workspace     # è la directory che il container vede

In K8s un disco condiviso come questo si chiama volume persistente. La parola chiave è persistente: sopravvive al container, e il container successivo può montarlo.

Poi due modifiche al lavoro vero e proprio. Dopo ogni pagina, si scrive sulla lavagna:

for i in range(start_page, total_pages):
    await asyncio.sleep(1.0)                                  # scarica una pagina
    with open(f"/workspace/{task_id}/checkpoint.json", "w") as f:
        json.dump({"pages_crawled": i + 1, "total": total_pages}, f)   # scrive subito

Prima di iniziare, si legge cosa ha lasciato il turno precedente:

start_page = 0
if os.path.exists(ckpt_file):
    start_page = json.load(open(ckpt_file)).get("pages_crawled", 0)
    print(f"checkpoint trovato, si riprende da pagina {start_page}")

Tutto qui. Niente transazioni distribuite, niente logiche di compensazione: un file JSON.

Ora si elimina di proposito il container che lavora

Questo passo si giudica solo rompendo qualcosa; leggere il codice non dimostra nulla.

A metà di un task, si elimina il container che sta lavorando:

kubectl delete pod <quello-che-sta-lavorando>

Si rilancia lo stesso task e si guarda il log del nuovo container:

checkpoint trovato, si riprende da pagina 17
[worker-b] task-a3f9 progress: 18/30
[worker-b] task-a3f9 progress: 19/30

Le prime diciassette pagine non vengono rifatte. Il container è cambiato, il lavoro è proseguito dov’era.

Una conseguenza che sfugge facilmente: l’endpoint di avanzamento ora può interrogare qualunque container vivo, perché montano tutti lo stesso disco. Lo stato non appartiene più a un container in particolare.

È questa la sensazione concreta della frase spesso citata: i container sono bestiame, i workspace sono animali domestici. Il bestiame è sostituibile; gli animali domestici hanno un nome e vanno accuditi.

Cosa manca ancora a questa versione (la parte onesta)

Uno, il «ripristino automatico» richiede una persona. Il codice lo dice chiaramente:

# nessun task_id significa task nuovo; con task_id significa ripresa

In altre parole, nulla sta sorvegliando i task che si sono fermati. Qualcuno deve accorgersene e rimandare la richiesta con l’ID originale. Non è failover automatico, è «ripristinabile» — e fra le due cose c’è di mezzo un intero meccanismo di monitoraggio e ritentativi. Colmare quel divario è il tema dei due articoli successivi.

Due, quel disco non si monta su tre container in un cluster vero. È richiesto come ReadWriteOnce, cioè una macchina alla volta. L’esperimento locale ha esattamente una macchina, quindi tre container che lo condividono non danno problemi; su un cluster multi-macchina reale, i container sulla seconda macchina non riescono a montarlo. Una condivisione vera richiede storage con accesso multi-macchina: NFS, o un servizio di file su cloud.

Tre, scrivere dopo ogni pagina è un compromesso, non un’impostazione predefinita. Scrivere più spesso perde meno in caso di crash e costa di più in I/O. In un sistema reale quella frequenza si ricava da quanto costa rifare un’unità di lavoro.

Cosa stabilisce questo passo

Una volta che l’avanzamento esce dal container, il container è davvero un esecutore sostituibile. Ma si noti cosa manca adesso: nessuno nel sistema sa se un task si è bloccato — il dispatcher consegna il lavoro e se ne dimentica.

E quando il pool è pieno, sa ancora dire soltanto «nessuna capacità disponibile».

Sono lo stesso vuoto: manca un posto che ricordi quale lavoro è ancora in sospeso. Il prossimo articolo lo colma — ed è lì che ho scoperto di non aver costruito la cosa che credevo di star costruendo.

In una riga: se lo stato sopravvive al container, il container può permettersi di morire.

Codice: agent-sandbox-oss/lab4.