MAATRIX / Блог / Откуда на сервере тысячи соединений в TIME_WAIT и зачем они вообще нужны

Откуда на сервере тысячи соединений в TIME_WAIT и зачем они вообще нужны

MAATRIX

Вы запускаете ss -tan | grep TIME_WAIT | wc -l на боевом сервере и видите четыре, пять, а иногда пятизначное число строк. Первая реакция — это утечка сокетов, атака или процесс, который не закрывает соединения. В подавляющем большинстве случаев ничего из этого не происходит: TIME_WAIT — штатное состояние TCP, придуманное специально для того, чтобы протокол не путал старые пакеты с новыми. Разберём, откуда берётся это состояние, почему его тысячи на нагруженном сервере и в какой момент оно из фонового шума превращается в реальную проблему с нехваткой портов.

Что происходит в момент закрытия TCP-соединения

TCP-соединение закрывается не одним пакетом, а обменом из четырёх шагов — это называют four-way handshake закрытия, хотя по факту два шага часто объединяются в один пакет FIN+ACK. Сторона, которая решила закрыть соединение первой (не важно, сервер это или клиент — тот, кто первым вызвал close() на сокете), отправляет пакет с флагом FIN. Это активная сторона закрытия (active closer). Дальше события идут так:

  1. Активная сторона отправляет FIN и переходит в состояние FIN_WAIT_1.
  2. Пассивная сторона подтверждает FIN пакетом ACK и переходит в CLOSE_WAIT — в этом состоянии она может ещё какое-то время досылать оставшиеся данные.
  3. Активная сторона получает ACK и переходит в FIN_WAIT_2, ожидая, пока пассивная сторона тоже закончит и пришлёт свой FIN.
  4. Когда пассивная сторона готова, она шлёт собственный FIN и переходит в LAST_ACK.
  5. Активная сторона подтверждает этот FIN пакетом ACK — и вот тут, вместо того чтобы сразу забыть о соединении, она переходит в TIME_WAIT.
  6. Пассивная сторона, получив финальный ACK, закрывает сокет немедленно.

Важный момент, который часто упускают: в TIME_WAIT оказывается именно тот, кто закрыл соединение первым. Если у вас сервер, который сам обрывает соединения с клиентами (например, HTTP-сервер без keep-alive, отдавший ответ и сразу закрывший сокет), то TIME_WAIT копится именно на сервере. Если, наоборот, клиенты обрывают соединения первыми (например, браузер закрывает вкладку), TIME_WAIT достаётся клиенту, а не вашему серверу. Подробно о самом рукопожатии и типичных зависаниях на этом этапе — в статье про TCP-рукопожатие.

Зачем вообще нужен TIME_WAIT

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

Первая — потерянный последний ACK. Пассивная сторона в состоянии LAST_ACK ждёт подтверждения своего FIN. Если это подтверждение (шаг 6 выше) потеряется в сети, пассивная сторона через таймаут повторно отправит FIN. Если бы активная сторона к этому моменту уже забыла о соединении и освободила сокет, повторный FIN пришёл бы в никуда — операционная система ответила бы на него RST-пакетом, который пассивная сторона восприняла бы как ошибку соединения, хотя на деле данные уже были доставлены. TIME_WAIT держит сокет живым ровно для того, чтобы при повторном FIN снова отправить ACK и закрыть цикл корректно.

Вторая, менее очевидная — защита от путаницы старых и новых пакетов. Сеть не гарантирует порядок доставки: пакет может задержаться, продублироваться или дойти с опозданием — например, из-за ретрансмита на промежуточном маршрутизаторе. Если бы TCP разрешал немедленно открыть новое соединение с той же четвёркой параметров (IP и порт отправителя, IP и порт получателя), что и у только что закрытого, запоздавший пакет из старого соединения рисковал бы быть принятым новым соединением как валидный — просто потому что адреса и порты совпали. TIME_WAIT длится достаточно долго, чтобы любой пакет, всё ещё блуждающий в сети от старого соединения, гарантированно "умер" по TTL, прежде чем та же четвёрка будет использована повторно.

Именно поэтому длительность TIME_WAIT привязана к величине MSL (Maximum Segment Lifetime) — теоретическому максимальному времени жизни пакета в сети — и равна 2×MSL. В Linux MSL исторически принят равным 30 секундам, отсюда и стандартные 60 секунд ожидания. Это не настраиваемый параметр в обычном смысле: в отличие от многих других таймаутов TCP, тайм-аут TIME_WAIT в мейнлайн-ядре Linux зашит константой, а не выведен в sysctl. Параметр net.ipv4.tcp_fin_timeout, который часто путают с таймаутом TIME_WAIT, на самом деле регулирует другое — сколько ядро ждёт в состоянии FIN_WAIT_2, если процесс "осиротел" (например, аварийно завершился, не закрыв сокет корректно).

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

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

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

Почему на сервере с высоким оборотом соединений их тысячи

Раз TIME_WAIT держится около 60 секунд, а активная сторона копит по одной записи на каждое закрытое ею соединение, несложно понять, откуда берутся большие числа. Если ваш сервер в среднем закрывает, скажем, 50 соединений в секунду как активная сторона, то в любой момент времени в TIME_WAIT будет одновременно находиться порядка 50 × 60 = 3000 записей — это грубая иллюстрация принципа "накопленное количество равно скорости притока, умноженной на время удержания", а не измеренное число для вашего сервера: у вас счёт может идти на другие величины в зависимости от реальной интенсивности закрытий.

Типичные сценарии, где сервер оказывается активной стороной закрытия массово:

  • HTTP без постоянных соединений. Старый HTTP/1.0-стиль или явно выставленный Connection: close заставляют сервер закрывать TCP-сокет сразу после каждого ответа. Каждый запрос — новое соединение, новое закрытие, новая запись в TIME_WAIT.
  • Reverse-proxy или балансировщик, который сам обрывает соединения с бэкендами после каждого запроса — типичная ситуация, если upstream-keepalive не настроен.
  • Короткоживущие соединения к базе данных или внешним API, если приложение открывает новое соединение на каждый запрос вместо переиспользования пула.
  • Батчевые задачи и воркеры, которые массово опрашивают внешние сервисы короткими запросами.

Само по себе накопление TIME_WAIT — это не поломка. Ядро специально спроектировано так, чтобы держать эти записи, а не мгновенно от них избавляться, и тысячи таких записей на активном сервере — совершенно ожидаемая картина, а не сигнал тревоги сам по себе. Тревогой это становится только при определённых условиях, разберём ниже.

Когда TIME_WAIT переходит в реальную проблему

Ключевое, что нужно понять: TIME_WAIT-запись в таблице соединений ядра идентифицируется полной четвёркой — локальный IP, локальный порт, удалённый IP, удалённый порт. Это резко меняет картину в зависимости от роли сервера.

Сервер принимает входящие соединения (пассивный слушатель на 80/443). Локальный порт у него фиксирован — допустим, 443. Но удалённый IP и порт у каждого клиента разные. Поэтому даже тысячи TIME_WAIT-записей с локальным портом 443 не конфликтуют друг с другом — они различаются по адресу клиента. Проблема здесь скорее в размере самой таблицы: у ядра есть параметр net.ipv4.tcp_max_tw_buckets, ограничивающий, сколько таких записей одновременно допустимо держать. При превышении лимита ядро начинает агрессивно вытеснять старые записи и пишет в dmesg предупреждение вида "TCP: time wait bucket table overflow" — в этом случае теряется часть защитных гарантий TIME_WAIT, но порты у слушающего сервера не заканчиваются.

Сервер сам выступает клиентом — инициирует исходящие соединения к базе данных, внешнему API, бэкенду за реверс-прокси. Здесь для каждого нового соединения к одному и тому же удалённому адресу ядро обязано выделить уникальный локальный порт из диапазона эфемерных портов (задаётся net.ipv4.ip_local_port_range, типично несколько десятков тысяч портов, точные границы зависят от дистрибутива). Пока порт числится занятым записью TIME_WAIT, новое соединение к тому же удалённому адресу не может безусловно его переиспользовать. Если скорость открытия новых исходящих соединений выше, чем ядро успевает высвобождать порты, вы упрётесь в буквальную нехватку: новые connect() начнут завершаться ошибкой вроде EADDRNOTAVAIL. Разбор именно такого случая с конкретными числами и командами — в статье про нехватку исходящих портов при 28 тысячах TIME_WAIT.

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

Как посмотреть, что происходит у вас на сервере

Прежде чем что-то менять, стоит понять реальную картину, а не гадать по ощущениям.

# Общее число соединений в TIME_WAIT прямо сейчас
ss -tan state time-wait | wc -l

# Сводка по всем состояниям TCP сразу
ss -s

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

# Текущий диапазон эфемерных портов
sysctl net.ipv4.ip_local_port_range

# Лимит на количество одновременных TIME_WAIT-записей
sysctl net.ipv4.tcp_max_tw_buckets

# Если лимит превышался, ядро оставит след в логе
dmesg | grep -i "time wait bucket"

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

Что не стоит делать в первую очередь

