Reducir los costes de almacenamiento KV de OpenNext en Cloudflare Workers
Learn English Sounds comenzó a gastar 0,50 $ al día en almacenamiento Cloudflare KV. Eso no parece mucho hasta que te das cuenta de que es para un sitio de contenido relativamente estático que antes costaba casi nada alojar. Investigué y encontré un error clásico de OpenNext en Cloudflare. Si estás ejecutando Next.js en Workers a través del adaptador @opennextjs/cloudflare, es probable que esto también te afecte.
¿Qué es Cloudflare KV?
Cloudflare KV (abreviatura de Key-Value) es una base de datos clave-valor distribuida globalmente diseñada para cargas de trabajo con muchas lecturas. Escribes un valor una vez y eventualmente se replica en centros de datos de todo el mundo, por lo que las lecturas son rápidas desde cualquier lugar. El precio tiene dos componentes: operaciones (lecturas, escrituras, eliminaciones) y almacenamiento (cobrado por GB-mes). El primer GB de almacenamiento está incluido, luego cuesta aproximadamente 0,50 $ por GB-mes adicional. Ese segundo número es lo que te perjudica cuando algo llena silenciosamente el espacio de nombres con datos obsoletos.
Para OpenNext, KV es donde se almacenan las páginas renderizadas para que las solicitudes futuras no tengan que volver a renderizarlas. Es rápido, barato por operación e ideal para almacenar en caché HTML y JSON. La trampa es que nada en KV caduca automáticamente. Si escribes una clave, permanece hasta que la elimines.
La configuración
El sitio utiliza OpenNext para implementar Next.js en Cloudflare Workers. La caché incremental (la capa de almacenamiento detrás de ISR y la caché de rutas completas) está configurada para usar KV con un envoltorio de caché regional. La configuración relevante se ve así:
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,
});
Esto es exactamente lo que recomiendan los documentos de OpenNext. Las páginas estáticas se renderizan en tiempo de compilación, se escriben en KV y se sirven desde una capa de API de caché regional al salir. El uso de CPU disminuye porque el worker rara vez tiene que renderizar algo. Se suponía que el costo de almacenamiento sería un error de redondeo.
¿Qué es la caché regional?
Vale la pena detenerse en esto, porque explica por qué podar KV no perjudica el rendimiento. Cloudflare da a cada worker acceso a la API Cache, una caché en memoria por región que reside en el centro de datos de borde al que llegó la solicitud. withRegionalCache de OpenNext envuelve la caché incremental de KV y utiliza esa API de caché como primera capa.
El orden de búsqueda de cualquier página almacenada en caché es: primero la API de caché regional, luego KV y luego volver a renderizar. La primera solicitud en una región extrae la página de KV y la almacena en la caché local. Cada solicitud subsiguiente en esa región se sirve desde la memoria en el borde sin tocar KV ni ejecutar el código de renderizado del worker. Con mode: "long-lived", las copias regionales permanecen mientras Cloudflare las mantenga, lo que en un nodo caliente suele ser de minutos a horas.
Por lo tanto, KV es almacenamiento en frío. Solo se lee cuando la caché de una región está fría o se ha vaciado. Podar claves KV no referenciadas no cambia la frecuencia con la que se lee KV; solo cambia cuánto almacenas. La CPU no se mueve porque nada en la ruta activa ha cambiado.
Lo que realmente encontré en KV
Listé las claves en el espacio de nombres. Había 276.637 de ellas. Cada una comenzaba con incremental-cache/. Agrupar por el segundo segmento de la ruta contó la verdadera historia:
$ 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
Treinta y nueve prefijos distintos. Cada uno es un BUILD_ID de Next.js de un despliegue anterior. Cada next build genera un nuevo ID de compilación. Cada despliegue escribe un nuevo universo de entradas de caché con el ámbito de ese ID. El worker activo solo lee el prefijo de su propia compilación. Los prefijos antiguos son peso muerto puro, y nada en OpenNext o Cloudflare los limpia por ti.
Así es como un sitio activo acumula silenciosamente más de 250.000 claves huérfanas.
Por qué tu compilación activa también crece
Hay un segundo patrón que vale la pena notar en esa lista. El prefijo más grande tenía 17.896 claves, el siguiente 13.068, y la mayoría de los demás rondaban las 6.500. El grande es la compilación activa actual. Tiene más claves que las otras porque el tráfico real sigue poblando nuevas páginas de cola larga en él con el tiempo. Eso es normal y está bien. El problema es todo lo que está debajo.
La solución
El worker activo solo lee de incremental-cache/<BUILD_ID_actual>/. Por lo tanto, después de cada despliegue exitoso, elimina todas las claves que no comiencen con ese prefijo. Las claves que eliminas están garantizadas de no ser referenciadas por ninguna ruta de código activa. Esto no es una invalidación de caché que cuesta CPU; es barrer basura que nunca se lee.
Un script de limpieza que se ejecuta después del despliegue
Agregué un script de Node al proyecto y lo encadené al comando de despliegue. Lee el ID de compilación de la salida de compilación de OpenNext, lista todas las claves de KV y elimina en masa todo lo que esté fuera del prefijo activo en fragmentos de 10.000 claves (el límite de eliminación masiva de wrangler).
// scripts/prune-kv-cache.mjs
// ---------------------------------------------------------------
// Elimina entradas de caché obsoletas de OpenNext de Cloudflare KV
// después de cada despliegue exitoso. Ejecutar como un paso posterior al despliegue.
// ---------------------------------------------------------------
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Espacio de nombres Cloudflare KV que respalda la caché incremental de OpenNext.
// Encuentra este ID en wrangler.toml bajo [[kv_namespaces]] para NEXT_INC_CACHE_KV.
const KV_NAMESPACE_ID = "<tu-id-de-espacio-de-nombres>";
// OpenNext escribe claves de caché bajo `incremental-cache/<BUILD_ID>/...`.
// El worker activo solo lee su propio BUILD_ID, por lo que cualquier otro prefijo es basura.
// Leemos el BUILD_ID recién compilado del disco para que el script siempre coincida
// con el despliegue que acaba de ocurrir.
const KEEP_BUILD_ID = readFileSync(".open-next/assets/BUILD_ID", "utf8").trim();
// `kv bulk delete` de wrangler acepta como máximo 10.000 claves por llamada.
const CHUNK_SIZE = 10000;
// 1) Lista cada clave en el espacio de nombres.
// maxBuffer se aumenta a 512 MB porque los sitios grandes pueden devolver decenas de MB de JSON.
const list = spawnSync(
"npx",
["wrangler", "kv", "key", "list", "--namespace-id", KV_NAMESPACE_ID, "--remote"],
{ encoding: "utf8", maxBuffer: 1024 * 1024 * 512 }
);
// 2) Decide qué mantener y qué eliminar.
// Mantener: incremental-cache/<KEEP_BUILD_ID>/... (el worker activo lee de aquí)
// Eliminar: incremental-cache/<cualquier otra cosa>/... (huérfanas de despliegues anteriores)
// Las claves no de caché se dejan solas con la segunda comprobación `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 en masa las claves huérfanas en fragmentos de 10k claves.
// wrangler lee cada fragmento de un archivo JSON en disco, por lo que usamos un directorio temporal
// y lo limpiamos al final, independientemente del éxito o fracaso.
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 espera una matriz JSON de nombres de clave, por ejemplo, ["clave1", "clave2", ...]
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" } // transmite la barra de progreso de wrangler directamente a la terminal
);
}
// 4) Limpia el directorio temporal e informa lo que hicimos.
rmSync(workDir, { recursive: true, force: true });
console.log(`Se podaron ${toDelete.length} claves de caché obsoletas.`);
Luego lo conecté a la cadena de despliegue en package.json para que se ejecute cada vez, automáticamente:
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --cacheChunkSize 1000 && npm run prune-kv-cache",
"prune-kv-cache": "node scripts/prune-kv-cache.mjs"
El orden importa. Compila primero para que el nuevo BUILD_ID exista en el disco. Despliega segundo para que el nuevo worker esté activo y leyendo del nuevo prefijo. Limpia tercero para que el prefijo antiguo sea seguro de eliminar en el momento en que se complete el cambio.
El resultado
En el sitio que limpié, KV pasó de 276.637 claves en 39 compilaciones a 7.619 claves en la única compilación activa, una reducción de aproximadamente el 97%. El costo de almacenamiento diario proyectado se redujo de 0,50 $ a unos pocos centavos. Ningún cambio en la ruta del código, ningún cambio en la estrategia de caché, ninguna regresión de CPU. La caché regional todavía envuelve KV exactamente como antes, por lo que las lecturas activas provienen de la API de caché y nunca tocan el almacenamiento.
Cuándo no deberías podar
Podar es el comportamiento predeterminado correcto para despliegues de versión única, pero hay situaciones reales en las que puede perjudicarte.
Reversión instantánea como red de seguridad. Si confías en wrangler rollback para volver a una versión anterior del worker en segundos, la caché de la versión anterior se habrá ido después de una poda. La reversión sigue funcionando, pero la primera oleada de solicitudes después de la reversión fallará y se volverá a renderizar. La CPU se pica durante unos minutos. Modifica el script para mantener las últimas N IDs de compilación en lugar de solo la activa si esto es importante.
Lanzamientos continuos o despliegues canarios. Si se implementan lanzamientos continuos de Cloudflare o cualquier tipo de despliegue gradual, varias versiones de worker están sirviendo tráfico al mismo tiempo. Cada una necesita su propio prefijo de compilación intacto. No podes hasta que el despliegue alcance el 100%.
Solicitudes en curso durante el despliegue. Siempre hay una breve ventana en la que el worker antiguo aún puede servir a algunos rezagados justo cuando el nuevo se activa. Fallarán la caché y se volverán a renderizar una vez. Para la mayoría de los sitios, esto es invisible. Vale la pena saberlo si manejas tráfico ráfaga.
Una nota sobre R2 como alternativa
OpenNext también ofrece una caché incremental basada en R2. El almacenamiento R2 es aproximadamente 33 veces más barato que KV (alrededor de 0,015 $ frente a 0,50 $ por GB-mes en el momento de escribir esto), y el envoltorio de caché regional se sitúa delante de él de la misma manera que se sitúa delante de KV. Por lo tanto, el rendimiento de tu ruta activa no cambia, pero el almacenamiento en frío se vuelve drásticamente más barato. Podar sigue teniendo sentido por higiene, pero la presión de los costos desaparece en su mayor parte. Si tu sitio es lo suficientemente grande como para que incluso la caché de la compilación activa sea considerable, vale la pena considerar cambiar a R2.