MAATRIX / Блог / Очередь accept переполнялась, и клиенты получали таймаут вместо ошибки

Очередь accept переполнялась, и клиенты получали таймаут вместо ошибки

MAATRIX

Хуже всего, когда сервис не падает, а просто зависает — метрики зелёные, CPU не занят, а часть клиентов молча ждёт ответ по 20-30 секунд и уходит по таймауту. Мы прошли через такой инцидент: причина оказалась не в приложении и не в базе, а в маленькой, редко упоминаемой настройке ядра — размере очереди accept у слушающего сокета. Разбираем по шагам, что мы видели, какие версии отбросили и что в итоге поменяли.

Первые сигналы: жалобы есть, дашборды молчат

Началось с тикетов в поддержку: часть запросов к API «зависает», а потом клиент получает таймаут. Не 500-ю ошибку, не 502 — именно тишину до истечения таймаута на стороне клиента. При этом штатные дашборды не показывали ничего тревожного: CPU backend-серверов держался в районе 30-40%, память в норме, очередь задач в приложении пустая, время ответа в APM по успешным запросам — обычное.

Первая мысль — точечная сетевая проблема у части клиентов. Но паттерн был другой: жалобы приходили волнами, примерно совпадающими с пиками входящего трафика (утренний всплеск синхронизаций у клиентов, которые дергают API пачками почти одновременно). В остальное время всё работало штатно, что и сбивало с толку — воспроизвести проблему «по требованию» не получалось, только ждать следующий всплеск.

Важная деталь, которая потом оказалась ключевой: в логах приложения по зависшим запросам не было вообще ничего. Ни строки о начале обработки, ни ошибки — как будто запрос никогда не доходил до кода. Это довольно сильный сигнал, что проблема на уровне ниже приложения, но в моменте мы его недооценили и полезли смотреть на код.

Что показали логи и метрики

Первым делом посмотрели access-лог nginx перед backend. Часть запросов действительно завершалась по таймауту апстрима:

2026/08/24 09:14:02 [error] 18211#18211: *4471822 upstream timed out (110: Connection timed out) while connecting to upstream, client: 5.188.x.x, server: api.example.com, request: "POST /v1/sync HTTP/1.1", upstream: "http://127.0.0.1:8000/v1/sync"

Строка «while connecting to upstream» — это не таймаут чтения ответа, а таймаут именно на этапе установления соединения nginx с backend. То есть проблема не в том, что backend долго считает ответ, а в том, что backend вообще не принимает соединение вовремя.

Дальше посмотрели на сам backend (gunicorn за nginx, приложение на Python). Воркеры простаивали — ни одного активного запроса в момент всплеска жалоб, память стабильна, нагрузка на процессор минимальная. Это сразу исключало версию про «медленный код» — воркерам физически нечего было обрабатывать, они ждали новых соединений.

Ключевую подсказку дал ss, запущенный прямо во время всплеска на backend-сервере:

$ ss -lnt
State    Recv-Q  Send-Q  Local Address:Port
LISTEN   128     128     127.0.0.1:8000

Recv-Q для LISTEN-сокета — это не «непрочитанные данные», а текущая глубина очереди accept: сколько уже установленных TCP-соединений ждут, пока приложение вызовет accept(). Send-Q в этой строке — сконфигурированный максимум этой очереди (backlog). Recv-Q был равен Send-Q, то есть очередь была забита под завязку.

Следующим шагом посмотрели агрегированную статистику ядра:

$ netstat -s | grep -i listen
    154892 times the listen queue of a socket overflowed
    154892 SYNs to LISTEN sockets dropped

Счётчик рос ровно в моменты всплесков и не рос в спокойные периоды. В dmesg нашлась и прямая строка от ядра:

$ dmesg | grep -i syn
[184729.221033] TCP: request_sock_TCP: Possible SYN flooding on port 8000. Sending cookies. Check SNMP counters.

Это не была настоящая SYN-флуд атака — просто ядро включило SYN cookies, потому что очередь полуоткрытых соединений заполнилась легитимным трафиком быстрее, чем backend успевал её разгребать.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотезы, которые отбросили

Прежде чем упереться в очередь accept, проверили несколько более привычных версий.

Медленные запросы к БД. Посмотрели slow query log PostgreSQL и pg_stat_activity в моменты всплесков — долгих или заблокированных запросов не было, среднее время выполнения не отличалось от обычного дня.

Паузы сборщика мусора. Приложение на Python, но на всякий случай подняли py-spy в проде на один из воркеров во время следующего всплеска — профиль не показал ничего похожего на длительную паузу интерпретатора, воркеры просто бездействовали.

DNS и внешние зависимости. Проверили, не тормозит ли резолвинг внешних сервисов, которые дергает /v1/sync — таймингов резолвера в трейсах не было видно вообще, потому что, как выяснилось позже, запрос даже не долетал до кода приложения.

