Keep-alive выключен: сколько вы переплачиваете временем за каждое новое соединение
Страница грузится будто через раз медленнее, чем должна, хотя сервер не перегружен, а канал не узкий. Одна из типичных причин, которую редко проверяют в первую очередь, — соединения не переиспользуются. На каждый запрос браузер (или прокси, или клиент API) заново поднимает TCP, заново делает TLS-рукопожатие, и только потом получает первый байт ответа. Если keep-alive выключен — случайно, из-за настроек прокси, или просто потому что дефолт неоптимален — вы платите за это временем на каждом запросе, а на странице из полусотни ресурсов эта цена умножается.
Содержание
- Что вообще такое keep-alive и с чем его не путают
- Сколько стоит установить новое TCP-соединение
- TLS поверх TCP: ещё несколько round-trip'ов
- Когда это умножается: страница из полусотни ресурсов
- HTTP/2 и мультиплексирование: почему keep-alive стал ещё важнее
- Как диагностировать: DevTools Connection ID и curl -v
- Настройка на сервере: keepalive_timeout и upstream-соединения
Что вообще такое keep-alive и с чем его не путают
HTTP keep-alive — это persistent connection: соглашение между клиентом и сервером не закрывать TCP-соединение после ответа на запрос, а держать его открытым для следующих запросов к тому же хосту. В HTTP/1.1 это поведение по умолчанию (в HTTP/1.0 было наоборот — по умолчанию закрывали, и keep-alive включали явным заголовком Connection: keep-alive). Сейчас же соединение закрывается, только если кто-то явно попросил — заголовком Connection: close, таймаутом бездействия или лимитом числа запросов на соединение.
Важно не путать это с TCP keepalive — механизмом на уровне сокета (SO_KEEPALIVE), который периодически шлёт пустые пробы, чтобы понять, жив ли ещё собеседник на другом конце, если оба молчат. Это разные вещи с одинаковым именем: TCP keepalive — про обнаружение мёртвого соединения, HTTP keep-alive — про переиспользование живого. Если вам интересна именно проблема с молча умирающими соединениями на TCP-уровне и NAT — это отдельная тема, разобранная в статье про то, как соединение умирает молча из-за NAT и балансировщиков. Здесь же речь про экономику переиспользования, а не про диагностику обрывов.
Сколько стоит установить новое TCP-соединение
Прежде чем сервер увидит хоть один байт вашего HTTP-запроса, стороны должны договориться об открытии TCP-соединения — это three-way handshake: клиент шлёт SYN, сервер отвечает SYN-ACK, клиент подтверждает ACK. Формально это полтора round-trip: SYN и SYN-ACK — это один RTT, а ACK можно отправить вместе с первым байтом запроса, не дожидаясь отдельного подтверждения. Но по факту, пока сервер не получит SYN-ACK от клиента подтверждённым, полезные данные не идут — то есть реальная задержка перед первым байтом запроса добавляет как минимум один полный RTT.
Если соединение уже открыто и просто переиспользуется — этого round-trip'а нет вообще. Запрос уходит сразу поверх существующего сокета. Разница между «есть открытое соединение» и «нужно поднять новое» — это буквально один RTT туда-обратно, который в отдельности звучит неубедительно (десятки миллисекунд), но умножается на число новых соединений на странице. Подробный разбор самого handshake по шагам, включая то, что происходит при потере SYN-пакета, — в статье про TCP-рукопожатие и почему при потере пакета всё «висит».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверTLS поверх TCP: ещё несколько round-trip'ов
Если сайт на HTTPS (а в 2026 году это почти всегда так), после TCP-хендшейка идёт TLS-рукопожатие — и оно тоже не бесплатно по round-trip'ам:
- TLS 1.2, полное рукопожатие — около двух дополнительных RTT: обмен ClientHello/ServerHello с сертификатом, затем обмен ключами.
- TLS 1.3, полное рукопожатие — один RTT: протокол сократили, сервер отвечает сразу с ключевым материалом и сертификатом в одном flight.
- TLS 1.3 с session resumption (PSK) — тоже один RTT для подтверждения, но без дорогой асимметричной криптографии на этом шаге, поэтому дешевле по CPU, хоть и не по числу round-trip'ов.
- TLS 1.3 0-RTT (early data) — данные можно отправить в том же flight, что и ClientHello, если у клиента есть валидный PSK от предыдущей сессии. Формально ноль дополнительных round-trip'ов до первого байта прикладных данных. Но у 0-RTT есть цена: ранние данные уязвимы к replay-атакам, поэтому 0-RTT обычно разрешают только для идемпотентных запросов (GET без побочных эффектов), а не для всего подряд — это компромисс, а не бесплатная опция для любого трафика.
Если сложить TCP-хендшейк и полное TLS 1.2-рукопожатие, получается порядка трёх RTT до первого байта ответа — против нуля, если соединение уже открыто и его просто продолжают использовать. При RTT в 30–50 мс (условный пример для соединения в пределах одного региона, у вас будет иначе в зависимости от маршрута) это создаёт заметную on-wire задержку ещё до того, как сервер вообще начал что-то обрабатывать. При более длинном плече — скажем, между разными континентами — та же арифметика даёт задержку в разы больше, просто потому что каждый RTT дороже. Подробный разбор того, что стороны говорят друг другу на каждом шаге TLS-рукопожатия, — в статье про TLS-рукопожатие до первого байта данных.
Когда это умножается: страница из полусотни ресурсов
Одно новое соединение на один запрос — не катастрофа. Проблема в том, что типичная веб-страница — это не один запрос, а десятки: HTML, несколько CSS, десяток-два JS-файлов, шрифты, иконки, изображения, запросы к API, трекеры аналитики. Если для каждого такого ресурса открывается новое TCP+TLS-соединение вместо переиспользования уже открытого — цена одного round-trip'а умножается на число ресурсов.
В HTTP/1.1 браузеры исторически открывают ограниченное число параллельных соединений на один хост (типично около шести, но keep-alive критичен даже с этим ограничением: важно не то, сколько соединений открыто одновременно, а сколько раз каждое из них приходится пересоздавать заново). Если keep-alive работает как положено — эти несколько соединений держатся открытыми и последовательно обслуживают все запросы к хосту одно за другим, без пересборки. Если keep-alive выключен (сервер или прокси закрывают соединение после каждого ответа) — каждый следующий запрос к тому же хосту снова платит за TCP- и TLS-рукопожатие, даже если предыдущее соединение закрылось секунду назад.
Отдельная и не менее частая причина той же проблемы — не браузер, а сам сервер: обратный прокси может держать keep-alive с клиентом, но при этом открывать новое соединение к бэкенду на каждый проксируемый запрос. Тогда пользователь этого не увидит напрямую в виде лишнего TCP-хендшейка в DevTools, но получит те же лишние round-trip'ы внутри инфраструктуры, только скрытые за прокси — сервер просто отвечает медленнее, чем мог бы.
HTTP/2 и мультиплексирование: почему keep-alive стал ещё важнее
HTTP/2 решает другую, соседнюю проблему — параллелизм внутри одного соединения. Вместо того чтобы держать несколько TCP-соединений на хост и слать по одному запросу за раз на каждое (как по сути работает HTTP/1.1 без pipelining), HTTP/2 мультиплексирует множество запросов и ответов в потоках (streams) внутри одного TCP-соединения. Это не отменяет ценность переиспользования соединения — наоборот, делает её выше: если у вас всего одно соединение на хост вместо шести, а это соединение приходится каждый раз пересобирать, вы теряете тот же round-trip, но теперь он блокирует буквально все запросы к хосту разом, а не одну шестую от них.
Иными словами, в HTTP/2 keep-alive — это уже не «желательная оптимизация», а условие, при котором вся модель мультиплексирования вообще имеет смысл. Разорванное и заново поднятое соединение в HTTP/2 стоит дороже относительно HTTP/1.1, потому что через него идёт больше конкурентного трафика. Подробнее о том, как устроено мультиплексирование в HTTP/2 и что происходит, когда один поток блокирует остальные из-за потери сегмента на TCP-уровне (head-of-line blocking, которая решается только в HTTP/3 сменой транспорта на QUIC) — в статье про мультиплексирование HTTP/2 и голову очереди.
Как диагностировать: DevTools Connection ID и curl -v
Прежде чем что-то настраивать, стоит убедиться, что проблема действительно в отсутствии переиспользования соединений, а не в чём-то другом.
В Chrome DevTools:
- Откройте вкладку Network, обновите страницу.
- Правым кликом по заголовку таблицы столбцов добавьте колонку Connection ID (её нет по умолчанию).
- Смотрите на значения ID для запросов к одному и тому же хосту. Если несколько запросов подряд идут с одинаковым Connection ID — соединение переиспользуется, всё в порядке. Если у каждого запроса свой новый ID — соединения открываются заново на каждый запрос.
- Полезно рядом смотреть на колонку Protocol (h2 или http/1.1) и на время в фазах Connecting и TLS в детальной вкладке Timing конкретного запроса — если эти фазы регулярно не нулевые для запросов к одному хосту подряд, это прямое подтверждение, что соединение каждый раз новое.
Через curl -v:
Отправьте несколько запросов к одному хосту в рамках одного вызова curl (используя --next или просто указав несколько URL) и смотрите на вывод:
curl -v https://example.com/style.css https://example.com/app.js
Для первого запроса вы увидите полный цикл установки соединения:
* Trying 203.0.113.10:443...
* Connected to example.com (203.0.113.10) port 443
* TLS handshake, Client hello (1):
...
* SSL connection using TLSv1.3 / ...
Для второго запроса к тому же хосту, если keep-alive работает, curl не будет заново открывать TCP и TLS, а покажет:
* Re-using existing connection with host example.com
> GET /app.js HTTP/1.1
Строка Re-using existing connection — это и есть подтверждение переиспользования. Если вместо неё вы снова видите Trying ... / Connected to ... / TLS handshake — значит, соединение закрылось между запросами, и стоит смотреть, кто его закрывает: сервер, прокси перед ним, или сам клиент выставляет Connection: close.
Ещё один способ проверить со стороны сервера — заголовки ответа. Если в ответе явно приходит Connection: close, это прямое указание клиенту не переиспользовать соединение — стоит поискать, где именно это выставляется (в конфиге веб-сервера, в промежуточном прокси или в коде приложения).
Настройка на сервере: keepalive_timeout и upstream-соединения
Если диагностика показала, что соединения пересоздаются там, где не должны, обычно дело в одном из двух мест — на стороне «клиент — прокси» или на стороне «прокси — бэкенд». Оба важны, и часто чинят только первое, забывая про второе.
Клиент — прокси/веб-сервер. В nginx за это отвечают keepalive_timeout (сколько секунду держать простаивающее соединение открытым, ожидая следующий запрос) и keepalive_requests (сколько запросов максимум можно обслужить на одном соединении, прежде чем nginx его закроет и заставит клиента переподключиться). Смысл не в том, чтобы вписать какое-то универсальное «правильное» число — оно зависит от паттерна трафика, — а в том, чтобы вообще не забыть, что оба параметра существуют и по умолчанию не бесконечны:
http {
keepalive_timeout 65s;
keepalive_requests 1000;
}
Если keepalive_timeout выставлен в 0 (иногда это остаётся от старого конфига или от попытки «на всякий случай отключить лишнее») — keep-alive с клиентами фактически выключен, и каждый запрос платит за новое рукопожатие. Слишком короткий таймаут даёт тот же эффект на практике, если между запросами страницы проходит больше времени, чем разрешено. Слишком длинный — держит воркер-соединения занятыми простаивающими клиентами, что на сервере с большим числом одновременных посетителей может упереться в лимит worker_connections раньше, чем в CPU или сеть.
Прокси — бэкенд (upstream). Это соединение легко забыть, потому что оно не видно в браузере вообще. По умолчанию nginx открывает новое TCP-соединение к бэкенду на каждый проксируемый запрос, даже если keep-alive с клиентом настроен идеально. Чтобы включить пул переиспользуемых соединений к upstream, нужно явно задать это в конфиге:
upstream backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
Здесь keepalive 32 — размер пула простаивающих соединений на воркер-процесс, которые nginx будет держать открытыми к каждому upstream-серверу вместо того, чтобы закрывать их сразу после ответа. Директивы proxy_http_version 1.1 и proxy_set_header Connection "" обязательны: по умолчанию nginx проксирует по HTTP/1.0 и с заголовком Connection: close к upstream, что полностью убивает переиспользование, даже если директива keepalive в блоке upstream прописана. Это одна из тех настроек, которую легко один раз задать и забыть проверить на новом сервере — прокси при этом продолжает нормально работать, просто чуть медленнее, чем мог бы, и без явных ошибок в логах. Развёрнутый разбор именно этой пары соединений, с цифрами по числу round-trip'ов на разных сценариях нагрузки, — в статье про keep-alive между прокси и приложением.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Чем HTTP keep-alive отличается от TCP keepalive?
Это разные механизмы с похожим названием. HTTP keep-alive — про переиспользование уже установленного соединения для нескольких запросов подряд, это тема данной статьи. TCP keepalive — про периодические пробы на уровне сокета, чтобы понять, жив ли ещё собеседник, если оба молчат; это отдельный механизм, разобранный в статье про соединения, которые умирают молча из-за NAT.
Если сайт уже на HTTP/2, нужно ли вообще думать про keep-alive?
Да, и даже больше, чем в HTTP/1.1. HTTP/2 не отменяет идею персистентного соединения — он на ней строится, мультиплексируя запросы внутри одного долгоживущего TCP-соединения. Если это единственное соединение разрывается и пересобирается, простаивают сразу все параллельные потоки, которые через него шли.
TLS 1.3 0-RTT — можно просто включить и не думать?
Не совсем. 0-RTT ускоряет установку соединения, но ранние данные (early data) уязвимы к replay-атакам — злоумышленник теоретически может повторно отправить перехваченный early-data пакет. Поэтому 0-RTT обычно ограничивают идемпотентными запросами (например, GET без побочных эффектов на сервере) и не используют вслепую для всего трафика, включая запросы с состоянием.
Что будет, если поставить keepalive_timeout слишком большим «на всякий случай»?
Соединения будут дольше висеть в ожидании следующего запроса от клиента, занимая слот в worker_connections. При небольшом числе одновременных посетителей это не заметно, но на сервере с высокой конкурентной нагрузкой можно раньше упереться в лимит одновременных соединений, чем если бы простаивающие соединения закрывались быстрее.
Как понять, что именно теряется на пересоздании соединений на конкретном сайте, а не в чём-то другом?
Смотрите на фазы Connecting и TLS в детальной вкладке Timing каждого запроса в DevTools — если они регулярно не нулевые для запросов к одному и тому же хосту в рамках одной загрузки страницы, время съедает именно установка соединения, а не обработка на сервере или передача данных.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →