Почему keep-alive между прокси и приложением экономит больше, чем между клиентом и сайтом
Когда говорят про keep-alive, почти всегда имеют в виду соединение между браузером и сайтом: один пользователь, одна вкладка, одно TCP-соединение, которое держат открытым, чтобы не пересобирать его на каждый клик. Но у любого сервера с обратным прокси есть второе, менее заметное соединение — между самим прокси (например, nginx) и приложением у него за спиной. Оно устроено иначе, и экономия от его переиспользования на практике часто оказывается больше, чем от keep-alive на стороне одного отдельного посетителя. Разберёмся, почему так, и как это настроить руками.
Содержание
Два разных соединения, которые легко перепутать
Возьмём типичную схему: браузер → nginx → приложение (Node.js, PHP-FPM через отдельный HTTP-сервис, Python-бэкенд на Gunicorn или Django через Uvicorn — неважно, важна сама схема «прокси перед приложением»).
Соединение «клиент → прокси» в подавляющем большинстве случаев обслуживает одного конкретного посетителя. Один человек открыл сайт — на его стороне один TCP-сокет к вашему серверу (иногда несколько — под разные хосты или из-за особенностей HTTP/1.1 без мультиплексирования, но порядок величины тот же: единицы соединений на одного человека). Если у вас 200 одновременных посетителей, это 200 более или менее независимых соединений «клиент → прокси», и каждое живёт своей жизнью: открылось, отработало сколько-то запросов, закрылось по таймауту или было закрыто клиентом.
Соединение «прокси → приложение» устроено принципиально по-другому. Это не персональный канал одного посетителя — это внутренний канал самого сервера, через который проходят запросы от всех посетителей сразу. nginx не открывает новое соединение к бэкенду под каждого клиента персонально; он открывает ограниченный пул соединений к upstream-серверу и прогоняет через него запросы всех, кто в данный момент обращается к сайту. Тех же 200 посетителей может обслуживать пул из нескольких десятков соединений к приложению — просто потому, что запрос обрабатывается быстро, соединение освобождается и тут же берётся для следующего запроса, возможно, уже от другого человека.
Это ключевое отличие определяет всё остальное в статье: с одной стороны — соединение «один к одному», с другой — соединение «один канал на много запросов от многих клиентов».
Почему установление соединения вообще стоит времени
Прежде чем говорить про экономию, стоит явно проговорить, откуда берётся сама стоимость. Открытие нового TCP-соединения — это не бесплатная операция: как минимум один обмен пакетами (SYN, SYN-ACK, ACK) между сторонами, прежде чем можно будет отправить хоть байт полезных данных. Если интересно, что происходит на этом этапе и почему он иногда «подвисает», — в отдельной статье про TCP-рукопожатие разобран этот механизм подробнее.
Если соединение между прокси и приложением идёт по HTTPS (например, приложение живёт на отдельном сервере или в отдельном контейнере с собственным TLS-терминированием, а не просто слушает localhost по HTTP), к TCP-рукопожатию добавляется ещё и TLS-рукопожатие — обмен сертификатами, согласование шифров, а в части схем ещё и дополнительный обмен ключами. Это отдельный набор round-trip’ов поверх уже установленного TCP-соединения; механика разобрана в статье про TLS-рукопожатие. Даже когда TLS между прокси и приложением не используется (частый случай — приложение и nginx на одном сервере, обмен идёт по localhost без шифрования), TCP-рукопожатие всё равно происходит и стоит времени процессора и минимум одного round-trip.
Важно: это не какая-то экзотическая задержка, которую никто не замечает. Это работа, которую серверу и приложению приходится проделывать заново на каждое новое соединение — выделить сокет, пройти рукопожатие, а на стороне приложения ещё и создать контекст соединения (аллокации, иногда — новый поток или воркер, в зависимости от модели параллелизма).
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак выглядит пул keep-alive между прокси и бэкендом
Без keep-alive к бэкенду каждый HTTP-запрос от прокси к приложению выглядел бы так: открыть TCP-соединение → отправить запрос → получить ответ → закрыть соединение. И так на каждый входящий запрос от любого клиента сайта. При достаточно активном сайте это означает, что бэкенд-соединения открываются и закрываются с частотой, равной частоте запросов ко всему серверу, а не частоте запросов одного посетителя.
С keep-alive между прокси и приложением схема другая: nginx держит пул уже установленных, «тёплых» соединений к upstream-серверу. Когда приходит новый запрос от любого клиента, nginx берёт свободное соединение из пула и отправляет запрос через него, а не открывает новое. После ответа соединение не закрывается, а возвращается в пул — ждать следующий запрос, возможно, уже от другого посетителя сайта.
Отсюда и разница масштабов экономии. Keep-alive на стороне одного клиента экономит установление соединения на количестве запросов, которые сделал именно этот один человек за сессию — это может быть немного, а может быть много, но это ограничено активностью одного посетителя. Keep-alive пула «прокси → бэкенд» экономит установление соединения на суммарном количестве запросов ко всему серверу от всех посетителей сразу — а это, при разумной посещаемости, кратно больше, чем активность одного клиента. Пул из условных 32–64 соединений может «поглотить» без единого нового TCP- или TLS-рукопожатия огромное количество запросов от сотен разных людей — просто потому, что соединения переиспользуются между ними, а не привязаны к конкретному посетителю.
Настройка upstream keep-alive в nginx
В nginx пул соединений к бэкенду настраивается директивой keepalive внутри блока upstream, а не в блоке server или location, где обычно ищут все настройки прокси. Базовый пример:
upstream backend_app {
server 127.0.0.1:8080;
# сколько простаивающих (idle) соединений держать открытыми
# к каждому апстрим-серверу, в расчёте на один воркер-процесс nginx
keepalive 64;
# опционально: сколько запросов можно прогнать через одно
# соединение прежде чем nginx его закроет и откроет новое
keepalive_requests 1000;
# опционально: как долго держать простаивающее соединение открытым
keepalive_timeout 60s;
}
server {
listen 443 ssl;
server_name example.com;
location / {
proxy_pass http://backend_app;
# обязательно для keep-alive к апстриму:
# HTTP/1.0 не поддерживает постоянные соединения по умолчанию
proxy_http_version 1.1;
# по умолчанию nginx сам подставляет заголовок Connection: close
# при проксировании — это нужно явно отключить
proxy_set_header Connection "";
}
}
Три момента здесь легко упустить, и без них директива keepalive в upstream просто не сработает:
proxy_http_version 1.1— без этого nginx проксирует запрос к бэкенду по HTTP/1.0, где постоянные соединения не подразумеваются по умолчанию.proxy_set_header Connection ""— по умолчанию nginx как прокси добавляет заголовокConnection: closeв запрос к апстриму, что явно говорит бэкенду закрыть соединение после ответа. Пустое значение убирает этот заголовок и оставляет решение за keep-alive-логикой самого соединения.- Число в
keepalive N— это не общий лимит соединений к бэкенду и не лимит на количество одновременных запросов. Это количество *простаивающих* соединений, которые держатся в пуле на один воркер-процесс nginx, ожидая следующего запроса. Активных (занятых прямо сейчас) соединений может быть больше — они не ограничены этой директивой.
Почему экономия здесь больше, чем на стороне одного клиента
Стоит проговорить это без домыслов и точных цифр, которые всё равно будут разными на каждом сервере: смысл не в том, что одно конкретное внутреннее соединение «дороже» одного клиентского. Разница — в кратности использования.
Соединение «клиент → прокси» переиспользуется в пределах сессии одного человека: сколько бы он ни кликал по сайту за время, пока держит вкладку открытой, это одно и то же соединение (или небольшое их число). Экономия от keep-alive здесь реальна, но её потолок — активность одного посетителя.
Соединение из пула «прокси → бэкенд» переиспользуется между *разными* посетителями — оно не принадлежит никому конкретно, оно принадлежит серверу. Если за то же время, что один клиент делает пять запросов через своё keep-alive-соединение к прокси, на сайт в сумме приходит пятьсот запросов от разных людей, все эти пятьсот запросов могут быть обслужены через тот же небольшой пул уже открытых соединений к бэкенду — без единого нового TCP- или TLS-рукопожатия на каждый из них. Экономия масштабируется не с активностью одного клиента, а с суммарной нагрузкой на весь сервер — а суммарная нагрузка почти всегда на порядки выше активности одного посетителя.
Отсюда практический вывод: на не самом высоконагруженном сайте выключенный keep-alive к бэкенду может быть почти незаметен — запросов немного, и накладные расходы на рукопожатия теряются на фоне остального. А вот при заметном потоке запросов ко всему серверу (не обязательно «высоконагруженный проект» в смысле тысяч RPS — заметная разница может проявиться и на более скромных числах, если приложение и так небыстрое) выключенный upstream keep-alive означает, что сервер на ровном месте выполняет TCP- (и, возможно, TLS-) рукопожатие практически на каждый входящий HTTP-запрос — то есть именно там, где эта работа наименее нужна.
Грабли и нюансы, которые стоит знать заранее
Бэкенд должен сам поддерживать keep-alive. Директива keepalive в nginx настраивает только его сторону пула. Если приложение за прокси само закрывает соединение после каждого ответа (например, из-за собственных настроек HTTP-сервера или из-за прокси-обёртки перед ним, которая этого не ожидает), эффекта не будет — nginx попробует переиспользовать соединение, получит ошибку или обнаружит, что оно уже закрыто, и откроет новое. Это стоит явно проверить в логах приложения или в его конфигурации сервера (таймауты вроде keepAliveTimeout в Node.js, аналогичные настройки в Gunicorn/uWSGI и так далее — параметры называются по-разному в разных стеках, стоит свериться с документацией конкретного сервера приложения).
keepalive_requests и keepalive_timeout — это защита, а не просто оптимизация. Слишком долго живущие соединения в пуле могут «протухать» — например, если бэкенд перезапустился, а nginx ещё не заметил, что старое соединение больше никуда не ведёт. Ограничение по числу запросов и по времени простоя снижает шанс напороться на такое мёртвое соединение и одновременно даёт возможность бэкенду периодически освобождать ресурсы, связанные с долгоживущими соединениями.
Здоровье пула стоит наблюдать, а не считать само собой разумеющимся. У nginx есть модуль ngx_http_upstream_module, и при включённом stub_status или коммерческом nginx plus можно видеть состояние апстримов; в опенсорсной версии для наблюдения за поведением апстрима на практике чаще смотрят на логи ошибок (upstream prematurely closed connection, no live upstreams и подобные) и на метрики самого приложения — открытые соединения, их среднее время жизни. Если в логах регулярно мелькают ошибки о преждевременно закрытых соединениях к апстриму — это почти всегда рассинхронизация таймаутов между nginx и бэкендом, и первое, что стоит сверить, — совпадают ли (или хотя бы согласованы ли) keepalive_timeout на стороне nginx и аналогичная настройка на стороне приложения.
Балансировка нагрузки и keep-alive не всегда дружат бесплатно. Если апстримов несколько и используется что-то отличное от round-robin по умолчанию (например, least_conn или хэш-балансировка), это не мешает keep-alive-пулу как таковому, но стоит помнить, что пул соединений держится *к каждому* апстрим-серверу отдельно — директива keepalive N в блоке upstream задаёт число простаивающих соединений на сервер в этом блоке, а не общий лимит на весь пул.
Если у вас ещё не настроен nginx как обратный прокси перед приложением, схема с нуля — от установки до базового проксирования — разобрана в статье nginx как reverse proxy на VPS; там же есть смежные настройки, которые обычно идут рядом с upstream keep-alive.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Нужно ли включать TLS между nginx и приложением, если оба находятся на одном сервере?
Обычно нет: если трафик не выходит за пределы сервера (localhost или unix-сокет), шифрование этого участка чаще всего избыточно, и накладные расходы TLS-рукопожатия на каждое новое соединение — ещё один аргумент в пользу keep-alive, а не в пользу включения TLS там, где без него можно обойтись. Если же приложение находится на отдельном сервере в другой сети, шифрование этого канала уже оправдано соображениями безопасности, и тогда экономия от переиспользования TLS-сессии в пуле особенно заметна.
Как понять, что keep-alive к бэкенду реально работает, а не просто прописан в конфиге?
Самый прямой способ — снять дамп трафика на loopback-интерфейсе (или на интерфейсе между прокси и бэкендом) во время нагрузки и посмотреть, открываются ли новые TCP-соединения на каждый запрос или запросы идут через уже установленные. Косвенно об этом также говорит статистика открытых файловых дескрипторов/сокетов на бэкенде — она не должна расти пропорционально числу запросов.
Что будет, если поставить keepalive слишком большим числом?
Каждое простаивающее соединение в пуле занимает файловый дескриптор и немного памяти на обеих сторонах — у nginx и у приложения. При заведомо избыточном значении вы просто держите открытыми больше соединений, чем реально используется, не получая взамен дополнительной пользы; конкретное разумное значение зависит от вашей нагрузки и от лимитов приложения на число одновременных соединений, и его стоит подбирать по факту, а не по случайному числу из примера.
Работает ли эта логика для gRPC или WebSocket через тот же прокси?
Идея пула переиспользуемых соединений к бэкенду сохраняется, но детали отличаются: gRPC поверх HTTP/2 и долгоживущие WebSocket-соединения ведут себя не так, как короткие HTTP/1.1-запросы, и настраиваются отдельными директивами (grpc_pass вместо proxy_pass, отдельные таймауты для upgrade-соединений). Общий принцип — не открывать новое соединение к бэкенду там, где можно переиспользовать существующее — остаётся тем же, но конкретные параметры конфига будут другими.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →