Обмежувач швидкості

Дата оновлення перекладу 2022-12-15

Обмежувач швидкості

"Обмежувач швидкості" контролює те, як часто якась подія (наприклад, 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