Перейти к содержимому
5 мин чтения

Как настроить TCP keepalive для LLM API за NAT

TCP keepalive для LLM API помогает быстро выявлять мёртвые сокеты за NAT. Настройте клиент, пул, прокси и балансировщик без ложных таймаутов.

Как настроить TCP keepalive для LLM API за NAT

Долгий вызов LLM сам по себе не означает, что соединение сломалось. Модель может честно думать, очередь может ждать GPU, а потоковый ответ может приходить редкими фрагментами. Но соединение, которое молча умерло в NAT, фаерволе или балансировщике, выглядит почти так же: клиент ждёт, метрики занятых сокетов растут, а ошибка появляется только после случайной записи или общего таймаута.

TCP keepalive для LLM API нужен не для того, чтобы сделать генерацию быстрее. Он нужен, чтобы отличить живое ожидание от сокета, который ядро ещё считает установленным, хотя устройство посередине уже забыло состояние. Это надо решать сразу в четырёх местах: в пуле клиента, в TCP-стеке, на прокси и на балансировщике. Одной настройки sysctl здесь недостаточно.

Зависший запрос и мёртвый сокет - разные неисправности

Зависший запрос остаётся на рабочем TCP-соединении, а мёртвый сокет существует только в памяти одной стороны. Если смешать эти случаи, команда либо начинает рвать нормальные долгие генерации, либо оставляет пользователей ждать соединения, которое уже никогда не ответит.

Представьте обычную цепочку: сервис приложения держит HTTPS-соединение к LLM-шлюзу через исходящий NAT. После периода простоя NAT удаляет запись трансляции. Клиентский процесс об этом не знает: локальное ядро не получило FIN или RST, поэтому сокет остаётся в состоянии ESTABLISHED. Следующий запрос библиотека отправляет в старое соединение из пула. Пакет исчезает на пути, а клиент ждёт ответ до общего deadline.

Теперь другой случай. Запрос дошёл до upstream, модель занята, ответ ещё не начался. Сокет исправен, но пользователь ждёт первый байт. TCP keepalive не даст полезного диагноза, потому что у стека могут быть неполученные данные или активная передача. Тут нужен таймаут первого байта и, если продукт поддерживает потоковую выдачу, контроль паузы между событиями.

Разделите в наблюдаемости как минимум четыре времени:

  • момент, когда клиент взял соединение из пула;
  • завершение TCP и TLS-подключения, если оно было;
  • первый байт HTTP-ответа;
  • последний байт ответа или причина закрытия.

Если вы видите долгий интервал до первого байта на новом соединении, ищите очередь, DNS, подключение или upstream. Если задержка возникает почти только на соединениях с большим idle age, ищите пул, NAT и правила простоя. Это различие экономит дни бесполезного тюнинга промптов.

TCP keepalive проверяет путь, а не работу модели

TCP keepalive посылает пробные TCP-сегменты на простаивающем соединении и ждёт реакцию peer. Он помогает ядру прекратить локально открытый сокет, когда удалённая сторона или сетевой путь исчезли. Он не измеряет скорость инференса, не проверяет валидность HTTP-сессии и не заставляет сервер прислать очередной токен.

RFC 9293 описывает keepalive как необязательный механизм. Стандарт требует, чтобы приложение могло включать и выключать его для конкретного соединения, а по умолчанию он был выключен. Там же закреплён исторический минимум простоя по умолчанию в два часа. Для API за NAT это почти всегда бесполезно: многие промежуточные устройства очищают неактивное состояние существенно раньше.

Важная оговорка из RFC 9293 часто теряется в пересказах: отсутствие ответа на одну конкретную пробу не доказывает смерть соединения. TCP не гарантирует доставку чистых ACK. Поэтому одна пропавшая проба не должна превращаться в немедленный обрыв. Нужны несколько попыток и конечный бюджет обнаружения.

