Балансировщик слал трафик на мёртвую ноду: проверялся порт, а не сервис
Каждый третий запрос к API падал по таймауту, а балансировщик клялся, что все три ноды здоровы. Панель HAProxy показывала три зелёных кружка, метрики CPU и памяти были в норме, а пользователи писали, что сайт «то работает, то нет». Разбирались два дня — и оказалось, что health-check проверял ровно то, что не имело значения: жив ли TCP-порт, а не жив ли сервис за ним.
Содержание
- Что мы увидели: часть запросов проваливалась с 504, ровно на треть
- Первые гипотезы: сеть, база данных, свежий деплой
- Что нашли, когда зашли на ноду руками
- Настоящая причина: TCP-хендшейк не требует ответа от приложения
- Почему это не поймал ни один из имеющихся мониторингов
- Что изменили: HTTP-health-check, событийная петля и внешний контур
Что мы увидели: часть запросов проваливалась с 504, ровно на треть
Первый сигнал пришёл из мониторинга внешней доступности — процент ошибок 504 Gateway Timeout начал скакать между 0% и примерно 30%. Не плавно, а рывками: то всё зелено, то треть трафика улетает в таймаут на 30 секунд (столько стоял timeout server в HAProxy).
В логах HAProxy это выглядело так:
Aug 27 14:12:03 lb1 haproxy[1421]: 10.0.0.42:51230 [27/Aug/2026:14:12:03.100] front_api back_api/node2 0/0/0/30045/30045 504 194 - - sHNN 412/412/138/46/0 0/0 "GET /api/orders HTTP/1.1"
sHNN в терминологии HAProxy значит: соединение с сервером было установлено (H), но сервер не ответил вовремя, и таймаут случился на стороне сервера. То есть TCP-хендшейк проходил нормально, а дальше — тишина 30 секунд.
При этом сама панель статистики HAProxy (/haproxy?stats) показывала ноду node2 зелёной, статус UP, счётчик health-check без единого падения. Это и было главной ловушкой: инструмент, которому мы доверяли для решения «слать сюда трафик или нет», сам был слеп к проблеме.
Совпадение по времени было почти идеальным: скачки 504 начинались и заканчивались одновременно с процессом на node2, но сам HAProxy ни разу не считал эту ноду недоступной. За сутки набралось три таких эпизода по 6–9 минут каждый.
Первые гипотезы: сеть, база данных, свежий деплой
Первой версией была сеть между балансировщиком и нодами — все три сервера сидели в одной приватной подсети на площадке в Великобритании, и мы предположили сбой коммутатора или проблему MTU при джамбо-фреймах. Прогнали mtr с lb1 до всех трёх нод во время следующего эпизода — потерь пакетов не было, задержка стабильная 0.2–0.4 мс, как и в норме.
Вторая версия — медленные запросы к базе. Полез в pg_stat_activity на PostgreSQL, ожидая увидеть зависшие транзакции или блокировки:
SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC
LIMIT 10;
Ничего длиннее 40 мс. Реплика тоже была в порядке, лаг репликации нулевой. База отпадала как причина.
Третья версия — свежий деплой. За два дня до инцидента выкатили новую версию API с изменением кеша заказов. Откатили деплой на всех трёх нодах — эпизоды продолжились через несколько часов уже на новой (откаченной) версии кода. Это исключило саму логику эндпоинта как прямую причину, хотя, забегая вперёд, косвенно к ней всё же привело.
Проверили ещё и авто-масштабирование по CPU — оно было отключено на этом контуре, ноды фиксированные, скейлинг ни при чём.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверЧто нашли, когда зашли на ноду руками
Все внешние проверки ничего не показывали, поэтому в момент следующего эпизода мы зашли на node2 по SSH напрямую и попытались обратиться к приложению локально:
$ curl -m 5 http://localhost:8080/health
curl: (28) Operation timed out after 5000 milliseconds
Приложение (Node.js, Express, один процесс на ноду) не отвечало даже на localhost. Но при этом порт был открыт:
$ ss -tln | grep 8080
LISTEN 0 511 0.0.0.0:8080 0.0.0.0:*
И что важнее — количество установленных соединений на этот порт росло:
$ ss -tn state established '( dport = :8080 or sport = :8080 )' | wc -l
47
Сорок семь установленных TCP-соединений, из которых ни одно не обслуживалось. strace -p <pid> на процессе Node.js показал, что он застрял в одном системном вызове и не двигался. top при этом показывал 100% одного ядра — процесс не «завис» в смысле остановки, он крутился, просто не в том месте.
Причина обнаружилась в коде: функция инвалидации кеша заказов, добавленная в том самом деплое (который мы откатили не полностью — конфигурация кеша осталась старой на диске и подтягивалась при старте), делала синхронную сериализацию растущего в памяти объекта через JSON.stringify без ограничения размера. Кеш никогда не очищался: TTL был выставлен, но ключ очистки не совпадал с ключом записи из-за опечатки в неймспейсе, поэтому объект рос на протяжении нескольких дней аптайма. Раз в какое-то время (в момент интенсивной записи в кеш) сериализация этого разросшегося объекта занимала синхронно единственный поток Node.js на 20–40 секунд — и всё это время событийный цикл не мог обработать ни один HTTP-запрос.
Настоящая причина: TCP-хендшейк не требует ответа от приложения
Здесь и была развязка детектива. Health-check в конфиге HAProxy выглядел так:
backend back_api
balance roundrobin
server node1 10.0.0.11:8080 check inter 2000 rise 2 fall 3
server node2 10.0.0.12:8080 check inter 2000 rise 2 fall 3
server node3 10.0.0.13:8080 check inter 2000 rise 2 fall 3
Директива check без option httpchk в HAProxy по умолчанию делает только TCP-проверку: пытается установить соединение на указанный порт и, если это удалось, считает сервер живым. Она не отправляет HTTP-запрос и не ждёт ответа приложения.
Проблема в том, что TCP-соединение на уровне ядра не требует участия прикладного процесса. Когда сокет находится в состоянии LISTEN, ядро само отвечает на SYN пакетом SYN-ACK и завершает трёхстороннее рукопожатие, кладя готовое соединение в очередь accept() (backlog, в нашем случае 511 — стандартное значение Node.js). Прикладной код должен вызвать accept(), чтобы забрать соединение из этой очереди и начать с ним работать. Но если событийный цикл заблокирован синхронной операцией, accept() просто не вызывается — а TCP-хендшейк для health-check'а уже успешно завершился до этого момента, потому что его делает ядро, а не приложение.
Поэтому HAProxy добросовестно подключался, получал SYN-ACK, закрывал соединение и отмечал ноду как UP — а реальные пользовательские запросы вставали в ту же очередь accept() следом и ждали, пока событийный цикл освободится. Если Node.js освобождался за 20–40 секунд, а timeout server в HAProxy стоял 30 секунд, часть запросов успевала дождаться ответа, часть — падала по таймауту. Отсюда и рваная картина «то 0%, то 30%» вместо ровного падения ноды.
Подробнее о механике самого рукопожатия и о том, почему на нём можно так легко ошибиться, — в разборе TCP-рукопожатия и того, почему всё виснет.
Почему это не поймал ни один из имеющихся мониторингов
У нас стоял вполне разумный на вид набор мониторинга: Prometheus снимал CPU/RAM/диск с каждой ноды, внешний аптайм-чекер раз в минуту дёргал главную страницу сайта (не API), а HAProxy имел встроенный health-check. Ни один из трёх не был рассчитан на этот сценарий.
- CPU-метрика показывала загрузку одного ядра на 100%, но это выглядело как «нормальная нагрузка под пиковый трафик», а не как аномалия — процесс же не падал и не рестартовался, alert на CPU у нас был настроен по средней загрузке за 5 минут по всем ядрам, и короткие пики в 20–40 секунд в него не попадали.
- Внешний аптайм-чекер ходил на статичную главную страницу, которая отдаётся из кеша Nginx перед бэкендом и не зависит от Node.js вообще — она была доступна 100% времени, пока реально страдал только
/api/*. - HAProxy, как выяснилось, проверял порт, а не сервис.
Это классическая проблема несогласованных уровней проверки: у каждой системы мониторинга своя зона ответственности, и никто не проверял именно то, что ломалось. Мы разбирали похожий по духу случай, когда health-check контейнера проверял не тот путь и контейнер считался живым, — см. «healthcheck проверял не то, и контейнер считался живым». Здесь та же болезнь, только на уровне балансировщика, а не оркестратора контейнеров.
Что изменили: HTTP-health-check, событийная петля и внешний контур
Правки внесли на нескольких уровнях, потому что проблема была многослойной: и в конфигурации HAProxy, и в самом приложении, и в мониторинге.
1. Health-check в HAProxy перевели на HTTP с реальной проверкой готовности:
backend back_api
balance roundrobin
option httpchk GET /health
http-check expect status 200
timeout check 3s
server node1 10.0.0.11:8080 check inter 2000 rise 2 fall 2
server node2 10.0.0.12:8080 check inter 2000 rise 2 fall 2
server node3 10.0.0.13:8080 check inter 2000 rise 2 fall 2
fall уменьшили с 3 до 2 — при HTTP-проверке ложные срабатывания на единичный сетевой глитч менее вероятны, чем при TCP, а среагировать на реальное зависание хочется быстрее.
2. Эндпоинт /health переписали так, чтобы он отражал состояние событийного цикла, а не просто существование процесса. Добавили измерение лага event loop через perf_hooks и стали отдавать 503, если лаг превышает порог:
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
app.get('/health', (req, res) => {
const lagMs = h.mean / 1e6;
if (lagMs > 200) {
return res.status(503).json({ status: 'degraded', lagMs });
}
res.status(200).json({ status: 'ok', lagMs });
});
Важная оговорка: если событийный цикл заблокирован полностью синхронной операцией, этот же самый обработчик /health тоже не выполнится вовремя — и тогда сработает timeout check 3s, что тоже честно даст HAProxy сигнал «сервер не отвечает». То есть даже в худшем случае (полная блокировка) HTTP-check спасает — просто через таймаут, а не через явный 503. TCP-check в этом же сценарии не спасал никогда, потому что ядро отвечало за приложение.
3. Устранили саму причину роста кеша. Поправили несовпадающий неймспейс в ключах инвалидации, добавили жёсткий предел на размер кеша в памяти (LRU с ограничением по количеству записей через lru-cache), а тяжёлую сериализацию большого объекта вынесли из горячего пути — теперь она либо стримится, либо считается за пределами основного event loop.
4. Добавили метрику лага event loop в Prometheus и алерт на неё отдельно от CPU:
- alert: EventLoopLagHigh
expr: nodejs_eventloop_lag_seconds > 0.5
for: 30s
labels:
severity: warning
annotations:
summary: "Event loop lag выше 500ms на {{ $labels.instance }}"
5. Внешний аптайм-чекер перевели на проверку самого API-эндпоинта, а не только статичной главной страницы — иначе он и дальше показывал бы «всё зелено» при следующей похожей проблеме.
Отдельно стоит сказать про выбор между HAProxy и Nginx для балансировки: дело было не в самом HAProxy — с option httpchk он справляется с такими случаями штатно, просто эта опция не была включена изначально. У Nginx open-source активных health-check вообще нет из коробки (только пассивные, по неудачным попыткам подключения), так что там эта же ошибка конфигурации ловится ещё хуже — придётся либо переходить на Nginx Plus, либо ставить nginx_upstream_check_module, либо использовать внешний контроллер состояния.
Похожая по духу история — когда балансировщик не знал о состоянии, которое было привязано к конкретной ноде: «состояние было локальным, а балансировщик об этом не знал». Оба случая объединяет одно: балансировщик по умолчанию знает ровно то, что вы явно попросили его проверять, и ни битом больше.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему TCP-check вообще существует, если он такой ненадёжный?
Потому что он дешёвый и подходит для сервисов, где само наличие открытого порта — уже достаточный сигнал: например, для баз данных или очередей, где логика приложения тесно связана с сетевым уровнем и полное зависание event loop физически невозможно (многопоточные сервисы с пулом воркеров ведут себя иначе, чем однопоточный Node.js). Для HTTP-API, особенно на однопоточном рантайме, TCP-check — это проверка «жив ли Linux», а не «жив ли сервис».
А если поставить fall 1, чтобы реагировать мгновенно на первый неудачный TCP-check?
Это не решит проблему из этого разбора: сам TCP-check в нашем случае никогда не был неудачным, потому что ядро отвечало за приложение. fall управляет тем, сколько неудачных проверок нужно для перевода ноды в DOWN, а не тем, что именно проверяется. Нужно было менять не порог, а метод проверки.
Могла ли эта же проблема случиться с многопоточным бэкендом, например на Java или Go с несколькими воркерами?
Риск ниже, но не нулевой. Если у сервиса несколько независимых обработчиков запросов (пул потоков, несколько процессов за одним портом через SO_REUSEPORT), полная блокировка всех одновременно менее вероятна — но то же самое рассинхронизирование «порт открыт, приложение не отвечает» бывает и там: например, при исчерпании пула соединений к базе, когда все воркеры зависают в ожидании свободного соединения, а сам HTTP-сервер продолжает принимать TCP-соединения в очередь. HTTP-health-check с реальной проверкой готовности снимает этот риск независимо от языка и модели конкурентности.
Как проверить, что новый health-check действительно ловит такие зависания, а не просто выглядит правильно в конфиге?
Нужен управляемый тест: временно заблокировать событийный цикл вручную (например, синхронным циклом на 10 секунд в тестовом эндпоинте) и посмотреть, переводит ли HAProxy ноду в DOWN за ожидаемое время. Мы такой тест теперь гоняем на стейджинге после любых изменений в конфиге балансировки — это дешевле, чем повторный разбор инцидента на проде.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →