MAATRIX / Блог / Как работает keep-alive и почему соединение умирает молча где-то посередине

Как работает keep-alive и почему соединение умирает молча где-то посередине

MAATRIX

Соединение выглядит живым — сокет открыт, ошибок нет. А потом вы пытаетесь что-то отправить и получаете таймаут после минуты тишины. Дело в том, что «соединение» в TCP — не провод между двумя точками, а договорённость, которую держат в памяти несколько устройств по пути, и любое из них может её забыть, не сказав об этом никому.

Что на самом деле связывает две точки в сети

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

Состояние соединения хранится в нескольких местах одновременно: в ядре клиента и в ядре сервера — таблицы TCP-сокетов с текущим состоянием (ESTABLISHED и так далее), в таблице conntrack любого NAT-устройства или файрвола на пути (дома, у провайдера, в дата-центре), в таблице сессий любого L4/L7-балансировщика или прокси.

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

Именно поэтому «соединение умерло» и «соединение умерло молча» — разные ситуации. При явном разрыве вы получаете RST или FIN и сразу об этом знаете. При молчаливой смерти где-то посередине — не получаете ничего: пакет просто улетает в пустоту, и вы узнаёте о проблеме только когда истечёт таймаут ожидания ответа.

TCP keep-alive: как ядро проверяет живого ли собеседника

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

По умолчанию в Linux keep-alive для сокета выключен — его должно явно включить приложение через опцию SO_KEEPALIVE:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

Дальше вступают системные параметры ядра, общие для всех сокетов с включённым keep-alive:

sysctl net.ipv4.tcp_keepalive_time    # сколько секунд простоя ждать перед первой пробой
sysctl net.ipv4.tcp_keepalive_intvl   # интервал между пробами, если ответа нет
sysctl net.ipv4.tcp_keepalive_probes  # сколько проб послать перед тем как считать соединение мёртвым

Стандартные значения по умолчанию в Linux — 7200 секунд (2 часа) до первой пробы, интервал 75 секунд между пробами и 9 попыток. Это задокументированные константы ядра — они объясняют, почему keep-alive «из коробки» почти бесполезен для веб- и API-нагрузок: два часа простоя до первой проверки — вечность для HTTP-клиента или воркера очереди.

Сама проба — это TCP-пакет с ACK, без данных, который не сдвигает номер последовательности. Это разговор двух ядер друг с другом, сокет приложения его не «видит». Если удалённая сторона жива, она отвечает обычным ACK, и таймер сбрасывается. Если после tcp_keepalive_probes попыток ответа нет, сокет получает ошибку (ETIMEDOUT или ECONNRESET, в зависимости от того, пришёл ли RST от промежуточного устройства или было полное молчание) — и приложение наконец узнаёт, что связь потеряна.

Посмотреть, включён ли keep-alive и какой у сокета таймер, можно так:

ss -to state established

В выводе будет видно timer:(keepalive,...) с оставшимся временем до следующей пробы — удобно, когда нужно проверить, действительно ли приложение включило опцию, а не просто понадеялось на неё.

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

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

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

Почему NAT и файрволы забывают о соединении раньше ядра

Вот тут и начинается основная проблема. Таблица conntrack на NAT-роутере или файрволе — это не бесконечный список: у каждой записи есть свой таймаут простоя, и он почти всегда короче, чем два часа по умолчанию у TCP keep-alive. В Linux с netfilter таймаут для установленных TCP-соединений задаётся отдельно:

sysctl net.netfilter.nf_conntrack_tcp_timeout_established

Значение по умолчанию в ядре — несколько суток, но это то, что стоит «из коробки» на голом Linux-сервере. На реальном оборудовании по пути — домашнем роутере, корпоративном файрволе, NAT провайдера, облачном security group — производитель или админ почти всегда ставит его значительно короче: от нескольких минут до часа, в зависимости от устройства. Точное число всегда своё, поэтому не берите ничьё «типичное» значение как гарантию — его нужно смотреть в документации к оборудованию или проверять эмпирически.

