Postmortem di sicurezza di Cloudflare Workers AI: un runbook per le fughe di KV cache tra tenant
In breve
Scopri come rilevare e correggere le fughe di KV cache multi-tenant nei backend di gioco con il runbook tecnico dell'incidente Cloudflare Workers AI.
Una funzionalità AI lato giocatore ha restituito una risposta contenente il testo di prompt di un altro studio. Non un crash. Non un 500. Una risposta coerente e pertinente consegnata con HTTP 200 e un conteggio di token corrispondente alla richiesta del chiamante.
Questa è la forma peggiore che un bug multi-tenant possa assumere: l'output è plausibile, quindi nessuno apre un ticket. Cloudflare ha affrontato esattamente questo il 27 agosto 2026, quando 155 risposte del modello GLM-5.3 Workers AI contenevano dati di sessioni di altri clienti. Ogni richiesta coinvolta era più lunga di 681.280 token. Il fix ha richiesto due modifiche — una di configurazione e una patch al motore — e la storia del rilevamento è più istruttiva del fix stesso.
Ecco il runbook: cosa si è rotto, come individuare la stessa classe di errore nel tuo stack, come rimediare e come evitare che si ripeta.
Cosa si è rotto davvero
Due problemi indipendenti si sono sovrapposti. Uno solo dei due sarebbe stato gestibile.
Problema uno: un disallineamento di capacità che nessuno ha verificato
Cloudflare ha distribuito GLM-5.3 con una finestra di contesto di 1.048.576 token. Il pool di KV cache sulle GPU di prefill conteneva 681.280 token.
Questo divario è importante perché la dimensione del pool non è un valore di configurazione. SGLang la determina all'avvio in base alla memoria GPU rimasta dopo il caricamento dei pesi del modello. La lunghezza del contesto dichiarata è un valore di configurazione separato. Niente nel sistema obbliga i due valori a coincidere, e niente ti avvisa quando divergono.
I test pre-lancio passavano. I codici HTTP erano 200, gli streaming body non contenevano frame di errore, il throughput sembrava normale. I test validavano il trasporto, non il contenuto.
Problema due: una richiesta rifiutata che si dichiarava ancora viva
Quando un prompt superava il pool, lo scheduler di SGLang lo rifiutava. Il percorso di rifiuto registrava l'errore per il client e sostituiva il prompt con un placeholder di un token, così la richiesta poteva essere smaltita attraverso il meccanismo del batch senza allocare memoria. Su un singolo nodo funzionava: il placeholder veniva eseguito, il batch terminava, il client riceveva un errore.
In modalità prefill/decode disaggregata — dove le GPU di prefill costruiscono la KV cache e la inviano via RDMA alle GPU di decode che generano i token — la sequenza era questa:
- Bootstrap. Il router ha comunicato al decode che il prompt era lungo 8 pagine. Il decode ha riservato 8 pagine KV.
- Admission in prefill. La coda ammetteva qualsiasi richiesta in cui
req.finished()restituiva False. Una richiesta rifiutata memorizzava il rifiuto into_finish, non infinished_reason, quindifinished()restituiva comunque False. La richiesta rifiutata veniva ammessa. - Forward pass in prefill. Il prompt era già stato sostituito da un placeholder di un token. Il prefill eseguiva un forward pass banale su quel singolo token.
- Trasferimento KV. Il prefill inviava una pagina di KV al decode e segnalava "transfer complete".
- Admission in decode. La coda del decode usava lo stesso controllo
finished()e accettava l'handoff. - Attenzione in decode. Il decode generava token prestando attenzione a tutte le 8 pagine riservate. Solo la pagina 0 era stata scritta da questa richiesta. Le pagine da 1 a 7 contenevano ancora i prompt e le generazioni parziali di altri tenant.
- Risposta. Il client riceveva HTTP 200, un conteggio
prompt_tokenscoerente e una risposta plausibile assemblata da pagine di cache stantie.
Ogni richiesta sopra la soglia di 681.280 token si comportava così. Il bug era stato segnalato upstream come issue #30233 il 6 luglio 2026, e il deployment interessato non aveva ancora adottato il fix.
Rilevalo prima dei tuoi giocatori
Non puoi ottenere l'isolamento testando i codici di stato. Servono tre segnali.
Segnale 1: dimensione del pool rispetto alla lunghezza del contesto, letta dal motore in esecuzione
Verificalo all'avvio e in CI, non da un file di configurazione. Il pool deriva dalla memoria residua, quindi una modifica alla quantizzazione, al tensor parallelism o alla dimensione dei CUDA graph lo sposta in silenzio.
# deploy_gate.py - run before promoting any disaggregated inference config
import json, os, sys, urllib.request
CONTEXT_LEN = 1_048_576 # the model's advertised window
HEADROOM = 1.25 # pool must exceed context, not merely match it
def server_info(base_url: str) -> dict:
with urllib.request.urlopen(f"{base_url}/get_server_info", timeout=10) as r:
return json.load(r)
def kv_pool_tokens(info: dict) -> int:
# SGLang reports the pool it sized at startup; field names vary by version
for key in ("max_total_num_tokens", "kv_pool_tokens", "max_total_tokens"):
if key in info:
return int(info[key])
raise RuntimeError("KV pool size not exposed by this build - fail closed")
def main(prefill_url: str) -> None:
pool = kv_pool_tokens(server_info(prefill_url))
required = int(CONTEXT_LEN * HEADROOM)
print(f"kv_pool={pool} required={required} context={CONTEXT_LEN}")
if pool < required:
print("FAIL: pool smaller than context window; over-length prompts reach the rejection path")
sys.exit(1)
print("OK: pool covers the full context window")
if __name__ == "__main__":
main(os.environ.get("PREFILL_URL", "http://localhost:30000"))
Esegualo su ogni nodo di prefill prima che il load balancer riceva traffico. Un exit code diverso da zero blocca il deploy.
Segnale 2: marcatori canary nella KV cache
Inserisci una stringa sentinella unica in un prompt lungo, lascia terminare la richiesta, poi invia un secondo prompt lungo che non dovrebbe mai contenerla. Controlla il testo restituito, non il codice di stato.
import uuid, requests
def canary_check(base_url: str, token: str, long_prompt_tokens: int) -> None:
sentinel = f"CANARY-{uuid.uuid4()}"
filler = "lorem ipsum " * (long_prompt_tokens // 2)
requests.post(base_url, json={
"prompt": f"{sentinel} {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
# A fresh, unrelated request must never surface the previous sentinel
r = requests.post(base_url, json={
"prompt": f"Summarize this: {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
assert sentinel not in r.text, f"KV cache leak: {sentinel} surfaced in a fresh response"
Esegui questo test sul prompt più lungo che intendi accettare, esattamente al limite, e al limite + 1 token. Il caso al limite è quello in cui scatta il percorso di rifiuto.
Segnale 3: latenza troppo buona
Un prefill a piena lunghezza richiede un tempo misurabile. Se un prompt di 8 pagine risponde nel tempo che dovrebbe richiedere un prompt di 1 pagina, stai leggendo la cache di qualcun altro. Registra la durata del prefill indicizzata per lunghezza del prompt e genera un alert sul p5 del bucket long-context. Un prompt lungo insolitamente veloce è una firma di leak, non un successo.
Il fix, in ordine
1. Fai sì che il pool contenga l'intero contesto
Cloudflare ha modificato la configurazione di GLM-5.3 per allinearla a GLM-5.2, liberando abbastanza memoria GPU da portare il pool di prefill a circa 2,04 milioni di token — circa il doppio del contesto di 1.048.576 token. Un prompt che rientra nella finestra di contesto ora rientra sempre anche nel pool, quindi il percorso di rifiuto non viene mai raggiunto per una richiesta valida.
2. Rendi comunque sicuro il percorso di rifiuto
Dimensionare il pool da solo è fragile. Qualsiasi modifica a quantizzazione, tensor parallelism, dimensioni dei CUDA graph o versione del modello sposta il pool in un modo facile da non notare.
La patch al motore fa uscire di scena una richiesta con un rifiuto pendente presso la coda di ammissione della disaggregazione, con il suo errore originale, prima che avvenga qualsiasi bootstrap, allocazione o trasferimento KV. Il nodo di decode verifica inoltre che il numero di token trasferiti corrisponda a quello riservato. La patch è arrivata upstream come PR #38704, con follow-up in #38935, ed è stata distribuita in SGLang v0.5.20 fino alla v0.5.22.
Se usi SGLang ovunque, fissa la versione alla v0.5.22 o successiva. Non è un upgrade da rimandare al prossimo sprint.
3. Verifica con la riproduzione, non con le sensazioni
Cloudflare ha validato entrambe le modifiche usando lo script di riproduzione del ricercatore e i propri test con marcatori canary prima di ripristinare il modello. Due test indipendenti, uno adversarial e uno interno. Questo è lo standard corretto prima di riabilitare un modello o una flotta.
Perché "ha restituito 200" non è un test
La causa principale del fallimento dei test era limitata e del tutto ordinaria: il team ha validato i codici di risposta HTTP e le prestazioni del motore su lunghezze di contesto elevate, senza validare output e coerenza del modello. I benchmark passavano perché chiedono "ha risposto?" non "ha risposto correttamente?".
Per una funzionalità AI ospitata, il controllo di correttezza è economico. Per un backend di gioco, il divario equivalente è di solito più ampio. Ecco la regola trasferibile: ogni pool di risorse condivise richiede un'asserzione che leghi la dimensione del pool alla dimensione della richiesta più grande che il sistema accetterà, e quell'asserzione deve essere eseguita sul processo live, non su un file di configurazione.
La stessa classe di bug vive nei backend di gioco
Sostituisci "pagine della KV cache" con "slot di sessione" e l'incidente si riproduce quasi riga per riga.
Allochi un pool fisso di slot per le partite, slot per lo stato dei giocatori o buffer per gli attori. Una richiesta riserva 8 slot. Un controllo di ammissione verifica state != Finished invece di state == Valid, quindi una prenotazione rifiutata viene comunque ammessa. Lo slot viene passato alla lobby successiva con solo lo slot 0 inizializzato. Gli slot da 1 a 7 contengono ancora gli ID dei giocatori, l'inventario e i dati di posizione della partita precedente.
È la stessa modalità di errore che abbiamo trattato in progettare backend di gioco in grado di sopravvivere ai compromessi: il leak non deve essere un attacco sofisticato, gli basta un modello di proprietà che il codice non applica davvero.
Ecco il pattern difettoso e il fix, in C#:
public sealed class SessionSlotPool
{
private readonly SessionSlot[] _slots;
private readonly Queue<int> _free = new();
// BUG: release without invalidation leaves stale state for the next tenant
public void ReleaseUnsafe(int[] indices)
{
foreach (var i in indices) _free.Enqueue(i);
}
// FIX: invalidate on release, stamp a generation, verify on read
public void Release(int[] indices)
{
foreach (var i in indices)
{
_slots[i].Reset(); // zero player list, inventory, match id
_slots[i].OwnerId = null;
_slots[i].Generation++; // stale handles now fail the check
_free.Enqueue(i);
}
}
public bool TryRead(int index, long expectedGeneration, out SessionSlot slot)
{
slot = _slots[index];
return slot.Generation == expectedGeneration && slot.OwnerId != null;
}
}
Il contatore di generazione è la parte economica. Trasforma "speriamo che lo slot sia stato sovrascritto" in un rifiuto netto e testabile. Qualsiasi lettura tramite un handle ormai stantio fallisce invece di restituire silenziosamente i dati di un altro giocatore.
La checklist per l'isolamento
- Verifica la dimensione del pool rispetto alla lunghezza del contesto all'avvio, dal processo in esecuzione. Non dalla configurazione. Un exit code diverso da zero blocca il deploy.
- Tratta "rejected" come uno stato terminale distinto da "finished". Interrompi all'ammissione, prima di qualsiasi allocazione o handoff, e restituisci l'errore originale.
- Verifica i conteggi di trasferimento su entrambi i lati. Se il decode ha riservato 8 pagine, deve confermare che 8 pagine sono arrivate. Un trasferimento di una pagina che dichiara "complete" è esattamente il bug.
- Testa il percorso di errore con asserzioni sul contenuto. Marcatori canary, stringhe sentinella, prompt al limite più uno. Controlla il corpo della risposta, mai il codice di stato.
- Esegui di nuovo il gate a ogni modifica della capacità. Quantizzazione, tensor parallelism, batch size e versione del modello spostano tutti il pool. Trattali come modifiche rilevanti per l'isolamento.
Il punto 5 è quello che i team saltano. Ogni modifica alla capacità è anche una modifica alla sicurezza, perché il pool deriva dalla memoria residua, non da una dichiarazione. Lo stesso ragionamento a somma zero vale per qualsiasi pool di risorse dimensionato a mano — la matematica dei trade-off in l'analisi dell'ibernazione dei server a spreco zero è la stessa aritmetica.
Quanto costa
L'isolamento non è gratuito, e il trade-off è onesto.
Raddoppiare il pool KV da 681.280 a circa 2,04 milioni di token consuma memoria GPU che altrimenti ospiterebbe più richieste concorrenti. Meno richieste concorrenti significa costo più alto per token generato. Su una flotta di prefill, quel margine è capacità che non puoi vendere.
Esiste un'alternativa più economica: tenere il pool piccolo e rifiutare i prompt troppo lunghi con un HTTP 400 esplicito al confine, prima che raggiungano lo scheduler. Questo preserva il throughput ma fallisce le richieste legittime a contesto lungo — che, per un modello di coding agentico, sono la maggior parte del prodotto.
Cloudflare ha scelto la correttezza al costo della capacità. Per una funzionalità rivolta ai giocatori, è quasi sempre la scelta giusta. L'alternativa — una risposta veloce, economica e plausibile contenente i dati di un altro giocatore — non è un trade-off che puoi difendere in un postmortem.
Dove ricade realmente l'onere dell'isolamento
Ogni sistema multi-tenant prende questa decisione: quali risorse condivise mettere in pool e riusare, e chi possiede l'invalidazione. Per le pagine KV su GPU la risposta è "il motore di inferenza, male, fino alla v0.5.22".
Per lo stato dei giocatori nel tuo gioco, la risposta di solito è "il tuo database, e dipende da te". Se preferisci non gestire affatto la semantica del pool, i servizi di stato legati all'account spostano la superficie: horizOn offre Cloud Save legato all'account con letture e scritture consapevoli delle revisioni, dove è il client a risolvere i conflitti invece che i tuoi server a indovinare quale slot è stantio.
Questo non elimina il lavoro di isolamento — nulla lo elimina. Lo restringe ai confini che controlli davvero.
Cosa fare questa settimana
Fai un audit di ogni pool condiviso che possiedi e fai tre domande. La dimensione del pool copre in modo dimostrabile la richiesta più grande che il sistema accetta? Una richiesta rifiutata termina prima di allocare qualsiasi cosa? Il lato ricevente verifica che ciò che è arrivato corrisponda a ciò che era stato riservato?
Se una qualsiasi risposta è "supponiamo di sì", hai lo stesso bug in attesa di una richiesta abbastanza lunga. Inizia dal pool il cui contenuto sarebbe più dannoso nella risposta di un altro giocatore.
Se sei pronto a smettere di mantenere quel pool per lo stato dei giocatori, prova horizOn gratuitamente — autenticazione, Cloud Save legato all'account, classifiche, config remota e crash report, senza dover mettere in piedi da zero un database e un modello di sessione. La documentazione API mostra la superficie d'integrazione in circa dieci minuti.