Автор статьи объясняет, как реализовать в HAProxy ограничение скорости обработки запросов (rate limiting) с определенных IP-адресов. Команда Mail.ru Cloud Solutions перевела его статью — надеемся, что с ней вам не придется тратить на это столько времени и усилий, сколько пришлось потратить ему.

Дело в том, что это один из самых популярных методов защиты сервера от DoS-атак, но в интернете трудно найти понятную инструкцию, как конкретно его настроить. Методом проб и ошибок автор заставил HAProxy ограничить частоту запросов по списку IP-адресов, который обновляется в реальном времени.

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

Опенсорсный и бесплатный HAProxy — высокодоступный балансировщик нагрузки и прокси-сервер. В последние годы он стал очень популярным, поскольку обеспечивает высокую производительность с минимумом ресурсов. В отличие от альтернативных программ, некоммерческая версия HAProxy Community Edition предлагает достаточное количество функций для надежной балансировки нагрузки.

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

Настройка балансировщика нагрузки


Чтобы сэкономить время и не отвлекаться на настройку инфраструктуры, возьмем образы Docker и Docker Compose — и быстро запустим основные компоненты.

Первая задача — поднять рабочий инстанс балансировщика нагрузки HAProxy с несколькими бэкенд-серверами Apache.

Клонируем репозиторий


$ git clone git@github.com:stargazer/haproxy-ratelimiter.git
$ cd haproxy-ratelimiter

Можете посмотреть на Dockerfile и docker-compose.yml с параметрами установки. Их обсуждение выходит за рамки данной статьи, поэтому остановимся на том, что они создали рабочий инстанс HAProxy под названием loadbalancer с двумя бэкенд-серверами api01 и api02. Для конфигурации HAProxy изначально будем использовать файл haproxy-basic.cfg, а затем переключимся на haproxy-ratelimiting.cfg.

Для простоты конфигурационный файл haproxy-basic.cfg сокращен до самого необходимого минимума и очищен от лишнего. Посмотрим на него:

defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms

frontend proxy
bind *:80

use_backend api

backend api
balance roundrobin

server api01 api01:80
server api02 api02:80

Раздел frontend proxy устанавливает HAProxy прослушивать порт 80 и пересылать все запросы в пул серверов api на бэкенде.

Раздел backend api определяет пул бэкенда api с двумя бэкенд-серверами api01 и api02 и соответствующими адресами. Сервер для обслуживания каждого входящего запроса выбирается алгоритмом балансировки нагрузки roundrobin, то есть, по сути, два доступных сервера используются по очереди.

Давайте запустим все три наши контейнера


$ sudo docker-compose up

Теперь у нас есть контейнер loadbalancer, который перенаправляет запросы на два сервера бэкенда api01 и api02. Мы получим ответ от одного из них, если введем в адресной строке http://localhost/.

Интересно несколько раз обновить страницу и посмотреть логи docker-compose.

api01_1 | 192.168.192.3 - - [08/Jan/2019:11:38:09 +0000] "GET / HTTP/1.1" 200 45
api02_1 | 192.168.192.3 - - [08/Jan/2019:11:38:10 +0000] "GET / HTTP/1.1" 304 -
api01_1 | 192.168.192.3 - - [08/Jan/2019:11:38:10 +0000] "GET / HTTP/1.1" 304 -
api02_1 | 192.168.192.3 - - [08/Jan/2019:11:38:11 +0000] "GET / HTTP/1.1" 304 -
api01_1 | 192.168.192.3 - - [08/Jan/2019:11:38:11 +0000] "GET / HTTP/1.1" 304 -
api02_1 | 192.168.192.3 - - [08/Jan/2019:11:38:11 +0000] "GET / HTTP/1.1" 304 -

Как видим, два сервера api обрабатывают запросы по очереди.

Теперь у нас есть инстанс HAProxy с очень простой конфигурацией балансировки нагрузки и имеется некоторое представление о том, как он устроен.

Добавляем лимит на количество запросов


Чтобы поставить лимит на количество запросов в балансировщик нагрузки, нужно изменить файл конфигурации в инстансе HAProxy. Следует убедиться, что контейнер loadbalancer использует конфигурационный файл haproxy-ratelimiter.cfg.

Просто измените Dockerfile, чтобы заместить файл конфигурации.

FROM haproxy:1.7
COPY haproxy-ratelimiter.cfg /usr/local/etc/haproxy/haproxy.cfg

Установка лимитов


Все настройки прописываются в конфигурационном файле haproxy-ratelimiter.cfg. Давайте внимательно его изучим.

defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms

frontend proxy
bind *:80

# ACL function declarations
acl is_abuse src_http_req_rate(Abuse) ge 10
acl inc_abuse_cnt src_inc_gpc0(Abuse) gt 0
acl abuse_cnt src_get_gpc0(Abuse) gt 0

# Rules
tcp-request connection track-sc0 src table Abuse
tcp-request connection reject if abuse_cnt
http-request deny if abuse_cnt
http-request deny if is_abuse inc_abuse_cnt

use_backend api
backend api
balance roundrobin

server api01 api01:80
server api02 api02:80

backend Abuse
stick-table type ip size 100K expire 30m store gpc0,http_req_rate(10s)

HAProxy предлагает набор низкоуровневых примитивов, которые обеспечивают большую гибкость и подходят для различных вариантов использования. Его счетчики часто напоминают мне накапливающий регистр (сумматор) в CPU. Они хранят промежуточные результаты, принимают на вход различную семантику, но в итоге — это просто цифры. Чтобы хорошо во всем разобраться, есть смысл начать с самого конца конфигурационного файла.

Таблица Abuse


backend Abuse
stick-table type ip size 100K expire 30m store gpc0,http_req_rate(10s)


Здесь мы устанавливаем фиктивный бэкенд под названием Abuse («злоупотребления»). Фиктивный, потому что он используется только для таблицы stick-table, к которой остальная конфигурация может ссылаться по имени Abuse. Stick-table — это таблица, хранящаяся в памяти процесса, где для каждой записи можно определить время жизни.

У нашей таблицы следующие характеристики:

  • type ip: запросы сохраняются в таблице по IP-адресу в качестве ключа. Таким образом, запросы с одного и того же IP-адреса будут ссылаться на одну и ту же запись. По сути, это означает, что мы отслеживаем IP-адреса и связанные с ними данные.
  • size 100K: таблица содержит максимум 100 тыс. записей.
  • expire 30m: срок хранения записей составляет 30 минут бездействия.
  • store gpc0,http_req_rate(10s): с записями хранится счетчик gpc0 и количество запросов IP-адреса за последние 10 секунд. С помощью gpc0 мы будем отслеживать, сколько раз IP-адрес замечен в злоупотреблениях. По сути, положительное значение счетчика означает, что IP-адрес уже помечен как подозрительный. Назовем этот счетчик abuse indicator.

В целом, таблица Abuse позволяет отслеживать, помечен ли IP-адрес как подозрительный, а также текущую частоту запросов с этого адреса. Поэтому у нас появляется история записей, а также информация в реальном времени.

Теперь перейдем в раздел frontend proxy и посмотрим, что там нового.

Функции и правила ACL


frontend proxy
bind *:80

# ACL function declarations
acl is_abuse src_http_req_rate(Abuse) ge 10
acl inc_abuse_cnt src_inc_gpc0(Abuse) gt 0
acl abuse_cnt src_get_gpc0(Abuse) gt 0

# Rules
tcp-request connection track-sc0 src table Abuse
tcp-request connection reject if abuse_cnt
http-request deny if abuse_cnt
http-request deny if is_abuse inc_abuse_cnt

use_backend api

Список управления доступом ACL (Access Control List) — это объявления функций, которые вызываются только при соответствии установленному правилу.

Давайте подробно рассмотрим все три записи ACL. Имейте в виду, что все они явно ссылаются на таблицу Abuse, которая в качестве ключа использует IP-адреса, поэтому каждая функция применяется к IP-адресу запроса:

  • acl is_abuse src_http_req_rate(Abuse) ge 10: функция is_abuse возвращает True, если текущая частота поступления запросов больше или равна десяти.
  • acl inc_abuse_cnt src_inc_gpc0(Abuse) gt 0: функция inc_abuse_cnt возвращает True, если инкрементируемое значение gpc0 больше нуля. Поскольку начальное значение gpc0 равно нулю, эта функция всегда возвращает True. Другими словами, она увеличивает значение abuse indicator, по сути, сигнализируя о злоупотреблениях с этого IP-адреса.
  • acl abuse_cnt src_get_gpc0(Abuse) gt 0: функция abuse_cnt возвращает True, если значение gpc0 больше нуля. Другими словами, он говорит, был ли этот IP-адрес уже замечен в злоупотреблениях.