Не путайте три похожих слова:

  • HTTP persistent connection позволяет использовать один TCP-сокет для нескольких запросов;
  • TCP keepalive проверяет простаивающий транспортный сокет;
  • heartbeat приложения передаёт осмысленное для протокола сообщение, например SSE-комментарий или ping в WebSocket.

Для streaming API heartbeat приложения обычно надёжнее как сигнал жизни ответа. Для молчащего соединения в пуле TCP keepalive уместнее. Для ограничения ожидания пользователя нужен deadline запроса. Они дополняют друг друга, а не заменяют.

Время обнаружения надо считать до настройки

В Linux время до закрытия мёртвого соединения примерно равно keepalive_time + keepalive_intvl × keepalive_probes. Это упрощённая модель для планирования, а не обещание миллисекундной точности. Планировщик, потери пакетов, реализация прокси и сеть добавят разброс.

Допустим, клиент начинает проверку после 30 секунд простоя, отправляет три пробы с интервалом 10 секунд. Ожидаемый бюджет обнаружения составит около 60 секунд. Если NAT удалил запись на 45-й секунде простоя, следующий запрос может попасть в окно до первой проверки. Это нормально: keepalive не предсказывает удаление записи, он сокращает время, за которое локальная сторона узнает о нём.

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

  1. самый короткий idle timeout у исходящего NAT, фаервола, ingress, egress и балансировщика;
  2. допустимое время, за которое сервис должен признать соединение негодным;
  3. число одновременно простаивающих соединений в пиковый час;
  4. допустимы ли потери мобильной или межрегиональной сети;
  5. есть ли уровень приложения, который уже регулярно посылает данные.

Если кратчайший известный timeout равен 60 секундам, первая проба через 50 секунд может быть поздней. Если вы ставите пробу каждые 5 секунд на сотнях тысяч сокетов, вы превращаете проверку живости в постоянный сетевой шум. Документ RFC 9643 по управлению TCP напоминает, что частые keepalive дают нагрузку на сеть и конечные точки, а интервал простоя не стоит опускать ниже 15 секунд без особой причины. Для серверного LLM API разумнее сначала ограничить возраст и idle time в пуле, а TCP keepalive оставить страховкой.

Клиентский пул должен выбрасывать старые соединения сам

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

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

Правильнее задать пулу два отдельных предела. idle timeout говорит, как долго соединение может лежать без работы. max lifetime ограничивает его общий возраст даже при периодическом использовании. Первый предел должен быть короче минимального timeout на сетевом пути. Второй защищает от долгоживущих соединений, накопивших редкие сбои, смену маршрута или состояние после обновления инфраструктуры.

Ещё один предел нужен на аренду соединения. Если весь пул занят долгими streaming-запросами, новый обычный запрос не должен ждать бесконечно, пока освободится слот. Метрика pool_acquire_duration часто объясняет «зависание» лучше, чем график TCP.

Для каждого исходящего вызова задайте отдельные бюджеты:

  • timeout подключения, включая DNS и TCP/TLS, если библиотека объединяет их;
  • timeout до первого байта ответа;
  • timeout паузы между байтами для streaming;
  • полный deadline операции;
  • ограниченное ожидание свободного соединения в пуле.

Не делайте все числа одинаковыми. Например, общий deadline в пять минут и межбайтовая пауза в пять минут означают, что пользователь не узнает о пропавшем потоке до самого конца бюджета. При streaming задавайте паузу по реальному поведению модели и формату ответа, а не по средней длительности генерации.

Настройка сокета важнее глобального sysctl

Выбирайте data residency
Данные хранятся внутри Казахстана, когда этого требуют ваши архитектурные и регуляторные ограничения.

net.ipv4.tcp_keepalive_time, tcp_keepalive_intvl и tcp_keepalive_probes в Linux задают системные значения по умолчанию. Документация Linux kernel объясняет, что интервал проб умножается на число проб и формирует время повторных проверок. Однако эти sysctl не включают keepalive на каждом сокете сами по себе.

