MAATRIX / Блог / Исходящие порты кончились: 28 тысяч соединений висели в TIME_WAIT

Исходящие порты кончились: 28 тысяч соединений висели в TIME_WAIT

MAATRIX

Сервис исправно отвечал на входящие запросы, метрики CPU и памяти были зелёные, а исходящие запросы к одному конкретному API начали падать волнами — по несколько сотен ошибок за минуту, потом снова тишина. Разработчики винили удалённый сервис, сетевики — файрвол, но правда оказалась в том, что на самом сервере физически кончились свободные исходящие порты: 28 тысяч соединений застряли в состоянии TIME_WAIT и не давали открыть ни одного нового сокета. Разберём инцидент по шагам: что было видно в логах, какие версии отбросили и что в итоге поменяли, чтобы это не повторилось.

Первые сигналы

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

ConnectionError: HTTPSConnectionPool(host='api.payments.example', port=443):
Max retries exceeded with url: /v1/charge
(Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object>:
Failed to establish a new connection: [Errno 99] Cannot assign requested address'))

Ошибка Cannot assign requested address (EADDRNOTAVAIL) — это не отказ от удалённой стороны и не таймаут по сети. Так ядро Linux сообщает, что не может выделить локальный (IP, порт) для нового исходящего соединения. То есть проблема была не «там», а «здесь», на самом сервере.

Волнообразность инцидента тоже была характерной: ошибки шли пачками по 3–5 минут, затем сервис на время восстанавливался, потом всё повторялось. Совпадение с пиками нагрузки (вечерние часы, больше транзакций) навело на мысль, что дело в исчерпании какого-то ограниченного ресурса, который со временем «рассасывается» — и это уже прямой намёк на TIME_WAIT, хотя на этом этапе команда ещё не была в этом уверена.

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

Первым делом на сервере посмотрели состояние TCP-соединений:

ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
  28114 TIME-WAIT
    412 ESTAB
     38 LISTEN
      3 SYN-SENT

28 тысяч соединений в TIME_WAIT — это уже серьёзный сигнал. Проверили диапазон эфемерных портов, из которого ядро выделяет локальные порты для исходящих соединений:

cat /proc/sys/net/ipv4/ip_local_port_range
32768   60999

Несложная арифметика: доступно около 28 231 порта. Из них 28 114 были заняты соединениями в TIME_WAIT к одному и тому же удалённому адресу — api.payments.example:443. Свободных портов для новых соединений к этому же хосту почти не оставалось, поэтому connect() регулярно падал с EADDRNOTAVAIL.

Отдельно проверили, что дело именно в парах с одним и тем же удалённым адресом, а не в общем исчерпании соединений на сервере:

ss -tan state time-wait '( dport = :443 )' | grep 'api.payments.example' | wc -l

Число совпало почти один в один с общим количеством TIME_WAIT. Это сразу сузило круг: проблема — в том, как приложение общается с этим конкретным API, а не в сети в целом.

Посмотрели также счётчики ядра:

nstat -az | grep -i tcp
netstat -s | grep -i "time wait"

Ничего экзотического: TCP-стек просто честно выполнял RFC — после активного закрытия соединения сокет держит порт занятым 2×MSL (по умолчанию в Linux это фиксированные 60 секунд), чтобы гарантированно отловить запоздавшие пакеты предыдущего соединения и не спутать их с новым. При достаточно высокой частоте открытия и закрытия соединений к одному и тому же адресу эти 60 секунд превращаются в очередь из десятков тысяч «зависших» портов.

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

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

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

Гипотезы, которые не подтвердились

Прежде чем дойти до правильного объяснения, разобрали и отбросили несколько версий.

DDoS или аномальный входящий трафик. Первая мысль дежурного инженера. Проверили входящий трафик по интерфейсу и по LISTEN-сокетам — ничего необычного, счётчик ESTAB для входящих соединений оставался стабильным. Проблема была именно с исходящими соединениями, так что версию отбросили в первые же 10 минут.

Переполнение таблицы conntrack. На сервере используется iptables/nftables с NAT для части трафика, и у переполнения conntrack бывают похожие симптомы — сервер вдруг перестаёт открывать новые соединения. Проверили:

cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max

Заполненность была на уровне 40% от лимита — не то. Про сам механизм переполнения conntrack и почему он даёт похожую картину, есть отдельный разбор: таблица conntrack переполнилась, соединения рвутся. Симптомы там пересекаются с нашим случаем, но причина другая — там ограничение на количество отслеживаемых соединений в NAT-таблице, а не на количество свободных локальных портов.

Проблемы с DNS-резолвом. Проверили, не «зависает» ли резолвинг api.payments.example — иногда деградация DNS маскируется под похожие ошибки подключения. Резолвинг отрабатывал стабильно, TTL записи был в порядке, версию сняли.

Рейт-лимит на стороне удалённого API. Написали в поддержку платёжного провайдера и проверили собственные логи ответов — если бы это был 429 Too Many Requests, приложение получило бы HTTP-ответ и залогировало бы его. Но Cannot assign requested address возникает ещё до отправки байта в сеть, до всякого TCP-рукопожатия — значит, удалённая сторона тут вообще ни при чём. О том, что происходит на уровне самого TCP-рукопожатия и какие ошибки оно даёт, разобрано в статье TCP-рукопожатие: почему всё виснет — полезно для сравнения симптомов.

Нехватка памяти или файловых дескрипторов. free -m и ulimit -n были в норме, ss -s не показывал критического роста числа сокетов сверх TIME_WAIT. Ресурсные лимиты процесса тоже были не при чём.

Отбросив пять версий подряд, команда вернулась к тому, что бросалось в глаза с самого начала: 28 тысяч TIME_WAIT к одному хосту — это не побочный эффект, а сама причина.

Как нашли настоящую причину

Дальше разобрали код клиента, который обращается к платёжному API. Оказалось, что каждый вызов создавал новый экземпляр HTTP-клиента без переиспользования соединения:

import requests

def charge(payload):
    # новое соединение на каждый вызов — TCP-хендшейк с нуля,
    # а после ответа сокет закрывается активно клиентом
    response = requests.post(
        "https://api.payments.example/v1/charge",
        json=payload,
        timeout=5,
    )
    return response.json()

Функция requests.post() без сессии каждый раз открывает новое TCP-соединение (плюс TLS-хендшейк — двойные накладные расходы) и закрывает его сразу после получения ответа. Именно сторона, которая закрывает соединение первой, переходит в TIME_WAIT — и в данном случае это был наш сервер, потому что он завершал соединение сразу после чтения ответа, не дожидаясь, пока это сделает удалённая сторона.

При нагрузке около 150–200 вызовов в секунду к одному и тому же удалённому IP:порт очередь TIME_WAIT росла быстрее, чем освобождалась за 60 секунд. Ключевой момент: уникальность TCP-соединения определяется четвёркой (локальный IP, локальный порт, удалённый IP, удалённый порт). Поскольку удалённый адрес и порт были одни и те же (один API, порт 443), а локальный IP тоже один — единственной переменной, которая могла отличать одно соединение от другого, оставался локальный порт. Из ~28 тысяч доступных портов вычиталось до 28 тысяч занятых TIME_WAIT почти без остатка.

Проверочный расчёт: за 60 секунд TIME_WAIT при 200 запросах/сек накапливается до 12 000 занятых портов в устойчивом состоянии — но пиковая нагрузка вечером была выше средней, и в моменты всплесков очередь TIME_WAIT подходила к границе доступного диапазона портов почти впритык, а любая случайная задержка на стороне API (даже в пределах нормы) добавляла к очереди ещё сотни-другие соединений и добивала до предела. Отсюда и волнообразность: не постоянная нехватка, а периодическое касание потолка.

Что изменили в коде

Первое и главное исправление — не открывать новое TCP-соединение на каждый вызов, а переиспользовать его через requests.Session() с HTTP keep-alive:

import requests
from requests.adapters import HTTPAdapter

session = requests.Session()
adapter = HTTPAdapter(
    pool_connections=20,
    pool_maxsize=20,
    max_retries=0,
)
session.mount("https://", adapter)

def charge(payload):
    response = session.post(
        "https://api.payments.example/v1/charge",
        json=payload,
        timeout=5,
    )
    return response.json()

Session держит пул уже установленных соединений и переиспользует их между вызовами благодаря HTTP keep-alive, пока удалённая сторона не закроет соединение сама или пока оно не протухнет по таймауту простоя. Это убрало саму причину: соединения перестали открываться и закрываться на каждый запрос, число активных TCP-сессий к API стабилизировалось на уровне размера пула (в данном случае 20 вместо тысяч короткоживущих).

Отдельно проверили похожий паттерн в других частях системы — сервис ходил ещё и в собственную MySQL-базу без пула соединений на одном из фоновых воркеров. Там до аварии дело не дошло, но по той же логике добавили пул заранее — материал о том, как этого рода проблема выглядит на стороне базы, есть в статье MySQL: ошибка Too many connections, причины и решение, а общий подход к пулам соединений — в статье connection pooling: зачем нужен и как настроить.

Что изменили на уровне ОС

Код — это главное исправление, но чтобы не зависеть от дисциплины каждого нового куска кода, тронули и настройки ядра — как страховку на случай, если где-то ещё останется похожий паттерн.

Расширили диапазон эфемерных портов (было 32768–60999, стало 15000–61000, около 46 тысяч портов вместо 28 тысяч):

# /etc/sysctl.d/99-network-tuning.conf
net.ipv4.ip_local_port_range = 15000 61000

Включили быстрое переиспользование сокетов в TIME_WAIT для исходящих соединений:

net.ipv4.tcp_tw_reuse = 1

Важный нюанс, который специально проговорили в команде: tcp_tw_reuse безопасно включать только для исходящих соединений (когда сервер выступает клиентом), что как раз наш случай. Опция tcp_tw_recycle в современных ядрах убрана и намеренно не рассматривалась — она была небезопасна за NAT и ломала соединения у клиентов, сидящих за одним внешним IP.

Применили и проверили:

sysctl --system
sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse

Дополнительно завели алерт на количество TIME_WAIT-соединений, чтобы в следующий раз не тратить первые 20 минут на догадки:

# простая проверка для cron/мониторинга
COUNT=$(ss -tan state time-wait | wc -l)
if [ "$COUNT" -gt 15000 ]; then
    echo "WARN: TIME_WAIT count is $COUNT" 
fi

Порог 15 000 — не универсальное число, а ориентир, подобранный под конкретный диапазон портов и профиль нагрузки этого сервера; для другой конфигурации порог стоит пересчитать под свой ip_local_port_range и типичную интенсивность исходящих запросов.

Как проверить у себя, что вы не наступите на те же грабли

Несколько практических шагов, которые стоит проделать заранее, а не во время инцидента.

Посмотреть текущее распределение состояний TCP на сервере:

ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn

Если TIME_WAIT регулярно исчисляется тысячами, а не десятками — стоит разобраться, к каким адресам идут эти соединения:

ss -tan state time-wait | awk '{print $4}' | cut -d: -f1 | sort | uniq -c | sort -rn | head

Проверить, какой диапазон портов доступен и насколько он близок к исчерпанию:

cat /proc/sys/net/ipv4/ip_local_port_range

Пробежаться по коду и найти места, где HTTP-клиент, клиент базы данных или клиент очереди создаётся заново на каждый вызов вместо переиспользования соединения или пула. Это касается не только requests в Python — та же ошибка встречается с http.Client в Go без переиспользования Transport, с новым Node.js http.request без агента с keepAlive: true, с созданием нового JDBC-соединения на каждый запрос в Java.

Таблица ориентиров, к чему приводит одна и та же по сути проблема на разных уровнях стека:

СлойСимптом при коротких соединенияхТипичное исправление
HTTP-клиент к внешнему APIEADDRNOTAVAIL, рост TIME_WAIT к одному хостуkeep-alive, пул соединений (Session, Transport, Agent)
Клиент БД (MySQL/Postgres)Too many connections, рост коротких сессийconnection pool (pgbouncer, встроенный пул драйвера)
NAT/проброс через один внешний IPИсчерпание портов на уровне NAT, а не хостанесколько исходящих IP, увеличение диапазона SNAT-портов

Если сервис в принципе делает много кратких исходящих соединений к внешним сервисам (веб-хуки, парсинг, интеграции с десятками API) и живёт за NAT с одним внешним IP, дефицит портов может проявиться и на уровне маршрутизатора/шлюза, а не только на самом хосте — это стоит проверять отдельно, особенно на VPS с общим исходящим IP.

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

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

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

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

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

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

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

Это защитный механизм TCP из RFC 793: сторона, закрывшая соединение первой, ждёт 2×MSL (в Linux — фиксированные 60 секунд), чтобы гарантированно не спутать запоздавшие пакеты старого соединения с новым, использующим ту же четвёрку адресов и портов. Убрать это состояние совсем — значит рисковать целостностью данных при повторном использовании порта.

Можно ли просто сократить длительность TIME_WAIT через sysctl?

В современных ядрах Linux 2×MSL для TIME_WAIT не выведен в отдельный настраиваемый параметр (это не то же самое, что net.ipv4.tcp_fin_timeout, который управляет другим состоянием — FIN-WAIT-2). Правильный путь — не бороться с длительностью TIME_WAIT, а уменьшить частоту открытия новых соединений через keep-alive и пулы, и при необходимости расширить диапазон портов и включить tcp_tw_reuse.

Чем tcp_tw_reuse отличается от убранного tcp_tw_recycle?

tcp_tw_reuse разрешает использовать сокет в TIME_WAIT для нового исходящего соединения только при выполнении дополнительных проверок по временным меткам TCP и безопасен для типичного сценария клиент → сервер. tcp_tw_recycle работал агрессивнее и ломался за NAT, когда несколько клиентов выходят в сеть с одного внешнего IP — поэтому его убрали из современных ядер.

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

Смотреть на конкретный текст ошибки: Cannot assign requested address / EADDRNOTAVAIL возникает на этапе connect(), до отправки данных в сеть, и это почти всегда означает нехватку локальных ресурсов (портов), а не проблему на стороне сети или удалённого сервиса.

Если сервер стоит за NAT/фаерволом с одним внешним IP, а не только с одним внутренним — что тогда?

Тогда исчерпание портов может произойти на уровне NAT-шлюза раньше, чем на самом сервере. В этом случае помогает либо привязка нескольких исходящих IP на шлюзе, либо расширение диапазона портов SNAT, либо, опять же, снижение числа одновременных коротких соединений на уровне приложения — тот же keep-alive работает и здесь.

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

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

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