open atlas
↑ К треку
Node.js с нуля до senior NODE · 08 · 04

Деплой без простоя: health-проверки, дренаж и выкатка

Деплой-502 — гонка: SIGTERM закрывает сервер раньше, чем балансировщик снимет его с маршрутизации, и новые запросы бьют в умирающий под. Почини порядок: сначала урони readiness, потом закрой и выйди — и выбери выкатку (rolling, blue-green, canary) с обратносовместимой миграцией.

NODE Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Каждый деплой дашборд загорался одинаково: чистая зелёная линия, затем 4-секундный всплеск 502 и ECONNRESET, затем снова зелёный. Никто ничего не ломал — новый образ был в порядке, старый образ был в порядке. Сервис выкатывался двенадцать раз в день, так что двенадцать раз в день кусок пользователей получал жёсткую ошибку без причины. Дежурный инженер добавил обработчик process.on("SIGTERM"), который звал server.close(), и счёл дело сделанным. Следующий деплой всё равно дал всплеск. Баг был не в том, завершается ли старый под — он завершался безупречно. Баг был в том, что он завершался, пока балансировщик всё ещё слал ему трафик.

Деплой-502 — это гонка, а не падение

Ты уже знаешь (из урока по контейнеризации Node), что на выкатке оркестратор шлёт SIGTERM, ты его ловишь, зовёшь server.close() и выходишь до того, как истечёт terminationGracePeriodSeconds. Это заставляет твой под завершиться чисто. Но это не делает чистым деплой, потому что чистое завершение и снятие с балансировщика — два независимых события, которые гоняются между собой.

Вот точная последовательность при наивном rolling-деплое. Оркестратор решает заменить под A. Он делает две вещи, и критично, что они не синхронизированы: шлёт SIGTERM в контейнер пода A и — отдельно, через контроллер endpoints/service, а затем балансировщик или kube-proxy на каждом узле — начинает удалять под A из набора endpoints, куда маршрутизируется трафик. Первое почти мгновенно. Второе распространяется через control plane: обновление endpoint должно быть замечено, затем балансировщик или правила iptables/IPVS на каждом узле должны быть переписаны. Это распространение занимает от сотен миллисекунд до нескольких секунд.

Так что в это окно под A в худшем из возможных состояний: он получил SIGTERM, твой обработчик вызвал server.close() (что прекращает приём новых соединений), но LB по-прежнему держит под A в ротации и продолжает открывать к нему новые соединения. Новые соединения к сокету, который больше не делает accept(), отбиваются — клиент видит ECONNREFUSED/ECONNRESET, или LB возвращает синтетический 502/503. Умножь на каждый деплой: всплеск 502 на ~2–10 секунд за выкатку, на каждую выкатку, масштабируясь с твоим rps. Этот сбой невидим в логе любого отдельного запроса и очевиден только на агрегированном графике error-rate — именно поэтому он живёт месяцами.

Фикс — развернуть порядок так, чтобы LB перестал маршрутизировать раньше, чем ты перестанешь принимать. Синхронизировать два события не получится, но можно вставить задержку, которая даст де-регистрации выиграть гонку.

Liveness против readiness: две проверки, две совершенно разные задачи

Kubernetes даёт две health-проверки, которые новички путают, а сеньоры держат жёстко раздельно, потому что они отвечают на разные вопросы и имеют противоположные последствия.

  • Liveness-проба — «процесс завис? тогда перезапусти». Провал liveness убивает под (он получает SIGTERM, затем SIGKILL, затем стартует свежий контейнер). Это самоисцеление от зависшего event loop или дедлока.
  • Readiness-проба — «надо ли слать сюда трафик прямо сейчас?» Провал readiness удаляет под из endpoints Service, так что LB перестаёт маршрутизировать на него, но под не убивает. Это переключатель маршрутизации, а не выключатель.

Эта разница и есть рычаг, чинящий деплой-502. По SIGTERM сначала урони readiness: переключи in-memory флаг, чтобы readiness-эндпоинт вернул 503. Контроллер endpoints замечает провал и снимает тебя с ротации. Затем подожди достаточно, чтобы де-регистрация реально распространилась, и только тогда зови server.close() и сливай in-flight-работу. Ты вручную упорядочил гонку в свою пользу.

