Форма заказа была открыта ещё до релиза. Пользователь успел выбрать товар, вписать адрес, отвлёкся на звонок. Пока он разговаривал, на сервере выкатили новую сборку. Он возвращается, нажимает «Оформить», и вместо подтверждения видит ошибку про Server Action. В логах строка: Failed to find Server Action. Страница выглядела совершенно здоровой, кнопка нажималась, поля были заполнены. Что же сломалось?
Эта статья про то, как рассуждать о такой ошибке, когда приложение Next.js работает в двух экземплярах за балансировщиком на VPS. Сразу скажу: у одного и того же текста в логе бывает несколько разных причин, и лечатся они разными средствами. Самая частая ошибка при разборе — найти одну причину, починить её и объявить победу, а на следующем релизе получить тот же симптом.
Что на самом деле едет в браузер вместе с формой
Server Action — это функция, которая выполняется на сервере, а вызывается из интерфейса. Браузеру не передают её код. Ему отдают ссылку на неё: идентификатор, по которому сервер потом поймёт, какую именно функцию надо запустить. Когда пользователь жмёт кнопку, браузер отправляет запрос с этим идентификатором и аргументами.
Теперь важная деталь. Идентификатор создаётся при сборке. Страница, которую вы отдали пользователю, «помнит» тот набор идентификаторов, который существовал в момент её рендера. Если сервер, который принимает отправку, собран из другой версии кода, он вправе не узнать идентификатор. С его точки зрения пришёл запрос на функцию, которой в этой сборке нет.
Это первая линия разлома: несовпадение версий приложения. Страница из версии A, обработчик из версии B. Одинаковые ключи тут ничего не изменят, потому что проблема не в шифровании, а в самом наборе действий.
Замкнутые переменные и зачем им шифрование
Вторая линия разлома тоньше. Server Action нередко объявляют внутри серверного компонента, и тогда функция захватывает переменные из окружающего кода: идентификатор заказа, id пользователя, какое-то значение, вычисленное при рендере. Эти значения нужно как-то передать обратно на сервер вместе с вызовом, но отдавать их браузеру открытым текстом не хочется.
По документации по самостоятельному хостингу Next.js, фреймворк шифрует замкнутые переменные Server Functions, и по умолчанию ключ для этого создаётся заново для каждой сборки. Выстраивается цепочка: страница отправила зашифрованный фрагмент, принимающий экземпляр должен его расшифровать. Если у него другой ключ, расшифровка невозможна, и запрос не получится обработать как следует.
Здесь легко увидеть ловушку. Допустим, вы собрали приложение один раз и запустили этот артефакт на двух VPS. Ключ тогда встроен в одну и ту же сборку, и экземпляры согласны. Но если вы собираете отдельно на каждом сервере (скажем, запускаете next build на обоих), каждая сборка получит свой ключ. Страница, обслуженная первым узлом, попадёт при отправке на второй, и тот скажет, что не видит такого действия. Документация прямо указывает: при нескольких серверных экземплярах ключ должен быть одинаковым, а расхождение приводит к ошибкам вида Failed to find Server Action.
Три причины под одной строкой лога
Полезно разложить симптом на три разных механизма. Внешне они неотличимы, но различаются тем, что нужно изменить.
| Причина | Как проявляется | Что исправляет |
|---|---|---|
| Разные ключи шифрования у экземпляров | Сбои случайны: зависят от того, какой узел отдал страницу и какой принял отправку | Единый NEXT_SERVER_ACTIONS_ENCRYPTION_KEY при сборке |
| Разные версии приложения на узлах | Сбой во время выката, пока один узел новый, а другой старый | Согласованный выкат, защита от рассогласования через deploymentId |
| Страница старше развёрнутой сборки | Сбой у тех, кто долго держал вкладку; воспроизводится даже на одном экземпляре | Не ключ: нужно решать, как старая вкладка переживает релиз |
Обратите внимание на третью строку. Если у вас один-единственный экземпляр и вы заменили его новой сборкой, старая вкладка всё равно обращается с идентификатором, которого может не быть в новом билде. Единый ключ тут не помогает по определению: проблема не в расхождении между узлами, а в расхождении между временем рендера страницы и временем её отправки.
Какие параметры согласуются между узлами
Теперь конкретика. Из документации следуют два имени, которые стоит запомнить.
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY— переменная окружения с ключом шифрования. Её задают при сборке, и значение встраивается в результат сборки (build output). То есть менять её на запущенном сервере бессмысленно: ключ уже внутри артефакта.deploymentId— параметр, предназначенный для защиты от рассогласования версий при rolling deployment, то есть при поочерёдном обновлении узлов.
Из того, что ключ встроен в сборку, вытекает практическое следствие для двух VPS. Есть два честных пути. Первый: собрать один раз, а затем разложить один и тот же артефакт на оба сервера. Второй: собирать на каждом, но подставлять в окружение сборки один и тот же ключ из вашего хранилища секретов. Оба пути дают согласие по ключу. Но важно понимать, что во втором случае согласованность ключа не означает одинаковость сборок: две сборки одного коммита с одним ключом всё равно могут отличаться деталями, а сборки разных коммитов с одним ключом отличаются кодом.
Это и есть центральное различие, ради которого стоит дочитать до конца. Общий ключ и согласованная сборка — разные вещи, и одна не заменяет другую. Ключ отвечает на вопрос «смогу ли я расшифровать то, что зашифровал соседний узел». Согласованная сборка отвечает на вопрос «знаю ли я вообще такую функцию». Можно иметь первое без второго и наоборот.
Ключ — секрет. Его не стоит класть в репозиторий, в логи сборки или в публичные переменные окружения. В примерах ниже я использую заглушку, а не настоящее значение, и вам не советую копировать готовые ключи из чужих статей: сгенерируйте свой и храните там же, где остальные секреты.
Что делает deploymentId и чего он не делает
Идентификатор развёртывания нужен для ситуации, когда в какой-то момент в работе одновременно находятся две версии приложения. Он помогает защититься от рассогласования версий при rolling deployment. Это механизм, который делает рассогласование заметным и управляемым, а не магическое средство, которое склеивает версии.
В той же документации отдельно подчёркнуто, что общий кеш и координация его инвалидирования — самостоятельные задачи. Иначе говоря, deploymentId не превращает два узла в один и не синхронизирует то, что каждый из них закешировал. Если вы используете общий кеш между экземплярами, о нём нужно думать отдельно; об этом подробнее написано в соседней статье про общий кеш и обновление двух экземпляров Next.js на VPS.
Практический вывод: идентификатор развёртывания полезен тем, что связывает запросы со сборкой, из которой они пришли. Но решение о том, что делать с запросом от слишком старой страницы, остаётся за вами: показать понятное сообщение, предложить обновить страницу или маршрутизировать запрос к узлу прежней версии на время выката. Какая из стратегий подходит, зависит от вашей системы, и универсального рецепта я предлагать не буду.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Старая вкладка при последовательном обновлении двух узлов
Теперь соберём картину во времени. Допустим, у вас два VPS, A и B, перед ними балансировщик, который распределяет запросы между ними. Выкат идёт последовательно: сначала обновляем A, потом B.
- До релиза. Оба узла на версии 1. Пользователь открывает форму, и страницу отдаёт, например, узел B. В ней зашиты идентификаторы действий версии 1 и зашифрованные фрагменты с ключом версии 1.
- Обновлён A. Узел A теперь на версии 2, B пока на версии 1. Пользователь жмёт кнопку, балансировщик отправляет запрос на A. Это узел новой версии, и идентификатор из старой страницы он может не узнать. Ошибка.
- Обновлён и B. Теперь оба узла на версии 2. Пользователь жмёт кнопку снова, но страница по-прежнему версии 1. Ошибка теперь стабильна: куда бы ни попал запрос, версия 1 нигде не живёт.
Заметьте, что на втором шаге результат зависит от случайности балансировки. Если запрос попал на B (ещё версия 1), всё сработает. Именно поэтому такие сбои выглядят плавающими и вызывают подозрение на «кривую сеть» или «глючный сервер». На деле сервер не глючит, он честно выполняет разные программы.
Поведение зависит и от того, меняются ли идентификаторы действий между сборками, а это определяется содержимым кода, а не вашими пожеланиями. Если в релизе вы не трогали файлы с действиями, возможно, всё обойдётся. Но рассчитывать на это при проверке не стоит: безопаснее считать, что любая вкладка старше релиза потенциально уязвима, и проверять именно этот случай.
Есть ещё один момент, который путает людей. Если ключ в сборках разный, а идентификаторы совпали, ошибка может прийти из-за невозможности расшифровать замкнутые переменные, а не из-за незнакомого идентификатора. Текст в логе при этом может выглядеть одинаково. Поэтому нельзя по одной строке сделать вывод о причине: нужно проверить, какая версия и какой ключ были у принявшего узла.
Опыт: старая вкладка и два адресуемых экземпляра
Чтобы не гадать, воспроизведите сбой в контролируемых условиях. Нужна минимальная форма с Server Action и два экземпляра, к каждому из которых можно обратиться напрямую, минуя балансировщик: например, на разных портах одного VPS или на двух серверах с открытыми для проверки адресами. Ниже условная схема; порты, пути и имена выбраны для примера.
Шаг первый: соберите версию 1 и запустите её на обоих адресах. Откройте форму через экземпляр A в обычной вкладке и не закрывайте её.
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=<ваш_секретный_ключ> npm run build
PORT=3001 npm run start
PORT=3002 npm run startЗдесь <ваш_секретный_ключ> — заглушка. Подставьте значение, сгенерированное вами и хранящееся в секретах, а не вставляйте его в историю командной строки на общем сервере.
Шаг второй: внесите в код видимое изменение, затрагивающее действие (например, добавьте поле), и соберите версию 2. Остановите экземпляр на порту 3001 и запустите на его месте новую сборку. Экземпляр на 3002 пока остаётся на версии 1: вы имитируете середину rolling-выката.
Шаг третий: вернитесь в старую вкладку и отправьте форму. Страница была получена с порта 3001, но отправка зависит от адреса, на который браузер отправит запрос. Если страница использует относительные пути и вы ходите через единый адрес, отправку обработает тот узел, который выберет балансировщик. Для чистоты опыта сделайте два прогона: отправьте форму на 3001 (версия 2) и на 3002 (версия 1) и сравните результаты. Запишите, что вы увидели в браузере и в логах каждого экземпляра.
Шаг четвёртый: обновите и второй узел до версии 2, затем отправьте форму из той же старой вкладки ещё раз. Теперь ни один узел не обслуживает версию 1.
Что вы можете увидеть, зависит от вашей версии Next.js и от содержимого изменений, поэтому конкретный исход я предсказывать не стану. Но у такого опыта есть ценность независимо от исхода: он показывает, умеет ли ваша форма пережить релиз, и отделяет проблему ключа от проблемы версий.
Чтобы разделить причины, добавьте вариацию. Соберите два экземпляра одной и той же версии кода, но с разными значениями NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, затем откройте страницу на одном и отправьте на другом. Если сбой появился, а версии кода идентичны, виновата именно разница ключей. Повторите с одинаковым ключом: если ошибка исчезла, вы подтвердили собственную гипотезу на своей сборке. А если при одинаковом ключе и разных версиях кода сбой остался, вы увидели то самое различие между общим ключом и согласованной сборкой.
Таблица итогов такого эксперимента может выглядеть так (значения вы заполняете сами по результатам):
| Версия кода на узлах | Ключ на узлах | Источник страницы | Результат отправки |
|---|---|---|---|
| Одинаковая | Одинаковый | Любой узел | записать |
| Одинаковая | Разный | Узел A, отправка на B | записать |
| Разная | Одинаковый | Старый узел, отправка на новый | записать |
| Разная | Одинаковый | Страница из версии 1, оба узла на версии 2 | записать |
Как из этого выбрать решение
Теперь можно связать наблюдения с мерами. Я буду различать то, что следует из документации, и то, что является моим инженерным соображением.
Из документации следует: ключ должен быть одинаковым у всех экземпляров, задаётся при сборке и встраивается в результат. Значит, базовая гигиена для двух узлов — единый артефакт сборки либо единый ключ в окружении сборки. Без этого вы получаете сбои, которые не зависят от старых вкладок и проявляются у любого пользователя.
Из документации также следует, что deploymentId предназначен для защиты от рассогласования версий при rolling deployment. Используйте его, если ваш выкат действительно поочерёдный и в какой-то промежуток времени работают две версии.
Дальше начинаются соображения проектировщика, а не фактов из руководства:
- Сделайте выкат короче. Чем меньше окно, в котором живут две версии, тем меньше вероятность попасть в середину. Но короткое окно не отменяет вкладку, открытую вчера.
- Решите, что видит пользователь при отказе. Лучше понятное сообщение «страница устарела, обновите её» и сохранение введённых данных на клиенте, чем сырая ошибка. Как именно сохранять введённое, зависит от вашей формы.
- Не полагайтесь на идентификаторы, которые не менялись. Если в этом релизе действия не затронуты, форма может сработать. Но это случайное везение, а не гарантия.
- Привязывайте узел к версии на время выката. Если балансировщик умеет направлять запросы по признаку версии, можно держать старый узел для старых страниц какое-то время. Это сложнее и требует поддержки в вашей схеме проксирования.
Постоянный ключ полезен и необходим, но я бы не обещал, что он устранит любую ошибку старой вкладки. Он убирает один класс сбоев, тот, что связан с невозможностью расшифровки между узлами. Остальные классы остаются, и их надо проверять отдельно.
Где здесь VPS и его ресурсы
Два экземпляра Next.js на VPS — обычная схема: два отдельных сервера либо два процесса на одном. Важно не путать их по смыслу. Два процесса на одном сервере дают возможность обновлять поочерёдно, но не дают независимости от отказа самой машины. А два VPS не превращаются сами по себе в отказоустойчивую систему: за согласованность версий и ключа отвечаете вы.
Для опыта, описанного выше, подойдёт небольшая конфигурация: вам нужна возможность запустить сборку и два процесса. На VPS MAATRIX доступны NVMe-диски, процессоры AMD EPYC и root-доступ, а тарифы идут от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ, 320 ГБ). Сколько ресурсов нужно именно вашему приложению, покажут замеры: сборка Next.js обычно прожорливее, чем сам запуск, поэтому имеет смысл смотреть на потребление памяти во время next build, а не только в рабочем режиме. Описание тарифов не является обещанием ёмкости под конкретную нагрузку. Root-доступ нужен здесь практически: вы сможете самостоятельно настроить окружение сборки, переменные и проксирование. Условия и список локаций смотрите на странице аренды VPS MAATRIX.
Возвращаясь к кнопке
Вся история сводится к простому наблюдению: сервер и страница живут в разном времени. Сервер обновился минуту назад, а страница родилась вчера и всё ещё уверена, что мир такой, каким он был при рендере. Ключ шифрования заставляет два узла говорить на одном языке. Согласованная сборка заставляет их знать одни и те же функции. Идентификатор развёртывания помогает увидеть, что версии разошлись. Ничто из этого само по себе не научит старую страницу жить в новом мире.
Поэтому в проверку релиза стоит включить странный, но честный пункт: открыть форму до выката, не трогать её, выкатить, а потом нажать кнопку отправки. Именно с той вкладки, которую никто не обновлял. Пользователи ведут себя так каждый день, и ваш релиз должен пережить их.