Вернуться в блог

Снижение затрат на хранилище KV в OpenNext на Cloudflare

2026-05-115 min read

Learn English Sounds начал тратить 0,50 доллара в день на хранилище Cloudflare KV. Это не кажется большой суммой, пока вы не поймете, что это для сайта со статичным контентом, хостинг которого раньше стоил почти ничего. Я углубился и обнаружил классическую ловушку OpenNext на Cloudflare. Если вы используете Next.js на Workers через адаптер @opennextjs/cloudflare, это, вероятно, коснется и вас.

Что такое Cloudflare KV?

Cloudflare KV (сокращение от Key-Value) — это глобально распределенное хранилище пар ключ-значение, предназначенное для рабочих нагрузок с интенсивным чтением. Вы записываете значение один раз, и оно в конечном итоге реплицируется в центры обработки данных по всему миру, поэтому чтение происходит быстро из любого места. Ценообразование состоит из двух компонентов: операций (чтение, запись, удаление) и хранилища (оплачивается за ГБ-месяц). Первые 1 ГБ хранилища включены, затем примерно 0,50 доллара за каждый дополнительный ГБ-месяц. Именно второе число вас подводит, когда что-то незаметно заполняет пространство имен устаревшими данными.

Для OpenNext KV используется для кэширования отрисованных страниц, чтобы будущие запросы не требовали повторной отрисовки. Это быстро, дешево за операцию и идеально подходит для кэширования HTML и JSON. Ловушка в том, что в KV ничего не истекает автоматически. Если вы записываете ключ, он остается до тех пор, пока вы его не удалите.

Настройка

Сайт использует OpenNext для развертывания Next.js на Cloudflare Workers. Инкрементальный кэш (уровень хранения для ISR и полного кэширования маршрутов) настроен на использование KV с оберткой регионального кэша. Соответствующая конфигурация выглядит следующим образом:

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,
});

Это именно то, что рекомендуют документация OpenNext. Статические страницы отрисовываются во время сборки, записываются в KV и обслуживаются из регионального слоя Cache API. Использование ЦП снижается, поскольку worker редко нуждается в отрисовке чего-либо. Стоимость хранения должна была быть погрешностью округления.

Что такое региональный кэш?

Стоит остановиться на этом, потому что это объясняет, почему очистка KV не влияет на производительность. Cloudflare предоставляет каждому worker доступ к API Cache — кэшу в памяти для каждого региона, который находится в центре обработки данных на периферии, куда поступил запрос. withRegionalCache от OpenNext оборачивает инкрементальный кэш KV и использует этот API кэша в качестве первого уровня.

Порядок поиска любой кэшированной страницы: сначала региональный API кэша, затем KV, затем повторная отрисовка. Первый запрос в регионе извлекает страницу из KV и сохраняет ее в локальном кэше. Каждый последующий запрос в этом регионе обслуживается из памяти на периферии без обращения к KV или выполнения кода отрисовки worker. С параметром mode: "long-lived" региональные копии сохраняются до тех пор, пока Cloudflare их поддерживает, что на активном узле обычно составляет от нескольких минут до нескольких часов.

Таким образом, KV — это холодное хранилище. Он считывается только тогда, когда кэш региона холодный или вытеснен. Удаление неиспользуемых ключей KV не меняет частоту чтения KV; оно только меняет объем хранимых данных. ЦП не загружается, потому что ничего в горячем пути не изменилось.

Что я на самом деле нашел в KV

Я перечислил ключи в пространстве имен. Их было 276 637. Каждый из них начинался с incremental-cache/. Группировка по второму сегменту пути раскрыла всю картину:

$ 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

Тридцать девять различных префиксов. Каждый из них — это BUILD_ID Next.js из предыдущего развертывания. Каждый next build генерирует новый идентификатор сборки. Каждое развертывание записывает новую вселенную записей кэша, привязанных к этому идентификатору. Активный worker читает только префикс своей собственной сборки. Старые префиксы — это чистый мертвый груз, и ничего в OpenNext или Cloudflare не удаляет их за вас.

Вот как активный сайт незаметно накапливает более 250 000 заброшенных ключей.

Почему ваша активная сборка тоже растет

Во втором шаблоне, на который стоит обратить внимание в этом списке. Самый большой префикс содержал 17 896 ключей, следующий по величине — 13 068, а большинство остальных колебались около 6 500. Самый большой — это текущая активная сборка. У нее больше ключей, чем у других, потому что реальный трафик со временем продолжает добавлять в нее новые страницы с длинным хвостом. Это нормально. Проблема во всем, что ниже.

Исправление

Активный worker читает только из incremental-cache/<current-BUILD_ID>/. Поэтому после каждого успешного развертывания удаляйте все ключи, которые не начинаются с этого префикса. Гарантируется, что удаляемые ключи не используются ни одним из активных путей кода. Это не инвалидация кэша, которая стоит ЦП; это уборка мусора, который никогда не читается.

Скрипт очистки, который запускается после развертывания

Я добавил скрипт Node.js в проект и связал его с командой развертывания. Он считывает идентификатор сборки из вывода сборки OpenNext, перечисляет все ключи KV и массово удаляет все, что находится за пределами активного префикса, по 10 000 ключей за раз (лимит массового удаления wrangler).

// scripts/prune-kv-cache.mjs
// ---------------------------------------------------------------
// Удаляет устаревшие записи инкрементального кэша OpenNext из Cloudflare KV
// после каждого успешного развертывания. Запускается как шаг после развертывания.
// ---------------------------------------------------------------

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, которое лежит в основе инкрементального кэша OpenNext.
// Найдите этот ID в wrangler.toml в разделе [[kv_namespaces]] для NEXT_INC_CACHE_KV.
const KV_NAMESPACE_ID = "<your-namespace-id>";

// OpenNext записывает ключи кэша под `incremental-cache/<BUILD_ID>/...`.
// Активный worker читает только из своего BUILD_ID, поэтому любой другой префикс — это мусор.
// Мы считываем свежесобранный BUILD_ID с диска, чтобы скрипт всегда соответствовал
// развертыванию, которое только что произошло.
const KEEP_BUILD_ID = readFileSync(".open-next/assets/BUILD_ID", "utf8").trim();

// `kv bulk delete` в wrangler принимает максимум 10 000 ключей за вызов.
const CHUNK_SIZE = 10000;

// 1) Перечислить каждый ключ в пространстве имен.
// maxBuffer увеличен до 512 МБ, поскольку большие сайты могут возвращать десятки МБ JSON.
const list = spawnSync(
  "npx",
  ["wrangler", "kv", "key", "list", "--namespace-id", KV_NAMESPACE_ID, "--remote"],
  { encoding: "utf8", maxBuffer: 1024 * 1024 * 512 }
);

// 2) Определить, что сохранить, а что удалить.
// Сохранить:   incremental-cache/<KEEP_BUILD_ID>/...   (активный worker читает отсюда)
// Удалить: incremental-cache/<любой другой>/...  (заброшено из предыдущих развертываний)
// Любые не-кэш ключи остаются без изменений благодаря второй проверке `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) Массово удалить заброшенные ключи по 10 тыс. ключей за раз.
// wrangler считывает каждый пакет из JSON-файла на диске, поэтому мы используем временный каталог
// и очищаем его в конце, независимо от успеха или неудачи.
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 ожидает JSON-массив имен ключей, например ["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" } // передавать индикатор выполнения wrangler напрямую в терминал
  );
}

// 4) Очистить временный каталог и сообщить о проделанной работе.
rmSync(workDir, { recursive: true, force: true });
console.log(`Pruned ${toDelete.length} stale cache keys.`);
Затем я встроил его в цепочку развертывания в package.json, чтобы он запускался каждый раз автоматически:

"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --cacheChunkSize 1000 && npm run prune-kv-cache",
"prune-kv-cache": "node scripts/prune-kv-cache.mjs"

Порядок имеет значение. Сначала сборка, чтобы новый BUILD_ID существовал на диске. Затем развертывание, чтобы новый worker был активен и читал из нового префикса. Затем очистка, чтобы старый префикс стал безопасным для удаления в тот момент, когда завершится переключение.

Результат

На сайте, который я очистил, количество ключей KV сократилось с 276 637 в 39 сборках до 7 619 в единственной активной сборке, что составляет примерно 97% сокращение. Прогнозируемая ежедневная стоимость хранения снизилась с 0,50 доллара до нескольких центов. Никаких изменений в пути кода, никаких изменений в стратегии кэширования, никакого регресса ЦП. Региональный кэш по-прежнему оборачивает KV точно так же, как и раньше, поэтому горячие чтения поступают из API кэша и никогда не касаются хранилища.

Когда не следует выполнять очистку

Очистка — это правильный выбор по умолчанию для развертываний с одной версией, но есть реальные ситуации, когда она может вам навредить.

Мгновенный откат как мера безопасности. Если вы полагаетесь на wrangler rollback для возврата к предыдущей версии worker за секунды, кэш предыдущей версии будет утерян после очистки. Откат по-прежнему работает, но первая волна запросов после отката будет пропущена и повторно отрисована. ЦП будет загружен в течение нескольких минут. Измените скрипт, чтобы сохранять последние N идентификаторов сборки вместо одного активного, если это важно.

Поэтапные выпуски или канареечные развертывания. Если используются поэтапные выпуски Cloudflare или какой-либо вид постепенного развертывания, трафик обслуживается несколькими версиями worker одновременно. Каждой из них требуется свой собственный префикс сборки. Не выполняйте очистку до тех пор, пока развертывание не достигнет 100%.

Запросы в процессе развертывания. Всегда есть короткое окно, когда старый worker может обслуживать несколько запоздалых запросов в тот момент, когда запускается новый. Они пропустят кэш и будут повторно отрисованы один раз. Для большинства сайтов это незаметно. Стоит знать, если вы обрабатываете всплески трафика.

Замечание об R2 как альтернативе

OpenNext также поставляется с инкрементальным кэшем на базе R2. Хранилище R2 примерно в 33 раза дешевле KV (около 0,015 доллара против 0,50 доллара за ГБ-месяц на момент написания), и обертка регионального кэша находится перед ним так же, как и перед KV. Таким образом, производительность горячего пути не меняется, но холодное хранилище становится значительно дешевле. Очистка по-прежнему имеет смысл для гигиены, но давление на затраты в основном исчезает. Если ваш сайт достаточно большой, чтобы даже кэш активной сборки был значительным, стоит рассмотреть переход на R2.

Будьте в курсе

Получайте последние посты и аналитику на вашу почту.

Unsubscribe anytime. No spam, ever.