Как упоминалось ранее, ACL — это просто декларации, то есть объявления функций. Они не применяются к входящим запросам, пока не срабатывает какое-то правило.

Имеет смысл взглянуть на правила, определенные в том же разделе frontend. Правила поочередно применяются к каждому входящему запросу — и запускают функции из списка ACL, которые мы только что определили.

Давайте посмотрим, что делает каждое правило:

  • tcp-request connection track-sc0 src table Abuse: добавляет запрос в таблицу Abuse. Поскольку в таблице ключом является IP-адрес, это правило пополняет список IP-адресов.
  • tcp-request connection reject if abuse_cnt: отклоняет новые TCP-соединения, если IP-адрес уже замечен в злоупотреблениях, то есть помечен как abuse. По сути, запрещает новые TCP-соединения с такими IP-адресами.
  • http-request deny if abuse_cnt: запрещает доступ, если IP-адрес уже замечен в злоупотреблениях. Это относится к уже установленным соединениям с IP-адресами, которые только что помечены как abuse.
  • http-request deny if is_abuse inc_abuse_cnt: запрещает доступ, если is_abuse и inc_abuse_cnt оба возвращают True. Другими словами, будет отказ в доступе, если с этого IP-адреса в настоящее время запросы поступают с высокой частотой, а затем этот IP-адрес вносится в черный список.

По существу, мы вводим два типа проверок: в реальном времени и по черному списку из истории запросов. Второе правило отвергает все новые TCP-соединения, если IP-адрес был замечен в злоупотреблениях. Третье правило запрещает обслуживание HTTP-запросов для IP-адреса из черного списка, независимо от текущей частоты запросов с этого адреса. Четвертое правило гарантирует, что HTTP-запросы с IP-адреса будут отклонены в тот самый момент, как только преодолен порог частоты запросов. Таким образом, второе правило в основном работает на новых TCP-соединениях, третье и четвертое — на уже установленных соединениях, причем первое — это историческая проверка, а второе — проверка в реальном времени.

Попробуем фильтр в деле!


Теперь можем снова собрать и запустить наши контейнеры.

$ sudo docker-compose down
$ sudo docker-compose build
$ sudo docker-compose up

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

Давайте направим наш браузер на http://localhost/. Если быстро обновить страничку с десяток раз, мы превысим порог в десять запросов за десятисекундный интервал — и наши запросы будут отклонены. Если мы продолжим обновлять страницу, новые запросы будут отклоняться сразу — еще до того, как установлено TCP-соединение.

Вопросы


Почему лимит составляет десять запросов на десять секунд?


Таблица Abuse определяет http_req_rate(10s), то есть частота запросов измеряется в окне в десять секунд. Функция is_abuse из ACL возвращает True при частоте запросов ?10 в течение указанного интервала. Таким образом, злоупотреблением считается частота запросов в десять и более запросов за десять секунд.

В этой статье для примера мы решили установить низкий лимит, чтобы проще было проверить работоспособность ограничителя.

В чем разница между правилами http-request и tcp-request connection?


Из документации:

http-request: оператор http-request определяет набор правил, которые применяются на сетевом уровне 7 [по модели OSI]

Из документации:
tcp-request connection: выполнение действия над входящим соединением в зависимости от условия на сетевом уровне 4

Зачем отбрасывать HTTP-запросы, если мы в любом случае полностью отбрасываем TCP-запросы?


Представьте, что HTTP-запросы на сервер отправляют несколько TCP-соединений с одного IP-адреса. Частота HTTP-запросов быстро превысит пороговые значения. Именно тогда вступает в действие четвертое правило, которое отбрасывает запросы и вносит IP-адрес в черный список.

Теперь вполне возможно, что HTTP-соединения с того же IP-адреса остаются открытыми (см. постоянное HTTP-соединение), а частота HTTP-запросов упала ниже порогового значения. Третье правило гарантирует продолжение блокировки HTTP-запросов, поскольку abuse indicator срабатывает на этот IP.

А теперь предположим, что через несколько минут тот же IP пытается установить TCP-соединения. Они отбрасываются немедленно, так как действует второе правило: оно видит помеченный IP-адрес — и сразу отбрасывает соединения.

Вывод


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

Что еще почитать:

  1. Как реализуется отказоустойчивая архитектура в платформе Mail.ru Cloud Solutions.
  2. Лучшие 10 хитростей и советов по Kubernetes.
  3. Наш канал в Телеграм о цифровой трансформации.