Как стать автором
Обновить
100.9
Нетология
Меняем карьеру через образование

Защищаем сервис от перегрузки с помощью HAProxy

Время на прочтение13 мин
Количество просмотров7.1K

Если вам доводилось использовать HAProxy для балансировки трафика, вы наверняка как минимум слышали, что этот продукт умеет отслеживать показатели активности сервиса и пользователей и реагировать на них по предопределённым условиям. Обычно в статьях на эту тему приводится пример ограничения пользователя по исходному IP-адресу, если частота запросов с него превышает некоторый предопределённый заранее лимит. Вот, к примеру, такая статья с сайта разработчиков.

Я бы хотел немного углубиться в тематику использованного механизма stick tables, но поговорить не про пользователей, активно интересующихся вашим сайтом, а про нагрузочную способность, или ёмкость, всего сайта (ну или каких-то его путей). Во-первых, любой сервис ограничен в количестве одновременных запросов, которые возможно обслужить на существующих ресурсах. Во-вторых, чаще всего у сервиса не одна площадка или хотя бы не один экземпляр балансёра. А это значит, что поймать одинокого пользователя — это, конечно, здорово, но хотелось бы решить и другую интересную задачу: защитить сервис от перегрузки в целом и в случае, если балансёров более одного. Бонусом поговорим о проблеме умного перераспределения нагрузки между локациями.

Наш сервис

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

Вот наша площадка с кодовым именем Alpha. У нас есть три сервера (server01a, server02a, server03a), которые постоянно и асинхронно получают новости от какого-то секретного источника. Есть балансировщик трафика balancer01a на базе HAProxy, который распределяет клиентскую нагрузку между тремя серверами и следит, чтобы они были живы и отвечали пользователям. 

Рисунок 1. Первоначальная схема сервиса
Рисунок 1. Первоначальная схема сервиса

Предположим, что серверы не имеют каких-то скрытых ограничений и зависимостей и их производительность зависит лишь от используемого железа или типа виртуальной машины. Мы провели нагрузочные испытания и выяснили, что вот эти конкретные серверы способны генерировать и выдавать пользователям двести страниц в секунду каждый. Мы заложили, что серверы необходимо обслуживать, а площадка способна работать с пиковой нагрузкой без одного сервера. Таким образом, нагрузочная способность площадки Alpha на текущий момент составляет 400 пользовательских запросов в секунду, или 400 RPS (requests per second).

Давайте напишем простой конфиг для HAProxy, соответствующий нашей картинке:

frontend fe_news
  bind :80
  default_backend be_news

backend be_news
  default-server check
  server server01a 10.0.0.11:80
  server server02a 10.0.0.12:80
  server server03a 10.0.0.13:80

Frontend-секция fe_news определяет параметры, обращённые в сторону пользователя, а backend-секция be_news — в сторону серверов.

Как видите, в конфигурационном файле нет ничего, что защищало бы площадку от наплыва посетителей. Давайте это исправим.

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

Ограничивать отдельных пользователей нам мало проку, так как новостная волна может приносить множество новых посетителей, которые зайдут лишь раз. И вот этих суммарных запросов нам вполне хватит, чтобы не выдержать. В нашем случае необходимо подсчитать число одновременных запросов на площадку (request rate) и что-то сделать с теми, которые сверх плана.

Для реализации этого замысла нам понадобятся stick tables. Я немного расскажу о них здесь, а узнать о них более подробно вы сможете в документации.

Stick tables

Stick table в HAProxy — это напоминающая кеш база данных типа key-value в памяти приложения, в которой можно хранить различные счётчики в привязке к какому-то первичному ключу. Ключом может быть IP-адрес, число или произвольная строка фиксированной длины. С кешем таблицу роднит то, что для каждой таблицы задаётся фиксированное время, спустя которое необновляемая строка исчезает. Таблица всегда привязана к прокси-секции (frontend, backend, listen). Секция не может иметь более одной таблицы. 

Есть и другой способ объявления stick table — это секция peers, но об этом мы поговорим позднее.

Пример объявления stick table:

frontend fe_news
  bind :80 
  stick-table type ipv6 size 1m expire 10s store http_req_rate(10s)

Обратите внимание, что ключ типа IPv6 может хранить и IPv4-адреса, преобразовав их в IPv6-формат. Размеры полей также есть в документации. Для IPv6-ключей это примерно 60 КБ на запись.

В данном конкретном случае мы храним частоту HTTP-запросов пользователя за последние 10 секунд (http_req_rate(10s)). В сущности, всё равно, чьи адреса — клиента или сервера — хранить. Но для серверов вряд ли бы понадобился миллион записей (size 1m) и 10-секундный expiration period (expire 10s). Счётчики могут быть довольно разнообразными, и их может быть более одного. Подробная документация на эту тему есть здесь.

Sticky сounters

Сама по себе таблица бесполезна, — нужно в ней что-то хранить. Для этого в HAProxy есть так называемые sticky counters. Sticky counter — это правило в прокси-секции, которое связывает определённый атрибут с указанной stick table. По-умолчанию для некоммерческой версии HAProxy таких правил три на запрос, но их количество можно увеличить глобальной директивой tune.stick-counters. Каждое правило добавляет 16 КБ к структуре с информацией о соединении и ещё 16 к каждому запросу. Привязка осуществляется при помощи track-метода запроса или ответа. Пример:

frontend fe_news
  bind :80
  stick-table type ipv6 size 1m expire 10s store http_req_rate(10s)
  http-request track-sc0 src

В этом примере мы используем sticky counter sc0, чтобы отслеживать частоту HTTP-запросов с каждого IP-адреса для прокси-секции fe_news в локальной для этой секции stick table. 

ACL expressions

На основе значений, которые отслеживаются с помощью sticky counters, можно совершать определённые действия с трафиком или просто считать полезную статистику для журналов. Или, например, вот так можно начать блокировать (возвращать 403) IP-адрес клиента, с которого происходит слишком много запросов за единицу времени на главную страницу /:

frontend fe_news
  bind :80
  stick-table type ipv6 size 1m expire 10s store http_req_rate(10s)
  http-request track-sc0 src if { path / }
  http-request deny if { sc0_http_req_rate() gt 100 }

Обращаю внимание, что счётчик sc0 будет увеличиваться как на успешных, так и на неуспешных попытках. То есть для разблокировки необходимо, чтобы клиент за последние 10 секунд совершил менее 100 попыток открыть страницу /, иначе будет получать вечную ошибку 403.

Защищаем от перегруза сайт

Мы познакомились со stick tables, и теперь давайте вернёмся к нашей задаче. Напомню, мы хотим ограничить трафик на сайт 400 запросами в секунду и нам всё равно, будет это один клиент или миллион. Вот как мы изменим frontend-секцию:

frontend fe_news
  bind :80
  default_backend be_news
  stick-table type integer size 1 expire 1s store http_req_rate(1s)
  http-request track-sc0 always_true
  http-request deny if { sc0_http_req_rate() gt 400 }

Что здесь происходит:

  1. Мы объявили для нашей основной frontend-секции stick table типа «целое число» с одной единственной строкой, от которой мы хотим знать «количество запросов за последнюю секунду».

  2. На каждый пользовательский запрос мы обновляем ту самую единственную строчку в таблице с помощью предопределённой константы always_true, которая преобразуется в какое-то целое число. Нам неважно какое (хотя это будет единица) — лишь бы одинаковое. Таким образом мы актуализируем счётчик http_req_rate для таблицы.

  3. Если за последнюю секунду суммарный счётчик запросов превысил 400, мы возвращаем пользователю ошибку 403.

Всё ли в порядке в таком решении

Помните, я говорил, что счётчик будет срабатывать как для успешных, так и для неуспешных запросов? Это значит, что как только наш сайт достигнет 400 запросов в секунду, он начнёт возвращать ошибку 403 всем клиентам. Кроме того, для ситуаций перегруза есть более подходящий код ответа, чем 403, — это код 429 (Too Many Requests).

Давайте исправим решение. Для этого нам понадобятся переменные. Несколько слов о них есть в официальной документации.

frontend fe_news
  bind :80
  default_backend be_news
  stick-table type integer size 1 expire 1s store http_req_rate(1s)
  http-request track-sc0 always_true
  http-request set-var(req.rps) sc0_http_req_rate()
  http-request set-var(req.rnd) rand,mod(req.rps)
  acl rps_over_limit var(req.rps) -m int gt 400
  acl unlucky_request var(req.rnd) -m int ge 400
  use_backend be_error_429 if rps_over_limit unlucky_request

backend be_error_429
  errorfile 429 /etc/haproxy/errors/429.http
  http-request deny deny_status 429

Давайте разбираться:

  1. Мы всё так же считаем количество запросов за последнюю секунду (request per second, RPS) в stick-таблице.

  2. Далее мы складываем текущее значение RPS в переменную req.rate. Затем в переменную req.rnd мы кладём остаток от деления случайного числа на текущий request rate.

  3. Потом мы добавляем два ACL. Первый из них (rps_over_limit) срабатывает, если количество запросов в секунду превышает лимит в 400. Второй (unlucky_request) — тогда, когда значение случайного числа также будет выше этого лимита или равно ему. 

  4. Если оба ACL сработали, мы возвращаем ошибку. Логика здесь такая: предположим, текущая нагрузка на сайт — 600 RPS. Значит, ACL rps_over_limit будет срабатывать всегда. Генератор случайных чисел будет нам возвращать значения в диапазоне от 0 до 599. Пороговое значение — 400. Таким образом, в 33% случаев ACL unlucky_request сработает и пользовательский запрос будет отвергнут. Но в остальных 66% unlucky_request не сработает и клиент получит свой контент.

  5. Мы хотим красиво оформить ошибку и поэтому возвращаем её отдельной прокси-секцией be_error_429. В ней мы определяем, какой конкретно шаблон для страницы с ошибкой использовать, и возвращаем эту заранее подготовленную страницу. Кстати, вместо ошибки вы можете отдать и что-то другое: например, кешированную копию контента или какую-нибудь игру, чтобы отвлечь пользователя от чтения новостей.

Второй балансёр

Неожиданно мы вспоминаем, что балансёр тоже живёт на сервере, а значит, его тоже нужно иногда обслуживать. То есть нам как минимум нужен ещё один экземпляр HAProxy.

Рисунок 2. Второй балансёр на площадке Alpha
Рисунок 2. Второй балансёр на площадке Alpha

Как же нам теперь защищать сайт? 

Уполовинить лимиты до 200 RPS на каждый балансёр? Плохо: с одной стороны, распределение трафика между балансёрами может быть неравномерным, с другой — в моменты работы на одном из балансёров мы будем проседать в ёмкости.

Использовать распределённый счётчик трафика для координации? Отличная идея. Но вот незадача: в случае HAProxy — это часть коммерческой версии продукта (Global Profiling Engine). Если вы можете себе позволить коммерческую подписку, то это самый правильный путь. С альтернативами тоже всё непросто, так как это должен быть хороший бесплатный распределённый счётчик, который хорошо интегрируется с HAProxy, публично доступен и поддерживается разработчиками. Если вы знаете такой, напишите пожалуйста, об этом в комментариях.

А пока давайте воспользуемся тем, что у нас уже есть, и попробуем реализовать распределённый счётчик. Для этого нам понадобятся возможности HAProxy по распределённой синхронизации stick tables.

Распределённая синхронизация stick tables

HAProxy обладает возможностью active-standby-репликации stick-таблиц с одного узла на множество других. Главное, что следует помнить об этом процессе, — то, что он односторонний. 

Если вы что-то запишете в реплицируемую таблицу на одном хосте, это изменение перезапишет содержимое таблиц на остальных хостах. Просто перезапишет, а не добавит. Это важно.

Если какой-то из участников синхронизации перезапускается, он автоматически подтянет содержимое таблиц к себе. У этого есть один приятный побочный эффект: перезапуск локального HAProxy приводит к тому, что новый экземпляр вытягивает из старого всё содержимое таблиц, поэтому оно не теряется. А значит, даже если вы объявите группу синхронизации серверов из одного локального хоста, это уже принесёт определённую пользу.

Для настройки синхронизации в HAProxy есть специальная peers-секция, в которой объявляются все участники обмена, а также могут быть объявлены stick-таблицы. Помните, я говорил, что кроме прокси-секций их можно объявлять ещё одним способом? Более того, в peers-секции может быть объявлено множество stick-таблиц. Изменение в любой из них вызовет рассылку оповещений всем участникам. 

Давайте посмотрим на пример для нашей новой топологии сайта для балансёра balancer01a:

global
  localpeer balancer01a

peers news_peers
  bind :10000
  # You can use SSL as well:
  # bind 10.0.0.1:10000 ssl crt mycerts/pem
  # default-server ssl verify none
  server balancer01a # local peer
  server balancer02a 10.0.0.2:10000
  table balancer01a type integer size 1 expire 3s store http_req_rate(3s)
  table balancer02a type integer size 1 expire 3s store http_req_rate(3s)

В группу синхронизации включены оба балансёра площадки Alpha. Кроме того, сразу в этой группе мы объявим две таблицы, которые будем использовать: одну на хосте balancer01a, а вторую — на balancer02a. Такого рода настройка потребует наличия уникального файла конфигурации для каждого из экземпляров балансёров, но это легко решается с помощью шаблонизации (Jinja или любой другой шаблонизатор, который вам нравится).

Обратите внимание на localpeer-параметр секции global. Он задаёт идентификатор, по которому HAProxy будет выбирать в peers-секции локальный сервер. По умолчанию будет использовано hostname сервера, но лучше не рисковать и явно задать значение.

Также заметьте, что я увеличил период request_rate с одной секунды до трёх. Это сделано для того, чтобы сгладить всплески трафика, которые могут появиться из-за каких-то задержек в синхронизации.

Итак, у нас есть две таблицы. Давайте модифицируем frontend-секцию так, чтобы она использовала новые таблицы и учитывала изменения на соседнем балансёре. Заодно учтём изменение в интервале расчёта среднего числа запросов. 

Вот конфигурация для balancer01a. Жирным шрифтом я показал, что изменилось:

frontend fe_news
  bind :80
  default_backend be_news
  http-request track-sc0 always_true table news_peers/balancer01a
  http-request set-var(req.rps01a) sc0_http_req_rate(news_peers/balancer01a),div(3)
  http-request set-var(req.rps02a) sc0_http_req_rate(news_peers/balancer02a),div(3)
  http-request set-var(req.rps) var(req.rps01a),add(req.rps02a)

  http-request set-var(req.rnd) rand,mod(req.rps)
  acl rps_over_limit var(req.rps) -m int gt 400
  acl unlucky_request var(req.rnd) -m int ge 400
  use_backend be_error_429 if rps_over_limit unlucky_request

Для balancer02a секция будет отличаться на одну строчку:

frontend fe_news
  http-request track-sc0 always_true table news_peers/balancer02a

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

Вторая площадка

Наша медиаимперия растёт, и всё больше пользователей читают новости у нас. Дела идут настолько хорошо, что мы решили запустить ещё одну площадку — Beta. 

Рисунок 3. Две площадки: Alpha и Beta
Рисунок 3. Две площадки: Alpha и Beta

Однако мы не знаем, на какую площадку придёт пользователь: вдруг бо́льшая часть будет смаршрутизирована на Alpha и там будет перегруз с 429-ми ошибками в сторону пользователей, а Beta будет простаивать? Что делать?

Может, подключить все серверы под все балансёры и равномерно распределять трафик? Можно. Но трафик между локациями стоит денег, и не хотелось бы тратить его, если локальные серверы простаивают.

А давайте отправлять трафик на соседнюю площадку, только когда локальная уже загружена? Кроме того, мы знаем, что каждый сервер вытягивает 200 RPS нагрузки. Почему мы считаем 400 RPS на локацию? Давайте исходить из числа живых серверов. Вперёд!

Дисклеймер: я буду приводить конфигурации для balancer01a, для всех остальных всё будет похоже, как и в предыдущем примере.

Итак, сначала мы должны поправить peers-секцию. Жирным шрифтом выделено то, что изменилось:

peers news_peers
  bind :10000
  server balancer01a # local peer
  server balancer02a 10.0.0.2:10000
  server balancer01b 10.0.1.1:10000
  server balancer02b 10.0.1.2:10000
  table balancer01a type integer size 1 expire 3s store http_req_rate(3s)
  table balancer02a type integer size 1 expire 3s store http_req_rate(3s)
  table balancer01b type integer size 1 expire 3s store http_req_rate(3s)
  table balancer02b type integer size 1 expire 3s store http_req_rate(3s)

Здесь ничего интересного, кроме очевидных изменений. Единственное, что я бы настоятельно рекомендовал в реальной жизни, — включить SSL, как для синхронизации, так и для общения с серверами.

А вот сейчас станет интереснее. Во-первых, нам нужно переделать backend-секции, чтобы добавить туда новые серверы:

backend be_news_local
  server server01a 10.0.0.11:80 track be_news/server01a
  server server02a 10.0.0.12:80 track be_news/server02a
  server server03a 10.0.0.13:80 track be_news/server03a

backend be_news
  default-server check
  server server01a 10.0.0.11:80
  server server02a 10.0.0.12:80
  server server03a 10.0.0.13:80
  server server01b 10.0.1.11:80
  server server02b 10.0.1.12:80
  server server03b 10.0.1.13:80

Теперь у нас две секции: в одной только серверы текущей локации, во второй — все. Чтобы дважды не проверять доступность одного и того же сервера, воспользуемся опцией track, которая говорит балансёру: статус этого сервера бери из статуса того сервера из той прокси-секции.

Теперь давайте посмотрим на frontend-секцию:

