Как я построил кеш страниц для многодоменного проекта с помощью PVC и кастомного подхода

от автора

У меня был проект, где один Next.js сайт обслуживал несколько доменов, и возникла задача — эффективно кешировать страницы, чтобы не пересоздавать их каждый раз. Сначала я попробовал внедрить кеширование через Redis: я написал хендлер, подключил его, но вскоре обнаружил, что Redis потребляет колоссальный объём оперативной памяти — порядка 100 ГБ, и это при том, что ещё не все запросы были закешированы. Тогда я решил поискать другой подход и обратил внимание на PVC — общее хранилище, которое могли бы использовать все поды. Я начал изучать варианты работы с PVC и довольно быстро пришёл к идее общего кеш-хранилища для всех подов. Я попробовал просто писать данные в PVC, но столкнулся с проблемой: каждый раз, когда под поднимался, он перезаписывал кеш. До тех пор, пока не подняты все поды, данные постоянно перезаписывались, а мне нужно было, чтобы первый под записал данные, а последующие только читали их. Я начал искать, как сделать кастомный кеш-хендлер, но готовых решений не нашёл.

Я нашёл в документации Next.js упоминание о кастомных кеш-хендлерах (вот ссылка), но там показан только минимальный интерфейс кеш-хендлера, без деталей про shared storage, инвалидацию и конкуренцию чтения/записи и не было примера, как это использовать с PVC. Разбираясь, я понял, что у кеш-хендлера есть ключевые методы. set — отвечает за сохранение данных, get — за получение, а revalidateTag — обновляет данные по тегам. Для работы с общим хранилищем мне также пришлось отдельно продумать сериализацию и десериализацию записей на стороне Node.js. Я создал хранилище на PVC, где записывал страницы, а поды затем читали данные оттуда. Так я получил централизованное кеширование. Вот реализация кеш-хендлера из документации:

