Ограничитель скорости

Дата обновления перевода 2023-01-16

Ограничитель скорости

"Ограничитель скорости" контролирует то, как часто некоторое событие (например, HTTP-запрос попытки входа) может происходить. Ограничение скорости часто используется в качестве меры безопасности для защиты сервисов от чрезмерного использования (преднамеренной или нет) и для поддержания их доступности. Также полезно контролировать ваши внутренние или внешние процессы (например, органичивать количество одновременно обрабатываемых сообщений).

Symfony использует эти ограничители скорости во встроенных функциях вроде "регулирования входа в систему", которая ограничивает количество неудачных попыток входа пользователя в заданный период времени, но вы можете использовать их и в собственных функциях.

Caution

По определению, ограничители скорости Symfony требуют запуска Symfony в PHP-процессе. Это делает их бесполезными для защиты от DoS-атак. Такие меры защиты должны потреблять как можно меньше ресурсов. Рассмотрите использование Apache mod_ratelimit, органичение скорости NGINX или прокси (вроде AWS или Cloudflare) для предотвращения чрезмерной нагрузки на ваш сервер.

Политика ограничения скорости

Ограничитель скорости Symfony реализует несколько из наиболее распространенных политик для внедрения ограничений скорости: фиксированное окно, скользящее окно, ведро токенов.

Ограничитель скорости фиксированного окна

Это самая простая техника, которая основывается на установке ограничения на заданный период времени (например, 5000 запросов в час или 3 попытки входа в систему каждые 15 минут).

В графике ниже, ограничение установлено на "5 токенов в час". Каждое окно запускается с первой попытки (т.е. 10:15, 11:30 и 12:30). Как только попыток в окне будет 5 (голубые квадраты), все другие будут отклонены (красные квадраты).

Главный недостаток - неравномерное распределение использования ресурсов во времени, это может чрезмерно нагрузить сервер на границах окон. В предыдущем примере, в промежутке между 11:00 и 12:00 было принято 6 запросов.

Это более значимо с большими ограничениями. Например, в случае 5000 запросов в час, пользователь может сделать 4999 запросов в последнюю минуту часа, и еще 5000 запросов в течение первой минуты следующего часа, что приведет к общему количеству 9999 запросов в течение двух минут и потенциально перегрузит сервер. Эти периоды чрезмерного использования называются "всплески".

Ограничитель скорости скользящего окна

Алгоритм скользящего окна является альтернативой алгоритму фиксированного окна, который призван уменьшить количество всплесков. Вот тот же пример, что и выше, но с использованием часового временного окня, скользящего по временной шкале:

Как вы видите, это удаляет границы окна, и предотвращает 6ой запрос в 11:45.

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

Например: ограничение составляет 5000 запросов в час; пользователь сделал 4000 запросов в предыдущий час, и 500 запросов в текущему часу. По истечению 15 минут текущего часа (25% окна) количество попыток будет считаться так: 75% * 4,000 + 500 = 3,500. В текущий момент времени пользователь может сделать еще 1500 запросов.

Математически, чем ближе последнее окно, тем большее влияние будет иметь количество попыток последнего окна на текущее ограничение. Таким образом гарантируется, что пользователь может сделать 5000 запросов в час, но только, если они равномерно распределены.

Ограничитель скорости ведра токенов

Эта техника реализует алгоритм текущего ведра, который определяет постоянно обновляемый предел использования ресурсов. Грубо говоря, он работает так:

  • Создается ведро с изначальным набором токенов;
  • Новый токен добавляется в ведро с предопределенной частотой (например, каждую секунду);
  • Позволяя событию потреблять один или более токенов;
  • Если в ведре все еще есть токены, событие происходит; если нет - отклоняется;
  • Если ведро достигло полной нагрузки, новые токены сбрасываются.

График ниже отображает ведро токенов 4 размера, наполненного скоростью в 1 токен за 15 минут:

Этот алгоритм обрабатывает более сложный выдержанный алгоритм для контроля всплесков. Например, он может позволить пользователю попробовать ввести пароль 5 раз, а затем - только раз в 15 минут (если только пользователь не подождет 75 минут и не получит еще 5 попыток).

Установка

Перед использованием ограничителя скорости впервые, выполните следующую команду, чтобы установить ассоциированный компонент Symfony в вашем приложении:

1
$ composer require symfony/rate-limiter

Конфигурация

Следующий пример создает два разных ограничителя скорости для API-сервиса, чтобы внедрить разные уровни сервиса (платные и бесплатные):

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        anonymous_api:
            # используйте 'sliding_window', если предпочитаете эту политику
            policy: 'fixed_window'
            limit: 100
            interval: '60 minutes'
        authenticated_api:
            policy: 'token_bucket'
            limit: 5000
            rate: { interval: '15 minutes', amount: 500 }

Note

Значение опции interval должно быть числом с последующим указанием единиц, принятых относительными форматами PHP-данных (например, 3 секунды, 10 часов, 1 день, и т.д.)

В ограничителе anonymous_api, после создания первого HTTP-запроса, вы можете сделать до 100 запросов в последующие 60 минут. После этого времени, счетчик обнуляется, и у вас есть еще 100 запросов на следующие 60 минут.

В ограничителе authenticated_api, после создания первого HTTP-запроса, вы можете сделать до 5000 запросов в целом, и это число растет со скоростью +500 запросов каждые 15 минут. Если вы не сделаете такое количество запросов, неиспользованные не суммируются (опция limit предотвращает возможность этого числа быть больше, чем 5,000).

