MAATRIX / Блог / TIME_WAIT съел сеть: считаем, через сколько минут у вас кончатся порты

TIME_WAIT съел сеть: считаем, через сколько минут у вас кончатся порты

MAATRIX

Сервис бьёт по внешнему API сотни раз в секунду, каждый запрос — новое HTTP-соединение без keep-alive, и в какой-то момент исходящие вызовы начинают падать с Cannot assign requested address или EADDRNOTAVAIL. Порты физически не кончились — они застряли в состоянии TIME_WAIT и ждут, пока ядро их освободит. Ниже — не разбор конкретного инцидента, а рабочая методика: как по частоте открытия соединений и времени жизни TIME_WAIT посчитать, через сколько минут (или часов) у вас на сервере кончится запас портов, и что сделать, чтобы этот момент не наступил вообще.

Что такое TIME_WAIT и почему это не утечка

TIME_WAIT — штатное состояние TCP-соединения после его закрытия. Сторона, которая первой вызвала close() (активная сторона закрытия), не удаляет сокет сразу, а держит его записанным ещё некоторое время. Смысл в том, чтобы корректно обработать запоздавшие пакеты: если по сети всё ещё бродит дублированный или задержанный сегмент от уже закрытого соединения, а порт тем временем успели переиспользовать под новое соединение с теми же адресами и портами, старый пакет может быть ошибочно принят за часть нового потока и испортить данные. TIME_WAIT — это защитный тайм-аут, а не баг и не забытый разработчиками ресурс.

Длительность TIME_WAIT в спецификации завязана на удвоенное MSL (Maximum Segment Lifetime — теоретическое время жизни пакета в сети), отсюда и обозначение 2×MSL. На практике конкретное значение — это настройка (или константа) конкретной операционной системы и её версии: у разных ядер и разных ОС оно отличается, где-то регулируется через параметры ядра, где-то зашито жёстче. Не полагайтесь на цифру «из интернета» — узнайте своё значение на своей системе (как — в разделе про измерения ниже) и используйте его в расчётах. Дальше в тексте это время будет обозначаться как T_tw — именно его вы должны подставлять своим, измеренным или задокументированным для вашей ОС значением.

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

Почему короткие соединения без переиспользования плодят TIME_WAIT лавинообразно

Проблема появляется не от TIME_WAIT самого по себе, а от частоты, с которой соединения открываются и закрываются. Если каждый HTTP-запрос к внешнему API — это отдельное TCP-соединение: connect → TLS handshake → один запрос → close, то клиентская сторона (как правило, именно она инициирует закрытие после ответа, если сервер не закрыл первым) на каждый такой цикл кладёт в TIME_WAIT ровно один сокет.

При низкой частоте — единицы запросов в секунду — это незаметно: сокеты успевают истечь быстрее, чем накапливаются. Но при высокой частоте открытия/закрытия соединений (типичный сценарий — микросервис, который ходит к внешнему платёжному шлюзу, геокодеру, LLM-провайдеру или любому REST API без connection pooling) сокеты в TIME_WAIT начинают накапливаться быстрее, чем ядро успевает их освобождать. Это не линейный рост в бесконечность — система стабилизируется на определённом уровне занятости, — но если этот уровень превышает доступный диапазон портов, соединения кончаются буквально физически: новых свободных портов для connect() просто не остаётся.

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

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

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

Общая формула: сколько соединений одновременно живёт в TIME_WAIT