frontend fe_news
  bind :80
  default_backend be_news

  http-request track-sc0 always_true table news_peers/balancer01a
  http-request set-var(req.rps01a) sc0_http_req_rate(news_peers/balancer01a),div(3)
  http-request set-var(req.rps02a) sc0_http_req_rate(news_peers/balancer02a),div(3)
  http-request set-var(req.rps01b) sc0_http_req_rate(news_peers/balancer01b),div(3)
  http-request set-var(req.rps02b) sc0_http_req_rate(news_peers/balancer02b),div(3)
  http-request set-var(req.rpsa) var(req.rps01a),add(req.rps02a)
  http-request set-var(req.rpsb) var(req.rps01b),add(req.rps02b)
  http-request set-var(req.rps) var(req.rpsa),add(req.rpsb)
  http-request set-var(req.local_srv_count) nbsrv(be_news_local)
  http-request set-var(req.srv_count) nbsrv(be_news)
  http-request set-var(req.avg_local_srv_rps) var(req.rpsa),div(req.local_srv_count)
  http-request set-var(req.avg_srv_rps) var(req.rps),div(req.srv_count)
  http-request set-var(req.rnd) rand,mod(req.avg_srv_rps)

  acl local_rps_ok var(req.avg_local_srv_rps) -m int le 200
  acl local_srv_alive var(req.local_srv_count) -m int gt 0
  acl rps_over_limit var(req.avg_srv_rps) -m int gt 200
  acl unlucky_request var(req.rnd) -m int ge 200

  use_backend be_error_429 if rps_over_limit unlucky_request
  use_backend be_news_local if local_srv_alive local_rps_ok

Разбираемся с конфигурацией:

  1. Мы посчитали RPS для каждого балансёра и сложили результаты в переменные req.rps01a, req.rps02a, req.rps01b и req.rps02b.

  2. Затем мы посчитали отдельно RPS для площадки Alpha и площадки Beta и положили результаты в req.rpsa и req.rpsb.

  3. Посчитали суммарный RPS на все локации и положили его в req.rps.

  4. Функция nbsrv() возвращает количество живых серверов из указанной прокси-секции. Мы использовали её, чтобы посчитать, сколько в среднем RPS приходится на локальный для площадки Alpha-сервер (req.avg_local_srv_rps). Затем то же самое для всех серверов сервиса (req.avg_srv_rps).

  5. Потом мы сгенерировали случайное число в диапазоне от 0 до среднего RPS на сервер и сохранили число в (req.rnd).

  6. Дальше начинается серия условных команд:

    • если средний RPS на сервер сервиса больше 200 и случайное число больше или равно 200 — вернуть пользователю ошибку;

    • если локальные серверы живы (хоть один) и суммарная нагрузка на них локальным для площадки трафиком в норме (меньше 200 на сервер в среднем) — использовать прокси-секцию be_news_local, то есть обслужить запрос локально (use_backend be_news_local);

    • в противном случае — отдать запрос на обработку случайному живому серверу сервиса (default_backend be_news).

Итак, что мы получили:

  • Если нагрузка в норме — не больше 200 RPS в среднем на локальный сервер, — пользователь будет обслужен сервером той же площадки, куда он пришёл.

  • Если загрузка немного выше нормы для площадки (скажем, 610 RPS для площадки с тремя живыми серверами), но в целом на сервис она приемлема — запрос будет обслужен случайным сервером с одной из двух площадок.

  • Если нагрузка выше максимально допустимой (200 × количество число живых серверов), то пользовательские запросы будут частично отброшены, чтобы в среднем уложиться в разрешённый уровень.

    Если N — число живых серверов, CurrentRPS — текущий трафик, а MaxServerRPS — максимальный RPS, который может быть обслужен сервером, то вероятность получения пользователем ошибки равна:

     {max(CurrentRPS-{MaxServerRPS × N, 0)} \over MaxServerRPS × N}

Спасибо, что дочитали до конца статьи! Я попытался показать, насколько stick tables с распределённой синхронизацией мощный и полезный компонент HAProxy. К сожалению, я встречал не так много документации по этому вопросу. Даже синтаксис работы с переменными в официальной документации описан весьма скупо. Поэтому я надеюсь, что статья будет вам полезной. Конечно, в реальных условиях придётся раздельно считать нагрузку на разные «ручки» сервиса, и бэкенды будут разной ёмкости. Но для первого в HAProxy есть ACL, а для второго — веса серверов. В общем, всё получится!

Когда растёте вы, растёт и ваш проект. Чтобы расти, нужно выйти из зоны комфорта и сделать шаг к переменам. Можно изучить новое, начав с бесплатных занятий, или открыть перспективы с обучением «Сетевой инженер». С промокодом CODEHABR10 цена ещё приятнее.

Теги:
Хабы:
Всего голосов 18: ↑18 и ↓0+18
Комментарии0

Публикации

Информация

Сайт
netology.ru
Дата регистрации
Дата основания
2011
Численность
501–1 000 человек
Местоположение
Россия
Представитель
Мария Верховцева