Самая частая ошибка — броситься тюнить ядро вместо того, чтобы разобраться в источнике нагрузки. Два параметра, о которых вспоминают чаще всего:

  • net.ipv4.tcp_tw_reuse — разрешает ядру использовать сокет в TIME_WAIT для нового исходящего соединения, если включены TCP-таймстампы и есть основания считать это безопасным для нумерации пакетов. Относительно безопасный параметр, но касается только исходящих соединений и не устраняет саму причину — рост числа открытий-закрытий.
  • net.ipv4.tcp_tw_recycle — агрессивно сокращал время удержания TIME_WAIT, но был убран из ядра Linux, потому что ломал соединения от клиентов за NAT: несколько машин за одним адресом с разными таймстампами воспринимались как источник "нелегитимных" повторов и получали случайные обрывы. Если в старом мануале вам советуют включить этот параметр — на современных ядрах его либо нет, либо включать не стоит.

Уменьшить сам таймаут TIME_WAIT "в лоб" в стандартном ядре Linux не получится — он не вынесен в sysctl намеренно, потому что это защитный механизм, а не настройка производительности. Попытки его обойти на уровне ядра решают симптом, но не причину: сервер продолжает открывать и закрывать TCP-соединения с той же скоростью.

Правильное решение: меньше открытий-закрытий, а не короче таймаут

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

Для HTTP-трафика — включите и настройте keep-alive там, где он ещё не работает:

# nginx как фронтенд для клиентов
http {
    keepalive_timeout 65s;
    keepalive_requests 1000;
}

# nginx как reverse-proxy — держим соединения с бэкендом открытыми
upstream backend {
    server 127.0.0.1:8000;
    keepalive 32;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

Без явного proxy_http_version 1.1 и сброса заголовка Connection nginx по умолчанию закрывает соединение с апстримом после каждого запроса, даже если сам апстрим готов держать keep-alive — это одна из самых частых причин лишних TIME_WAIT именно на стороне reverse-proxy.

Для соединений с базой данных — используйте пул соединений вместо открытия нового соединения на каждый запрос: PgBouncer перед PostgreSQL, встроенные пулы в ORM (SQLAlchemy, ActiveRecord и аналоги), пул в самом драйвере. Разница принципиальная: вместо цикла "открыть TCP-соединение → выполнить один запрос → закрыть" приложение держит небольшой набор долгоживущих соединений и просто занимает/освобождает их. Как это настроить на практике и какие параметры пула выбирать в зависимости от нагрузки — отдельно разобрано в статье про connection pooling, а базовые понятия — в статье о том, что такое connection pool.

Для межсервисных вызовов используйте HTTP-клиенты с переиспользованием сессии, а не создание нового клиента на каждый вызов. В большинстве современных HTTP-библиотек keep-alive включён по умолчанию, но легко случайно отключается явным закрытием сессии после каждого запроса или созданием клиента внутри цикла — стоит проверить в коде, если TIME_WAIT растёт быстрее, чем логично для нагрузки.

Если проблема с исходящими портами всё ещё актуальна (например, физически нельзя держать пул к внешнему сервису, который сам обрывает соединения), временной мерой может быть расширение диапазона эфемерных портов:

sysctl -w net.ipv4.ip_local_port_range="1024 65535"

Но это именно временная мера, расширяющая потолок, а не устраняющая причину — если скорость открытия соединений продолжит расти, вы упрётесь в новый, просто более высокий потолок. Заодно стоит проверить, не залипают ли данные в буфере отправки перед закрытием сокета — смежная тема, разобранная в статье про буфер сокета и данные, которые ушли, но не были получены.

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

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

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

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

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

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

Тысячи TIME_WAIT — это признак DDoS-атаки?

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

TIME_WAIT ест заметно память или CPU?

Каждая запись занимает немного памяти в таблице ядра, по отдельности это малозаметно. Реальная проблема наступает либо от превышения tcp_max_tw_buckets, либо от исчерпания диапазона исходящих портов — оба случая разобраны выше.

Можно ли принудительно уменьшить время удержания TIME_WAIT?

В стандартном ядре Linux — не через штатный sysctl-параметр, время жёстко задано в коде (2×MSL). Обходной путь tcp_tw_recycle был убран из ядра из-за поломок за NAT. Надёжный путь — не бороться с длительностью TIME_WAIT, а снизить частоту открытий-закрытий соединений.

Поможет ли SO_REUSEADDR в коде приложения?

Это другой механизм: он разрешает процессу заново забиндиться на порт с "хвостами" от предыдущего запуска (частый случай при рестарте сервиса), но не устраняет TIME_WAIT и не решает нехватку исходящих портов.

Чем TIME_WAIT отличается от CLOSE_WAIT?

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

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

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

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