Skip to content
Appunti personali
5 min di lettura

Spingere finché qualcosa cede: quanto regge davvero, e perché mi sono fermato prima dell’isolamento duro

Ultimo articolo della serie sul sandbox per agenti. Cosa dimostrano venti task concorrenti e cosa no; perché gli esperimenti di caos che uccidono i nodi non si possono eseguire su un portatile; e la teoria dietro «l’isolamento dei container finisce qui, dopo ci sono le macchine virtuali» — il punto in cui il mio hardware è finito.

Ognuno dei cinque articoli precedenti ha ripulito il disastro creato da quello prima: isolamento → avvio a freddo → costo a vuoto → stato perduto → code nei picchi. Ogni passo regge preso da solo.

Ma «regge da solo» e «reggono insieme» sono due affermazioni diverse. Quest’ultimo articolo fa due cose: spingere finché qualcosa cede e dire chiaramente dove mi sono fermato e perché.

Il test di carico: venti task lunghi in concorrenza

L’ultimo lab assembla i pezzi precedenti, e lo script di carico è essenziale:

async def main():
    print("lancio 20 task concorrenti da 60 secondi...")
    tasks = [submit_task(client, i) for i in range(20)]
    await asyncio.gather(*tasks)

Venti richieste insieme, ciascuna con circa un minuto di lavoro, contro un pool di pochi container.

Cosa dimostra — le promesse degli articoli precedenti, mantenute:

  • nessuno dei venti task va perso; gli eccedenti aspettano in coda;
  • l’utente riceve una posizione in fila, non una pagina di errore;
  • l’avanzamento è interrogabile in ogni momento, da qualunque container, perché vive sul disco condiviso;
  • un container che finisce prende il successivo senza alcun intervento umano.

Cosa non dimostra — la metà più importante:

  • venti concorrenti è un numero da portatile. I sistemi veri cedono nell’ordine delle centinaia o migliaia: la coda che si riempie più in fretta di quanto si svuoti, i limiti di connessione, le scritture su disco che rallentano;
  • durante l’esecuzione non si è rotto nulla. Nessun container ucciso, nessun nodo ucciso, nessuna rete interrotta. Un test di carico misura l’essere occupati, non l’essere rotti;
  • non sono stati misurati né quanto ci vuole ad accorgersi di un guasto né quanto ci vuole a ripristinare: nel sistema non c’è nulla in grado di misurarli.

Gli esperimenti di caos previsti sono esperimenti che non posso eseguire

Il progetto originale: reggere 500 sessioni attive, uccidere il 30% dei nodi del cluster, uccidere il primario della coda e misurare tre numeri — tempo di rilevamento, tempo di ripristino, quanti utenti hanno visto un errore.

Nessuno dei tre è possibile nel mio ambiente, per ragioni concrete:

  • uccidere il 30% dei nodi: il cluster locale ha un nodo. Ucciderlo non è una prova, è uno spegnimento;
  • uccidere il primario della coda: Redis gira come istanza singola senza replica, quindi non c’è alcun failover da osservare;
  • 500 sessioni attive: questo portatile non regge 500 container che consumano memoria ciascuno.

Non è una scusa da «più avanti», è il prezzo d’ingresso di questo tipo di esperimenti: il chaos engineering richiede un cluster capace di assorbire danni, non una macchina capace di eseguire il codice. I primi cinque passi stanno su un portatile perché verificano difetti architetturali. Il settimo verifica il comportamento su scala, e la scala non si può simulare.

Già che siamo in tema di onestà, una cosa che l’articolo precedente aveva già ammesso: nemmeno il vero failover automatico esiste ancora. Quando un container muore, il task che teneva non viene riassegnato — qualcuno deve accorgersene e qualcuno deve rilanciarlo. Per colmarlo servono una tabella di stato dei task (chi lavora su cosa, da quando) e una regola di timeout (fermo troppo a lungo, si riprende e si riassegna). È questa la linea fra «si auto-ripara» e «si può riprendere».

Un passo più in là: l’isolamento dei container finisce qui

L’ultimo tema della serie è un passo che ho elaborato sulla carta senza costruirlo. Dirlo esplicitamente conta, altrimenti sembra fatto.

Torniamo alla domanda del primo articolo: un agente esegue codice che un modello ha generato poco prima. Un container per task ha impedito ai task di interferire tra loro — ma quanto è duro davvero il confine di un container?

Un’analogia: i container sono stanze divise dentro un unico edificio. I muri sono veri, ma fondamenta e impianti sono condivisi. Quegli impianti condivisi sono il kernel del sistema operativo: tutti i container usano lo stesso, e un suo difetto rende decorativi i tramezzi. Per i carichi ordinari il rischio è accettabile, perché il codice che gira è codice revisionato da te. Per il codice generato e non affidabile la premessa cambia.

Il settore offre due strade, entrambe verso «case separate»:

Approccio Come funziona Cosa costa
gVisor inserisce un kernel in spazio utente fra il container e quello reale, gestendo le system call al posto suo parte comunque in fretta, ma alcune system call non sono supportate e certi carichi rallentano sensibilmente
Kata Containers mette ogni container dentro una macchina virtuale leggera con un kernel proprio il confine più duro, pagato in tempo di avvio e memoria — e richiede la virtualizzazione hardware

Passare dall’uno all’altro in Kubernetes non è difficile: una RuntimeClass indirizza una classe di carichi verso un runtime diverso, senza modifiche applicative.

Non l’ho implementato, per una ragione precisa: il cluster locale gira dentro un container su un portatile, e annidarci dentro una macchina virtuale richiede la virtualizzazione annidata — e più memoria di quanta la macchina abbia. Questo passo non è poco chiaro: è finito l’hardware. Lo scrivo lo stesso perché sapere dov’è il confine e quanto costa è già il risultato del passo: il codice non affidabile finisce in una macchina virtuale, non in un container, e quel giudizio non richiede di averlo eseguito.

Il bilancio dell’intera serie

A ripensarci, nessun passo è stato semplicemente «meglio». Ognuno ha scambiato qualcosa con qualcos’altro:

Ottenuto Pagato
Isolamento 45 secondi di avvio a freddo
Risposta immediata una fila di container ferma in permanenza
Avanzamento che sopravvive a un crash una dipendenza dallo storage e un compromesso sulla frequenza di scrittura
Picchi in coda invece che respinti un componente in più che deve restare vivo
(non fatto) un confine duro per il codice non affidabile tempo di avvio, memoria, requisiti hardware

L’architettura non si progetta, la impongono le fatture. È per questo che è una sequenza di esperimenti e non un unico diagramma finale: presi singolarmente, il pool caldo, il disco condiviso e la coda sembrano tutti over-engineering. Percorsi nell’ordine dei conti, si vede benissimo cosa ha reso necessario ciascun componente.

Cosa resta sul tavolo, nel mio ordine di priorità: rendere reale il modello pull (il più economico, ed elimina di netto la contesa) → una tabella di stato dei task con riassegnazione a timeout (è questo che significa auto-riparazione) → storage scrivibile da più macchine → e solo dopo l’aggiornamento dell’isolamento.

In una riga: funzionare non è reggere; dichiarare cosa non è stato verificato vale più di un componente in più.

Tutto il codice: agent-sandbox-oss. Le idee sono benvenute come issue.