Архитектура бэкенда
Дренаж и порядок выключения: переверни граф зависимостей
Маршрутизация сдренирована, 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. При выключении переворачиваешь это:
- Останови HTTP-сервер / сдренируй запросы в полёте — новое не может прийти, существующие запросы заканчиваются.
- Останови фоновые воркеры и сбрось очереди — дай джобам в процессе завершиться или сделать чекпоинт.
- Закрой хранилища последними — пул базы, затем 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-alive | Connection: close, закрыть простаивающие сокеты | server.close() висит до SIGKILL |
| Дозавершить в полёте | Дать активным запросам закончиться и ответить | Оборванные запросы, 502 |
| Остановить воркеры | Сдренировать очереди, завершить/чекпоинт джобы | Полусделанные джобы потеряны |
| Закрыть хранилища | Pool end, затем Redis, в самом конце | Запрос в полёте упирается в закрытый пул |
| Страховочный таймаут | Форс-выход до конца grace period | SIGKILL посреди уборки, состояние потеряно |
Обработчик выключения вызывает server.close() и затем ждёт, но процесс висит до SIGKILL, хотя ни один запрос активно не выполняется. Почему?
Почему пул базы надо закрывать после того, как HTTP-сервер сдренирован, а не одновременно?
Расставь чистый дренаж-и-разбор после SIGTERM (обратный порядок зависимостей):
- 1 Перестать принимать новые соединения и слать Connection: close на ответах
- 2 Закрыть простаивающие keep-alive сокеты и дать запросам в полёте закончиться
- 3 Остановить фоновые воркеры и сбросить их очереди
- 4 Закрыть хранилища последними — пул базы, затем Redis
- 01Почему server.close() недостаточно и как дренировать keep-alive соединения?
- 02Что такое обратный порядок зависимостей и почему нужен ещё и страховочный таймаут?
У обработчика дверь закрыта и маршрутизация сдренирована; теперь он должен сдренировать соединения и разобрать ресурсы, не вернув потерю. server.close() лишь останавливает новые соединения — он игнорирует простаивающие keep-alive сокеты, что HTTP/1.1 держит открытыми по умолчанию, так что считает их активными и висит до SIGKILL; дренируй их, посылая Connection: close и принудительно закрывая простаивающие сокеты, пока запросы в полёте заканчиваются. Затем разбирай в обратном порядке зависимостей, зеркале запуска: HTTP-сервер и запросы в полёте первыми, затем воркеры и очереди, затем хранилища последними, потому что запрос в полёте посреди дренажа может ещё запросить базу, и рано закрытый пул превращает чистый дренаж обратно в потерю запросов. Оберни всё это в страховочный таймаут ниже grace period, чтобы один застрявший запрос не держал процесс в заложниках у SIGKILL — залогированный, самовыбранный выход бьёт ядро, выдёргивающее вилку. Порядок покупает корректность, таймаут покупает чистую смерть при зависании, и нужны оба. Механика теперь полна для запросов, что влезают в окно — но часть работы не влезает: длинные запросы и фоновые джобы, что не могут закончиться вовремя. Следующий урок, первый сеньорный бит юнита, спрашивает, что делать с работой, которую дедлайн обрежет.