RE:NODE

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

Корректная остановка и health-проверки, которые что-то значат

Как SIGTERM, слив соединений и health-эндпоинт, проверяющий нужное, превращают перезапуск из сбоя в ничто: запросы больше не теряются.

0 прочтений

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

Что на самом деле останавливает ваш процесс#

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

СигналМожно перехватитьКто отправляет
SIGTERM (15)Даdocker stop, кнопка Stop в панели, systemctl stop, kill без флага
SIGINT (2)ДаCtrl-C в терминале
SIGHUP (1)ДаЗакрытие терминала; часто переиспользуется как «перечитать конфиг»
SIGKILL (9)Нетkill -9, истёкший таймаут остановки, убийца процессов ядра при нехватке памяти

SIGTERM - это просьба. SIGKILL - не просьба: ядро удаляет процесс, и ни строчка вашего кода не выполняется. Любой достойный механизм остановки - это гонка между двумя: кто-то шлёт SIGTERM, ждёт фиксированное число секунд, затем шлёт SIGKILL. У Docker по умолчанию 10 секунд. У Kubernetes 30. Что бы ни стояло перед вашим приложением, узнайте это число, потому что ваша остановка должна уложиться в него, иначе она - украшение.

Две остановки вообще пропускают вежливую версию. Кнопка Kill намеренно шлёт SIGKILL - для этого она и нужна, и это правильно, когда процесс завис. А остановка по нехватке памяти не даёт вам совсем ничего: на RE:NODE при достижении лимита памяти контейнер останавливается и запускается заново начисто, а не уходит в swap, что лучше для всех остальных на ноде и фатально для всего, что ваш процесс держал в памяти. Если потеря обрабатываемой работы важна, ответ - не более хитрый обработчик остановки, а отсутствие нехватки памяти; обычные причины разобраны в лимитах памяти Node.

Проблема PID 1: сигнал, который не приходит#

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

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

bash
#!/bin/sh# Wrong: the shell stays as PID 1 and the signal stops herenode dist/server.js# Right: the shell replaces itself with node, which becomes PID 1exec node dist/server.js

Правила, которые из этого вытекают:

  • Пусть команда запуска будет самой средой выполнения: node dist/server.js, python -m gunicorn .... Не npm start, не yarn dev, не скрипт, где забыли exec.
  • Если обёртка неизбежна, поставьте exec в последней строке.
  • Процесс, который в контейнере действительно является PID 1, наследует и обязанность подбирать осиротевшие дочерние процессы. Node и Python этого не делают, для этого и существуют --init и tini. Это важно, если ваше приложение порождает подпроцессы; иначе игнорируйте.

Проверяйте, а не предполагайте. Выведите одну строку в обработчике, остановите сервер из панели и поищите эту строку в консоли. На RE:NODE вывод консоли не фильтруется, так что строка об остановке действительно видна, а не обрезана; что ещё стоит там печатать, описано в чтении консоли.

Слив соединений HTTP-сервера в Node#

Схема всегда одна: перестать брать новую работу, закончить старую, освободить ресурсы, выйти. В Node тонкость - keep-alive-соединения: они остаются открытыми после ответа и не дают server.close() когда-либо вызвать колбэк.

shutdown.js
const server = app.listen(process.env.PORT || 3000);let shuttingDown = false;async function shutdown(signal) {  if (shuttingDown) return;  shuttingDown = true;  console.log(`${signal} received, draining`);  // 1. Fail readiness first, so anything checking stops sending traffic.  app.locals.ready = false;  // 2. Give the checker one interval to notice before we close the door.  await new Promise((resolve) => setTimeout(resolve, 5000));  // 3. Stop accepting connections; the callback fires when the last one ends.  server.close(async () => {    await pool.end();          // database    await worker?.close();     // job queue    console.log("drained, exiting");    process.exit(0);  });  // 4. Idle keep-alive sockets would hold us open forever. Node 18.2+.  server.closeIdleConnections();  // 5. A hard deadline, shorter than whatever will send SIGKILL.  setTimeout(() => {    console.error("drain timed out, forcing exit");    process.exit(1);  }, 20_000).unref();}process.on("SIGTERM", () => shutdown("SIGTERM"));process.on("SIGINT", () => shutdown("SIGINT"));

Шаги, которые люди пропускают, - 2 и 4.

Шаг 2 нужен потому, что health-проверки опрашиваются, а не присылаются. Если балансировщик проверяет каждые пять секунд, а вы закрываетесь мгновенно, он продолжает слать запросы вам ещё до пяти секунд после того, как вы перестали слушать, и каждый из них - 502. Если сначала провалить readiness, а потом подождать один интервал опроса, эти запросы превратятся в такие, которые просто не были отправлены. Когда снаружи никто не проверяет - один процесс за одним прокси, - паузу можно сократить, но никогда не до нуля.

Шаг 4 - ловушка keep-alive. Браузер держит простаивающее HTTP-соединение открытым для повторного использования; собственный server.keepAliveTimeout в Node по умолчанию пять секунд, но клиент, продолжающий слать запросы, может держать сокет сколько угодно. server.closeIdleConnections() закрывает те, что сейчас ничего не делают, и не трогает те, что в середине запроса. Node также начинает помечать ответы на существующих соединениях заголовком Connection: close, когда сервер закрывается, и это говорит клиентам переподключиться в другое место.

Жёсткий дедлайн на шаге 5 должен быть короче периода ожидания платформы, иначе вы просто сдвинули SIGKILL на более позднее время. .unref() не даёт таймеру удерживать цикл событий открытым, когда всё остальное уже закончилось.

Соединения WebSocket сами не заканчиваются, поэтому server.close() будет ждать их вечно. Закрывайте их явно с кодом закрытия (1001, «going away»), чтобы клиенты знали, что нужно переподключиться, и разносите переподключения на стороне клиента случайной задержкой - иначе все пользователи переподключатся в одну и ту же десятую долю секунды. Шторм переподключений подробнее описан в WebSocket за обратным прокси.

Python: gunicorn, uvicorn и остальные#

Gunicorn уже реализует весь этот шаблон; работа сводится в основном к тому, чтобы знать, какой сигнал что делает.

СигналЭффект
TERMКорректная остановка: воркеры заканчивают текущие запросы, не дольше --graceful-timeout
QUITБыстрая остановка: воркеры останавливаются сразу
HUPПеречитать конфигурацию и перезапустить воркеры по одному
USR2Запустить новый master рядом со старым

По умолчанию --graceful-timeout равен 30 секундам, и --timeout (сколько воркер может молчать, прежде чем master его убьёт) тоже по умолчанию 30. Если период ожидания вашей платформы - 10 секунд, 30 у gunicorn ничего не значит - уменьшите его:

bash
$ gunicorn app.wsgi:application \    --bind 0.0.0.0:8000 --workers 3 \    --timeout 30 --graceful-timeout 8 --max-requests 1000 --max-requests-jitter 100

--max-requests перезапускает воркер после указанного числа запросов - грубый, но эффективный ответ на медленную утечку памяти. Jitter не даёт всем воркерам перезапуститься одновременно.

Для ASGI uvicorn сам обрабатывает SIGTERM и поддерживает --timeout-graceful-shutdown. В FastAPI очистка принадлежит обработчику lifespan:

python
from contextlib import asynccontextmanager@asynccontextmanagerasync def lifespan(app):    pool = await create_pool()    app.state.pool = pool    yield                      # the application runs here    await pool.close()         # runs on shutdown, before the process exits

Всё после yield выполняется во время остановки. Если вы всё ещё пользуетесь @app.on_event("shutdown"), он работает, но новый код принято писать в lifespan.

Остановка воркера посреди задачи#

HTTP-запрос длится миллисекунды. Фоновая задача может длиться минуты, и тот же 10-секундный период ожидания действует, так что «доделать текущую задачу» часто невозможно. Ответ - не более длинный таймаут, а безопасность прерывания.

  • Подтверждайте позже. task_acks_late=True в Celery и аналоги означают, что задача помечается выполненной только после завершения. Убейте воркер на полпути, и задача вернётся в очередь, а не исчезнет.
  • Делайте задачи идемпотентными. Если позднее подтверждение приведёт к повторной доставке задачи, она иногда выполнится дважды. Двойное списание с карты - не гипотетический пример.
  • Сначала перестаньте брать задачи. По SIGTERM скажите воркеру не брать новые задачи, затем дайте текущей закончиться в пределах дедлайна. Celery называет это тёплой остановкой (warm shutdown), а второй SIGTERM превращает её в холодную (cold). await worker.close() в BullMQ ждёт активные задачи; передача true заставляет закрыться принудительно.
  • Предзагружайте меньше. Воркер, который забрал пятьдесят задач, но не начал их, на время перезапуска удерживает эти пятьдесят задач в заложниках. worker_prefetch_multiplier = 1 в Celery, небольшая конкурентность в остальных.
  • Сохраняйте промежуточные точки. Задача, обрабатывающая 50 000 строк, должна записывать прогресс, чтобы после перезапуска продолжить, а не начинать заново.

Сами варианты очередей, включая то, что делать без Redis, разбирает фоновые задачи на маленьком сервере.

Liveness, readiness и почему их смешение приводит к сбоям#

Два вопроса, два разных ответа, и их смешение превращает пятисекундный сбой базы данных в двадцатиминутный простой.

  • Liveness: сломан ли процесс без возможности восстановления? Провал означает «перезапустите меня». Проверять он должен почти ничего - что цикл событий крутится и HTTP-сервер отвечает. Никакой базы, никакого кэша, никаких сторонних API.
  • Readiness: должен ли этот процесс получать трафик прямо сейчас? Провал означает «обойдите меня на время». Здесь можно проверять зависимости, потому что запуск без подключения к базе - реальная причина не слать запросы.

Положите базу в проверку liveness, и вы построили усилитель. База икнула, каждый экземпляр проваливает liveness, каждый перезапускается, все подключаются заново одновременно, и база, которая была ненадолго медленной, теперь действительно лежит. А перезапуск тем временем уничтожил кэши и пулы соединений, которые помогли бы ей восстановиться.

Стоит знать и третий вид, если ваше приложение долго загружается: startup-проверку, дающую длинный льготный период до начала liveness. JVM или крупное приложение на Python, которому нужно 60 секунд на запуск, иначе будет убито liveness-проверкой с таймаутом 10 секунд - снова и снова в цикле, выглядящем в точности как падение. Если сервис бесконечно перезапускается, а в логах каждый раз чистый старт, причина обычно в этом - проблема того же рода, что и игровой сервер, который постоянно перезапускается.

Health-эндпоинт, который стоит иметь#

javascript
// Liveness: no dependencies. If this cannot answer, the process is stuck.app.get("/healthz", (req, res) => res.status(200).json({ status: "ok" }));// Readiness: cheap dependency checks, short timeouts, cached briefly.let cached = { at: 0, body: null, code: 503 };app.get("/readyz", async (req, res) => {  if (Date.now() - cached.at < 2000) return res.status(cached.code).json(cached.body);  const checks = {};  try {    await Promise.race([      pool.query("SELECT 1"),      new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 1500)),    ]);    checks.database = "ok";  } catch (error) {    checks.database = `failed: ${error.message}`;  }  const ok = app.locals.ready && Object.values(checks).every((v) => v === "ok");  cached = {    at: Date.now(),    code: ok ? 200 : 503,    body: { status: ok ? "ok" : "degraded", checks, version: process.env.GIT_SHA },  };  if (!ok) res.set("Retry-After", "5");  res.status(cached.code).json(cached.body);});

Детали, которые делают его полезным, а не декоративным:

  • `SELECT 1`, а не настоящий запрос. Вы проверяете, что соединение работает, а не что схема верна.
  • Таймаут на каждую проверку. Health-эндпоинт, который зависает, хуже того, который падает, потому что исход решает собственный таймаут проверяющего, а он обычно намного длиннее.
  • Кэшируйте результат на секунду-две. Монитор, прокси и скрипт деплоя, опрашивающие каждые пять секунд, - это удивительно много нагрузки, если каждый опрос открывает соединение.
  • `503`, никогда `500`. 503 Service Unavailable с Retry-After - правильное «не сейчас, попробуйте позже»; 500 означает, что что-то бросило исключение, и мониторы относятся к нему иначе.
  • Возвращайте версию. Хэш коммита в теле превращает эндпоинт в проверку деплоя: вы видите, какая сборка на самом деле отвечает.
  • Никаких секретов в теле. Эти эндпоинты в итоге оказываются без аутентификации. Строкам подключения, именам хостов и трассировкам стека там не место.

Health-проверки также порождают огромный шум в логах - по строке каждые несколько секунд, вечно, заглушающей нужные строки. На RE:NODE консоль сворачивает шум веб-сервера за счётчиком, который можно отключить, - это как раз эта проблема. Если вы ведёте свои логи, исключите health-пути из access-лога, а не пролистывайте их. Остальные доводы в пользу этого есть в каких логах стоит хранить.

Кто всё это потребляет? Меньше, чем принято думать в небольшой конфигурации. У открытого nginx только пассивные проверки здоровья - max_fails и fail_timeout помечают upstream плохим после неудачных запросов, - а активный опрос доступен только в коммерческой версии. Так что на одной машине полезные потребители - ваш скрипт деплоя (подождать /readyz, прежде чем переключать трафик), ваш монитор аптайма и вы сами. Это всё равно стоит иметь: о том, на что оповещать, - и это категорически не каждая неудачная проверка, - см. мониторинг, который что-то говорит.

Проверка до того, как это понадобится#

Ничего из этого нельзя считать работающим по умолчанию. Доказать это можно за двадцать минут:

  1. Запустите приложение и подайте на него постоянную нагрузку - autocannon -c 20 -d 30 http://localhost:3000/ или hey, любой инструмент, показывающий ответы не из класса 2xx.
  2. На полпути отправьте настоящий сигнал: kill -TERM $(pgrep -f "node dist/server.js") или нажмите Stop в панели.
  3. Смотрите в консоль. Вы должны увидеть строку вашего обработчика, затем строку о сливе, затем выход процесса.
  4. Прочитайте итог нагрузочного инструмента. Проходной балл - ноль неудачных запросов. Несколько означают неверный порядок слива; сотни - что сигнал так и не дошёл.
  5. Засеките время. Если слив занимает дольше периода ожидания, сокращайте жёсткий дедлайн, пока не уложитесь.

Затем сломайте всё намеренно: остановите базу данных и проверьте, что /readyz возвращает 503, а /healthz по-прежнему 200. Если падают оба, ваша проверка liveness делает слишком много, и вы построили описанный выше усилитель.

Еженедельный перезапуск по расписанию - хороший стимул для всего этого. Если путь остановки верен, перезапуск незаметен; если нет, вы узнаете об этом по собственному графику, а не во время инцидента. На RE:NODE вкладка Schedules выполняет cron-выражение над упорядоченными задачами - действие питания, бэкап, команда консоли, - так что перезапуск в 04:00 в понедельник настраивается за минуту. Когда они полезны и когда они лишь способ не замечать утечку, описано в расписаниях перезапуска, которые помогают.

Одно предупреждение о циклах перезапуска. Наблюдатель за падениями на RE:NODE раз в две минуты проверяет, не откатился ли аптайм назад; три перезапуска за час выводят предупреждение на странице сервера и открывают тикет, а шесть приостанавливают сервер. Перезапуски, которые вы запросили сами, не считаются. Это страховка от процесса, который не может запуститься, но она же означает, что плохо настроенная health-проверка, убивающая здоровое приложение, в конце концов остановит его совсем.

FAQ#

В чём разница между SIGTERM и SIGKILL?

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

Сколько должна длиться корректная остановка?

Меньше периода ожидания, который всё равно вас убьёт, - обычно от 10 до 30 секунд. Задайте явный жёсткий дедлайн на несколько секунд раньше и принудительно завершайтесь по его истечении, чтобы застрявший запрос не превратил перезапуск в убийство.

Почему мой обработчик остановки никогда не выполняется?

Почти всегда потому, что сигнал ушёл обёртке. Если команда запуска - npm start или shell-скрипт без exec, ваше приложение - дочерний процесс и никогда не видит SIGTERM. Запускайте среду выполнения напрямую.

Должна ли моя health-проверка обращаться к базе данных?

В readiness - да, с коротким таймаутом. В liveness - нет. Проверка liveness, зависящая от базы, превращает кратковременную проблему базы в одновременный перезапуск всех экземпляров, что только ухудшает проблему.

Какой код состояния должен возвращать нездоровый эндпоинт?

503 Service Unavailable с заголовком Retry-After. 500 оставьте для настоящих ошибок, а 200 возвращайте, только когда процесс действительно готов обслуживать запросы.

Помогают ли health-проверки на одном сервере?

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


Комментарии

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

0/2000