Ridurre i costi di archiviazione KV di OpenNext su Cloudflare Workers
Learn English Sounds ha iniziato a spendere 0,50 $ al giorno per lo storage Cloudflare KV. Non sembra molto finché non ti rendi conto che è per un sito con contenuti statici-ish che in precedenza costava quasi nulla da ospitare. Ho approfondito e ho trovato un classico tranello di OpenNext su Cloudflare. Se stai eseguendo Next.js su Workers tramite l'adattatore @opennextjs/cloudflare, questo probabilmente riguarda anche te.
Cos'è Cloudflare KV?
Cloudflare KV (abbreviazione di Key-Value) è un archivio chiave-valore distribuito a livello globale progettato per carichi di lavoro con molte letture. Scrivi un valore una volta e questo viene replicato nei data center di tutto il mondo, quindi le letture sono veloci da qualsiasi luogo. Il prezzo ha due componenti: operazioni (letture, scritture, eliminazioni) e storage (addebitato per GB-mese). Il primo 1 GB di storage è incluso, quindi sono circa 0,50 $ per GB-mese aggiuntivo. Quest'ultimo numero è ciò che ti colpisce quando qualcosa riempie silenziosamente lo spazio dei nomi con dati obsoleti.
Per OpenNext, KV è dove vengono memorizzate le pagine renderizzate in modo che le richieste future non debbano essere renderizzate nuovamente. È veloce, economico per operazione e ideale per la memorizzazione nella cache di HTML e JSON. La trappola è che nulla in KV scade automaticamente. Se scrivi una chiave, rimane finché non la elimini.
La configurazione
Il sito utilizza OpenNext per distribuire Next.js su Cloudflare Workers. La cache incrementale (il livello di storage dietro ISR e la cache completa del percorso) è configurata per utilizzare KV con un wrapper di cache regionale. La configurazione pertinente è la seguente:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache";
import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache";
import d1TagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache";
export default defineCloudflareConfig({
incrementalCache: withRegionalCache(kvIncrementalCache, {
mode: "long-lived",
bypassTagCacheOnCacheHit: true,
}),
tagCache: d1TagCache,
});
Questo è esattamente ciò che raccomandano le documentazioni di OpenNext. Le pagine statiche vengono renderizzate al momento della build, scritte in KV e servite da un livello di Cache API regionale in uscita. L'utilizzo della CPU diminuisce perché il worker raramente deve renderizzare qualcosa. Il costo dello storage doveva essere un errore di arrotondamento.
Cos'è la cache regionale?
Vale la pena soffermarsi, perché spiega perché la potatura di KV non danneggia le prestazioni. Cloudflare fornisce a ogni worker l'accesso all'API Cache, una cache in memoria per regione che si trova nel data center edge colpito dalla richiesta. withRegionalCache di OpenNext avvolge la cache incrementale KV e utilizza tale API Cache come primo livello.
L'ordine di ricerca per qualsiasi pagina memorizzata nella cache è: prima l'API Cache regionale, poi KV, poi il re-rendering. La prima richiesta in una regione estrae la pagina da KV e la memorizza nella cache locale. Ogni richiesta successiva in quella regione viene servita dalla memoria all'edge senza toccare KV o eseguire il codice di rendering del worker. Con mode: "long-lived" le copie regionali rimangono attive per tutto il tempo in cui Cloudflare le mantiene, il che su un nodo caldo è tipicamente da minuti a ore.
Quindi KV è uno storage a freddo. Viene letto solo quando la cache di una regione è fredda o viene espulsa. La potatura delle chiavi KV non referenziate non cambia la frequenza con cui KV viene letto; cambia solo quanto si memorizza. La CPU non si muove perché nulla del percorso attivo è cambiato.
Cosa ho trovato effettivamente in KV
Ho elencato le chiavi nello spazio dei nomi. Ce n'erano 276.637. Ognuna iniziava con incremental-cache/. Raggruppando per il secondo segmento del percorso si è rivelata la vera storia:
$ wrangler kv key list --namespace-id <id> --remote \
| jq -r '.[].name' | awk -F/ '{print $2}' \
| sort | uniq -c | sort -rn | head
17896 Mqxc5oa6LB-nPqyGFrVbV
13068 zMp2Qi0YY-gVHmaXRB9Ce
9108 zMlI9hb_vFtk705L8GDKL
8748 -KcEM0U2hAvoQH7n5dyV5
...
5895 W-RYs65XPM1m0Dg899-cz
Trentanove prefissi distinti. Ognuno è un BUILD_ID di Next.js da una distribuzione passata. Ogni next build genera un nuovo ID di build. Ogni distribuzione scrive un nuovo universo di voci di cache con ambito a quell'ID. Il worker attivo legge solo il prefisso della propria build. I vecchi prefissi sono puro peso morto e nulla in OpenNext o Cloudflare li pulisce per te.
È così che un sito attivo accumula silenziosamente oltre 250.000 chiavi orfane.
Perché anche la tua build attiva continua a crescere
C'è un secondo schema degno di nota in quell'elenco. Il prefisso più grande aveva 17.896 chiavi, il successivo 13.068 e la maggior parte delle altre si aggirava intorno alle 6.500. Quello grande è la build attualmente attiva. Ha più chiavi delle altre perché il traffico reale continua a popolarla con nuove pagine a coda lunga nel tempo. Questo è normale e va bene. Il problema è tutto ciò che sta sotto.
La correzione
Il worker attivo legge solo da incremental-cache/<current-BUILD_ID>/. Quindi, dopo ogni distribuzione riuscita, elimina tutte le chiavi che non iniziano con quel prefisso. Le chiavi che stai eliminando sono garantite per non essere referenziate da alcun percorso di codice attivo. Questa non è un'invalidazione della cache che costa CPU; è una pulizia di spazzatura che non viene mai letta.
Uno script di potatura che viene eseguito dopo la distribuzione
Ho aggiunto uno script Node al progetto e l'ho collegato al comando di distribuzione. Legge l'ID della build dall'output della build di OpenNext, elenca tutte le chiavi KV ed elimina in blocco tutto ciò che è al di fuori del prefisso attivo in blocchi da 10.000 chiavi (il limite di eliminazione in blocco di wrangler).
// scripts/prune-kv-cache.mjs
// ---------------------------------------------------------------
// Elimina le voci obsolete della cache incrementale di OpenNext da Cloudflare KV
// dopo ogni distribuzione riuscita. Eseguire come passaggio post-distribuzione.
// ---------------------------------------------------------------
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Spazio dei nomi Cloudflare KV che supporta la cache incrementale di OpenNext.
// Trova questo ID in wrangler.toml sotto [[kv_namespaces]] per NEXT_INC_CACHE_KV.
const KV_NAMESPACE_ID = "<your-namespace-id>";
// OpenNext scrive le chiavi della cache sotto `incremental-cache/<BUILD_ID>/...`.
// Il worker attivo legge solo il proprio BUILD_ID, quindi qualsiasi altro prefisso è spazzatura.
// Leggiamo il BUILD_ID appena creato dal disco in modo che lo script corrisponda sempre
// alla distribuzione che è appena avvenuta.
const KEEP_BUILD_ID = readFileSync(".open-next/assets/BUILD_ID", "utf8").trim();
// `kv bulk delete` di wrangler accetta al massimo 10.000 chiavi per chiamata.
const CHUNK_SIZE = 10000;
// 1) Elenca ogni chiave nello spazio dei nomi.
// maxBuffer viene aumentato a 512 MB perché i siti di grandi dimensioni possono restituire decine di MB di JSON.
const list = spawnSync(
"npx",
["wrangler", "kv", "key", "list", "--namespace-id", KV_NAMESPACE_ID, "--remote"],
{ encoding: "utf8", maxBuffer: 1024 * 1024 * 512 }
);
// 2) Decidi cosa tenere e cosa eliminare.
// Tieni: incremental-cache/<KEEP_BUILD_ID>/... (il worker attivo legge da qui)
// Elimina: incremental-cache/<qualsiasi altra cosa>/... (orfano da distribuzioni precedenti)
// Le chiavi non di cache vengono lasciate intatte dal secondo controllo `startsWith`.
const keepPrefix = `incremental-cache/${KEEP_BUILD_ID}/`;
const toDelete = JSON.parse(list.stdout)
.map((k) => k.name)
.filter((n) => n.startsWith("incremental-cache/") && !n.startsWith(keepPrefix));
// 3) Elimina in blocco le chiavi orfane in blocchi da 10k chiavi.
// wrangler legge ogni blocco da un file JSON su disco, quindi utilizziamo una directory temporanea
// e la puliamo alla fine, indipendentemente dal successo o dal fallimento.
const workDir = mkdtempSync(join(tmpdir(), "kv-prune-"));
for (let i = 0; i < toDelete.length; i += CHUNK_SIZE) {
const chunkPath = join(workDir, `chunk_${i}.json`);
// wrangler si aspetta un array JSON di nomi di chiavi, ad es. ["key1", "key2", ...]
writeFileSync(chunkPath, JSON.stringify(toDelete.slice(i, i + CHUNK_SIZE)));
spawnSync(
"npx",
["wrangler", "kv", "bulk", "delete", "--namespace-id", KV_NAMESPACE_ID, "--remote", chunkPath],
{ stdio: "inherit" } // trasmette la barra di avanzamento di wrangler direttamente al terminale
);
}
// 4) Pulisci la directory temporanea e segnala cosa abbiamo fatto.
rmSync(workDir, { recursive: true, force: true });
console.log(`Potate ${toDelete.length} chiavi di cache obsolete.`);
Quindi l'ho collegato alla catena di distribuzione in package.json in modo che venga eseguito ogni volta, automaticamente:
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --cacheChunkSize 1000 && npm run prune-kv-cache",
"prune-kv-cache": "node scripts/prune-kv-cache.mjs"
L'ordine è importante. Prima la build in modo che il nuovo BUILD_ID esista sul disco. Poi la distribuzione in modo che il nuovo worker sia attivo e legga dal nuovo prefisso. Infine la potatura in modo che il vecchio prefisso diventi sicuro da eliminare non appena il rollover è completato.
Il risultato
Sul sito che ho ripulito, KV è passato da 276.637 chiavi su 39 build a 7.619 chiavi nell'unica build attiva, una riduzione di circa il 97%. Il costo di storage giornaliero previsto è sceso da 0,50 $ a pochi centesimi. Nessuna modifica al percorso del codice, nessuna modifica alla strategia di caching, nessuna regressione della CPU. La cache regionale avvolge ancora KV esattamente come prima, quindi le letture frequenti provengono dall'API Cache e non toccano mai lo storage.
Quando non dovresti potare
La potatura è l'impostazione predefinita corretta per le distribuzioni a versione singola, ma ci sono situazioni reali in cui può danneggiarti.
Rollback istantaneo come rete di sicurezza. Se ti affidi a wrangler rollback per tornare a una versione precedente del worker in pochi secondi, la cache della versione precedente viene persa dopo una potatura. Il rollback funziona ancora, ma la prima ondata di richieste dopo il rollback andrà persa e verrà renderizzata nuovamente. La CPU aumenta per qualche minuto. Modifica lo script per mantenere le ultime N build ID invece di solo quello attivo se questo è importante.
Distribuzioni progressive o canary. Se sono in gioco le distribuzioni progressive di Cloudflare o qualsiasi tipo di rollout graduale, più versioni del worker servono traffico contemporaneamente. Ognuna necessita del proprio prefisso di build intatto. Non potare finché il rollout non raggiunge il 100%.
Richieste in corso durante la distribuzione. C'è sempre una breve finestra in cui il vecchio worker potrebbe ancora servire alcuni ritardatari proprio mentre il nuovo viene avviato. Perderanno la cache e verranno renderizzati una volta. Per la maggior parte dei siti questo è invisibile. Vale la pena saperlo se gestisci traffico a raffiche.
Una nota su R2 come alternativa
OpenNext fornisce anche una cache incrementale basata su R2. Lo storage R2 è circa 33 volte più economico di KV (circa 0,015 $ contro 0,50 $ per GB-mese al momento della scrittura), e il wrapper di cache regionale si trova di fronte ad esso allo stesso modo in cui si trova di fronte a KV. Quindi le prestazioni del percorso attivo non cambiano, ma lo storage a freddo diventa drasticamente più economico. La potatura ha ancora senso per l'igiene, ma la pressione sui costi scompare in gran parte. Se il tuo sito è abbastanza grande da rendere considerevole anche la cache della build attiva, vale la pena considerare il passaggio a R2.