Приложение сначала включает SO_KEEPALIVE, затем при необходимости устанавливает значения для конкретного сокета через TCP_KEEPIDLE, TCP_KEEPINTVL и TCP_KEEPCNT. Страница tcp(7) документирует эти socket options для Linux. Если HTTP-библиотека не даёт доступа к сокету, изменение sysctl может не решить проблему или затронет процессы, которые вы не собирались менять.

Проверьте текущие системные значения на узле:

sysctl net.ipv4.tcp_keepalive_time \
      net.ipv4.tcp_keepalive_intvl \
      net.ipv4.tcp_keepalive_probes

# Пример формы вывода:
# net.ipv4.tcp_keepalive_time = 7200
# net.ipv4.tcp_keepalive_intvl = 75
# net.ipv4.tcp_keepalive_probes = 9

Такая конфигурация означает, что сокет с включённым keepalive может молчать два часа до первой пробы. Для NAT с короткой жизнью записи это не защита. Но не спешите менять параметры на всей машине. Сначала выясните, применяет ли ваш HTTP-клиент SO_KEEPALIVE, и можно ли задать параметры на исходящих соединениях именно LLM-клиента.

В Go это обычно делают через custom dialer и control-функцию сокета. В Java ищите настройки транспорта конкретного HTTP-клиента, а не только параметры JVM. В Node.js проверьте, вызывает ли используемый agent socket.setKeepAlive() и какие значения передаёт. В Python определяющим будет транспорт выбранной библиотеки и способ создания connection pool. Названия методов меняются, но принцип один: настройку нужно проверить на реальном сокете, а не только увидеть в конфиге процесса.

Прокси должен различать молчание и долгий ответ

Прокси легко создаёт ложный диагноз. Nginx по умолчанию использует proxy_read_timeout 60s. Официальная документация Nginx уточняет важную деталь: этот таймаут действует между двумя успешными операциями чтения от upstream, а не на всю передачу ответа. Если upstream ничего не отправляет в этот интервал, Nginx закрывает соединение.

Для LLM это значит следующее. Нестриминговый запрос может легально молчать до готового JSON-ответа. Streaming-ответ может жить час, пока каждая пауза между фрагментами не превышает proxy_read_timeout. Нельзя поставить одно значение для обеих моделей поведения и ожидать понятных сбоев.

Минимальный фрагмент Nginx-конфигурации для маршрута с потоковой выдачей может выглядеть так:

location /v1/chat/completions {
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    proxy_connect_timeout 5s;
    proxy_send_timeout 30s;
    proxy_read_timeout 90s;

    proxy_buffering off;
    proxy_pass http://llm_upstream;
}

Этот пример не является универсальными числами. proxy_connect_timeout ограничивает создание соединения к upstream. proxy_send_timeout относится к паузам при передаче запроса upstream. proxy_read_timeout ограничивает паузу чтения ответа. Для SSE proxy_buffering off нужен, чтобы прокси не копил фрагменты и не превращал поток в поздний цельный ответ.

Отдельно проверьте таймауты на входящей стороне: клиент к вашему ingress, ingress к приложению, приложение к шлюзу, шлюз к провайдеру. Самый короткий таймаут побеждает. Если клиент ждёт 120 секунд, а ingress закрывает ответ после 60 секунд молчания, увеличение timeout в SDK ничего не исправит.

Балансировщик и NAT могут потерять состояние без уведомления

Ограничивайте трафик ключами
Rate-limits на уровне ключа ограничивают поток запросов до того, как он перегрузит приложение.

Устройство с состоянием не обязано сообщать обеим сторонам, что забыло TCP-сессию. Оно может просто удалить запись после периода простоя. Поэтому локальная машина видит ESTABLISHED, пока не попробует передать данные или пока keepalive не получит несколько неудач.

Самая частая ошибка в расследовании - спрашивать у владельца сети только «какой у вас TCP timeout». В цепочке может быть несколько ответов: контейнерный NAT на узле, корпоративный фаервол, облачный egress, WAF, ingress и балансировщик у провайдера. Вам нужен минимальный timeout для направления трафика, а не один красивый параметр из документации.

Соберите таблицу с четырьмя колонками: участок, idle timeout, кто им владеет, как вы это подтвердили. В последней колонке не пишите «со слов команды». Укажите конфигурацию, административный документ, тест или трассу. Если конкретное устройство недоступно, заложите консервативный предел пула и проверьте его в искусственном обрыве.

Пассивный TCP keepalive не всегда сохраняет NAT-запись так, как вы ожидаете. Некоторые устройства учитывают пробные сегменты, некоторые имеют свои правила, а часть проблемных соединений ломается не из-за idle cleanup, а из-за смены маршрута, перезапуска peer или перегруженного state table. Цель настройки не в том, чтобы любой ценой держать запись NAT вечно. Цель в том, чтобы быстро перестать выдавать мёртвый сокет из пула.

Проверять надо искусственным обрывом, а не графиком ошибок

График ошибок не докажет, что keepalive работает. Он покажет только последствия, смешанные с ошибками DNS, перегрузкой upstream и лимитами API. Нужен короткий воспроизводимый тест, где вы знаете момент, когда путь исчез.

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

sudo iptables -I OUTPUT -p tcp -d 203.0.113.20 --dport 443 -j DROP

# После проверки удалите именно добавленное правило.
sudo iptables -D OUTPUT -p tcp -d 203.0.113.20 --dport 443 -j DROP

Адрес из диапазона 203.0.113.0/24 предназначен для документации. Заменяйте его на адрес своего тестового upstream, но не проводите такой опыт против общего production endpoint.

Во время теста снимите пакеты с обеих сторон точки отказа:

sudo tcpdump -ni any 'host 203.0.113.20 and tcp port 443'

Ищите последовательность: последнее полезное приложение сообщение, затем keepalive-пробы или новая запись после взятия сокета из пула, отсутствие ответов, закрытие сокета и понятная ошибка в приложении. Одновременно запишите возраст соединения, идентификатор запроса, попытку повтора и причину закрытия. Без этих полей tcpdump подтвердит сетевой факт, но не объяснит, почему приложение продолжило ждать.

После DROP повторите тест с RST, если ваша лаборатория позволяет. DROP имитирует тихую потерю состояния и обычно проявляет худший сценарий. RST проверяет, умеет ли клиент быстро убрать соединение из пула после явного отказа.

Повтор запроса нельзя выдавать за лечение сети

Оставляйте аудитный след
Для расследований платформа хранит аудит-логи запросов на уровне шлюза.

Когда старый сокет умирает, библиотека часто предлагает retry. Это полезно для идемпотентного чтения или для операции с надёжным ключом дедупликации. Для POST к LLM API автоматический повтор без условий опасен: upstream мог получить тело запроса, начать генерацию, вызвать инструмент или записать результат, а ответ потерялся уже на обратном пути.

Сетевой сбой до первого байта не доказывает, что upstream ничего не сделал. TCP-стек клиента знает, что он не получил ответ, но не знает, обработал ли сервер запрос. Поэтому разделите повторы по семантике операции.

Безопасный вариант выглядит так: клиент создаёт request ID, сервер или прикладной слой хранит результат операции для этого ID на ограниченное время, а повтор возвращает тот же результат вместо запуска второй работы. Если такого контракта нет, повторяйте только ошибки, которые возникли до отправки тела, либо явно показывайте пользователю неопределённый исход.

Для вызовов, которые передают простой prompt без инструментов и побочных эффектов, риск дублирования может быть приемлемым, но это должно быть продуктовым решением. Инженер не должен тихо принять его настройкой HTTP-клиента.

Чеклист перед изменением production таймаутов

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

Проверьте следующее:

  • У клиента есть лимиты на подключение, первый байт, межбайтовую паузу, полный запрос и ожидание пула.
  • Пул ограничивает idle age и общий возраст сокетов, а не полагается на вечные HTTP-соединения.
  • Для исходящих сокетов подтверждён SO_KEEPALIVE, а per-socket интервалы короче минимального известного timeout на пути.
  • Nginx и балансировщики имеют отдельные правила для обычных и streaming-вызовов, а их самый короткий timeout известен команде.
  • Логи связывают request ID, connection ID, возраст сокета, время первого байта и причину закрытия.

Если вы используете единый OpenAI-совместимый шлюз, не переносите ответственность за эти пределы на gateway. Например, AI Router может упростить маршрут к разным моделям через один endpoint, но клиентский pool всё равно решает, будет ли он повторно использовать устаревшее соединение.

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

Часто задаваемые вопросы

Чем TCP keepalive отличается от HTTP keep-alive?

Нет. HTTP keep-alive означает повторное использование HTTP-соединения, а TCP keepalive - низкоуровневые пробные сегменты, которые помогают ядру обнаружить пропавший путь. HTTP-клиент может держать пул соединений, но без настроенного TCP keepalive старый сокет за NAT всё равно способен выглядеть открытым до следующей попытки записи.

Поможет ли TCP keepalive, если LLM долго генерирует ответ без токенов?

Для долгого, но молчащего HTTP-ответа TCP keepalive обычно не поможет. У соединения есть непогашенные данные, поэтому стек может не начать свои проверки. Нужны отдельный deadline на запрос и heartbeat на уровне протокола, если сервер способен его посылать.

Какие значения TCP keepalive выбрать для API за NAT?

Первый интервал должен быть короче минимального idle timeout на пути, но не настолько коротким, чтобы создавать лишний трафик на каждом сокете. Начните с 30 секунд простоя, трёх проб через 10 секунд и подтвердите выбор в тесте с реальным NAT или балансировщиком. Не копируйте эти числа вслепую в мобильную сеть или межрегиональный канал.

Достаточно ли настроить tcp_keepalive_time в Linux?

На Linux проверьте sysctl tcp_keepalive_time, tcp_keepalive_intvl и tcp_keepalive_probes, но не считайте их гарантией для процесса. Общие sysctl влияют только на сокеты, где включён SO_KEEPALIVE, и приложение или библиотека может переопределить интервалы на конкретном сокете. Проверяйте настройки в том же рантайме, который выполняет HTTP-вызовы.

Как доказать, что виноват NAT, а не LLM-провайдер?

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

Как proxy_read_timeout влияет на стриминг LLM?

proxy_read_timeout измеряет паузу между чтениями от upstream, а не полную длительность ответа. Для потоковой генерации он должен быть больше самой длинной допустимой паузы между событиями. Таймаут клиента должен быть согласован с ним так, чтобы ошибка возникала в понятном месте, а не случайно на одном из прокси.

Нужно ли отключать connection pooling для LLM API?

Отключать пул обычно не нужно. Лучше дать каждому повторно используемому соединению конечный срок жизни, ограничить время простоя и при ошибке соединения один раз безопасно повторить идемпотентный запрос. Полный отказ от пула часто заменяет редкие зависания постоянным ростом TLS-рукопожатий и задержки.

Можно ли повторять POST-запрос к LLM после обрыва соединения?

Повторяйте только запросы, для которых вы можете подтвердить отсутствие принятого результата или дедуплицировать операцию по идентификатору клиента. Повтор POST после обрыва опасен: upstream мог уже начать генерацию, списание или вызов инструмента. Сетевая ошибка не сообщает, на какой стороне произошёл разрыв.

Почему нельзя решить зависания большим timeout в клиенте?

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

Что должен делать LLM API-шлюз при зависших соединениях?

Шлюз должен иметь ограниченный idle timeout для входящих и исходящих соединений, понятные журналы времени первого и последнего байта и правила повторов, которые не дублируют небезопасные операции. Для команд, которым нужен единый OpenAI-совместимый endpoint и контроль трассировки запросов, AI Router позволяет сохранить привычный SDK при смене base_url. Но настройки клиента и его пула соединений всё равно остаются ответственностью приложения.