RE:NODE

Эксплуатация13 мин чтения

Деплой без простоя на одном небольшом сервере

Кластер не нужен. Откуда на самом деле берётся пауза при деплое, три приёма, которые закрывают её на одной машине, и миграции, которые всё портят.

Обновлено

0 прочтений

Деплой без простоя обычно описывают через балансировщики нагрузки и rolling update, что бесполезно, если сервер у вас один. На одной машине у паузы более простая причина: старый процесс останавливается раньше, чем новый готов отвечать. Закройте это - и всё. Закрывают это три вещи: запустить новый процесс на втором порту и переключать трафик, только когда он отвечает; научить старый процесс доделывать уже принятые запросы, а не бросать их; и писать миграции, с которыми могут жить обе версии кода. Ни одной из них не нужна вторая машина, а первые две требуют примерно по двадцать строк работы.

Третья - то место, где живёт настоящая сложность, и именно поэтому в большинстве «деплоев без простоя» по-прежнему остаётся тридцатисекундная дыра.

Откуда на самом деле берётся пауза#

Прежде чем чинить, измерьте. Деплой на одном сервере - это последовательность с измеримой дырой посередине:

  1. Инструмент деплоя говорит старому процессу остановиться.
  2. Процесс завершается. Если он умирает мгновенно, все запросы в полёте теряются.
  3. Порт освобождается.
  4. Новый процесс запускается, загружает конфиг, открывает пул соединений с базой и занимает порт.
  5. Он начинает отвечать.

Простой - это расстояние между шагами 2 и 5, и шаг 4 занимает почти всё. Это число сильно различается, и своё нужно знать:

СтекТипичное время до первого ответа
Небольшой HTTP-сервис на Node или Python0.2-1 секунда
Next.js или крупное приложение на Node2-6 секунд
Django или Rails с прогретым кэшем3-10 секунд
Сервис на JVM10-60 секунд
Игровой сервер, загружающий мирот 20 секунд до нескольких минут

Замерьте один раз вручную: остановите сервис, запустите его и посмотрите, когда запрос впервые проходит. Если ответ меньше секунды, то, честно говоря, обычный перезапуск в 04:00 - разумное инженерное решение, а остальное в этой статье необязательно. Если ответ восемь секунд, а деплоите вы три раза в день, это не так.

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

Запустите новый процесс до остановки старого#

Ключевой приём в том, что на публичном порту должно слушать не ваше приложение. Поставьте впереди reverse proxy, запустите приложение на внутреннем порту, и деплой превращается в такую последовательность: запустить новую копию на другом внутреннем порту, дождаться, пока она здорова, направить прокси на неё, остановить старую. Публичный порт не закрывается никогда. Общий случай разобран в статье что делает reverse proxy; здесь - тот конкретный, ради которого он и нужен.

С nginx переключение - это правка двух строк и reload:

