pm2 reload не даёт zero downtime в fork-режиме (11 из 57 запросов упали). Рабочая схема: два порта Next.js, upstream nginx и curl-петля.
Если вы прошли весь путь этой серии - Next.js под PM2 в режиме fork на 127.0.0.1:3000, nginx спереди, ufw закрыт - остался последний практический вопрос: как выкатить новую версию кода так, чтобы ни один посетитель не увидел обрыв соединения. Наивный pm2 restart останавливает процесс и запускает заново - между «остановил» и «снова слушает порт» есть реальный зазор. pm2 reload звучит как решение специально для этого, но в fork-режиме с одним процессом он своё обещание не выполняет. Ниже - рабочая схема zero-downtime деплоя Next.js: два процесса PM2 на двух портах и переключение nginx одной командой reload, плюс честный разбор, когда вместо этого стоит взять PM2 cluster mode.
Коротко. В fork-режиме с одним процессом
pm2 reloadне даёт настоящего нуля простоя - в первой статье серии на этом же стенде провал длился около секунды: 11 из 57 запросов вернулиconnection refused. Честных пути два: (1) blue-green - два процесса Next.js на двух портах, сборка в неактивном, проверка локально, переключение nginx через `nginx -t && systemctl reload nginx`; (2) PM2 cluster mode с `instances: 2` и выше - технически работает с `next start`, если процесс запущен напрямую через путь к бинарнику, но держит вторую копию процесса в памяти постоянно и требует отдельно решать вопрос с ISR-кешем Next.js, не общим между процессами по умолчанию. Для маленького VPS этой серии выбран первый путь - лишняя память тратится только на несколько секунд во время самого деплоя.
pm2 restart web делает ровно то, что звучит: останавливает процесс, ждёт завершения, запускает заново. Между остановкой и моментом, когда новый процесс уже слушает порт, порт свободен - любой запрос в эту паузу получит `connection refused`.
pm2 reload задуман для другого - PM2 документирует его как переход с нулевым простоем в противовес restart. Механизм рассчитан на cluster mode: несколько воркеров одного приложения слушают один порт через встроенный `cluster`-модуль Node, и PM2 поднимает новых по одному, пока старые ещё отвечают - в любой момент хотя бы один жив. Эта серия статей намеренно использует PM2 в fork-режиме с `instances: 1` (решение первой статьи цикла: на VPS с 2 ГБ ОЗУ вторая постоянная копия процесса не нужна, а сборка и так упирается в пик около 1.2 ГБ), и здесь `reload` физически не на что переключать - второго воркера нет, и PM2 фактически проходит тот же цикл «остановить-запустить», что и `restart`. Подтверждено вживую в первой статье серии: `pm2 reload` дал провал около секунды, 11 из 57 запросов подряд вернулись с `connection refused`. Это не баг PM2, а следствие того, что переключаться реально не на кого.
Blue-green - схема, при которой параллельно существуют две одинаковые копии приложения, и трафик переключают с одной на другую одним движением, а не поднимают новую версию поверх старой. У обоих подходов ниже одна цель - в любой момент времени хотя бы один живой процесс отвечает, - но разная цена:
Blue-green: два порта + nginx | PM2 cluster mode | |
|---|---|---|
Постоянный расход RAM | Как у одного процесса - ~120-220 МБ (замер из статьи про утечку памяти) | Умножается на число инстансов - постоянно, а не только на время деплоя |
Пик RAM во время деплоя | Кратковременный: пик сборки (~1.2 ГБ) плюс старый процесс, затем несколько секунд два процесса вместе | Не меняется на деплое, но базовая нагрузка выше всё время |
Что переключает трафик | nginx: `proxy_pass` на новый порт через reload | PM2 внутри одного порта, через `cluster`-модуль Node |
ISR-кеш Next.js между процессами | Не проблема - трафик обслуживает один процесс за раз | По умолчанию раздельный на процесс - нужен `cacheHandler` с общим хранилищем для консистентности |
Когда выбрать | Маленький VPS, один сервер, без запаса RAM | Есть запас RAM и/или уже используете Redis для другой задачи |
Для стенда этой серии - дешёвый VPS на 2 ГБ без внешнего кеша - blue-green даёт тот же результат без постоянных затрат памяти и без нового класса багов с расходящимся ISR-кешем.
Вместо одного каталога `/var/www/app` из первой статьи серии понадобятся два независимых - старый должен отвечать, пока новый собирается.
`.env.production` кладём в оба каталога одинаковый. Дальше - сборка в обоих, до первого запуска: next start ищет уже собранный .next, без сборки процесс просто не поднимется командой ниже.
Если на этом сервере уже работает один процесс `web` на порту 3000 из первой статьи серии - удалите его, иначе он и новый `web-a` столкнутся за один и тот же порт:
Конфиг PM2 описывает оба процесса сразу - оба в fork-режиме, напрямую через бинарник Next, без npm-обёртки (разбор - в первой статье серии). Держите файл вне каталогов релизов, как и раньше - например, прямо в `/var/www/ecosystem.config.js`:
-p 3000 / -p 3001 - разные порты для двух копий приложения, иначе второй процесс не запустится.-H 127.0.0.1 - оба слушают только локальный интерфейс, та же «вторая линия защиты», что и в статье про nginx и ufw.Запускаем оба процесса один раз и сразу останавливаем `web-b` - в стабильном состоянии работает только один, второй просто числится в PM2:
В конфиге сайта из статьи про nginx и ufw вместо строки `proxy_pass` используем `include` на отдельный файл-сниппет - его и будем переключать. Понадобятся два таких сниппета, а не один: сам прокси на приложение, и отдельно - `alias` на статику Next.js, которая у вас уже настроена по первой статье серии и указывает на конкретный каталог релиза (`app`), а не на `app-a`/`app-b`. Если переключить только `proxy_pass` и забыть про этот второй блок, сайт продолжит отдавать HTML с новой версии, но её JS и CSS будут отвечать 404 - хешированные файлы лежат уже в другом каталоге, а alias всё ещё смотрит на старый:
В конфиге сайта `location /` и `location /_next/static/` меняем на `include` каждый на свою пару сниппетов. Заголовки в `location /` оставляем полностью как в статье про nginx и ufw - здесь их важно не урезать, каждый нужен по своей причине, уже разобранной там:
X-Forwarded-Proto https здесь - константа, а не `$scheme`, по той же причине, что и в статье про nginx и ufw: за Cloudflare Tunnel локальный переход от cloudflared до nginx всегда идёт по http, и `$scheme` в этом месте молча подставил бы «http» вместо реального протокола посетителя. Если у вас нет туннеля и nginx сам терминирует TLS - используйте `$scheme`, как в базовой статье про nginx.
active-upstream.conf и active-static.conf - симлинки, не файлы сами по себе. Деплой переключает именно цели этих двух симлинков разом, а не сам `location`-блок. После этой разовой настройки трафик и статика идут на `web-a`/`app-a`, `web-b`/`app-b` стоит наготове, но не запущен.
Сначала смотрим, кто сейчас активен:
readlink -f /etc/nginx/snippets/active-upstream.conf
Вывод с `upstream-a.conf` значит - живой `web-a` на 3000, деплоим в `app-b`. Дальше пример именно для этого случая; в обратном - меняйте `a` и `b` местами.
1. Собираем новую версию в неактивном каталоге - тот же порядок сборки, что и в первой статье серии, с бонусом: `app-a` в это время как ни в чём не бывало отвечает на реальные запросы, его файлы никто не трогает.
2. Запускаем неактивный процесс с новым кодом. `web-b` сейчас `stopped`, эта же команда его поднимет заново. Важно: указывайте путь к конфигу, а не только имя процесса, и берите абсолютный путь - PM2 перечитывает файл целиком, а команда должна отработать из любого каталога, включая тот, где вы только что собирали `app-b`:
3. Проверяем новую версию по её собственному порту - до того, как её увидит хоть один посетитель:
Ожидаем `200`. Если код другой или `curl` не достучался - останавливаемся здесь: nginx всё ещё смотрит на `web-a`, посетители ничего не заметили. Смотрите `pm2 logs web-b`, чините, повторяйте шаг 2.
4. Переключаем nginx - только теперь, когда новая версия подтверждённо отвечает сама по себе. Переключаем оба симлинка разом - и upstream, и статику, иначе получите рабочий HTML с 404 на JS/CSS:
`ln -sfn` меняет цель симлинка одной атомарной операцией. `reload` поднимает новые worker-процессы nginx уже с новым `include`, а старые дослуживают открытые до этого соединения сами и закрываются - на этом держится отсутствие разрыва (подробный разбор `nginx -t`/`reload` - в статье про nginx и ufw).
5. Выжидаем, останавливаем старый процесс и фиксируем новое состояние в PM2. Сразу после `reload` часть запросов ещё донашивается старыми воркерами nginx, которые всё ещё говорят со старым портом - прибьёте `web-a` раньше, эти «хвостовые» запросы оборвутся. Пары-тройки секунд обычно достаточно для типичного сайта; при долгих запросах (стриминг, тяжёлая генерация) увеличьте паузу под свой `proxy_read_timeout`. И последний шаг, который легко забыть: без `pm2 save` список процессов, который PM2 поднимет после перезагрузки сервера, останется прежним - `web-a` снова окажется online, а `web-b` нет, хотя nginx уже смотрит на 3001. Сайт переживёт саму перезагрузку с 502, пока кто-то не заметит и не поднимет процесс руками.
Деплой закончен: `web-b` на 3001 обслуживает прод, `web-a` остановлен и ждёт следующего раунда, а `pm2 save` зафиксировал именно это состояние на случай перезагрузки. В следующий раз ролями меняются местами.
Самая честная проверка - не полагаться на ощущения, а непрерывно опрашивать сайт прямо во время переключения. Запустите в отдельном терминале со своего компьютера, не с VPS, начните за минуту до деплоя:
while true; do curl -o /dev/null -s -w "%{http_code} %{time_total}\n" https://ваш-домен/; sleep 0.2; done
-w "%{http_code} %{time_total}\n" печатает код ответа и время запроса вместо самого тела страницы.sleep 0.2 - опрос около пяти раз в секунду, достаточно часто, чтобы поймать провал в доли секунды.Что вы должны увидеть за весь деплой: непрерывную ленту `200 0.045`, без единого `000` (соединение вообще не установилось), без `502`/`504` и без скачков `time_total`. Для сравнения: та же проверка через nginx во время обычного `pm2 restart` на одном процессе покажет несколько строк `502` подряд - nginx на порту 80 никуда не делся и честно отвечает сам, просто ему нечего проксировать, пока порт приложения свободен. `000` в этой петле вы увидите, только если опрашивать порт приложения напрямую, в обход nginx - тогда соединение действительно не устанавливается. Через nginx работающий деплой должен не показать ни того, ни другого.
Если держать вторую копию процесса в памяти постоянно не проблема - VPS с запасом или уже есть Redis для другой задачи, - cluster mode даёт нулевой простой без пары портов и ручного переключения nginx. Работает с `next start`, запущенным напрямую через путь к бинарнику (как и сделано в этой серии): PM2 использует встроенный `cluster`-модуль Node, который прозрачно делит порт между несколькими процессами без правок в приложении. Конфиг отличается двумя строками:
Дальше `pm2 reload ecosystem.config.js --only web` действительно даёт zero-downtime - поднимает нового воркера, дожидается ответа, гасит старого.
Цена - в двух вещах. Вместо одного процесса на ~120-220 МБ постоянно работают минимум два - RAM примерно удваивается не разово на деплой, а фоном всегда. И кеш ISR (incremental static regeneration - механизм Next.js, который кеширует сгенерированные страницы и обновляет их по расписанию или по запросу, а не заново рендерит на каждый заход) по умолчанию хранится в памяти отдельно у каждого процесса; документация Next.js прямо говорит, что при нескольких инстансах он не общий, и разные воркеры могут какое-то время отдавать разное содержимое одной страницы, пока не настроен общий `cacheHandler` (например, с Redis). Для сайта без активного ISR это не критично; для сайта с частой ревалидацией - новый класс бага, не связанный с самим деплоем.
Только в cluster mode с несколькими инстансами - там есть кому передать эстафету. В fork-режиме с одним процессом `reload` фактически проходит тот же цикл остановки и запуска, что и `restart`: в замере на стенде этой серии провал длился около секунды, 11 из 57 запросов вернули `connection refused`.
Держите два процесса Next.js на двух портах через PM2, собирайте новую версию в каталоге, который сейчас не обслуживает трафик, проверяйте её локально по собственному порту и только потом переключайте `proxy_pass` в nginx на новый порт через `nginx -t && systemctl reload nginx`. Старый процесс останавливайте через несколько секунд после переключения, не сразу.
Да, если `script` указывает прямо на бинарник Next, а не на `npm`/`yarn` как обёртку. Плата - постоянно удвоенная память и собственный in-memory ISR-кеш, не общий между процессами по умолчанию.
Запустите непрерывный опрос сайта каждые 0.2 секунды с печатью кода ответа и времени запроса, начните его до деплоя. Если в логе нет ни одной строки с `000`, `502` или `504` за всё время переключения - простоя не было.
Вынесите `proxy_pass` в отдельный файл-сниппет, на который основной конфиг ссылается через `include`, и переключайте симлинк на нужный сниппет - `ln -sfn` меняет цель одной атомарной операцией, а старые воркеры nginx после `reload` дослуживают уже открытые соединения сами.
pm2 restart и pm2 reload в fork-режиме с одним процессом не дают честного нуля простоя - провал в замере составил около секунды.Этой статьёй цикл про деплой Next.js на VPS закрыт: от первого запуска приложения под PM2 и nginx до периметра через ufw и до выкладки новых версий без разрыва соединения. Весь путь по порядку: общий разбор деплоя сервиса на сервере, разворачивание Next.js на VPS, диагностика утечки памяти, healthcheck, который правда перезапускает, nginx и ufw перед приложением и эта статья про деплой без простоя. Если процесс, который зависает, а не падает, ещё не прикрыт внешней проверкой - начните со статьи про healthcheck: без него blue-green переключит трафик на новую версию, но не заметит, если она через час зависнет сама по себе.