Verlagen van OpenNext KV-opslagkosten op Cloudflare Workers
Learn English Sounds begon met het uitgeven van $0,50 per dag aan Cloudflare KV-opslag. Dat klinkt niet veel, totdat je beseft dat dit voor een statische-achtige content site is die voorheen bijna niets kostte om te hosten. Ik dook erin en ontdekte een klassieke OpenNext op Cloudflare valkuil. Als je Next.js op Workers draait via de @opennextjs/cloudflare adapter, treft deze je waarschijnlijk ook.
Wat is Cloudflare KV?
Cloudflare KV (kort voor Key-Value) is een wereldwijd gedistribueerde key-value store ontworpen voor read-heavy workloads. Je schrijft een waarde één keer en deze wordt uiteindelijk gerepliceerd naar datacenters over de hele wereld, zodat reads overal snel zijn. De prijsstelling bestaat uit twee componenten: operaties (reads, writes, deletes) en opslag (per GB-maand). De eerste 1 GB opslag is inbegrepen, daarna kost het ongeveer $0,50 per extra GB-maand. Dat tweede getal is wat je krijgt als iets stilletjes de namespace vult met verouderde gegevens.
Voor OpenNext is KV waar gerenderde pagina's worden opgeslagen, zodat toekomstige verzoeken niet opnieuw hoeven te renderen. Het is snel, goedkoop per operatie en ideaal voor het cachen van HTML en JSON. De valkuil is dat niets in KV automatisch verloopt. Als je een sleutel schrijft, blijft deze bestaan totdat je deze verwijdert.
De opstelling
De site gebruikt OpenNext om Next.js te implementeren op Cloudflare Workers. De incrementele cache (de opslaglaag achter ISR en full route caching) is geconfigureerd om KV te gebruiken met een regionale cache wrapper. De relevante configuratie ziet er als volgt uit:
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,
});
Dit is precies wat de OpenNext documentatie aanbeveelt. Statische pagina's worden tijdens de build gerenderd, naar KV geschreven en vanaf een regionale Cache API-laag geserveerd. CPU-gebruik daalt omdat de worker zelden iets hoeft te renderen. Opslagkosten zouden een afrondingsfout moeten zijn.
Wat is de regionale cache?
Het is de moeite waard om hier even bij stil te staan, omdat het verklaart waarom het opschonen van KV de prestaties niet schaadt. Cloudflare geeft elke worker toegang tot de Cache API, een per regio in-memory cache die zich bevindt in het edge datacenter waar de aanvraag binnenkwam. OpenNext's withRegionalCache wikkelt de KV incrementele cache en gebruikt die Cache API als eerste laag.
De lookup-volgorde voor elke gecachte pagina is: eerst de regionale Cache API, dan KV, dan opnieuw renderen. De eerste aanvraag in een regio haalt de pagina uit KV en slaat deze op in de lokale Cache. Elke volgende aanvraag in die regio wordt vanuit het geheugen aan de edge geserveerd zonder KV aan te raken of de render code van de worker uit te voeren. Met mode: "long-lived" blijven de regionale kopieën bestaan zolang Cloudflare ze bewaart, wat op een warme node meestal minuten tot uren is.
KV is dus koude opslag. Het wordt alleen gelezen wanneer de cache van een regio koud is of wordt geëvactueerd. Het opschonen van niet-verwezenlijkte KV-sleutels verandert niet hoe vaak KV wordt gelezen; het verandert alleen hoeveel je opslaat. De CPU blijft gelijk omdat niets aan het hete pad is veranderd.
Wat ik daadwerkelijk in KV vond
Ik heb de sleutels in de namespace opgesomd. Er waren er 276.637. Elk van hen begon met incremental-cache/. Groeperen op het tweede padsegment vertelde het echte verhaal:
$ 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
Negenendertig verschillende prefixes. Elk ervan is een Next.js BUILD_ID van een vorige implementatie. Elke next build genereert een nieuwe build-ID. Elke implementatie schrijft een nieuw universum van cache-items die aan die ID zijn gekoppeld. De live worker leest alleen de prefix van zijn eigen build. Oude prefixes zijn puur dood gewicht, en niets in OpenNext of Cloudflare ruimt ze voor je op.
Dat is hoe een actieve site stilletjes meer dan 250.000 verweesde sleutels verzamelt.
Waarom je actieve build ook blijft groeien
Er is een tweede patroon dat de moeite waard is om op te merken in die lijst. De grootste prefix had 17.896 sleutels, de op één na grootste 13.068, en de meeste van de rest zweefden rond de 6.500. De grote is de momenteel actieve build. Deze heeft meer sleutels dan de andere omdat echt verkeer er na verloop van tijd nieuwe long-tail pagina's in blijft vullen. Dat is normaal en prima. Het probleem zijn alle items daaronder.
De oplossing
De actieve worker leest alleen uit incremental-cache/<huidige-BUILD_ID>/. Dus na elke succesvolle implementatie, verwijder je elke sleutel die niet met die prefix begint. De sleutels die je verwijdert, zijn gegarandeerd niet-verwezenlijkt door een live code-pad. Dit is geen cache-invalidatie die CPU kost; het is het opruimen van afval dat nooit wordt gelezen.
Een opschoonsCRIPT dat na implementatie wordt uitgevoerd
Ik heb een Node-script toegevoegd aan het project en dit gekoppeld aan het implementatiecommando. Het leest de build-ID uit de OpenNext build-output, somt alle KV-sleutels op en verwijdert in bulk alles buiten de actieve prefix in chunks van 10.000 sleutels (de wrangler bulk-delete limiet).
// scripts/prune-kv-cache.mjs
// ---------------------------------------------------------------
// Verwijdert verouderde OpenNext incrementele cache-items uit Cloudflare KV
// na elke succesvolle implementatie. Uitvoeren als een post-implementatie stap.
// ---------------------------------------------------------------
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Cloudflare KV namespace die de incrementele cache van OpenNext ondersteunt.
// Vind deze ID in wrangler.toml onder [[kv_namespaces]] voor NEXT_INC_CACHE_KV.
const KV_NAMESPACE_ID = "<jouw-namespace-id>";
// OpenNext schrijft cache-sleutels onder `incremental-cache/<BUILD_ID>/...`.
// De actieve worker leest alleen zijn eigen BUILD_ID, dus elke andere prefix is afval.
// We lezen de vers gebouwde BUILD_ID van schijf, zodat het script altijd overeenkomt
// met de implementatie die zojuist heeft plaatsgevonden.
const KEEP_BUILD_ID = readFileSync(".open-next/assets/BUILD_ID", "utf8").trim();
// wrangler's `kv bulk delete` accepteert maximaal 10.000 sleutels per aanroep.
const CHUNK_SIZE = 10000;
// 1) Som elke sleutel in de namespace op.
// maxBuffer is verhoogd naar 512 MB omdat grote sites tientallen MB's aan JSON kunnen retourneren.
const list = spawnSync(
"npx",
["wrangler", "kv", "key", "list", "--namespace-id", KV_NAMESPACE_ID, "--remote"],
{ encoding: "utf8", maxBuffer: 1024 * 1024 * 512 }
);
// 2) Bepaal wat te behouden en wat te verwijderen.
// Behouden: incremental-cache/<KEEP_BUILD_ID>/... (de live worker leest hieruit)
// Verwijderen: incremental-cache/<iets anders>/... (verweesd van eerdere implementaties)
// Niet-cache sleutels worden met rust gelaten door de tweede `startsWith` controle.
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) Bulk-verwijder de verweesde sleutels in chunks van 10k sleutels.
// wrangler leest elke chunk uit een JSON-bestand op schijf, dus we gebruiken een tijdelijke map
// en ruimen deze aan het einde op, ongeacht succes of falen.
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 verwacht een JSON-array van sleutelnamen, bijv. ["sleutel1", "sleutel2", ...]
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" } // stream wrangler's voortgangsbalk rechtstreeks naar de terminal
);
}
// 4) Ruim de tijdelijke map op en rapporteer wat we hebben gedaan.
rmSync(workDir, { recursive: true, force: true });
console.log(`Opgeruimd ${toDelete.length} verouderde cache-sleutels.`);
Vervolgens heb ik het in de implementatieketen in package.json geplaatst, zodat het elke keer automatisch wordt uitgevoerd:
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --cacheChunkSize 1000 && npm run prune-kv-cache",
"prune-kv-cache": "node scripts/prune-kv-cache.mjs"
De volgorde is belangrijk. Bouw eerst, zodat de nieuwe BUILD_ID op schijf bestaat. Implementeer daarna, zodat de nieuwe worker live is en leest van de nieuwe prefix. Ruim als derde op, zodat de oude prefix veilig kan worden verwijderd zodra de overgang is voltooid.
Het resultaat
Op de site die ik heb opgeschoond, ging KV van 276.637 sleutels over 39 builds naar 7.619 sleutels in de enkele actieve build, een reductie van ongeveer 97%. De verwachte dagelijkse opslagkosten daalden van $0,50 naar een paar cent. Geen code-pad wijzigingen, geen caching-strategie wijzigingen, geen CPU-regressie. De regionale cache wikkelt KV nog steeds precies zoals voorheen, dus hete reads komen van de Cache API en raken nooit de opslag.
Wanneer je niet moet opschonen
Opschonen is de juiste standaardinstelling voor single-version implementaties, maar er zijn reële situaties waarin het je kan schaden.
Directe rollback als vangnet. Als je wrangler rollback gebruikt om in seconden terug te schakelen naar een eerdere worker-versie, is de cache van de eerdere versie verdwenen na een opschoonactie. De rollback werkt nog steeds, maar de eerste golf aan verzoeken na de rollback zal allemaal missen en opnieuw renderen. De CPU piekt gedurende een paar minuten. Pas het script aan om de laatste N build-ID's te behouden in plaats van alleen de actieve als dit belangrijk is.
Rolling releases of canary implementaties. Als Cloudflare Rolling Releases of enig ander type geleidelijke uitrol in het spel is, dienen meerdere worker-versies tegelijkertijd verkeer. Elk heeft zijn eigen build-prefix intact nodig. Ruim niet op totdat de uitrol 100% bereikt.
In-flight verzoeken tijdens implementatie. Er is altijd een kort venster waarin de oude worker nog een paar achterblijvers kan bedienen net als de nieuwe opkomt. Deze zullen de cache missen en één keer opnieuw renderen. Voor de meeste sites is dit onzichtbaar. Het is goed om te weten als je met bursty verkeer omgaat.
Een opmerking over R2 als alternatief
OpenNext levert ook een R2-gebaseerde incrementele cache. R2-opslag is ongeveer 33 keer goedkoper dan KV (ongeveer $0,015 versus $0,50 per GB-maand op het moment van schrijven), en de regionale cache wrapper zit ervoor op dezelfde manier als voor KV. Je hete-pad prestaties veranderen dus niet, maar koude opslag wordt dramatisch goedkoper. Opschonen is nog steeds zinvol voor hygiëne, maar de kosten-druk verdwijnt grotendeels. Als je site groot genoeg is dat zelfs de cache van de actieve build aanzienlijk is, is overschakelen naar R2 het overwegen waard.