Файрвол и conntrack. Посмотрели счётчики iptables -L -v -n и заполненность таблицы conntrack (sysctl net.netfilter.nf_conntrack_count против nf_conntrack_max) — до лимита было далеко, правила ничего не дропали.

Нехватка воркеров. Логично было предположить, что просто не хватает процессов gunicorn под нагрузкой. Но если бы дело было в этом, воркеры были бы заняты обработкой (высокий CPU, растущая очередь задач внутри приложения). Вместо этого они простаивали в ожидании accept() — это и есть тот сигнал, который в итоге увёл расследование на уровень ниже приложения, к самому сокету.

Как на самом деле устроена очередь accept

Когда клиент открывает TCP-соединение, на сервере задействованы фактически две очереди, а не одна.

Первая — очередь SYN (queue полуоткрытых соединений), в которую попадает клиент сразу после отправки SYN, до завершения трёхстороннего рукопожатия. Её размер регулируется net.ipv4.tcp_max_syn_backlog, подробнее о самом рукопожатии и о том, где оно может «зависать», можно почитать в разборе TCP-рукопожатия.

Вторая — очередь accept (она же completed connection queue): сюда соединение попадает уже после успешного рукопожатия и ждёт, пока приложение вызовет accept() и заберёт его. Её максимальный размер — это минимум из двух чисел: аргумента backlog, который приложение передаёт в вызов listen(fd, backlog), и системного лимита net.core.somaxconn. Именно эту вторую очередь и показывает ss -lnt в столбцах Recv-Q/Send-Q.

Если очередь accept заполнена, а приходит новое, уже полностью установленное соединение (или новый SYN, который не помещается в SYN-очередь), поведение ядра зависит от net.ipv4.tcp_abort_on_overflow:

ЗначениеПоведение ядраЧто видит клиент
0 (по умолчанию)SYN/пакет молча отбрасывается, соединение не устанавливаетсяКлиентский TCP-стек не получает ответа и ретранслирует SYN сам, с растущими паузами (обычно около 1с, затем около 3с, затем около 6-7с — точные интервалы зависят от ОС клиента и её настроек ретрансмиссии)
1На попытку установить соединение при переполненной очереди отправляется RSTКлиент почти сразу получает явную ошибку Connection refused

Именно tcp_abort_on_overflow=0 — поведение по умолчанию в большинстве дистрибутивов — и объясняет, почему в логах nginx мы видели не мгновенный отказ, а таймаут «while connecting to upstream»: с точки зрения клиента (в нашем случае — nginx как клиент к backend) соединение как будто зависло, а на самом деле ядро backend-сервера просто игнорировало входящие пакеты, пока в очереди не освобождалось место.

Реальная причина: backlog не подняли, когда выросли пики

Собрав всё вместе, картина сложилась такая. Backend поднимался через gunicorn со значением backlog по умолчанию — 128 одновременно ожидающих соединений. Это же число фигурировало и в системном net.core.somaxconn — сервер не первый месяц работал с этими настройками, и раньше их хватало с большим запасом.

Проблема пришла не из-за деградации сервера, а из-за роста самого трафика: у клиентов, которые дёргают /v1/sync, синхронизация настроена по крону на близкие временные метки (у многих — на начало минуты по UTC), поэтому вместо равномерного потока запросов backend время от времени получал короткий, но плотный всплеск одновременных подключений — заметно больше, чем 128, укладывающихся в доли секунды. Nginx перед backend держит keepalive-пул к апстриму, но именно в эти окна пул не спасал: новых TCP-соединений к backend в пике требовалось больше, чем позволяла очередь accept.

Backend физически успевал разгрести очередь за пределами всплеска — воркеры не были перегружены в среднем по минуте. Но в конкретную секунду пика очередь на 128 слотов заполнялась до предела, лишние SYN отбрасывались ядром, и клиенты (в данном случае — сам nginx как проксирующий клиент) утыкались в ретрансмиссии и таймаут вместо мгновенного ответа. Пиковая нагрузка выросла постепенно, вместе с числом интеграций, а backlog так и остался на значении «по умолчанию», которое никто осознанно не выбирал и не пересматривал.

Отдельно стоит сказать, почему это не поймали раньше по обычным метрикам: средняя утилизация CPU и RSS по минутным интервалам сглаживает секундные всплески почти начисто. Проблема жила на масштабе долей секунды, а мониторинг агрегировал данные по более грубым интервалам — классический разрыв между тем, что показывает дашборд, и тем, что реально происходит на пике. Похожая история с расхождением между «средним» и «пиковым» разбиралась в статье про то, как нагрузка на графике держалась на двадцати процентах, а очередь всё равно росла — там причиной был лимит cgroup, но сам паттерн «средние метрики спокойны, а на пике всё стоит» очень похож.

Что изменили после инцидента

Правки внесли на трёх уровнях — ядро, приложение и наблюдаемость.

На уровне ядра подняли системный лимит очереди accept и очереди SYN с запасом под реальные пики:

