Контекст
Краткое описание стека: фронтенд strapi.io — это приложение на Next.js 13 с Pages Router и ISR. У нас более 1000 статически генерируемых страниц. Контент поступает из Strapi CMS, а редакторы пользуются плагином strapi-plugin-revalidate-button для публикации обновлений. Приложение работает в Kubernetes на 3 репликах за балансировщиком нагрузки, а в качестве CDN-слоя используется CloudFront.
Если вас интересует история изначальной настройки ISR — она описана в отдельной статье. Эта же посвящена тому, что происходит, когда такая конфигурация сталкивается с горизонтальным масштабированием.
В нашей панели администратора Strapi есть одна кнопка. Она называется «Revalidate». Нажав её, редактор должен сбросить закешированную версию страницы, чтобы отобразился свежий контент. Казалось бы, всё просто.
Долгое время нажатие этой кнопки почти ни к чему не приводило. Жмёшь — страница показывает старый контент. Жмёшь снова — всё тот же старый контент. Пробуешь другой браузер, режим инкогнито, очищаешь локальный кеш — результат тот же. Дело было не в том, что кнопка явно сломана. Просто большую часть времени она не давала никакого видимого эффекта.
Разбираясь с причиной, мы углубились в механизмы кеширования Next.js, в особенности сети Kubernetes и в итоге настроили Redis Pub/Sub — решение, которое отлично работает, но живёт в файле, где ему, честно говоря, не место. Вот вся история.
Кеш, о котором никто не знал
На самом деле это хорошо известное ограничение ISR в Next.js при самостоятельном развёртывании с несколькими экземплярами. Оно регулярно всплывает в обсуждениях на GitHub, но если вы сами с этим не обожглись, легко пропустить.
Суть проблемы: res.revalidate() сбрасывает ISR-кеш только на том экземпляре, где был вызван. Каждый под Next.js хранит собственную копию каждой статической страницы — в памяти и на диске. Когда запрос на ревалидацию приходит через балансировщик нагрузки, его получает один из трёх подов. Этот под перегенерирует страницу. Остальные два продолжают отдавать старую версию.
Дальше становится ещё хуже. Когда редактор нажимает кнопку несколько раз подряд, балансировщик нагрузки, как правило, направляет его запросы на один и тот же под — тот, который уже был ревалидирован. В итоге редактор снова и снова сбрасывает кеш на уже актуальном поде, пока два других продолжают отдавать устаревший контент. Повторные нажатия не помогают.
CloudFront делал ситуацию ещё более непредсказуемой. Сам запрос на инвалидацию CDN работал нормально — CloudFront сбрасывал граничный кеш как положено. Но затем ему нужно было перезапросить страницу с источника. Источник — это наш балансировщик, который направлял запрос к одному из трёх подов. Если попадал на ревалидированный — отлично, свежий контент закеширован на граничном узле. Если попадал на один из двух оставшихся — устаревший контент снова оказывался в CloudFront.
На практике ситуация нередко оказывалась хуже, чем подсказывает теория вероятностей, потому что маршрутизация балансировщика не является чисто случайной. В зависимости от повторного использования соединений и проверок работоспособности подов можно было попасть в ситуацию, когда один и тот же устаревший под постоянно получал запросы. Редакторы фактически перестали доверять кнопке.
Три подхода, которые не сработали
Кастомный обработчик кеша (Custom Cache Handler). Next.js позволяет заменить бэкенд кеша через конфигурацию. Звучит идеально для синхронизации состояния между подами. Только вот эта возможность распространяется на Data Cache в App Router, а не на механизм cacheHandler Pages Router с res.revalidate() через Redis. Разные слои кеширования, разные API. К нашей конфигурации не применимо.
instrumentation.ts. Теоретически — чистое решение: файл, запускающийся один раз при старте сервера, идеально подходящий для фоновых процессов вроде Redis-подписок. Но было два препятствия: в Next.js 13 эта возможность была экспериментальной, и кроме того, существовал известный баг — файл вообще не выполнялся в режиме standalone output. Мы используем standalone для Docker. Несколько часов ушло на то, чтобы понять, почему наш код никогда не запускается, прежде чем мы нашли соответствующий issue на GitHub.
Общий PVC между подами. Примонтировать один и тот же том с файловой системой, чтобы все поды разделяли директорию .next/cache. Звучит разумно, пока не узнаёшь, что Next.js держит поверх файловой системы ещё и LRU-кеш в памяти. Общий диск не сбрасывает RAM остальных подов. Следующий вариант.
Что сработало: Redis Pub/Sub
На тот момент Redis в нашем кластере не было, поэтому мы подняли новый экземпляр специально для этого. Механизм Pub/Sub позволяет рассылать сообщения всем подписчикам на канале. Схема, которую мы выбрали:
-
Запрос на ревалидацию приходит на любой под.
-
Этот под публикует сообщение в канал Redis вместо прямого вызова
res.revalidate(). -
Все поды подписаны на этот канал.
-
Каждый под получает сообщение и сбрасывает собственный кеш.
-
После того как все поды ревалидированы, инвалидируется кеш CloudFront.
Шаг 4 требует небольшого пояснения. Когда под получает сообщение из Redis, ему нужно вызвать res.revalidate(path), чтобы сбросить свой ISR-кеш. Проблема в том, что res.revalidate() доступен только внутри обработчика API-роута в Next.js — это метод объекта ответа, а не что-то, что можно импортировать и вызывать из произвольного места кодовой базы. Функции nextjs.revalidate(path), которую можно использовать в фоновом процессе или колбэке Redis-подписчика, попросту не существует. Если бы instrumentation.ts работал, у нас, возможно, был бы доступ к внутренностям сервера, позволяющий обойти это ограничение. Но раз этот вариант отпал, нам понадобился другой способ.
Наш обходной путь: получив сообщение из Redis, каждый под делает HTTP-запрос к самому себе (http://127.0.0.1/api/internal-revalidate). Под вызывает собственный API-эндпоинт, чтобы запустить ревалидацию. Это обходной способ получить доступ к res.revalidate(), но он работает надёжно.
Если Redis недоступен, эндпоинт возвращается к прямой ревалидации на одном поде — старое поведение. Не идеально, но и краша нет.
Разбор реализации
Рассмотрим ключевые фрагменты. Это упрощённые версии продакшн-кода — достаточно, чтобы понять логику, но не для прямого копирования.
Основа решения — синглтон Pub/Sub-клиента, управляющий соединениями с Redis. Поскольку Redis требует отдельных клиентов для публикации и подписки, а разным частям приложения нужен только один из них, мы разделили подключение на два метода. Эндпоинт ревалидации подключает только издателя (publisher), а инициализация подписчика в _app.js — только подписчика (subscriber). Если Redis недоступен, приложение продолжает работу без него:
class PubSubClient {
static #instance = null
#publisher = null
#subscriber = null
static getInstance() {
if (!PubSubClient.#instance) {
PubSubClient.#instance = new PubSubClient()
}
return PubSubClient.#instance
}
async connectPublisher() {
if (this.#publisher) return
this.#publisher = new Redis(redisOptions)
await this.#publisher.connect()
}
async connectSubscriber() {
if (this.#subscriber) return
this.#subscriber = new Redis(redisOptions)
await this.#subscriber.connect()
}
get publisher() { return this.#publisher }
get subscriber() { return this.#subscriber }
}
Основной эндпоинт ревалидации — тот, к которому обращается плагин Strapi, — использует только сторону издателя. Вместо прямого вызова res.revalidate() он публикует сообщение в Redis. Если Redis недоступен, поведение возвращается к старой схеме с одним подом:
// /api/revalidate.js
export default async function handler(req, res) {
// ... проверка авторизации, определение pageToRevalidate из запроса ...
const client = PubSubClient.getInstance()
await client.connectPublisher()
if (client.publisher) {
await client.publisher.publish(
'strapi-web:revalidate',
JSON.stringify({ path: pageToRevalidate, timestamp: Date.now() })
)
} else {
await res.revalidate(pageToRevalidate)
}
return res.json({ revalidated: true, page: pageToRevalidate })
}
Далее — внутренний эндпоинт, который каждый под вызывает у самого себя. Он защищён общим секретом и доступен только с localhost; его единственная цель — дать Redis-подписчику возможность вызвать res.revalidate():
// /api/internal-revalidate.js — доступен только с localhost
export default async function handler(req, res) {
// ... проверка общего секрета ...
const path = req.query.path
await res.revalidate(path)
return res.json({ revalidated: true, path })
}
И наконец — та часть, которой мы меньше всего гордимся. Поскольку instrumentation.ts не работал в standalone-режиме, единственным надёжным местом для серверной инициализации в Pages Router Next.js 13 оказался модульный уровень _app.js. Когда этот файл загружается на сервере, код верхнего уровня выполняется один раз — именно там мы и настраиваем Redis-подписку. Эта сторона использует только соединение подписчика:
// _app.js — серверная инициализация на уровне модуля
if (typeof window === 'undefined') {
const revalidatePath = async (path) => {
const port = process.env.PORT || 3000
await fetch(
`http://127.0.0.1:${port}/api/internal-revalidate?path=${encodeURIComponent(path)}`,
{ headers: { /* общий секрет для авторизации */ } }
)
}
const { PubSubClient } = await import('src/lib/redis/pubsub-client.js')
const client = PubSubClient.getInstance()
await client.connectSubscriber()
client.subscriber.on('message', (channel, message) => {
if (channel !== 'strapi-web:revalidate') return
const { path } = JSON.parse(message)
revalidatePath(path)
})
await client.subscriber.subscribe('strapi-web:revalidate')
}
Побочный эффект на уровне модуля. Постоянное соединение с Redis, инициализированное из точки входа React-приложения. Не тот код, которым хвастаются на конференциях, но он делает своё дело без нареканий.
Что мы вынесли из этого
Проблема, с которой мы столкнулись, хорошо задокументирована — если знать, где искать. Но найти эту информацию без подсказки непросто. Поиск «Next.js ISR multiple instances» выдаёт GitHub-issues и обсуждения многолетней давности. Официальная документация упоминает об этом почти вскользь. Это из тех вещей, которые обнаруживаешь в продакшне, а не на этапе разработки.
Больше всего нас удивило то, насколько сильно ограничения фреймворка определили итоговую архитектуру. Причина, по которой под вызывает сам себя через HTTP; причина, по которой мы инициализируем Redis в _app.js; причина, по которой нам нужен второй API-эндпоинт — всё это следствие конкретных ограничений Pages Router в Next.js 13 и сломанного instrumentation.ts в standalone-режиме. Решение отражает имеющиеся ограничения, а не какой-то учебниковый паттерн.
Одно архитектурное решение, которое мы рады, что приняли заранее, — это graceful degradation (плавная деградация). Когда Redis недоступен, ревалидация всё равно работает на одном поде. Когда внутренний эндпоинт не отвечает, ошибка логируется и выполнение продолжается. Система никогда не ломается полностью. Это осознанный выбор, и он даёт уверенность в том, что сбой Redis не приведёт к каскадной потере возможности публиковать контент.
Хорошая новость: для всех остальных ситуация становится лучше. В Next.js 15 instrumentation.ts стабилизирован, и баг в standalone-режиме исправлен. API cacheHandler повзрослел, а библиотеки вроде @neshca/cache-handler корректно реализуют общий Redis-кеш между экземплярами. Если вы начинаете новый проект сегодня, у вас есть куда более удобные инструменты. Но если вы поддерживаете приложение на Next.js 13 Pages Router в Kubernetes и вам нужна реальная ревалидация по запросу — можем подтвердить: описанный подход работает. Он уже несколько недель обслуживает strapi.io, и редакторы перестали жаловаться. А это, если вы когда-нибудь работали с контент-командами, и есть главный показатель успеха.