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

Сокеты в продакшене: таймауты, лимиты и исчерпание fd

Продакшн-сбои сокетов предсказуемы: простаивающие сокеты без таймаутов держат файловые дескрипторы, ECONNRESET/EPIPE роняют незакалённые обработчики, а безграничный accept исчерпывает fd. Добавь таймауты, лимиты и graceful drain.

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

В 3 часа ночи сервис перестаёт принимать соединения. CPU около нуля, память в норме, в логах ни ошибки — он просто висит. Дежурный инженер запускает lsof -p <pid> | wc -l и видит 65 535 открытых файловых дескрипторов. Виновник: клиенты, открывшие сокет, ничего не приславшие и ушедшие — half-open или тихо умершие соединения без таймаута. Каждое держало файловый дескриптор вечно. Процесс не падал; он задохнулся. Каждый сокет, что выдаёт ядро, — конечный ресурс, и в продакшене вопрос не если клиент ведёт себя плохо, а когда.

Простаивающим сокетам нужен таймаут, иначе они держат fd вечно

Инцидент из хука воспроизводим в любом Node-сервере, который выезжает без трёх осознанных защит. Вот как работает каждый режим отказа и что именно его останавливает.

TCP-сокет после принятия занимает файловый дескриптор — конечный per-process ресурс ядра (часто по умолчанию 1024 или ограничен ulimit -n). Node не накладывает idle-таймаут по умолчанию: сокет, что подключился и затем замолчал, будет висеть открытым бесконечно. Плохой или мёртвый клиент — упавший без чистого закрытия или half-open соединение, чей FIN так и не пришёл — поэтому течёт дескриптором, который никогда не возвращается. Накопи достаточно — и процесс больше не может ничего accept()-ить: зависание из хука.

Защита — socket.setTimeout(ms), который эмитит событие 'timeout' после ms бездействия (нет ни чтений, ни записей). Важно: событие таймаута не закрывает сокет за тебя — оно лишь уведомляет — поэтому ты обязан действовать:

server.on("connection", (socket) => {
  socket.setTimeout(30_000);                 // 30с бюджет простоя
  socket.on("timeout", () => {
    socket.destroy(new Error("idle timeout")); // ТЫ обязан закрыть
  });
  socket.on("error", () => {});               // глушим ошибки после destroy
});

Для HTTP-серверов та же идея даётся как server.setTimeout(), плюс server.headersTimeout, server.requestTimeout и server.keepAliveTimeout — последний охраняет промежуток между keep-alive запросами. Атака Slowloris (DoS через тысячи медленных соединений, держащих сервер занятым) — ровно оружейная версия хука: атакующий открывает много соединений и капает байты медленно, держа каждое чуть под любым наивным таймаутом, исчерпывая ёмкость соединений почти без трафика. Таймауты заголовков и запроса — то, что её останавливает.

Keep-alive: переиспользуй рукопожатие, но ограничь простой

Keep-alive — переиспользование одного TCP-соединения для многих запросов, избегающее свежего рукопожатия (и TLS-согласования) каждый раз — большой выигрыш по задержке. Но простаивающее keep-alive соединение всё равно держит файловый дескриптор и слот. Натяжение настройки реально: слишком короткий keepAliveTimeout — и ты выбрасываешь выгоду переиспользования, переподключаясь постоянно; слишком долгий — и простаивающие клиенты копят дескрипторы, что ты мог бы отдать активным.

Есть и пресловутая гонка между сервером, закрывающим простаивающее keep-alive соединение, и клиентом, шлющим ещё один запрос по нему: запрос приземляется на сокет, который сервер разбирает, порождая ECONNRESET, который клиент видит как упавший запрос. Смягчение — сделать keepAliveTimeout сервера длиннее idle-таймаута вышестоящего балансировщика, чтобы LB всегда закрывал первым, на соединении, которое знает простаивающим — классический источник прерывистых 502, если порядок обратный.

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

Почему keep-alive таймаут сервера должен превышать таймаут балансировщика? Кто закрывает простаивающее соединение, выигрывает гонку чисто; кто шлёт по соединению, которое другая сторона только что закрыла, проигрывает с reset. Если idle-таймаут LB короче, LB шлёт запрос на соединение, что твой сервер уже начал закрывать, и клиент получает 502/ECONNRESET. Сделай сервер живущим дольше LB, и LB всегда инициирует закрытие на реально простаивающем сокете — нет запроса в полёте, который можно сбросить.

ECONNRESET и EPIPE — не баги, а вторник

Два кода ошибок доминируют в продакшн-логах сокетов, и относиться к ним как к экзотике — ошибка.

ОшибкаЧто случилосьВерная реакция
ECONNRESETПир прислал RST — резкое закрытие (падение, kill, обрыв сети)Очисти это соединение; не роняй процесс
EPIPEТы записал в сокет, который пир уже полностью закрылПрекрати писать; соединения больше нет

Оба эмитятся как события 'error' на сокете, и железное правило из урока про TCP применяется в продакшене с силой: необработанное 'error' сокета кидает исключение и роняет весь процесс, убивая вместе с ним каждое другое соединение в полёте. Один плохой клиент никогда не должен мочь уронить сервер, обслуживающий тысячи. Поэтому каждому сокету — принятому на сервере и открытому на клиенте — нужен обработчик 'error', который логирует-и-очищает, а не перебрасывает. Асимметрия для усвоения: ECONNRESET — это то, что сделали с тобой (пир сбросил), EPIPE — это ты пишешь в закрытую трубу — но оба сводятся к «это одно соединение мертво, отпусти его, продолжай обслуживать остальных».

Ограничь соединения, затем сливай аккуратно

Корневая причина в хуке — безграничный рост ресурса. server.maxConnections ограничивает одновременные сокеты: за лимитом сервер перестаёт принимать (OS backlog поглощает несколько, затем отказывает), что сбрасывает нагрузку вместо коллапса в исчерпание fd. Это грубый инструмент, но реальный страховочный трос. Соедини его с поднятым осознанно ulimit -n — и у тебя верхняя граница, что ты выбрал, а не обнаружил в 3 ночи.

Вторая половина — graceful shutdown. На SIGTERM (деплой, scale-down) ты обязан слить, а не бросить: server.close() перестаёт принимать новые соединения и зовёт колбэк, как только все существующие завершатся — но он будет ждать вечно на долгоживущем простаивающем keep-alive сокете, поэтому ты также проходишь по активным сокетам, сигналишь им завершиться и destroy()-ишь отставших после дедлайна. Пропуск этого превращает каждый деплой в волну ECONNRESET для пользователей в полёте.

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

Твой Node-сервер стоит за балансировщиком, и ты видишь прерывистые 502/ECONNRESET на в остальном здоровых запросах.

Викторина

Долго работающий Node-сервис медленно перестаёт принимать новые соединения без падения, без высокого CPU и без роста памяти. Вероятная причина?

Викторина

socket.setTimeout(30000) эмитит событие 'timeout', но соединение остаётся открытым и продолжает держать fd. Почему?

Вспомните перед уходом
  1. 01
    Почему Node-сервер может исчерпать файловые дескрипторы без падения, без скачка CPU и без роста памяти — и как это предотвратить?
  2. 02
    Почему надо обрабатывать ECONNRESET/EPIPE на каждом сокете и что добавляет graceful shutdown?
Итог

Продакшн-сбои сокетов не случайны — это три повторяющиеся дисциплины, которые ты либо применяешь, либо за них платишь. Первая: таймаутить простаивающие сокеты — каждый принятый сокет держит конечный файловый дескриптор, Node не ставит idle-таймаут по умолчанию, поэтому socket.setTimeout(ms) плюс явный destroy() в обработчике 'timeout' — то, что возвращает дескрипторы, утёкшие от мёртвых или half-open клиентов, а HTTP-овские headersTimeout/requestTimeout — то, что бьёт Slowloris. Вторая: относись к ECONNRESET и EPIPE как к рутине — оба всплывают как события 'error' сокета, а необработанное роняет весь процесс и каждое соединение на нём, поэтому каждому сокету нужен обработчик ошибки, очищающий одно соединение. Третья: ограничивай и сливай — server.maxConnections и осознанный ulimit -n ограничивают рост, чтобы ты не исчерпал дескрипторы тихо, а server.close() со сливом активных сокетов на SIGTERM превращает деплои из штормов reset’ов в чистые передачи. Настраивай keep-alive между переиспользованием и коплением и сделай keepAliveTimeout сервера живущим дольше балансировщика, чтобы убить гонку прерывистых 502. Ограничь ресурс, таймаутни простой, обработай неизбежную ошибку, слей на выходе. Теперь, когда встретишь Node-сервис, который «висит» без падения и без высокого CPU, первый шаг — lsof -p <pid> | wc -l — и ты сразу знаешь, какие три недостающие строки это вызвали.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.