Сегодня пользователи ожидают от веб-приложений мгновенного отклика. Оптимизация производительности становится критически важной задачей для разработчиков. Один из способов повысить эффективность работы серверов и уменьшить время отклика — «прилипание» HTTP-запросов.
Когда приложения обслуживают много пользователей одновременно, каждая миллисекунда задержки может привести к потере клиентов и снижению качества обслуживания. Прилипание запросов позволяет минимизировать количество необходимых операций, эффективнее распределять ресурсы и улучшить пользовательский опыт.
Меня зовут Ринат Фатхуллин, я владелец продукта Platform V SynGX — веб- и обратного прокси-сервера на основе Nginx. Наш продукт полностью заместил Nginx Plus в Сбере, в том числе благодаря расширенной поддержке «липких» сессий.
Здесь я подробно рассмотрю сценарии использования и особенности этого подхода. Статья будет особенно интересна специалистам, которые занимаются обеспечением бесперебойной работы высоконагруженных сервисов.
Зачем нужны «липкие» сессии
В контексте прокси-сервера прилипание, или «липкие» сессии, — это механизм, позволяющий сохранять связь между первым и последующими HTTP-запросами клиента и узлом в группе балансировки, обрабатывающим эти запросы. Наличие связи означает, что все запросы клиента с определёнными свойствами прокси-сервер будет перенаправлять на один и тот же узел в группе балансировки.
Преимущество этого подхода лежит на поверхности. Когда на экземпляр сервиса приходит первый запрос от клиента, сервис начинает запрашивать данные по клиенту у других сервисов и сохранять у себя в кеше. Но сбор данных занимает время, и если запросы клиента каждый раз будут попадать на разные экземпляры сервиса, то собирать придётся многократно, а это нецелесообразно. Если данные уже есть в кеше, то сервис сможет быстрее ответить на клиентский запрос.
Механизм прилипания решает ещё одну важную задачу — распределение клиентских запросов на основании определённых характеристик. Например, зная IP клиентского запроса, мы можем определить страну или город клиента с помощью базы MaxMindDB и перенаправить запрос на узел, который обслуживает все клиентские запросы из этой страны или города.
Как правило, разработчики веб-сервисов сами определяют, необходимо ли им прилипание клиентских запросов, чтобы улучшить клиентский опыт и пользовательские характеристики сервиса. Обычно прилипание запросов требуется в рамках сеанса пользователя по работе с сервисом. Пример такого сеанса — 15 минут, которые человек потратил на посещение интернет-магазина, поиск и отбор товаров, оформление заказа.
Прилипание даёт ряд преимуществ сервисам, которые поддерживают создание сеанса:
- Согласованность: все запросы в рамках сеанса обрабатываются одним и тем же узлом, поэтому данные пользователя остаются согласованными. Это особенно важно для приложений, которые хранят данные сеанса локально на сервере.
- Производительность: данные пользователя в рамках сеанса не нужно синхронизировать между узлами сервиса, поэтому производительность сервиса может быть выше благодаря сокращению накладных расходов.
- Простота: прилипание запросов может упростить архитектуру сервиса, поскольку разработчикам не нужно внедрять распределенные кеши или распределённые базы данных, в которых данные должны быть одинаковыми и согласованными.
Но, несмотря на очевидные преимущества, у прилипания запросов есть и ряд недостатков:
- Влияние на масштабируемость: прилипание может приводить к неравномерному распределению нагрузки. Определённые узлы могут быть перегружены, в то время как другие — недостаточно загружены. Этот дисбаланс может повлиять на общую производительность и масштабируемость сервиса.
- Недостаточная надежность: в случае сбоя узла сервиса все сеансы, связанные с этим узлом, прерываются, а данные теряются. Это приводит к ошибкам. Реализовать отработку отказа в этих случаях может быть сложно.
- Влияние на управление состоянием: сервисы с прилипанием запросов должны управлять состоянием сеанса пользователя на стороне сервера. Это может усложнить развёртывание и масштабирование, особенно в распределённых средах.
Все эти моменты стоит учитывать, если вы планируете воспользоваться возможностями «липких» сессий.
Как используется прилипание в контексте сеансов пользователей
Прилипание запросов может применяться в самых разных случаях. Рассмотрим самые распространённые.
Интернет-магазины
- Корзины для покупок: если привязать сеанс к определённому узлу сервиса, то все действия, связанные с корзиной (добавление или удаление товаров) будут направляться на один и тот же сервер. Это поможет предотвратить потерю товаров или несоответствие содержимого корзины.
- Оформление заказа: поддержание сеанса с одним и тем же сервером при оформлении заказа гарантирует бесперебойную обработку информации об оплате и доставке без перерывов или потери данных.
Игровые приложения
- Многопользовательские онлайн-игры: поддержание постоянной сессии и своевременное обновление данных для каждого игрока имеют решающее значение. Прилипание запросов обеспечивает последовательное управление состоянием игры, прогрессом игрока и взаимодействиями.
Финансовые услуги
- Онлайн-банкинг: привязанный к определённому узлу сервиса сеанс гарантирует, что все транзакции и действия пользователя последовательно выполняет один и тот же сервер. Это крайне важно для поддержания безопасности и целостности данных при конфиденциальных финансовых операциях.
- Торговые платформы: прилипание запросов помогает эффективно управлять сеансами пользователей и гарантирует, что торговые данные, действия пользователя и обновления рынка последовательно обрабатываются и отображаются.
Какие есть альтернативы прилипанию запросов
Многие современные приложения используют конструкции без состояния и/или распределённое управление сеансами пользователей, чтобы устранить ограничения, связанные с прилипанием запросов:
- Приложения без состояния (stateless): данные пользователя в рамках сеанса могут храниться на стороне клиента (например, в файлах cookie или локальном хранилище) или в централизованном хранилище сеансов, таком как Redis или база данных. Такой подход позволяет любому узлу в группе балансировки обрабатывать любой запрос, что повышает надёжность сервиса.
- Распределённое кеширование: для централизованного хранения данных сеанса пользователя можно использовать такие инструменты, как Redis, Memcached или другие распределённые кеши. Эти данные доступны для чтения и записи всем узлам в группе балансировки. Такой метод позволяет горизонтально масштабировать сервис.
- Глобальные балансировщики нагрузки (GSLB): балансировщики могут обеспечить более интеллектуальную маршрутизацию с учётом географии пользователя, работоспособности сервиса в каком-либо ЦОДе и других факторов, уменьшая потребность в прилипании запросов.
Если прилипание всё же необходимо, следующий шаг — выбор вариантов, которые предоставляет прокси-сервер. Как правило, в большинстве прокси-серверов (Nginx, HAProxy, Envoyproxy и др.) методы прилипания реализованы похожим образом, но по-разному описываются в конфигурации.
Варианты прилипания клиентских HTTP-запросов
Рассмотрим варианты прилипания клиентских HTTP-запросов, которые предоставляет известный веб- и прокси-сервер Nginx. Они представлены директивами, которые задаются в секции http/upstream:
- ip_hash
- hash
- sticky cookie
- sticky route
- sticky learn
Первый вариант представлен директивой ip_hash
в
стандартном модуле Nginx ngx_http_upstream_module. Клиентские запросы
объединяются по IP-адресу клиента, указанному на уровне TCP-протокола.
Это означает, что запросы с одним и тем же IP клиента будут всегда
передаваться на один и тот же узел в группе балансировки.
Помните, что при прохождении клиентских запросов через маршрутизатор
IP-адрес клиента с большой вероятностью будет заменён на IP-адрес
этого устройства и станет неподходящим для распределения запросов. Также
важно, что значение HTTP-заголовка X-Forwarded-For
не используется при выборе узла для передачи запроса при заданной директиве ip_hash
.
Второй вариант представлен директивой hash
в
стандартном модуле Nginx ngx_http_upstream_module. Клиентские запросы
объединяются по ключу, заданному в качестве обязательного аргумента для
директивы. В качестве ключа можно использовать постоянное значение,
какую-либо встроенную переменную Nginx, либо их комбинацию. В частности,
в качестве ключа можно использовать значение заголовка X-Forwarded-For
(hash $http_x_forwarded_for
).
Это означает, что HTTP-запросы с одним и тем же значением ключа будут
всегда передаваться на один и тот же узел в группе балансировки.
При использовании методов прилипания с помощью директив ip_hash
и hash
любое добавление или удаление узлов в группе балансировки может
привести к перераспределению большинства прилипаний на другие узлы —
даже тех, для которых это и не нужно. Для борьбы с перераспределением у
директивы hash
есть параметр consistent
, который значительно уменьшает количество нежелательных перераспределений, но не гарантирует, что их полностью не будет.
Все остальные варианты представлены директивой sticky
, заданной в стандартном модуле Nginx ngx_http_upstream_module, с параметрами cookie
, route
или learn
. Причём параметры route
и learn
доступны только в коммерческой версии Nginx Plus.
При использовании директивы cookie
в общем случае
предполагается, что клиентские запросы объединяются и перенаправляются
на один и тот же узел по наличию в запросах некоторой cookie с
определённым значением. Напомню, что одна или несколько cookie могут
передаваться в запросе в HTTP-заголовке Cookie
, а в ответе — в HTTP-заголовке Set-Cookie
.
Для рассмотрения будем использовать примерно такую простую конфигурацию Nginx:
http { server { listen 8000; location / { proxy_pass my_upstream; } } server { listen 9001; location / { return .... } } } upstream my_upstream { server 127.0.0.1:9001; server 127.0.0.1:9002; sticky cookie my_cookie; }
Клиентские запросы на Nginx будем с помощью утилиты curl отправлять
на сервер, слушающий порт 8000. Этот сервер будет перенаправлять запросы
на внутренние серверы, слушающие порты 9001 и 9002. Внутренние серверы
будут возвращать разные ответы, из которых станет понятна логика
алгоритмов прилипания. Напомню, что директива sticky
с разными параметрами указывается в секции http/upstream
.
Начнём с ситуации, когда в конфигурации задан способ прилипания sticky cookie.
Пусть на Nginx пришёл первый запрос от клиента без cookie. В этом
случае Nginx в соответствии с заданным алгоритмом балансировки выберет
какой-либо узел и перенаправит запрос на него. При этом после получения
ответа от узла Nginx добавит в него заголовок Set-cookie
, в
котором укажет значение cookie, равное значению MD5-хеша от IP-адреса и
порта узла, на который был отправлен запрос и от которого получили
ответ.
Вот листинг команды curl
http://127.0.0.1:8000
:
Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0) > GET / HTTP/1.1 > Host: 127.0.0.1:8000 > User-Agent: curl/7.61.1 > Accept: */* > < HTTP/1.1 200 OK < Date: Mon, 27 Jan 2025 10:18:45 GMT < Content-Type: text/plain < Content-Length: 2 < Connection: keep-alive < Set-Cookie: my_cookie=2b46daf0ba503cbe30c3b46dfad73e0a; Path=/ < * Connection #0 to host 127.0.0.1 left intact
Nginx в ответе установил значение cookie my_cookie=2b46daf0ba503cbe30c3b46dfad73e0a
.
Если следующие запросы будут содержать cookie с этим значением (равным значению MD5-хеша от IP-адреса и порта узла в группе балансировки), то Nginx будет их перенаправлять на узел, соответствующий значению cookie. Если этот узел окажется недоступен, Nginx выберет новый узел и укажет в ответе новое значение cookie.
Почему выбранный узел будет считаться недоступным? За это в Nginx
отвечает механизм пассивной проверки работоспособности узла. Для его
настройки используются параметры max_fails
и fail_timeout
в директиве upstream/server
, а также директивы с префиксом proxy_next_upstream
.
В коммерческих версиях прокси-серверов (в том числе в Nginx Plus)
реализован также механизм активной проверки работоспособности,
результаты которого тоже будут учитываться при выборе узла. Здесь можно почитать описание этого механизма.
Если нужная cookie в запросе уже есть, то Nginx в ответе не добавляет заголовок Set-cookie
.
Отправим HTTP-запрос curl -v
http://127.0.0.1:8100/
--cookie "my_cookie=2b46daf0ba503cbe30c3b46dfad73e0a"
и получим в ответ:
* Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0) > GET / HTTP/1.1 > Host: 127.0.0.1:8000 > User-Agent: curl/7.61.1 > Accept: */* > Cookie: my_cookie=2b46daf0ba503cbe30c3b46dfad73e0a > < HTTP/1.1 200 OK < Date: Mon, 27 Jan 2025 10:21:49 GMT < Content-Type: text/plain < Content-Length: 2 < Connection: keep-alive < * Connection #0 to host 127.0.0.1 left intact
Как видите, никакие cookie в ответе не заданы.
Что меняется в случае способа прилипания sticky route?
На самом деле немногое. Основное отличие в том, что для поиска нужного узла Nginx использует:
- в случае sticky cookie — значение определённой cookie, которое должно равняться MD5-хешу от IP-адреса и порта узла;
- в случае sticky route — значение вычисляемой при обработке запроса переменной (это может быть также cookie, заданная как $cookie_a), которое должно совпадать со значением дополнительного параметра route в директиве upstream/server.
За передачу клиенту информации о том, что необходимо добавить в
запрос для прилипания, отвечает узел, на который будет перенаправлен
запрос. Обычно также используется cookie и добавление в ответ заголовка Set-cookie
, значение которой клиент использует при повторных обращениях.
Часть конфигурации при этом может выглядеть так:
map $cookie_jsessionid $route_cookie { ~.+\.(?P<route>\w+)$ $route; } map $request_uri $route_uri { ~jsessionid=.+\.(?P<route>\w+)$ $route; } upstream test_https2 { server 127.0.0.1:8082 route=b; server 127.0.0.2:8082 route=a; sticky route $route_cookie $route_uri; }
Что здесь происходит? С помощью первой директивы map
при обработке каждого запроса в переменную $route_cookie
заносится часть значения cookie jsessionid
,
если это значение соответствует регулярному выражению. Если значение
cookie регулярному выражению не соответствует, то значение переменной $route_cookie
будет пустым.
То же самое происходит с помощью второй директивы map
для переменных $request_uri
и $route_uri
.
Далее при выборе узла для проксирования Nginx:
- сначала пытается сопоставить значение переменной $route_cookie со значением параметра route для server;
- если это не удаётся — значение переменной $route_uri со значением параметра route для server.
Если совпадение найдено, то именно на этот узел (upstream/server
) будет перенаправлен запрос.
В директиве sticky route
можно указать несколько переменных, значения которых будут сопоставляться со значением параметра route
.
А для чего нужен метод прилипания sticky learn?
Рассмотрим ситуацию. Клиент делает первый запрос, который
прокси-сервер распределяет на один из узлов в группе балансировки. Узел
отвечает с заголовком Set-Cookie
, в котором указана cookie my_cookie
. Клиент получает ответ, запоминает значение cookie my_cookie
и все последующие запросы делает с ней. При этом клиент хочет, чтобы
все последующие запросы попадали на узел, который ответил на первый
запрос (если узел «живой»), независимо от того, что происходит с другими
узлами.
Казалось бы, для этих целей можно использовать алгоритм прилипания hash. Но я уже говорил, что у него есть недостаток: любое добавление или удаление узлов в группе балансировки может привести к перераспределению большинства прилипаний на другие узлы — даже там, где это не нужно. В такой ситуации может использоваться алгоритм прилипания sticky learn.
Вот как это работает. В каждом запросе от клиента прокси-сервер проверяет наличие cookie, указанной в параметре lookup
.
Если этой cookie в запросе нет, то прокси-сервер выбирает узел в
соответствии с заданной стратегией балансировки с учётом информации о
доступности или недоступности узлов.
Когда от узла приходит ответ на запрос, прокси-сервер проверяет наличие в нём cookie, заданной с помощью параметра create
. Ниже я привёл пример, где эта переменная указывает на заголовок Set-Cookie и cookie examplecookie
в ней. Если такая cookie есть, то прокси-сервер извлекает её и
запоминает в привязке к узлу в группе балансировки, который её выставил.
Все последующие запросы от клиента с этой cookie будут перенаправляться
на этот же узел. Однако если выбранный узел выведен из балансировки по
результатам активной проверки работоспособности, то SynGX выбирает
другой узел в соответствии с заданным алгоритмом балансировки, как если
бы привязки не существовало.
А как конкретно прокси-сервер запоминает привязку значения cookie к узлу в группе балансировки? Напомню, что параметр learn
для директивы sticky
доступен только в коммерческой версии Nginx Plus, поэтому сказать
однозначно я не могу. Однако поскольку функциональность sticky learn
доступна в нашем собственном прокси-сервере Platform V SynGX на основе
Nginx, то расскажу, как нам удалось его реализовать.
Как Platform V SynGX запоминает привязку значений cookie к узлам в группе балансировки
Platform V SynGX — высокопроизводительный веб- и обратный прокси-сервер, который СберТех разработал на кодовой базе Nginx. В 2024 году мы полностью заместили этим решением коммерческую версию Nginx Plus в Сбере, и сейчас оно установлено более чем на 15 000 серверов в инфраструктуре банка. Platform V SynGX используется в системах c высоким уровнем критичности, работающих в режиме 24/7/365: сайт Сбера, мобильный банк, СберБизнес и других.
Как Platform V SynGX запоминает привязку значений cookie к узлам в группе балансировки? В конфигурации это может выглядеть, например, так:
upstream backend { server backend1.example.com:8080; server backend2.example.com:8081; sticky learn create=$upstream_cookie_examplecookie lookup=$cookie_examplecookie zone=client_sessions:1m timeout=1h cookie_max_size=256 gc_period=5m; }
Что Platform V SynGX будет делать в этом случае?
Всё начинается с первого запроса от клиента, в котором нет cookie, указанной в параметре lookup
. В примере конфигурации выше это cookie с именем examplecookie
. В этом случае Platform V SynGX выберет доступный узел в соответствии с заданным алгоритмом балансировки и перенаправит запрос на него.
Получив ответ от узла, решение проанализирует его на наличие cookie, указанной в параметре create
(в примере выше это cookie с именем examplecookie
). Если такая cookie есть (задана в HTTP-заголовке Set-cookie
), то Platform V SynGX
создаcт запись «ключ-значение», которая содержит привязку значения этой
cookie к узлу. Ключом будет значение cookie, а значением — узел, куда
необходимо проксировать последующие запросы. Все подобные записи
Platform V SynGX хранит в распределённой памяти (shared memory), чтобы обеспечить доступ к ним всех рабочих процессов (workers).
Клиент получает cookie в ответ на свой первый запрос, сохраняет её у себя и все последующие запросы делает уже с ней.
Если в запросе от клиента есть cookie (в примере конфигурации выше это cookie с именем examplecookie
), то Platform V SynGX
берет её значение и начинает искать соответствующий ей узел в
распределённой памяти. Если находит, то перенаправляет на него запрос.
Если соответствующего ключа и значения в памяти нет, то Platform V SynGX, как и ранее, выберет доступный узел в соответствии с заданным алгоритмом балансировки и перенаправит запрос на него.
Звучит просто, но могут возникать интересные вопросы:
- В какой структуре хранить пары «ключ-значение», чтобы обеспечить быстрый поиск по ключу?
- Как сделать так, чтобы несколько рабочих процессов не блокировали или минимально блокировали друг друга при обращении к этой структуре?
- Сколько памяти выделять для хранения необходимого количества пар «ключ-значение»?
- Как сделать так, чтобы хватало памяти?
- Что делать, если выделенная память закончилась, но необходимо добавить новую запись «ключ-значение»?
Начну с предпоследнего вопроса. Ответ может быть такой: удалять старые, давно не используемые пары «ключ-значение». Для этого будем для каждой пары дополнительно хранить время последнего использования и с некоторой периодичностью запускать сборщик мусора.
А как сделать так, чтобы неиспользуемые ключи можно было получить, не тратя время на перебор всей таблицы? Для ответа на этот вопрос мы добавили следующие параметры:
- zone=client_sessions:1m: определяет размер выделенной памяти для хранения всех пар «ключ-значение» (здесь 1 мегабайт);
- timeout=1h: определяет, по истечении какого времени считать пару давно не используемой (здесь 1 час);
- cookie_max_size=256: определяет максимальный размер значения cookie, используемого в качестве ключа (здесь 256 байт);
- gc_period=5m: определяет, как часто запускать сборщик мусора (здесь раз в 5 минут);
- session_rotate: определяет, нужно ли освобождать место для новой пары, удаляя самую «древнюю», если выделенная память уже заполнена;
- fill_limit_minor_log: определяет пороговую долю активных пар от максимально возможного количества, при которой выдаётся сообщение в журнал ошибок на уровне [warn];
fill_limit_major_log
: определяет пороговую долю активных клиентских сессий от максимально возможного количества, при которой выдаётся сообщение в журнал ошибок на уровне[error]
;- fill_limit_crit_log: определяет пороговую долю активных клиентских сессий от максимально возможного количества, при которой выдаётся сообщение в журнал ошибок на уровне [crit].
Мы также реализовали предоставление метрик о текущем количестве активных записей «ключ-значение» в памяти и общее количество использования этих пар при распределении запросов на узлы.
Подведем итоги
- Механизм прилипания HTTP-запросов может улучшить клиентский опыт использования веб-сервиса.
- Open-source версия Nginx предоставляет несколько вариантов прилипания, которых может быть вполне достаточно для работы.
- Platform V SynGX, как и другие коммерческие версии прокси-серверов, предоставляет дополнительные варианты прилипания, которые могут быть интересны искушённым разработчикам или архитекторам веб-сервисов.
Platform V SynGX обладает рядом функциональных и нефункциональных преимуществ относительно open-source версии Nginx. Если вам интересно больше узнать о них, — обращайтесь: ответим на все вопросы и проведём для вас демонстрацию 😊