const cacheHandler = {  async get(cacheKey, softTags) {    const entry = cache.get(cacheKey)    if (!entry) return undefined     // Check if expired    const now = Date.now()    if (now > entry.timestamp + entry.revalidate * 1000) {      return undefined    }     return entry  },    async set(cacheKey, pendingEntry) {    // Wait for the entry to be ready    const entry = await pendingEntry     // Store in your cache system    cache.set(cacheKey, entry)  },}

Используя Node.js, я реализовал метод set, который подготавливает запись, вычисляет срок её актуальности и сохраняет данные в общем внешнем хранилище:

Ниже приведены упрощённые и обезличенные фрагменты кода. Они передают архитектуру решения и ключевые идеи, но не повторяют продовую реализацию один в один.

async set(key, data, ctx) {  if (this.isBuildPhase || !this.storageUrl) {    return;  }  await this.initPromise;  const existingRequest = this.pendingWrites.get(key);  if (existingRequest) {    return existingRequest;  }  const entryKey = getEntryKey(key);  const request = (async () => {    try {      if (data == null) {        await storage.remove(entryKey);        return;      }      const revalidateSec = extractRevalidateSeconds(data, ctx);      const tags = parseTagsFrom(data, ctx);      const ttl = revalidateSec > 0 ? revalidateSec : DEFAULT_TTL_SEC;      const now = Date.now();      const entry = {        value: data,        modifiedAt: now,        tags,        revalidateSec: ttl,        expiresAt: now + ttl * 1000,      };      await storage.write(entryKey, encodeEntry(entry), ttl);      await this.touchEntry(entryKey);      await this.evictIfNeeded();    } catch (error) {      console.error('[CacheHandler] Failed to write cache entry', error);    } finally {      this.pendingWrites.delete(key);    }  })();  this.pendingWrites.set(key, request);  return request;}

Метод get читает запись из внешнего хранилища, декодирует её и дополнительно проверяет, не устарела ли она по времени или по тегам:

async get(key) {  if (this.isBuildPhase || !this.storageUrl) {    return null;  }  await this.initPromise;  const existingRequest = this.pendingReads.get(key);  if (existingRequest) {    return existingRequest;  }  const entryKey = getEntryKey(key);  const request = (async () => {    try {      const storage = await this.initPromise;      const rawValue = await storage.read(entryKey);      if (!rawValue) {        return null;      }      let entry;      try {        entry = decodeEntry(rawValue);      } catch (error) {        console.error('[CacheHandler] Failed to decode cache entry', error);        await storage.remove(entryKey);        return null;      }      if (        typeof entry?.expiresAt === 'number' &&        entry.expiresAt > 0 &&        entry.expiresAt <= Date.now()      ) {        await storage.remove(entryKey);        return null;      }      if (        entry?.tags?.length &&        await this.isEntryStaleByTags(entry.tags, entry.modifiedAt)      ) {        await storage.remove(entryKey);        return null;      }      await this.touchEntry(entryKey);      return entry;    } catch (error) {      console.error('[CacheHandler] Failed to read cache entry', error);      return null;    } finally {      this.pendingReads.delete(key);    }  })();  this.pendingReads.set(key, request);  return request;}

Метод revalidateTag не удаляет запись напрямую, а помечает связанные теги как инвалидированные. Благодаря этому при следующем чтении устаревшая запись будет отброшена и пересобрана заново:

async revalidateTag(tags) {  if (this.isBuildPhase || !this.storageUrl) {    return;  }  await this.initPromise;  const tagsList = Array.isArray(tags) ? tags : [tags].filter(Boolean);  if (!tagsList.length) {    return;  }  const invalidatedAt = Date.now();  await storage.markTagsInvalid(tagsList, invalidatedAt);}

Так шаг за шагом я построил кастомный подход, интегрированный с PVC.

Когда я реализовывал кастомный кеш-хендлер на PVC, у меня возникли проблемы. PVC медленнее памяти, и нужно было избегать гонки. Во-первых, я решал проблему устаревших данных с помощью функции isEntryStaleByTags:

async isEntryStaleByTags(tags, modifiedAt) {  if (!tags.length) {    return false;  }  const invalidatedAt = await storage.getTagsInvalidationTime(tags);  return invalidatedAt > modifiedAt;}

Во-вторых, я ограничивал количество записей функцией evictIfNeeded :

async evictIfNeeded() {  const overflow = await storage.getOverflowCount();  if (overflow <= 0) {    return;  }  const staleEntries = await storage.getLeastRecentlyUsed(overflow);  await storage.removeEntries(staleEntries);}

В-третьих, я гарантировал, что при записи мы аккуратно обновляем индекс (при каждом чтении или записи я обновляю индекс активности записи, чтобы можно было ограничивать размер кеша) через touchEntry:

  async touchEntry(entryKey) {    await storage.touchIndex(entryKey, Date.now());  }

Эти функции вместе обеспечили актуальность, ограничение и последовательность кеша.

Отдельной задачей оказался сброс кеша. В какой-то момент я попробовал схему с L1-кешем на каждом поде: каждый экземпляр приложения держал локальный горячий кеш, а для синхронизации инвалидации я использовал внешний сигнал через Redis. На практике это оказалось слишком сложным. Каждый под должен был отслеживать изменения во внешнем слое, вовремя понимать, что данные устарели, и отдельно сбрасывать свой локальный L1-кеш. Чем больше подов становилось в системе, тем менее удобной и предсказуемой выглядела такая схема.

В итоге мы отказались от идеи использовать Redis как основное или координирующее хранилище для всего кеша. Вместо этого Redis остался только небольшим быстрым L1-слоем для самых горячих записей — например, для последней тысячи наиболее посещаемых страниц. Всё остальное продолжило храниться в PVC как в основном общем хранилище. Такой подход позволил не раздувать потребление оперативной памяти, но при этом сохранить быстрый доступ к самой актуальной части кеша.

Кроме того, при подключении кастомного кеш-хендлера я добавил его в финальный runtime-образ на этапе сборки.

COPY --from=builder /workspace/custom-cache-handler.js ./custom-cache-handler.js

В next.config.ts я добавил конфигурацию, которая указывает путь к кастомному кеш-хендлеру через переменную окружения. Заодно я отключил встроенный in-memory-кеш, чтобы приложение не расходовало лишнюю память поверх внешнего хранилища.

  cacheHandler: process.env.HANDLER_PATH    ? path.resolve(process.env.HANDLER_PATH)    : undefined,  cacheMaxMemorySize: 0

Так я подключил кастомный кеш‑хендлер к Next.js, отключил встроенный in‑memory‑кэш и получил предсказуемую схему кэширования, которая работает в многоподовом окружении с общим PVC.

ссылка на оригинал статьи https://habr.com/ru/articles/1028314/