nginx
upstream app {    server 127.0.0.1:3001;}server {    listen 80;    location / {        proxy_pass http://app;        proxy_http_version 1.1;        proxy_set_header Upgrade $http_upgrade;        proxy_set_header Connection "upgrade";        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;        proxy_read_timeout 60s;    }}
bash
$ nginx -t && nginx -s reload

nginx -s reload по замыслу мягкий: главный процесс запускает новые рабочие процессы с новым конфигом и позволяет старым дообработать свои запросы перед выходом. Всегда сначала запускайте nginx -t: reload со сломанным конфигом отклоняется, а restart со сломанным конфигом - это авария.

запроспосле reloadтолько текущиезаписидоделываетКлиентдержит соединениеReverse proxyмягкий reloadНовый процесспорт 3001, здоровСтарый процесспорт 3000, дренажБаза данныходна схема, два читателя
Переключение происходит в прокси, а не в приложении

Два менеджера процессов делают это за вас, и о них стоит знать.

gunicorn умеет заменять себя, не роняя сокет. Отправьте мастеру USR2, и он перезапустится с новым кодом, пока старые рабочие процессы продолжают обслуживать; когда новый мастер поднялся, отправьте старому WINCH, чтобы он мягко вывел своих рабочих, а затем QUIT. Для чистой смены кода без смены конфига обычно хватает простого HUP: он перезапускает рабочие процессы по одному, а при нескольких рабочих это значит, что кто-то всегда отвечает. Учтите, что --preload сводит это на нет, потому что код загружается в мастере, а не в рабочих процессах.

PM2 в кластерном режиме делает то же самое для Node: pm2 reload app перезапускает рабочие процессы по одному, а не все сразу, и если добавить wait_ready: true и вызвать process.send("ready") после того, как сервер действительно слушает, он дожидается сигнала от каждого рабочего процесса, прежде чем убивать следующий. Это настоящий rolling deploy на одной машине. Впрочем, прежде чем подключать его, прочитайте PM2 или панель хостинга: если процесс уже контролирует панель, вы можете решать проблему, которой у вас нет.

Graceful shutdown - это около десяти строк#

Вторая половина приёма - старый процесс, который правильно ведёт себя на выходе. Обычная остановка отправляет SIGTERM; kill не отправляет ничего вообще. Обработайте SIGTERM, перестаньте принимать новые соединения, доделайте то, что есть, затем выходите - с жёстким сроком, чтобы застрявший запрос не держал деплой открытым вечно.

javascript
const server = app.listen(process.env.PORT || 3000);let shuttingDown = false;process.on("SIGTERM", () => {    if (shuttingDown) return;    shuttingDown = true;    server.close(() => {        pool.end().then(() => process.exit(0));    });    // Keep-alive sockets will otherwise hold server.close open.    if (server.closeIdleConnections) server.closeIdleConnections();    setTimeout(() => process.exit(1), 15000).unref();});

Строка с closeIdleConnections важнее, чем кажется. server.close() останавливает слушателя, но ждёт, пока существующие соединения закончатся, а keep-alive-соединения HTTP сами не заканчиваются: если не закрыть простаивающие, вы будете упираться в 15-секундный таймаут при каждом деплое. Метод есть в свежих версиях Node; проверьте свою.

Для приложения WSGI или ASGI аналог - это конфигурация, а не код: gunicorn --graceful-timeout 30 даёт рабочим процессам полминуты на завершение, а uvicorn обрабатывает SIGTERM, отказывая новым соединениям и дожидаясь текущих. Под systemd задайте срок явно, чтобы юнит не получил SIGKILL посреди дренажа:

/etc/systemd/system/myapp.service
[Service]ExecStart=/home/app/.venv/bin/gunicorn -c gunicorn.conf.py app:appExecReload=/bin/kill -HUP $MAINPIDKillSignal=SIGTERMTimeoutStopSec=45Restart=on-failureRestartSec=2

Какой бы ни был стек, схема одна и та же, и отказ тоже один и тот же: процесс, игнорирующий SIGTERM, убивают по истечении таймаута, когда работа ещё у него в руках. Детали по каждому фреймворку разобраны в статье graceful shutdown и health check.

Health check, которые что-то значат#

«Ждать, пока ответит» требует определения слова «ответит», и типичная ошибка - проверять не то.

Заведите два эндпоинта с разными задачами:

  • Liveness отвечает на вопрос «этот процесс не завис?» Он не должен проверять ничего, кроме самого себя, и возвращать 200, пока крутится цикл событий. Если liveness проверяет базу данных, тридцатисекундный сбой базы перезапускает вполне здоровое приложение, превращая небольшую проблему в аварию.
  • Readiness отвечает на вопрос «может ли этот процесс обслуживать трафик?» Он проверяет то, что нужно запросу: пинг базы, кэш, выполнены ли миграции, закончился ли прогрев. Именно его ждёт скрипт деплоя.
javascript
app.get("/livez", (req, res) => res.status(200).send("ok"));app.get("/readyz", async (req, res) => {    if (shuttingDown) return res.status(503).send("draining");    try {        await pool.query("SELECT 1");        res.status(200).send("ready");    } catch {        res.status(503).send("not ready");    }});

Ответ 503 от readiness во время дренажа - та мелочь, благодаря которой работает всё остальное: прокси перестаёт слать новые запросы на эту копию в момент, когда она начинает выключаться, а не в момент, когда закончила. Скрипт деплоя тогда превращается в цикл:

bash
$ for i in $(seq 1 60); do>   curl -fsS http://127.0.0.1:3001/readyz && break>   sleep 1> done

Шестьдесят попыток с интервалом в секунду, потом сдаться и не переключать. Никогда не переключайте по таймеру.

Миграции - самая трудная часть#

Добавить колонку безопасно. Переименовать - нет, потому что несколько секунд работают обе версии: старый код пишет в full_name, а новый читает display_name, и тот запрос, что попал не на ту сторону, получит null. Решение - никогда не делать переименование одним деплоем. Добавить, задеплоить, заполнить, переключить чтение, удалить в следующем релизе - три-четыре скучных деплоя вместо одного захватывающего. Это обычно называют expand and contract, и вся дисциплина состоит в этом.

ОперацияБезопасна во время деплоя?Почему
ADD COLUMN nullableДаСтарый код её игнорирует
ADD COLUMN со значением по умолчаниюДа на PostgreSQL 11+Старые версии переписывали таблицу
CREATE INDEX CONCURRENTLYДаНет исключительной блокировки. Нельзя запускать в транзакции
CREATE INDEXНетБлокирует запись на всё время построения
DROP COLUMNТолько когда её не читает никакой кодСтарый код, выбирающий её, падает с ошибкой
RENAME COLUMNНикогда одним шагомОбе версии не могут быть правы
ADD CONSTRAINT ... NOT VALIDДаЗатем отдельно VALIDATE CONSTRAINT
Смена типа колонкиОбычно нетЧасто переписывает и блокирует таблицу
Заполнение большой таблицыТолько пачкамиОдин большой UPDATE держит блокировки и раздувает таблицу

Ещё одна страховка, которая ничего не стоит и предотвращает худший вариант. DDL-операция, которая не может получить блокировку, будет ждать, а вместе с ней ждут все запросы, пришедшие следом, так что один заблокированный ALTER TABLE может остановить всё приложение, выглядя так, будто ничего не делает. Ограничьте её:

sql
SET lock_timeout = '3s';SET statement_timeout = '60s';ALTER TABLE orders ADD COLUMN display_name text;

Если за три секунды блокировку получить не удалось, операция падает, вы повторяете в более спокойную минуту, и никто ничего не замечает. Заполнение идёт пачками с коммитом между ними:

sql
UPDATE orders SET display_name = full_nameWHERE display_name IS NULL AND id IN (    SELECT id FROM orders WHERE display_name IS NULL LIMIT 5000);

Полную последовательность expand and contract для типичных случаев разбирает статья миграции без простоя.

Что нельзя сделать безостановочным#

Честный раздел, потому что половина читающих это ведёт игровые серверы, а не веб-приложения.

Перезапуск игрового сервера - это простой. Нет такого трюка с прокси, который сохранял бы мир Minecraft или Valheim загруженным, пока заменяется процесс, в котором он живёт: состояние мира находится в памяти этого процесса, и владеть им может только один процесс. Что можно сделать - сделать простой предсказуемым и коротким: назначить его на время, когда никого нет, предупредить в чате обратным отсчётом и убедиться, что остановка чистая и сохранение завершилось. Как выбрать час, описано в статье расписания перезапусков, которые помогают.

Прокси-сеть меняет форму проблемы, не убирая её. Если перед несколькими бэкендами Minecraft стоит Velocity, перезапуск сервера с миниграми не отключает тех, кто на survival, и можно сначала перевести игроков в лобби, чтобы они остались в сети, пока их сервер поднимается. Это действительно полезно, но это не нулевой простой для перезапускаемого сервера. Настройка есть в статье прокси-сети Minecraft на Velocity.

Долгоживущие соединения рвутся всегда. WebSocket, игровые сокеты и server-sent events нельзя передать от старого процесса новому. Ответ - на стороне клиента: переподключаться с экспоненциальной задержкой и заново подписываться после переподключения, чтобы перерыв был миганием, а не сломанной страницей. Если клиент не умеет чисто переподключаться, никакая работа на сервере не поможет. Настройка прокси, которая держит их живыми всё остальное время, описана в статье WebSocket за reverse proxy.

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

Перезапуск самой базы данных. Если приложение и база находятся на одном небольшом сервере и вы перезапускаете базу, приложение недоступно, как бы ни выглядел его процесс деплоя.

Как это делается на панели с одним контейнером#

На хостинге с панелью каждый сервер - это один контейнер с одним деревом процессов, и это определяет, что возможно.

Схема с двумя портами и внутри него работает, потому что ничто не мешает запускать новую копию на втором внутреннем порту - RE:NODE позволяет добавлять и убирать порты на вкладке Network, так что второй выделенный порт доступен, если переключение должно быть видно снаружи. Если впереди стоит прокси, в тарифы для приложений и сайтов входит слот reverse proxy: направьте A-запись на показанный адрес, и сертификат выпускается и продлевается автоматически, а реальный адрес клиента приходит в X-Forwarded-For, которому ваше приложение нужно явно сказать доверять.

Ограничение, под которое стоит планировать, - память. Во время перекрытия внутри одного лимита памяти работают две копии приложения, так что пиковое потребление примерно вдвое выше. Здесь это важнее, чем на большинстве хостингов: на лимите памяти ядро останавливает контейнер, и он запускается заново начисто, а не уходит в swap, - а это как раз тот жёсткий останов, которого вы пытались избежать. Если приложение занимает 700 МБ, а тариф 1 ГБ, убивает вас перекрытие, а не деплой. Либо рассчитывайте тариф с учётом перекрытия, либо используйте перезагрузку по одному рабочему процессу, при которой в памяти лишь один лишний воркер. Как выставить heap так, чтобы среда выполнения падала раньше контейнера, описано в статье лимиты памяти Node простыми словами.

Сам механизм деплоя - это Git: линейки приложений RE:NODE забирают код с GitHub через GitHub App с короткоживущими токенами, с переключателями pull-on-start и deploy-on-push, причём deploy-on-push перезапускает только сервер, который уже был запущен. Этот перезапуск и есть та пауза, о которой эта статья, так что обработчик graceful shutdown - это то, что стоит написать первым: он превращает перезапуск из оборванных соединений в паузу. Всё от начала до конца есть в статье деплой приложения на Node из GitHub.

Откат без второй аварии#

Процесс деплоя, который нельзя быстро откатить, не закончен. Три правила делают откат дешёвым:

  1. Держите предыдущий релиз на диске. Классическая схема - каталог на релиз с симлинком current, а откат - это перенаправление симлинка и reload. Удаление старых релизов после пятого - однострочное задание cron.
  2. Никогда не откатывайте базу данных. Миграции только вперёд, написанные так, чтобы предыдущая версия кода продолжала работать. Если вы соблюдали правило expand and contract, старый код нормально работает с новой схемой, и откатывается только приложение.
  3. Делайте бэкап до, а не после. Каждый раз, включая деплои, в которых вы уверены. На панели это одна кнопка или одна задача по расписанию.

И репетируйте всё это где-нибудь, кроме продакшена. У скрипта деплоя то же свойство, что и у бэкапа: пока его не запускали в тех условиях, для которых он написан, это гипотеза. Где прогонять, описано в статье staging и production на одном аккаунте.

FAQ#

Нужен ли балансировщик нагрузки для деплоя без простоя?

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

Как долго graceful shutdown должен ждать?

Достаточно, чтобы завершился ваш самый медленный обычный запрос, плюс запас: 15-45 секунд подходят для большинства веб-приложений. Поставьте жёсткий выход по истечении срока, чтобы один застрявший запрос не держал деплой открытым, и убедитесь, что собственный таймаут kill у супервизора длиннее вашего, иначе он убьёт процесс посреди дренажа.

Можно ли добиться нулевого простоя для сервера Minecraft или другой игры?

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

Что чаще всего приводит к потерянным запросам при деплое?

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

Нужно ли менять мои миграции базы данных?

Если две версии кода работают одновременно, да - в этом весь смысл expand and contract. Если вы согласны на короткую остановку, разрушающие миграции можно выполнять как обычно, и это вполне разумный компромисс для небольшого сервиса с тихим часом. Решайте осознанно, а не обнаруживайте это во время релиза.

Стоит ли делать эндпоинт health check в небольшом приложении?

Да, и в первую очередь ради деплоя, а не мониторинга. Это разница между переключением трафика, когда новый процесс готов, и переключением после произвольного sleep, а это самая частая причина, по которой «деплой без простоя» всё равно отдаёт ошибки четыре секунды.


Комментарии

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

0/2000