Когда запись в conntrack истекает, устройство просто удаляет её из таблицы. Оно не шлёт RST ни клиенту, ни серверу — ему это не нужно, оно уже забыло, что соединение вообще существовало. Оба конца по-прежнему считают сокет в состоянии ESTABLISHED. Дальше возможны два сценария:

  • следующий пакет от любой из сторон приходит на устройство, которое не находит для него записи в таблице — и просто отбрасывает пакет как не относящийся ни к какому известному соединению (без ответа вообще);
  • либо устройство трактует этот пакет как начало нового соединения, которое не проходит по правилам файрвола (потому что настоящее рукопожатие для него не было), и присылает RST — тогда сторона хотя бы узнаёт о разрыве, просто с опозданием и без внятной причины в логах.

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

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

Балансировщики и прокси: та же проблема, другой масштаб

L4- и L7-балансировщики, обратные прокси и облачные балансировщики нагрузки ведут себя точно так же, только у них есть ещё одна тонкость: они почти всегда терминируют TCP-соединение на себе и открывают отдельное соединение к бэкенду. Это значит, что keep-alive, который вы настроили между клиентом и прокси, вообще не имеет отношения к соединению между прокси и вашим сервером — это два разных TCP-сокета с двумя разными таймерами.

У nginx это видно прямо в конфиге:

http {
    keepalive_timeout 65s;       # таймаут простоя между клиентом и nginx

    upstream backend {
        server 10.0.0.5:8080;
        keepalive 32;             # пул постоянных соединений к бэкенду
    }

    server {
        location / {
            proxy_pass http://backend;
            proxy_read_timeout 60s;   # сколько ждать ответа от бэкенда
        }
    }
}

У HAProxy — похожая логика через timeout client, timeout server и timeout tunnel (последний важен отдельно для долгоживущих соединений вроде WebSocket, потому что стандартные таймауты рассчитаны на обычные HTTP-запросы и слишком коротки для «молчащих» долгих соединений).

Облачные балансировщики (ALB, NLB, GCP Load Balancer и подобные) добавляют ещё один слой с собственным таймаутом простоя — обычно около минуты по умолчанию, но это стоит проверять в документации конкретного провайдера. Если backend держит соединение дольше, чем разрешает балансировщик, тот его закроет — снова без уведомления клиента, который продолжит слать данные в уже закрытый со стороны сервера канал.

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

Практический вывод: интервал keep-alive должен быть короче самого короткого таймаута простоя на всём пути, а не просто короче, чем вам удобно. Если NAT держит пять минут, а балансировщик — минуту, ориентироваться нужно на минуту — иначе именно балансировщик молча закроет соединение раньше, чем сработает ваш keep-alive.

Где заканчивается TCP keep-alive и начинается heartbeat приложения

Важно понимать: TCP keep-alive проверяет лишь то, что стек TCP на другом конце пути ещё отвечает на пустой ACK. Он не проверяет:

  • что процесс приложения на сервере жив и не завис (сокет ядра может отвечать, даже если сам процесс висит в deadlock);
  • что приложение способно обработать реальный запрос, а не просто держит открытый файловый дескриптор;
  • что данные дойдут до конца пути, если по дороге есть прокси, терминирующий TCP — как раз описанный выше случай, когда keep-alive клиента вообще не долетает до бэкенда.

Heartbeat на уровне приложения — периодическое сообщение внутри уже установленного логического соединения (ping/pong в WebSocket, keepalive-фрейм в gRPC, служебное сообщение в очереди) — устроен иначе: он идёт тем же путём, что и обычные данные, через тот же код обработки, ту же очередь, тот же процесс. Если приложение не отвечает на heartbeat, значит, не ответит и на настоящий запрос — а вот TCP keep-alive может «пройти», пока приложение уже фактически недоступно.

TCP keep-aliveHeartbeat приложения
УровеньЯдро / стек TCPЛогика приложения
Что проверяетОтвечает ли стек TCP на другом концеЖив ли и отвечает ли сам процесс
Видит терминирующие проксиНет — обрывается на границе TCP-сегментаДа, если прокси прозрачно проксирует данные
Кто настраиваетСистемный администратор / sysctl, опция сокетаРазработчик приложения
Типичный интервал по умолчаниюЧасы (если вообще включён)Секунды-десятки секунд, задаётся протоколом приложения
Что получает вызывающий при сбоеОшибку сокета через продолжительное времяЯвный факт «heartbeat не пришёл», обычно быстрее

Из этого не следует, что TCP keep-alive не нужен — он дешёвый и хорошо ловит именно тот случай, ради которого создавался: полностью пропавшего собеседника (упавший сервер, оборванный канал). Но для «соединение формально есть, а толку от него ноль» — завис процесс, переполнена очередь, прокси разорвал внутреннее соединение — надёжнее heartbeat приложения, потому что он проверяет именно ту часть системы, которая вам действительно важна.

Как настроить оба механизма на практике

Начните с диагностики, а не настройки — иначе легко подкрутить не то. Посмотреть пробы keep-alive на проводе:

tcpdump -i any 'tcp[13] & 16 != 0 and tcp[13] & 8 == 0' -n

Это фильтр на пакеты только с флагом ACK и без данных — не единственные кандидаты в keep-alive-пробы, но хороший первый ориентир вместе с проверкой ss -to, о которой шла речь выше. Посмотреть таймаут conntrack на самом сервере:

sudo conntrack -L | grep ESTABLISHED
sysctl net.netfilter.nf_conntrack_tcp_timeout_established

Если соединение из вашего conntrack исчезает раньше, чем истекает ваш keep-alive-таймер — вот и причина «молчаливой смерти».

Настройка на уровне ядра для сервера, который держит много долгоживущих соединений (пример значений, не рецепт — подбирайте под свой самый короткий таймаут на пути):

sysctl -w net.ipv4.tcp_keepalive_time=60
sysctl -w net.ipv4.tcp_keepalive_intvl=10
sysctl -w net.ipv4.tcp_keepalive_probes=3

Так проба уйдёт через минуту простоя, а не через два часа, и три попытки с интервалом 10 секунд дадут обнаружение обрыва за полторы минуты вместо часов. Изменения через sysctl -w временные — для постоянного эффекта добавьте те же строки в /etc/sysctl.d/99-keepalive.conf и примените sysctl --system.

На уровне приложения то же самое настраивается точечно, без влияния на весь сервер. В Go:

dialer := net.Dialer{
    KeepAlive: 30 * time.Second,
}

В Node.js:

socket.setKeepAlive(true, 30000);

Для долгоживущих логических соединений (WebSocket, gRPC-стримы, очереди сообщений) добавьте heartbeat в самом протоколе поверх TCP — например, ping каждые 20–30 секунд с ожиданием pong в течение заведомо меньшего времени, чем самый короткий таймаут простоя на пути, и переподключением при отсутствии ответа. Это дороже в реализации, чем просто включить SO_KEEPALIVE, но именно этот механизм и разбирался в статье про соединения, которые рвались каждые шестьдесят секунд из-за таймаута прокси: пока не появился heartbeat, который укладывался в реальный таймаут промежуточного прокси, приложение узнавало о разрыве только по таймауту следующего реального запроса.

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

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

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

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

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

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

Если я включу TCP keep-alive, обрывы соединений совсем прекратятся?

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

Почему приложение не видит ошибку сразу, если соединение уже мёртвое?

Потому что ни TCP, ни IP не обязывают промежуточные устройства уведомлять конечные точки о том, что запись в их внутренней таблице удалена. Если keep-alive выключен или его интервал слишком большой, единственный способ узнать о разрыве — попытаться реально отправить данные и дождаться таймаута доставки.

Стоит ли просто выставить keep-alive на минимальное значение везде?

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

Чем WebSocket ping/pong отличается от TCP keep-alive, если оба посылают пустые проверочные пакеты?

Ping/pong идёт внутри уже открытого TCP-соединения как обычное сообщение протокола WebSocket, поэтому обрабатывается тем же кодом приложения и виден на всех уровнях прокси, которые терминируют TCP. TCP keep-alive работает ниже, на уровне сокета, и невидим для приложения и для прокси, которые видят только TCP-сегменты своего собственного соединения.

Нужно ли включать keep-alive на сервере, если его уже включил клиент?

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

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

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

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