import http from "node:http";

let ready = true;             // readiness flag, flipped on shutdown
const inflight = new Set();   // optional: track sockets for diagnostics

const server = http.createServer((req, res) => {
  if (req.url === "/healthz") {          // LIVENESS: process responds → alive
    res.writeHead(200).end("ok");        //   no DB call here — see the inset
    return;
  }
  if (req.url === "/ready") {             // READINESS: route to me?
    res.writeHead(ready ? 200 : 503).end(ready ? "ready" : "draining");
    return;
  }
  // ... real request handling ...
  res.writeHead(200).end("hello");
});

server.listen(3000);

process.on("SIGTERM", async () => {
  ready = false;                          // 1. fail readiness → LB drains us
  await sleep(LB_DEREGISTER_MS);          // 2. wait for de-registration to propagate
  server.close(() => process.exit(0));    // 3. stop accepting, finish in-flight, exit
  server.closeIdleConnections?.();        //    evict idle keep-alive sockets now
  setTimeout(() => process.exit(1), DRAIN_DEADLINE_MS).unref(); // 4. hard cap
});

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

Компромисс живёт в том, на что каждой пробе разрешено опираться. Readiness должна отражать реальные зависимости — если пул БД исчерпан, вернуть 503 и сбросить трафик — это корректное противодавление. Liveness не должна — и эта асимметрия и есть самая дорогая ошибка проб в проде.

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

Почему liveness никогда не должна проверять внешнюю зависимость? Потому что провал liveness убивает под, а внешние зависимости падают для всех подов разом. Представь флот из 30 подов, чей /healthz гоняет SELECT 1 к Postgres. У Postgres 5-секундная икота (failover, шторм блокировок, сетевой сбой). Liveness-проба каждого пода проваливается одновременно → Kubernetes делает SIGKILL всем 30 подам в один момент → 30 холодных стартов долбят и без того страдающую БД переподключениями → флот входит в restart-шторм CrashLoopBackOff, и твой краткий сбой БД превращается в полный outage. Процесс был совершенно здоров; он просто не мог достучаться до больной зависимости. Liveness отвечает на «жив ли мой event loop?» и ни на что больше. Здоровье зависимостей — дело readiness, которая сбрасывает трафик, ничего не убивая.

Дренаж in-flight-запросов и ловушка keep-alive

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

Поэтому корректный дренаж имеет дедлайн и активно выселяет простаивающие сокеты. Node 18.2+ добавил server.closeIdleConnections() (выселить сокеты без запроса в полёте) и server.closeAllConnections() (молот — убить всё). Форма: по SIGTERM, после провала readiness, зови server.close(cb), чтобы начать дренаж, сразу closeIdleConnections(), чтобы освободить простаивающие keep-alive-сокеты, и взведи таймер жёсткого дедлайна, который форсит выход, если in-flight-работа затянулась. Выставь server.headersTimeout/keepAliveTimeout, чтобы сокеты вообще не переживали запросы.

Числа должны вложиться корректно, иначе тебя отрежут посреди запроса:

# Деплой — временной бюджет, предотвращающий и 502, и SIGKILL посреди запроса
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 40   # MUST exceed preStop + drain, or you get SIGKILL (137)
      containers:
        - name: api
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "5"]    # bridge the LB-deregistration race
          readinessProbe:
            httpGet: { path: /ready, port: 3000 }
            periodSeconds: 2                # detect "draining" within ~2s
            failureThreshold: 1
          livenessProbe:
            httpGet: { path: /healthz, port: 3000 }  # NO dependency check
            periodSeconds: 10
            failureThreshold: 3             # ~30s before a restart — tolerant, not trigger-happy

terminationGracePeriodSeconds (дефолт k8s 30с) — это полный бюджет от SIGTERM до SIGKILL. Твой preStop-sleep () плюс in-app дренаж (15–25с) должны влезть в него с запасом — если дренаж переберёт grace period, kubelet шлёт SIGKILL (код выхода 137) прямо сквозь твои ещё работающие запросы, роняя их. Это и есть сбой от слишком низкого grace period: ты сменил один источник 502 (гонку LB) на другой (пилу убийства). preStop sleep 5 — это мостик «на всякий случай» для окна де-регистрации, даже если переключение readiness твоего приложения медленно распространяется.

