pm2 reload не даёт настоящего zero downtime в fork-режиме - замер показал провал около секунды, 11 из 57 запросов вернули connection refused. Рабочая схема: два процесса 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` из первой статьи серии понадобятся два независимых - старый должен отвечать, пока новый собирается.
sudo mkdir -p /var/www/app-a /var/www/app-b
sudo chown $USER:$USER /var/www/app-a /var/www/app-b
cd /var/www/app-a && git clone https://github.com/ваш/репозиторий.git .
cd /var/www/app-b && git clone https://github.com/ваш/репозиторий.git .
`.env.production` кладём в оба каталога одинаковый. Дальше - сборка в обоих, до первого запуска: next start ищет уже собранный .next, без сборки процесс просто не поднимется командой ниже.
cd /var/www/app-a && npm ci && npm run build
cd /var/www/app-b && npm ci && npm run build
Если на этом сервере уже работает один процесс `web` на порту 3000 из первой статьи серии - удалите его, иначе он и новый `web-a` столкнутся за один и тот же порт:
pm2 delete web
Конфиг PM2 описывает оба процесса сразу - оба в fork-режиме, напрямую через бинарник Next, без npm-обёртки (разбор - в первой статье серии). Держите файл вне каталогов релизов, как и раньше - например, прямо в `/var/www/ecosystem.config.js`:
module.exports = {
apps: [
{
name: 'web-a',
script: '/var/www/app-a/node_modules/next/dist/bin/next',
args: 'start -p 3000 -H 127.0.0.1',
cwd: '/var/www/app-a',
interpreter: 'node',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '450M',
env: { NODE_ENV: 'production', PORT: 3000 }
},
{
name: 'web-b',
script: '/var/www/app-b/node_modules/next/dist/bin/next',
args: 'start -p 3001 -H 127.0.0.1',
cwd: '/var/www/app-b',
interpreter: 'node',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '450M',
env: { NODE_ENV: 'production', PORT: 3001 }
}
]
}
-p 3000 / -p 3001 - разные порты для двух копий приложения, иначе второй процесс не запустится.-H 127.0.0.1 - оба слушают только локальный интерфейс, та же «вторая линия защиты», что и в статье про nginx и ufw.Запускаем оба процесса один раз и сразу останавливаем `web-b` - в стабильном состоянии работает только один, второй просто числится в PM2:
cd /var/www
pm2 start ecosystem.config.js
pm2 stop web-b
pm2 save
В конфиге сайта из статьи про nginx и ufw вместо строки `proxy_pass` используем `include` на отдельный файл-сниппет - его и будем переключать. Понадобятся два таких сниппета, а не один: сам прокси на приложение, и отдельно - `alias` на статику Next.js, которая у вас уже настроена по первой статье серии и указывает на конкретный каталог релиза (`app`), а не на `app-a`/`app-b`. Если переключить только `proxy_pass` и забыть про этот второй блок, сайт продолжит отдавать HTML с новой версии, но её JS и CSS будут отвечать 404 - хешированные файлы лежат уже в другом каталоге, а alias всё ещё смотрит на старый:
sudo mkdir -p /etc/nginx/snippets
echo 'proxy_pass http://127.0.0.1:3000;' | sudo tee /etc/nginx/snippets/upstream-a.conf
echo 'proxy_pass http://127.0.0.1:3001;' | sudo tee /etc/nginx/snippets/upstream-b.conf
sudo ln -sfn /etc/nginx/snippets/upstream-a.conf /etc/nginx/snippets/active-upstream.conf
echo 'alias /var/www/app-a/.next/static/;' | sudo tee /etc/nginx/snippets/static-a.conf
echo 'alias /var/www/app-b/.next/static/;' | sudo tee /etc/nginx/snippets/static-b.conf
sudo ln -sfn /etc/nginx/snippets/static-a.conf /etc/nginx/snippets/active-static.conf
В конфиге сайта `location /` и `location /_next/static/` меняем на `include` каждый на свою пару сниппетов. Заголовки в `location /` оставляем полностью как в статье про nginx и ufw - здесь их важно не урезать, каждый нужен по своей причине, уже разобранной там:
location /_next/static/ {
include /etc/nginx/snippets/active-static.conf;
}
location / {
include /etc/nginx/snippets/active-upstream.conf;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 60s;
}
sudo nginx -t && sudo systemctl reload nginx
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` в это время как ни в чём не бывало отвечает на реальные запросы, его файлы никто не трогает.
cd /var/www/app-b
git pull
npm ci
npm run build
2. Запускаем неактивный процесс с новым кодом. `web-b` сейчас `stopped`, эта же команда его поднимет заново. Важно: указывайте путь к конфигу, а не только имя процесса, и берите абсолютный путь - PM2 перечитывает файл целиком, а команда должна отработать из любого каталога, включая тот, где вы только что собирали `app-b`:
pm2 restart /var/www/ecosystem.config.js --only web-b --update-env
3. Проверяем новую версию по её собственному порту - до того, как её увидит хоть один посетитель:
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3001/
Ожидаем `200`. Если код другой или `curl` не достучался - останавливаемся здесь: nginx всё ещё смотрит на `web-a`, посетители ничего не заметили. Смотрите `pm2 logs web-b`, чините, повторяйте шаг 2.
4. Переключаем nginx - только теперь, когда новая версия подтверждённо отвечает сама по себе. Переключаем оба симлинка разом - и upstream, и статику, иначе получите рабочий HTML с 404 на JS/CSS:
sudo ln -sfn /etc/nginx/snippets/upstream-b.conf /etc/nginx/snippets/active-upstream.conf
sudo ln -sfn /etc/nginx/snippets/static-b.conf /etc/nginx/snippets/active-static.conf
sudo nginx -t && sudo systemctl reload nginx
`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, пока кто-то не заметит и не поднимет процесс руками.
sleep 5
pm2 stop web-a
pm2 save
Деплой закончен: `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, который прозрачно делит порт между несколькими процессами без правок в приложении. Конфиг отличается двумя строками:
exec_mode: 'cluster',
instances: 2,
Дальше `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 переключит трафик на новую версию, но не заметит, если она через час зависнет сама по себе.