Здесь работает закон Литтла (Little's Law) — универсальное соотношение для систем массового обслуживания: среднее число объектов в системе равно произведению скорости их поступления на среднее время нахождения в системе.

L = λ × T_tw

где:

  • L — среднее количество сокетов, одновременно находящихся в TIME_WAIT;
  • λ — частота открытия/закрытия коротких соединений, соединений в секунду (для одного и того же удалённого адреса и порта — это важно, см. ниже);
  • T_tw — среднее время жизни TIME_WAIT на вашей системе, в секундах.

Пример (числа условные, для иллюстрации метода): сервис открывает 300 новых соединений в секунду к одному и тому же внешнему API, TIME_WAIT на сервере измерен и составляет 60 секунд. Тогда:

L = 300 × 60 = 18 000

То есть в среднем 18 000 портов одновременно «заморожены» в TIME_WAIT для связки (ваш IP, порт) → (адрес API, 443). Это не гипотетический пик — это установившийся, постоянный фон при такой нагрузке.

Формула применяется именно к паре «локальный адрес — удалённый адрес:порт», потому что четвёрка (local_ip, local_port, remote_ip, remote_port) должна быть уникальной для активного соединения, и именно эта уникальность определяет, когда ядро посчитает порт «занятым» для данного направления. Если ваш сервис бьёт в один и тот же внешний хост и порт (характерный случай — один платёжный шлюз, один LLM API, одна база геокодирования), весь пул эфемерных портов расходуется именно на эту пару, и посчитанное L нужно сравнивать с реальным доступным диапазоном портов для этого направления.

Считаем, когда именно кончится диапазон портов

Диапазон эфемерных (динамических) портов, из которого ядро выбирает локальный порт для исходящих соединений, ограничен и настраивается. На Linux он смотрится и меняется так:

# текущий диапазон
cat /proc/sys/net/ipv4/ip_local_port_range
# пример вывода: 32768 60999

# расширить диапазон (пример, не готовое решение — подбирайте под себя)
sudo sysctl -w net.ipv4.ip_local_port_range="15000 61000"

Размер диапазона — R = верхняя_граница − нижняя_граница + 1. Из него нужно вычесть порты, уже занятые другими исходящими соединениями и локальными сервисами, слушающими на конкретных портах в этом же диапазоне — на практике для оценки достаточно взять текущее число занятых портов через ss (см. следующий раздел) и вычесть его из R, получив «свободный запас» R_free.

Дальше — два сценария:

Система устойчива (безопасный случай): если L = λ × T_tw меньше R_free с запасом, соединения в TIME_WAIT выходят на плато и никогда не съедают весь диапазон — старые сокеты освобождаются примерно с той же скоростью, с какой открываются новые.

Система идёт к исчерпанию: если λ × T_tw больше R_free, портов не хватит гарантированно — вопрос только во времени. Считая от старта (пустой пул TIME_WAIT, например после перезапуска процесса или сброса сетевого стека) и при постоянной частоте λ, число занятых портов растёт линейно, пока не упрётся в R_free:

t_исчерпания ≈ R_free / λ

Это верно, пока t_исчерпания меньше T_tw — то есть пока исчерпание наступает раньше, чем начинают истекать самые первые сокеты. Если нет (диапазон большой, а T_tw короткий), система успевает выйти на упомянутое выше плато L = λ × T_tw раньше, чем кончится диапазон, и живёт на этом плато бесконечно (при условии, что λ и T_tw дальше не растут).

Продолжим пример: R_free = 25 000 свободных портов, λ = 300 соединений/сек, T_tw = 60 сек. Проверяем условие: λ × T_tw = 18 000, что меньше R_free = 25 000 — система устойчива, портов хватает с запасом около 28%. Но если нагрузка вырастет до λ = 450/сек, L станет 27 000 — уже больше R_free, и система пойдёт к исчерпанию за t ≈ 25000 / 450 ≈ 56 секунд с момента, как нагрузка вышла на этот уровень. Обратите внимание, как резко меняется картина при росте λ всего в полтора раза — это не линейный запас прочности, а обрыв около порога.

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

Как измерить реальные λ и T_tw у себя, а не гадать по формуле

Прежде чем считать, нужны собственные числа, а не общие цифры из статьи.

Текущее число сокетов в TIME_WAIT и разбивка по направлениям:

ss -tan state time-wait | wc -l
# или разбивка по удалённому адресу — куда именно копится TIME_WAIT
ss -tan state time-wait | awk '{print $4}' | cut -d: -f1 | sort | uniq -c | sort -rn | head

Частота открытия соединений (λ) — снимаем два замера числа TIME_WAIT-сокетов к одному направлению с интервалом, кратным T_tw, тогда разница показывает не накопление, а установившийся поток (если система уже на плато). Более надёжный способ — считать реальные connect()-вызовы на уровне приложения (метрика в коде, счётчик в клиенте HTTP) или через nstat/ss -s до и после интервала:

ss -s   # сводная статистика TCP, включая число активных открытий
# снять дважды с интервалом в 1 секунду и посчитать разницу по "estab" и открытиям

Измерение T_tw напрямую — самый надёжный способ, не полагаться на документацию, а замерить: открыть тестовое короткое соединение к тестовому эхо-серверу, закрыть его первым (активным закрытием) и засечь время, за которое запись исчезнет из ss -tan state time-wait:

# в одном терминале — держим соединение под наблюдением
watch -n1 'ss -tan state time-wait | grep <тестовый-порт>'

Так вы получите не «общеизвестное» значение из статей в интернете (оно расходится между дистрибутивами, версиями ядра и системами), а число, реальное для вашего конкретно сервера, и именно его нужно подставлять как T_tw в формулу выше.

Практические решения: снижаем λ, а не гоняемся за T_tw

Из формулы L = λ × T_tw видно два рычага, но по-настоящему управляемый на практике только один — λ. Уменьшить T_tw административно почти всегда сложнее и рискованнее, чем снизить частоту открытия соединений, поэтому начинать нужно отсюда.

Переиспользование соединений (keep-alive / connection pooling) — главное и обычно достаточное решение. Вместо цикла connect → запрос → close на каждый вызов внешнего API держите пул уже открытых соединений и берите из него свободное:

import requests

# плохо: новое TCP-соединение на каждый вызов
def call_api_bad(payload):
    return requests.post("https://api.example.com/v1/charge", json=payload)

# хорошо: Session переиспользует TCP-соединения через connection pool
session = requests.Session()
adapter = requests.adapters.HTTPAdapter(pool_connections=20, pool_maxsize=20)
session.mount("https://", adapter)

def call_api_good(payload):
    return session.post("https://api.example.com/v1/charge", json=payload)

Один и тот же пул из 20 постоянных соединений способен обслужить те же 300 запросов в секунду, что и раньше открывали 300 новых сокетов ежесекундно — только теперь λ (частота именно открытия/закрытия) падает до считаных операций в минуту, а не сотен в секунду. Подробный разбор того, как устроен пул соединений и как его настраивать под конкретную нагрузку, — в статье про connection pooling.

В HTTP это соответствует заголовку Connection: keep-alive (в HTTP/1.1 он включён по умолчанию, но библиотека или прокси может его глушить) и переиспользованию TCP-сокета для нескольких последовательных запросов. Для HTTP/2 и HTTP/3 эффект ещё сильнее за счёт мультиплексирования: десятки логических запросов идут через одно физическое соединение.

tcp_tw_reuse — параметр ядра Linux, разрешающий использовать сокет в TIME_WAIT повторно для нового исходящего соединения, если это безопасно с точки зрения временных меток TCP (условие завязано на TCP timestamps):

sudo sysctl -w net.ipv4.tcp_tw_reuse=1
# или постоянно — в /etc/sysctl.conf

Это смягчает симптом, но не устраняет причину — при по-настоящему высокой λ смягчение отодвигает порог исчерпания, а не убирает его. Обратите внимание: tcp_tw_reuse касается исходящих соединений (клиентской стороны), а часто путаемый с ним SO_REUSEADDR на уровне сокета решает другую задачу — быстрый повторный bind на тот же адрес после перезапуска слушающего процесса, и от расхода эфемерных портов не спасает.

Расширение диапазона портов — поднимает R_free, но линейно, а не решает проблему при экспоненциально растущей нагрузке; используйте как дополнительный запас, а не как основной инструмент:

sudo sysctl -w net.ipv4.ip_local_port_range="10000 65535"

Несколько исходящих локальных IP-адресов — если сервер имеет более одного публичного или частного IP, с которого можно инициировать соединения, каждый локальный адрес даёт независимый пул эфемерных портов для той же пары (адрес назначения, порт назначения). Это увеличивает суммарный R_free кратно числу адресов, но требует поддержки на уровне приложения или балансировщика исходящих соединений.

Батчинг вместо потока одиночных вызовов — там, где протокол внешнего API это позволяет (bulk-эндпоинты, очереди на стороне провайдера), снижение числа отдельных HTTP-вызовов напрямую снижает λ без изменений в сетевом стеке вообще.

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

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

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

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

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

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

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

TIME_WAIT — это утечка соединений, которую нужно чинить в коде?

Нет, само состояние — штатный механизм TCP для защиты от запоздавших пакетов. Проблема не в наличии TIME_WAIT, а в частоте открытия новых соединений: если она высокая и соединения не переиспользуются, TIME_WAIT накапливается быстрее, чем истекает, и упирается в лимит доступных портов.

Можно ли просто уменьшить T_tw через sysctl и решить проблему?

На части систем это технически возможно, но снижает защиту от дублированных и запоздавших пакетов, ради которой TIME_WAIT и придуман — риск повреждения данных в редких сетевых условиях растёт. Снижение частоты открытия соединений (λ) — управляемый и безопасный рычаг, снижение T_tw — нет.

Формула L = λ × T_tw одинаково применима и к серверной, и к клиентской стороне?

Закон Литтла верен для любой из сторон, но на практике чаще упирается активная сторона закрытия — та, которая первой вызывает close(). Если сервис сам инициирует много исходящих запросов к внешним API и сам же их закрывает, именно на нём и накапливается TIME_WAIT для этого направления.

Как понять, что причина падений именно в нехватке портов, а не в чём-то другом?

Характерная ошибка — Cannot assign requested address (EADDRNOTAVAIL) при попытке нового исходящего соединения, а не таймаут и не отказ удалённой стороны. Дополнительно подтверждает диагноз резкий рост ss -tan state time-wait | wc -l синхронно с моментами сбоев.

Диапазон портов можно расширить до максимума и забыть про проблему?

Расширение диапазона отодвигает порог, но при растущей λ (например, при масштабировании сервиса) рано или поздно упрётесь снова. Устойчивое решение — держать λ низкой через переиспользование соединений, а расширенный диапазон использовать как запас прочности, а не как основную защиту.

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

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

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