# /etc/sysctl.d/99-accept-backlog.conf
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_syncookies = 1
$ sudo sysctl --system
$ sysctl net.core.somaxconn
net.core.somaxconn = 4096

tcp_syncookies оставили включённым — это не мешает нормальной работе и остаётся страховкой на случай настоящего SYN-флуда, а не просто легитимного всплеска.

На уровне приложения нужно было явно поднять backlog в самом вызове listen(), потому что системный лимит — это только потолок, а не то, что приложение использует автоматически:

# gunicorn.conf.py
backlog = 2048
$ gunicorn -c gunicorn.conf.py app:app

Для nginx как отдельного слушателя (там, где он сам терминирует клиентские соединения) backlog тоже задаётся явно в директиве listen:

server {
    listen 443 ssl backlog=4096;
    ...
}

Отдельно проговорили с командой, что просто «поднять backlog с запасом» — не панацея: если бы приложение не успевало обрабатывать поток соединений в принципе (не хватало воркеров или мощности), увеличенная очередь только отсрочила бы момент отказа и сделала бы задержки более растянутыми по времени вместо резкого всплеска ошибок. У нас же реальная пропускная способность приложения была достаточной — не хватало именно места в очереди на короткий пиковый момент, поэтому поднятие backlog было правильным, а не косметическим решением.

На уровне наблюдаемости добавили метрику из node_exporternode_netstat_Tcp_ListenOverflows (в терминах nstat это TcpExtListenOverflows) — и алерт на рост счётчика:

- alert: ListenQueueOverflow
  expr: increase(node_netstat_Tcp_ListenOverflows[5m]) > 0
  for: 0m
  labels:
    severity: warning
  annotations:
    summary: "Очередь accept переполнялась на {{ $labels.instance }}"

Это именно тот показатель, который раньше нигде не отслеживался, хотя он существовал в ядре всё это время. После инцидента прогнали синтетический нагрузочный тест (hey -c 500 -n 20000 https://api.example.com/v1/sync), подтвердили, что при сопоставимом с реальным пиком числе одновременных подключений счётчик ListenOverflows больше не растёт, и только после этого закрыли инцидент. Заодно этот же нагрузочный сценарий занесли в чек-лист перед следующими релизами, которые меняют что-то в сетевом стеке или в конфигурации воркеров — раньше такой проверки в процессе релиза просто не было.

Похожий класс проблем — когда клиент видит не осмысленную ошибку, а зависание с последующим таймаутом — разбирался и в статье про 504 Gateway Timeout в nginx: там причины другие (медленный апстрим, а не переполненная очередь), но диагностика начинается с того же вопроса — на каком именно этапе соединения всё встало.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро проверить, не переполняется ли очередь accept на моём сервере?

Посмотрите ss -lnt — если Recv-Q регулярно близко к Send-Q для нужного порта, очередь забивается. Для точного счётчика используйте netstat -s | grep -i listen (строка про «listen queue... overflowed») или nstat -az TcpExtListenOverflows дважды подряд с паузой, чтобы увидеть прирост за интервал, а не абсолютное число с момента загрузки сервера.

Что произойдёт, если поставить backlog «с большим запасом» и забыть про это?

Само по себе увеличение backlog не решает проблему нехватки мощности приложения — оно только даёт больше времени на всплеск, прежде чем очередь заполнится. Если приложение стабильно не успевает обрабатывать входящий поток, большая очередь просто растянет задержки по времени вместо резкого роста ошибок, а проблему с производительностью всё равно придётся решать отдельно.

В чём разница между Connection refused и зависанием с таймаутом при переполнении очереди?

Это зависит от net.ipv4.tcp_abort_on_overflow. При значении 0 (по умолчанию) ядро молча отбрасывает пакет, и клиент видит подвисание с ретрансмиссиями SYN, пока не истечёт его собственный таймаут. При значении 1 ядро сразу отправляет RST, и клиент получает явную ошибку Connection refused почти мгновенно — для многих сценариев это удобнее для диагностики, но означает более жёсткий отказ вместо шанса, что соединение всё же встанет в очередь на следующей попытке.

Нужно ли одинаково поднимать backlog и в приложении, и в net.core.somaxconn?

Да — реальный размер очереди accept это минимум из двух значений. Если поднять только системный somaxconn, но оставить в приложении backlog по умолчанию (например, 128 в gunicorn или Node.js), очередь всё равно будет ограничена этим меньшим числом.

Может ли эта же проблема проявляться не на HTTP, а, например, на очереди сообщений или на балансировщике перед несколькими бэкендами?

Механизм на уровне TCP один и тот же для любого сервиса, который слушает сокет — так же может переполниться очередь у брокера сообщений, у балансировщика, у SSH-демона под нагрузочным тестированием. Диагностика та же: ss -lnt по нужному порту и счётчик ListenOverflows в netstat -s.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →