awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Дренаж и порядок выключения: переверни граф зависимостей

Суть Дренаж — больше чем server.close(): keep-alive сокеты надо закрывать намеренно, а ресурсы — в обратном порядке зависимостей: HTTP-сервер первым, хранилища последними, иначе запрос в полёте упрётся в закрытый пул. Страховочный таймаут форсит выход, если дренаж завис.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 16 min

Маршрутизация сдренирована, SIGTERM пришёл, и твой обработчик вызывает ту единственную строку, что показывает каждый туториал: server.close(). Ты ждёшь, что процесс свернётся и выйдет. Вместо этого он висит, пока grace period не истечёт, и его всё равно SIGKILL’ят — а на другом деплое горстка запросов падает с ошибками database-connection-closed во время дренажа. Тебя кусают две отдельные истины. Первая: server.close() останавливает сервер от приёма новых соединений, но не закрывает уже открытые и простаивающие keep-alive соединения, так что эти сокеты держат сервер «занятым», и колбэк закрытия никогда не срабатывает. Вторая: когда ты всё же сумел выключиться, ты закрыл пул базы тем же махом, что и HTTP-сервер — но запрос, ещё в полёте, потянулся к пулу мгновением позже и нашёл его исчезнувшим. Чисто дренировать — это не один вызов; это закрытие правильных вещей в правильном порядке и ограничение всего этого так, чтобы один застрявший запрос не держал процесс в заложниках.

server.close() необходим, но недостаточен

server.close() делает ровно одно: останавливает сервер от приёма новых соединений и срабатывает колбэком, как только все существующие соединения закончились. Ловушка — keep-alive. HTTP/1.1 держит соединения открытыми по умолчанию (а HTTP/2 мультиплексирует поверх одного долгоживущего), чтобы клиенты могли переиспользовать сокет под много запросов. После того как запрос закончился, его соединение сидит простаивающим, но открытым, ожидая следующего запроса. server.close() не тронет эти простаивающие keep-alive сокеты — с его точки зрения они всё ещё «активные соединения» — так что колбэк не срабатывает, и процесс висит до SIGKILL.

Фикс — дренировать keep-alive намеренно:

  • Сигналь клиентам перестать переиспользовать сокет. Шли Connection: close на ответах во время выключения, чтобы каждый клиент закончил текущий запрос и переподключился в другом месте (к здоровому инстансу), вместо того чтобы держать сокет открытым.
  • Принудительно закрой простаивающие сокеты. Отслеживай открытые соединения и уничтожай простаивающие (не в середине запроса) немедленно, давая активным закончиться. Экосистема Node оборачивает это в хелперы; более новый рантайм выставляет server.closeIdleConnections() и server.closeAllConnections() ровно для этого.

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

Закрывай ресурсы в обратном порядке зависимостей

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

  1. Останови HTTP-сервер / сдренируй запросы в полёте — новое не может прийти, существующие запросы заканчиваются.
  2. Останови фоновые воркеры и сбрось очереди — дай джобам в процессе завершиться или сделать чекпоинт.
  3. Закрой хранилища последними — пул базы, затем Redis, затем любой другой даунстрим.

Причина конкретна: запрос в полёте, в середине дренажа, может ещё выпустить запрос в базу. Если ты закрыл пул базы до того, как этот запрос закончился, запрос упирается в закрытый пул, и он падает — ты превратил грациозный дренаж в ту самую потерю запросов, которую предотвращал. Хранилища — самая глубокая зависимость, так что они закрываются последними, после всего, что может их использовать. Закрытие пула Postgres само дренирует: pool.end() ждёт закрытия простаивающих клиентов и обрывает активные запросы после таймаута.

Страховочный таймаут

Дренаж может зависнуть. Запрос может застрять на медленном даунстриме, keep-alive клиент может никогда не прислать Connection: close, воркер может заклинить. Если ты просто await’ишь дренаж вечно, ты пролетаешь grace period и получаешь SIGKILL посреди уборки — теряя ровно то состояние, что пытался сбросить. Так что каждое продовое выключение оборачивает дренаж в страховочный таймаут (также force-shutdown или shutdown-watchdog): запусти таймер на меньше, чем grace period, и если дренаж не завершился к его срабатыванию, залогируй громко и форсь выход (process.exit(1)) на своих условиях. Самоинфликтный, залогированный выход за секунду до SIGKILL строго лучше, чем ядро, выдёргивающее вилку, потому что ты контролируешь код выхода и можешь сбросить логи и метрики первыми.

Почему это работает

Почему порядок закрытия так важен, когда grace period всё равно лишь тридцать секунд — разве всё не исчезнет вскоре независимо? Потому что порядок и дедлайн решают разные проблемы, и правильный дедлайн ничего не делает для порядка. Дедлайн ограничивает, как долго ты ждёшь; порядок определяет, завершится ли корректно та работа, что завершается. Представь запрос, что занимает 200ms и прошёл 100ms, когда началось выключение — хорошо внутри любого grace period. Если ты закрываешь пул базы конкурентно с HTTP-сервером, следующий запрос в базу этого запроса, выпущенный на 150ms, находит мёртвый пул и падает, хотя оставалось 29 с лишним секунд неиспользованного grace period. Запрос не вышел из времени; он вышел из зависимости, потому что ты убрал ресурс, который ему ещё был нужен, пока он легитимно работал. Обратный по зависимостям разбор кодирует простой инвариант: ресурс закрывается только когда всё, что могло его использовать, остановилось, что значит — каждый слой закрывается против тихого слоя под ним. Это точное зеркало запуска, где ты должен открыть базу до того, как HTTP-сервер сможет обслужить запрос, которому она нужна — выключение просто прогоняет граф зависимостей назад. Страховочный таймаут — ортогональная гарантия: порядок обеспечивает корректность, если дренаж завершается, а таймаут обеспечивает, что процесс всё равно умирает на своих условиях, если дренаж висит, так что ты никогда не меняешь застрявший запрос на SIGKILL, теряющий всё. Тебе нужны оба, потому что корректный порядок, который никогда не заканчивается, так же фатален, как быстрый финиш в неправильном порядке.

ШагДействиеПровал, если пропущен или переставлен
Перестать приниматьЗакрыть слушатель; отвергать новые соединенияНовая работа стартует на умирающем процессе
Дренировать keep-aliveConnection: close, закрыть простаивающие сокетыserver.close() висит до SIGKILL
Дозавершить в полётеДать активным запросам закончиться и ответитьОборванные запросы, 502
Остановить воркерыСдренировать очереди, завершить/чекпоинт джобыПолусделанные джобы потеряны
Закрыть хранилищаPool end, затем Redis, в самом концеЗапрос в полёте упирается в закрытый пул
Страховочный таймаутФорс-выход до конца grace periodSIGKILL посреди уборки, состояние потеряно
Викторина

Обработчик выключения вызывает server.close() и затем ждёт, но процесс висит до SIGKILL, хотя ни один запрос активно не выполняется. Почему?

Викторина

Почему пул базы надо закрывать после того, как HTTP-сервер сдренирован, а не одновременно?

Расставь шаги по порядку

Расставь чистый дренаж-и-разбор после SIGTERM (обратный порядок зависимостей):

  1. 1 Перестать принимать новые соединения и слать Connection: close на ответах
  2. 2 Закрыть простаивающие keep-alive сокеты и дать запросам в полёте закончиться
  3. 3 Остановить фоновые воркеры и сбросить их очереди
  4. 4 Закрыть хранилища последними — пул базы, затем Redis
Вспомните перед уходом
  1. 01
    Почему server.close() недостаточно и как дренировать keep-alive соединения?
  2. 02
    Что такое обратный порядок зависимостей и почему нужен ещё и страховочный таймаут?
Итог

У обработчика дверь закрыта и маршрутизация сдренирована; теперь он должен сдренировать соединения и разобрать ресурсы, не вернув потерю. server.close() лишь останавливает новые соединения — он игнорирует простаивающие keep-alive сокеты, что HTTP/1.1 держит открытыми по умолчанию, так что считает их активными и висит до SIGKILL; дренируй их, посылая Connection: close и принудительно закрывая простаивающие сокеты, пока запросы в полёте заканчиваются. Затем разбирай в обратном порядке зависимостей, зеркале запуска: HTTP-сервер и запросы в полёте первыми, затем воркеры и очереди, затем хранилища последними, потому что запрос в полёте посреди дренажа может ещё запросить базу, и рано закрытый пул превращает чистый дренаж обратно в потерю запросов. Оберни всё это в страховочный таймаут ниже grace period, чтобы один застрявший запрос не держал процесс в заложниках у SIGKILL — залогированный, самовыбранный выход бьёт ядро, выдёргивающее вилку. Порядок покупает корректность, таймаут покупает чистую смерть при зависании, и нужны оба. Механика теперь полна для запросов, что влезают в окно — но часть работы не влезает: длинные запросы и фоновые джобы, что не могут закончиться вовремя. Следующий урок, первый сеньорный бит юнита, спрашивает, что делать с работой, которую дедлайн обрежет.

Связанные уроки
Продолжить восхождение ↑Работа в полёте: длинные запросы, фоновые джобы и дедлайн
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.