RE:NODE

Приложения12 мин чтения

Деплой Next.js на сервер Node: сборка, standalone, кэш

Self-hosting Next.js: почему сборке нужно больше памяти, чем приложению, standalone-вывод и папки, которые он не копирует, переменные, вшитые в сборку, и кэш ISR.

2 прочтений

Next.js хостится самостоятельно двумя командами: next build создаёт каталог .next, а next start отдаёт его через HTTP-сервер Node на выбранном вами порту. Всё остальное, что идёт не так, - следствие четырёх фактов. Сборке нужно гораздо больше памяти и CPU, чем работающему приложению. Сборке нужны devDependencies, которые не нужны работающему приложению. Часть переменных окружения вшивается в результат сборки, а часть читается во время выполнения, и ничто не подскажет, какие из них какие. И кэш, благодаря которому быстро работает инкрементальная статическая регенерация, - это каталог на диске, поэтому у него есть свои требования к перезапускам и к работе в нескольких экземплярах.

Ниже разобрано, что каждый из этих пунктов значит на одном сервере за обратным прокси, а это обычный способ запускать Next.js, когда вы не на платформе, для которой он написан.

Сборка и запуск: две команды с очень разным аппетитом#

bash
$ npm ci                      # devDependencies included, on purpose$ npm run build               # next build$ npm run start -- -p 3000 -H 0.0.0.0

Первый сюрприз в том, что npm ci --omit=dev && npm run build не работает. TypeScript, Tailwind, PostCSS, ESLint и ваши пакеты с типами - это devDependencies, а next build в них нуждается. Либо ставьте всё и собирайте на сервере, либо собирайте где-то ещё и доставляйте результат. Почему npm ci остаётся правильным установщиком в обоих случаях, объясняет статья npm ci или npm install.

Второй - расход ресурсов. Компиляция и проверка типов приложения среднего размера может держать заметно больше гигабайта кучи и задействует все найденные ядра, тогда как то же приложение, обслуживающее трафик, после этого умещается в пару сотен мегабайт. Сборка в контейнере на 1 ГБ - классический сбой: она заканчивается JavaScript heap out of memory, либо контейнер останавливается на своём лимите, лог просто обрывается, а код выхода - 137. Ни одно из сообщений не говорит «купите больше памяти на девяносто секунд», но именно это оно и означает.

Три выхода в порядке возрастания стоимости:

  1. Собирайте в CI и деплойте результат. Сервер только ставит production-зависимости и запускает. Самый быстрый и наименее неожиданный вариант, и его стоит выбрать, если CI у вас уже есть.
  2. Временно собирайте на более крупном тарифе. Переход на уровень выше меняет лимит на уже существующем сервере, так что тариф, щедрый для сборки и достаточный для приложения, - законный ответ, когда сборки редки.
  3. Ограничить кучу и надеяться. NODE_OPTIONS=--max-old-space-size=1536 заставляет V8 агрессивнее собирать мусор, а не расти к потолку, которого у него нет. Часть сбоев она превращает в медленные сборки. Память она не создаёт. Два задействованных потолка объясняет статья почему ваше приложение Node падает на 2 ГБ при тарифе 4 ГБ.

Время сборки на дробной доле CPU - вторая половина компромисса. Сборка, занимающая 40 секунд на ноутбуке, может занять несколько минут на половине ядра, а это несколько минут простоя, если вы собираете на том же сервере, который обслуживает сайт.

при деплоеновый build idзагружается при стартевыделенный портISR пишет обратноРепозиторийapp/ и public/Слот proxyHTTPS на 443next buildнужны devDependenciesСервер Nodenext startВывод .nextсервер, статика, кэш
От push до отрисованной страницы

Standalone-вывод и две папки, которые он не копирует#

Если задать output: "standalone" в next.config.js, сборка выдаёт самодостаточный каталог: небольшой server.js и только те файлы node_modules, которые трассировка сочла необходимыми. Для деплоя это обычно на сотни мегабайт меньше, чем перенос всего дерева.