Ограничение скорости в действии

После установки и конфигурации ограничителя скорости, внедрите его в любой сервис или контроллер и вызовите метод consume(), чтобы попробовать потребить заданное количество токенов. Например, этот контроллер использует предыдущий ограничитель скорости для контроля количества запросов к API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;

class ApiController extends AbstractController
{
    // если вы используете автомонтирование сервиса, имя переменной должно быть:
    // "имя ограничителя скорости" (в camelCase) + суффикс "Limiter"
    public function index(Request $request, RateLimiterFactory $anonymousApiLimiter)
    {
        // создайте ограничитель, основываясь на уникальном идентификаторе клиента
        // (например, IP-адресе клиента, имени пользователя/адресе почты, ключе API, и т.д.)
        $limiter = $anonymousApiLimiter->create($request->getClientIp());

        // аргумент consume() - количество токенов для потребления
        // и возвращает объект типа Limit
        if (false === $limiter->consume(1)->isAccepted()) {
            throw new TooManyRequestsHttpException();
        }

        // вы также можете использовать метод ensureAccepted() - который вызывает
        // RateLimitExceededException, если ограничение было достигнуто
        // $limiter->consume(1)->ensureAccepted();

        // ...
    }

    // ...
}

Note

В реальном приложениии, вместо проверки ограничителя скорости во всех методах контроллера API, создайте слушателя или подписчика событий для события kernel.request и проверяйте ограничитель скорости сразу для всех запросов.

Подождите, пока токен будет доступен

Вместо создания запроса или процесса, когда ограничение уже было достигнуто, вы можете захотеть подождать новый доступный токен. Это можно сделать, используя метод reserve():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;

class ApiController extends AbstractController
{
    public function registerUser(Request $request, RateLimiterFactory $authenticatedApiLimiter)
    {
        $apiKey = $request->headers->get('apikey');
        $limiter = $authenticatedApiLimiter->create($apiKey);

        // блокирует приложение до возможности потребления заданного количества токенов
        $limiter->reserve(1)->wait();

        // необязательно, передайте максимальное время ожидания (в секундах), MaxWaitDurationExceededException
        // вызывается, если процесс должен ждать дольше. Например, чтобы ждать максимум 20 секунд:
        //$limiter->reserve(1, 20)->wait();

        // ...
    }

    // ...
}

Метод reserve() может зарезервировать токен в будущем. Используйте этот метод только если вы планируете ждать, иначе вы заблокируете другие процессы, резервируя неиспользованные токены.

Note

Не все стратегии допускают резервирования токенов в будущем. Такие стратегии могут вызывать ReserveNotSupportedException при вызове reserve().

В таких случаях, вы можете использовать consume() вместе с wait(), но при этом нет гарантии, что токен будет доступен после ожидания:

1
2
3
4
5
// ...
do {
    $limit = $limiter->consume(1);
    $limit->wait();
} while (!$limit->isAccepted());

Демонстрация статуса ограничителя скорости

При использовании ограничителя скорости в API, часто добавляются некоторые стандартные HTTP-заголовки для демонстрации статуса ограничения (например, оставшиеся токены, когда будут доступны новые токены, и т.д.)

Используйте объект RateLimit, возвращенный методом consume() (также доступен через метод getRateLimit() объекта Reservation, возвращенного методом reserve()), чтобы получить значение этих HTTP-заголовков:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;

class ApiController extends AbstractController
{
    public function index(Request $request, RateLimiterFactory $anonymousApiLimiter)
    {
        $limiter = $anonymousApiLimiter->create($request->getClientIp());
        $limit = $limiter->consume();
        $headers = [
            'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
            'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp(),
            'X-RateLimit-Limit' => $limit->getLimit(),
        ];

        if (false === $limit->isAccepted()) {
            return new Response(null, Response::HTTP_TOO_MANY_REQUESTS, $headers);
        }

        // ...

        $response = new Response('...');
        $response->headers->add($headers);

        return $response;
    }
}

Хранение состояния ограничителя скорости

Все политики органичителей скорости требуют хранения их состояния (например, сколько попыток уже было сделано в текущем временном окне). По умолчанию, все ограничители используют пул кеша cache.rate_limiter, созданный компонентом Cache.

Используйте опцию cache_pool, чтобы переопределить кеш, используемый конкретным ограничителем (или даже создайте новый пул кеша для него):

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        anonymous_api:
            # ...

            # использовать пул кеша "cache.anonymous_rate_limiter"
            cache_pool: 'cache.anonymous_rate_limiter'

Note

Вместо использования компонента Cache, вы также можете реализовать пользовательское хранилище. Создайте PHP-класс, который реализует StorageInterface, и используйте настройку storage_service каждого ограничителя в сервисном ID этого класса.

Использование блокировок для предотвращения состояний гонки

Состояние гонки может произойти, когда один ограничитель используется несколькими одновременными запросами (например, три сервера компании обращаются к вашему API в одно время). Ограничители скорости используют блокировки, чтобы защитить операции от таких состояний гонки.

По умолчанию, Symfony использует глобальную блокировку, сконфигурированную framework.lock, но вы можете использовать конкретную именованную блокировку через опцию lock_factory (или не использовать их вообще):

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        anonymous_api:
            # ...

            # использовать "lock.rate_limiter.factory" для этого ограничителя
            lock_factory: 'lock.rate_limiter.factory'

            # или не использовать механизм блокировки
            lock_factory: null