Стратегии выкатки: кто съедает плохой деплой

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

Выбери лучший вариант

Высоконагруженный сервис выкатывается ~12 раз в день. Недавний деплой имел тонкий логический баг, прошедший все health-пробы (под был 'ready' и 'live'), но возвращал неверные данные и поднял 5xx. Какая стратегия выкатки лучше всего ограничивает радиус поражения и даёт оборвать быстрее всех, при разумной стоимости?

Сбой, связывающий всё воедино, — это обратно-несовместимая миграция. Во время любого rolling- или canary-деплоя старая и новая версии работают одновременно против одной общей базы данных. Если миграция новой версии переименовывает или удаляет столбец, который старая версия всё ещё читает, старые (всё ещё обслуживающие) поды начинают падать — твой «zero-downtime» деплой только что сломал версию, которую ты даже не менял. Дисциплина — expand/contract: сначала выкати миграцию, которая только добавляет (новый nullable-столбец, новая таблица), и код, пишущий и в старую, и в новую форму; дай ей вызреть; затем, в более позднем деплое, contract — удали старый столбец, когда его больше не читает ни одна работающая версия. Каждая промежуточная схема должна читаться и версией, которую ты выкатываешь, и той, от которой ты уходишь. Пропусти expand/contract — и твоя идеально слитая, прогейченная пробами, выкаченная canary выкатка всё равно даст outage — просто со стороны базы.

Викторина

По SIGTERM твой обработчик сразу зовёт server.close() и выходит, с корректным in-app graceful shutdown, но без переключения readiness и без preStop-задержки. Почему деплои всё равно выдают всплеск 502/ECONNRESET?

Вспомните перед уходом
  1. 01
    Почему совершенно корректный graceful-shutdown-обработчик (SIGTERM → server.close() → exit) всё равно даёт всплеск 502 на каждом деплое, и в чём точный фикс?
  2. 02
    В чём разница между liveness и readiness и почему liveness никогда не должна проверять внешнюю зависимость вроде базы данных?
Итог

Всплеск 502 во время деплоя почти никогда не падение — это гонка двух независимых событий: оркестратор шлёт SIGTERM (почти мгновенно), пока отдельно снимает под с балансировщика (распространение по control plane от сотен мс до секунд). Учебно-корректный обработчик SIGTERM → server.close() → exit проигрывает эту гонку: он прекращает приём новых соединений, пока LB всё ещё маршрутизирует на него, так что новые запросы отбиваются и вылезают как ~2–10с 502/ECONNRESET на выкатку. Фикс — порядок, через две пробы, означающие противоположное: readiness — переключатель маршрутизации (урони её, чтобы слить LB, не умирая), liveness — выключатель убийства (урони её, и под перезапустится). По SIGTERM ты сначала роняешь readiness, ждёшь распространения де-регистрации (с мостиком preStop sleep ~5с), затем server.close() и closeIdleConnections(), чтобы выселить простаивающие keep-alive-сокеты, потом выходишь — всё внутри terminationGracePeriodSeconds (дефолт 30с; закладывай ~15–25с дренажа), иначе kubelet делает SIGKILL (код 137) посреди запроса. Критично: liveness никогда не должна зависеть от БД, иначе один сбой убивает весь флот в restart-шторм. Наконец, стратегия выкатки решает, кто съедает плохую версию, прошедшую пробы — rolling доезжает до 100% постепенно, blue-green переключает 100% мгновенно с быстрым откатом за ~2x стоимости, canary экспонирует лишь 1–5% и авто-откатывается по метрикам — и ни одна из них не переживёт обратно-несовместимую миграцию, ведь старая и новая версии работают вместе против одной базы, так что ты катаешь expand/contract-изменения схемы, читаемые обеими версиями. Теперь, когда увидишь повторяющийся всплеск 502 на каждом деплое, которого никакое изменение кода объяснить не может, — ты будешь смотреть на порядок провала readiness и server.close(), а не на код, что менялся.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.