next.config.js
const nextConfig = {  output: "standalone",  compress: false,      // the proxy in front already does this  poweredByHeader: false,};export default nextConfig;

Все спотыкаются на том, что standalone-сборка намеренно не копирует два каталога, поскольку предполагается, что их раздаёт CDN: public и .next/static. Если перенести папку standalone куда-то и запустить как есть, сайт загрузится без CSS, без JavaScript и без изображений, а консоль заполнится ошибками 404 для /_next/static/.... Скопируйте их внутрь:

bash
$ cp -r public .next/standalone/public$ cp -r .next/static .next/standalone/.next/static$ node .next/standalone/server.js

Сгенерированный сервер читает PORT и HOSTNAME из окружения. Задайте оба явно, а HOSTNAME=0.0.0.0 - в особенности, потому что сервер, привязанный к loopback внутри контейнера, недоступен для прокси, и сбой выглядит как поломка приложения, а не как сетевая настройка. Заметьте также, что next start - это не способ запуска standalone-сборки; сама сборка предписывает запускать server.js напрямую.

Standalone стоит использовать, когда мало места на диске или времени на установку. Когда их хватает, это ещё одна движущаяся часть, а в monorepo ему нужно указать корень трассировки файлов вывода на корень workspace, иначе трассированный бандл не увидит пакеты, лежащие вне каталога приложения.

Переменные окружения: вшиваются при сборке, читаются при запуске#

Это самая запутанная вещь в self-hosting Next.js, и в основе у неё простое правило.

ПеременнаяКогда читаетсяДля изменения нужно
NEXT_PUBLIC_*Встраивается в клиентский бандл при сборкеПересборка
Серверные переменные в динамическом кодеНа каждый запросПерезапуск
Серверные переменные на статически пререндеренных страницахПри сборке, в HTMLПересборка

NEXT_PUBLIC_API_URL - это не переменная в развёрнутом приложении. Это строка, которая была подставлена в ваш JavaScript во время сборки. Если задать другое значение на вкладке Startup и перезапустить, не изменится ничего, потому что менять уже нечего. Если значение должно отличаться между staging и production и используется в браузере, либо собирайте дважды, либо получайте его во время выполнения из маршрута API.

Обратная ловушка - серверная переменная, используемая на странице, которая была статически пререндерена. Значение, бывшее на момент сборки, лежит в HTML. Сделайте маршрут динамическим или читайте значение в пути с областью действия запроса, если оно должно быть актуальным.

Секреты без префикса NEXT_PUBLIC_ браузеру не открываются, в этом и весь смысл префикса, и им место в переменных окружения на вкладке Startup, а не в репозитории. Общий довод изложен в статье где хранить секреты на сервере приложений, и здесь он действует с удвоенной силой, потому что утёкшее значение NEXT_PUBLIC_ уже лежит в браузере каждого посетителя.

Кэш: ISR, ревалидация и что теряется при перезапуске#

Инкрементальная статическая регенерация рендерит страницу один раз, отдаёт сохранённую копию и заново рендерит её в фоне, когда та устаревает. При self-hosting это хранилище - каталог на диске в .next/cache. Отсюда три следствия.

Новая сборка - это новый кэш. У каждой сборки свой идентификатор, поэтому страницы, сгенерированные предыдущим деплоем, не используются повторно. Первый посетитель каждого маршрута после деплоя платит за рендер. На сайте с множеством маршрутов ISR это заметный холодный период, а значит, важные страницы стоит прогреть несколькими запросами после деплоя, а не оставлять их на долю первого реального пользователя.

Перезапуски безопасны, эфемерные диски - нет. Кэш переживает перезапуск, потому что каталог сохраняется. Пересборку он не переживает, а если файловая система выбрасывается между запусками, его нет вообще. На обычном сервере с постоянным хранилищем это не проблема, и это одно из тихих преимуществ запуска Next.js на обычной машине.

Больше одного экземпляра - больше одного кэша. Два процесса держат каждый свой, поэтому один и тот же URL может быть устаревшим в одном и свежим в другом. Для этого в next.config.js есть параметр cacheHandler: направьте его на общее хранилище, и все экземпляры согласуются. На одном сервере он вам не нужен, а имя этого параметра менялось между релизами, поэтому, если будете его применять, проверьте документацию для вашей мажорной версии.

Сама ревалидация бывает двух видов: по времени, задаваемой через export const revalidate = 3600 на маршруте или для отдельного fetch, и по требованию, через revalidatePath или revalidateTag, вызываемые из обработчика маршрута или серверного действия после изменения данных. Для контента, который меняется, когда человек нажимает «сохранить», почти всегда лучше вариант по требованию.

next/image и CPU, который никто не закладывал в бюджет#

next/image оптимизирует изображения по требованию в работающем сервере: меняет размер, перекодирует в современный формат и кэширует результат в .next/cache/images. Это реальный CPU и реальный диск на том же контейнере, что рендерит ваши страницы.

  • Установите sharp. Без него оптимизатор заметно медленнее, и Next предупреждает об этом при запуске.
  • Для внешних источников нужно настроить images.remotePatterns, иначе запросы падают с понятным сообщением о ненастроенном имени хоста.
  • Кэш индексируется по источнику, ширине и качеству, так что компонент, запрашивающий восемь размеров одного hero-изображения, порождает восемь кодирований. Сколько вариантов может существовать, регулируют deviceSizes и imageSizes.
  • minimumCacheTTL решает, как долго оптимизированный файл хранится, прежде чем будет создан заново.
  • unoptimized: true отключает всё это, и это правильный ответ, когда ваши изображения уже нужного размера и раздаются откуда-то ещё.

На тарифе с половиной ядра страница с дюжиной неоптимизированных исходных изображений сделает первый запрос медленным и заполнит каталог кэша перекодированными файлами. Это самая частая причина, по которой self-hosted сайт на Next локально ощущается нормально, а на небольшом сервере - вяло. Здесь неожиданным образом уместна статья что на самом деле меняет NVMe: кодирование упирается в CPU, так что более быстрый диск его не спасёт.

Что должен делать прокси перед приложением#

Собственный сервер Next обрабатывает HTTP, а прокси перед ним отвечает за TLS и имя хоста. На RE:NODE это слот proxy, входящий в каждый тариф приложений: направьте запись A на показанный адрес, и сертификат будет выпущен и продлён автоматически в окне в 21 день, а адрес клиента передаётся в X-Forwarded-For. Две половины разобраны в статьях что на самом деле делает обратный прокси и как направить домен на сервер.

На этой границе важно сделать правильно четыре вещи:

  • Сжатие один раз. Next по умолчанию сжимает ответы. Если сжимает и прокси, задайте compress: false в next.config.js и пусть этим занимается прокси, иначе вы платите дважды за одни и те же байты.
  • Протокол в пересылаемых заголовках. Код, строящий абсолютные URL, должен знать, что исходный запрос был по HTTPS. Читайте пересылаемые заголовки, а не предполагайте, иначе ваши canonical-ссылки и редиректы будут указывать на http://.
  • Стриминг. Ответы App Router передаются потоком, а границы Suspense приходят по частям. Прокси, полностью буферизующий ответы, превращает это в один поздний ответ: страница всё равно работает, но выигрыш в воспринимаемой скорости исчезает.
  • Статические ресурсы. Файлы в /_next/static содержат хеш содержимого и неизменяемы, поэтому их можно жёстко и надолго кэшировать. Какие заголовки для этого нужны, описано в статье HTTP-заголовки кэширования простыми словами, а в статье TTFB, Core Web Vitals и хостинг честно сказано, какие части оценки Lighthouse хостинг действительно способен сдвинуть.

Деплой и перезапуск#

Команда запуска на хосте, который собирает на самом сервере, выглядит так:

bash
npm ci && npm run build && npm run start -- -p $PORT -H 0.0.0.0

Это честно, но медленно, потому что сборка выполняется при каждом запуске. Лучше собирать, когда меняется код, а не когда стартует процесс: собирайте в CI, коммитьте или загружайте результат, а командой запуска пусть будет npm ci --omit=dev && npm run start.

Подключите репозиторий вместо загрузки файлов. На RE:NODE интеграция с Git работает только с GitHub, через GitHub App с короткоживущими токенами, чтобы работали приватные репозитории, и с двумя переключателями: подтягивать ветку при каждом запуске и деплоить при push, что перезапускает уже работавший сервер. Одна запись на каждый деплой показывает, какой именно push сейчас действует. Пошаговый разбор есть в статье деплой приложения Node.js из GitHub.

Деплой стоит провала: процесс останавливается, новый собирается или запускается, а пока он не слушает, прокси не с кем разговаривать. С одним процессом свести этот провал к нулю нельзя, можно лишь сделать его коротким. Собирайте заранее, деплойте при низком трафике и прочитайте статью деплой без простоя на сервере, где всего по одному, чтобы узнать, что действительно помогает. Если ваше приложение Next ещё и открывает маршруты API, от которых зависят другие системы, эксплуатационные советы из статьи деплой Express API в production - таймауты, корректное завершение, health-эндпоинты - применимы без изменений.

Диагностика#

`Could not find a production build in the '.next' directory`. Вы запустили без сборки, или результат сборки не попал в то, что было развёрнуто. .next обычно лежит в .gitignore, так что получение репозитория его не приносит.

Сборка убивается без ошибки, код выхода 137. Контейнер упёрся в лимит памяти во время сборки. Собирайте в другом месте или на более крупном тарифе.

Сайт загружается без стилей. Standalone-сборка без скопированных public и .next/static.

`EADDRINUSE` при запуске. Предыдущий процесс всё ещё держит порт. Остановите его как следует; в панели используйте Stop, а не запускайте вторую копию.

Страница не обновляется. Выясните, какой это кэш. Статически сгенерированной странице нужна ревалидация или пересборка; результат fetch может кэшироваться по умолчаниям вашей версии Next; браузер может её держать. Проверяйте в этом порядке.

Переменная окружения не действует. Если она начинается с NEXT_PUBLIC_, она была вшита в бандл при сборке, и нужна пересборка.

Изображения дают 404 или ошибку имени хоста. images.remotePatterns не перечисляет источник.

FAQ#

Нужна ли конкретная платформа для запуска Next.js?

Нет. next build и next start - поддерживаемый способ self-hosting, и процесс Node за обратным прокси запускает фреймворк так, как задумано. Вы берёте на себя эксплуатационную сторону: сборки, перезапуски, каталог кэша и сертификат, - об этом и вся остальная статья.

Почему сборке не хватает памяти, если приложение использует всего 200 МБ?

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

Стоит ли использовать standalone-вывод?

Используйте, если мало места на диске или времени на установку, либо если вы собираете образ для доставки. Не забудьте скопировать public и .next/static рядом со сгенерированным server.js и явно задайте HOSTNAME и PORT.

Можно ли хостить сайт на Next.js на статическом тарифе или тарифе PHP?

Только если экспортировать его. output: "export" даёт обычные HTML, CSS и JavaScript без сервера, которые раздаст любой статический хостинг, но при этом теряются серверный рендеринг, ISR, обработчики маршрутов и оптимизация изображений. Если ваш сайт - контент, меняющийся на этапе сборки, такой компромисс часто хорош; это разбирают статьи хостинг статических сайтов и веб-хостинг.

Сколько памяти нужно серверу Next.js во время работы?

Небольшому сайту после сборки комфортно в 512 МБ - 1 ГБ, а потребление растёт с конкурентностью, размером таблицы маршрутов и кэшем изображений. Пик - это сборка, а не обслуживание. Понаблюдайте за графиком памяти в консоли сутки, прежде чем решать, и прочитайте статью как рассчитать размер веб-приложения к дню запуска, если ждёте всплеск.

Почему страница устарела после изменения контента?

Потому что что-то намеренно её закэшировало. Выясните, статически ли сгенерирована страница и ждёт окна ревалидации, закэширован ли fetch или дело в браузере. Ревалидация по требованию через revalidatePath после изменения контента убирает гадание.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000