Comment gérer le trafic en rafale : algorithmes du seau percé et du seau à jetons

API7.ai

January 5, 2023

OpenResty (NGINX + Lua)

Dans les articles précédents, nous avons appris l'optimisation du code et la conception du cache, qui sont étroitement liées aux performances globales de l'application et méritent notre attention. Cependant, dans les scénarios commerciaux réels, nous devons également prendre en compte l'impact du trafic en rafale sur les performances. Le trafic en rafale ici peut être normal, comme le trafic provenant de nouvelles importantes, de promotions, etc., ou anormal, comme les attaques DDoS.

OpenResty est maintenant principalement utilisé comme couche d'accès pour les applications Web telles que les WAF (Web Application Firewall) et les API gateways, qui doivent gérer le trafic en rafale normal et anormal mentionné précédemment. Après tout, si vous ne pouvez pas gérer le trafic en rafale, les services back-end peuvent facilement être submergés, et l'entreprise ne répondra pas de manière appropriée. Aujourd'hui, nous allons donc examiner les moyens de gérer le trafic en rafale.

Contrôle du trafic

Le contrôle du trafic est une fonctionnalité essentielle pour les WAF et les API gateways. Il garantit que les services en amont peuvent fonctionner correctement en canalisant et en contrôlant le trafic entrant grâce à certains algorithmes, maintenant ainsi le système en bonne santé.

Étant donné que la capacité de traitement du back-end est limitée, nous devons prendre en compte plusieurs aspects tels que le coût, l'expérience utilisateur et la stabilité du système. Quel que soit l'algorithme utilisé, il entraînera inévitablement un ralentissement ou même un rejet des requêtes des utilisateurs normaux, sacrifiant ainsi une partie de l'expérience utilisateur. Par conséquent, nous devons contrôler le trafic tout en équilibrant la stabilité de l'entreprise et l'expérience utilisateur.

En fait, il existe de nombreux cas de "contrôle du trafic" dans la vie réelle. Par exemple, pendant les voyages du Nouvel An chinois, les flux de personnes s'accumulent dans les stations de métro, les gares, les aéroports et autres hubs de transport, car la capacité de traitement de ces véhicules est limitée. Par conséquent, les passagers doivent faire la queue et entrer dans la station par lots pour assurer leur sécurité et le bon fonctionnement du trafic.

Cela affecte naturellement l'expérience des passagers, mais dans l'ensemble, cela garantit le fonctionnement efficace et sûr du système. Par exemple, s'il n'y avait pas de file d'attente et de traitement par lots, mais que tout le monde était autorisé à entrer dans la station en masse, le résultat serait que l'ensemble du système serait submergé.

Revenons à la technologie, par exemple, supposons qu'un service en amont soit conçu pour gérer 10 000 requêtes par minute. Aux heures de pointe, s'il n'y a pas de contrôle de flux à l'entrée et que la pile de tâches atteint 20 000 par minute, les performances de traitement de ce service en amont se dégraderont peut-être à seulement 5 000 requêtes par minute et continueront à se détériorer, conduisant éventuellement à une indisponibilité du service. Ce n'est pas le résultat que nous souhaitons voir.

Les algorithmes de contrôle de trafic couramment utilisés pour faire face à ce type de trafic en rafale sont l'algorithme du seau percé et l'algorithme du seau à jetons.

Algorithme du seau percé

Commençons par examiner l'algorithme du seau percé, qui vise à maintenir un taux de requêtes constant et à lisser les pics de trafic. Mais comment est-ce réalisé ? Tout d'abord, regardons l'abstraction conceptuelle suivante tirée de l'introduction de Wikipédia à l'algorithme du seau percé.

algorithme du seau percé

Nous pouvons imaginer le trafic du client comme de l'eau s'écoulant d'un tuyau avec un débit incertain, parfois rapide, parfois lent. Le module de traitement du trafic externe, qui est le seau qui reçoit l'eau, a un trou au fond pour la fuite. C'est l'origine du nom de l'algorithme du seau percé, qui présente les avantages suivants :

Premièrement, que le flux entrant dans le seau soit un filet ou une inondation monstrueuse, il est garanti que le taux d'eau sortant du seau est constant. Ce trafic régulier est convivial pour les services en amont, c'est ce qu'on appelle le façonnage du trafic.

Deuxièmement, le seau lui-même a un certain volume et peut accumuler une certaine quantité d'eau. Cela équivaut à des requêtes client qui peuvent être mises en file d'attente si elles ne peuvent pas être traitées immédiatement.

Troisièmement, l'eau qui dépasse le volume du seau ne sera pas acceptée par le seau mais s'écoulera. La métaphore correspondante ici est que s'il y a trop de requêtes client qui dépassent la longueur de la file d'attente, un message d'échec sera directement renvoyé au client. À ce moment-là, le côté serveur ne peut pas gérer autant de requêtes et la mise en file d'attente devient inutile.

Alors, comment cet algorithme devrait-il être implémenté ? Prenons l'exemple de la bibliothèque resty.limit.req qui accompagne OpenResty. C'est un module de limitation de taux implémenté par l'algorithme du seau percé. Nous en parlerons plus en détail dans l'article suivant. Aujourd'hui, nous allons commencer par un bref aperçu des lignes de code suivantes, qui sont les clés :

local elapsed = now - tonumber(rec.last)
excess = max(tonumber(rec.excess) - rate * abs(elapsed) / 1000 + 1000,0)
if excess > self.burst then
    return nil, "rejected"
end
-- return the delay in seconds, as well as excess
return excess / rate, excess / 1000

Lisons brièvement ces lignes de code. Où elapsed est le nombre de millisecondes entre la requête actuelle et la dernière, et rate est le taux que nous avons défini par seconde. Comme la plus petite unité de rate est 0,001 s/r, le code implémenté ci-dessus doit être multiplié par 1000 pour le calculer.

excess indique le nombre de requêtes encore dans la file d'attente, 0 signifie que le seau est vide, aucune requête dans la file d'attente, et burst fait référence au volume du seau entier. Si excess est supérieur à burst, cela signifie que le seau est plein, et le trafic entrant sera directement rejeté ; si excess est supérieur à 0 et inférieur à burst, il entrera dans la file d'attente pour attendre le traitement, et le excess/rate renvoyé ici est le temps d'attente.

De cette façon, nous pouvons contrôler la longueur de la file d'attente du trafic en rafale en ajustant la taille de burst, tandis que la capacité de traitement du service back-end reste inchangée. Mais, bien sûr, cela dépend de votre scénario commercial que vous informiez l'utilisateur qu'il y a trop de requêtes et qu'il doit réessayer plus tard, ou que vous laissiez l'utilisateur attendre plus longtemps.

Algorithme du seau à jetons

L'algorithme du seau à jetons et l'algorithme du seau percé ont le même objectif, à savoir garantir que les services back-end ne soient pas submergés par des pics de trafic, bien que les deux ne soient pas implémentés de la même manière.

L'algorithme du seau percé utilise l'adresse IP du point final pour effectuer les bases du contrôle de trafic et de taux. De cette façon, le taux de sortie de l'algorithme du seau percé pour chaque client est fixe. Cependant, cela pose un problème :

Supposons que les requêtes de l'utilisateur A soient fréquentes et que les requêtes des autres utilisateurs soient peu fréquentes. Dans ce cas, l'algorithme du seau percé ralentira ou rejettera certaines des requêtes de A, même si le service peut les gérer à ce moment-là, même si la pression globale du service n'est pas très élevée.

C'est là que le seau à jetons devient utile.

Alors que l'algorithme du seau percé se concentre sur le lissage du trafic, le seau à jetons permet à des pics de trafic d'entrer dans le service back-end. Le principe du seau à jetons est de mettre des jetons dans le seau à un taux fixe et de continuer à les mettre tant que le seau n'est pas plein. De cette façon, toutes les requêtes provenant du point final doivent d'abord aller au seau à jetons pour obtenir un jeton avant que le back-end ne puisse les traiter ; s'il n'y a pas de jeton dans le seau, la requête sera rejetée.

Cependant, OpenResty n'implémente pas de seaux à jetons pour limiter le trafic et le taux dans sa bibliothèque. Voici donc une brève introduction au module de limitation de taux basé sur le seau à jetons lua-resty-limit-rate, qui est open-source par UPYUN, comme exemple :

local limit_rate = require "resty.limit.rate"

-- global 20r/s 6000r/5m
local lim_global = limit_rate.new("my_limit_rate_store", 100, 6000, 2)

-- single 2r/s 600r/5m
local lim_single = limit_rate.new("my_limit_rate_store", 500, 600, 1)

local t0, err = lim_global:take_available("__global__", 1)
local t1, err = lim_single:take_available(ngx.var.arg_userid, 1)

if t0 == 1 then
    return -- global bucket is not hungry
else
    if t1 == 1 then
        return -- single bucket is not hungry
    else
        return ngx.exit(503)
    end
end

Dans ce code, nous avons configuré deux seaux à jetons : un seau à jetons global, et un seau à jetons avec ngx.var.arg_userid comme key, divisé par utilisateur. Il y a une combinaison des deux seaux à jetons, qui présente l'avantage principal suivant :

  • Ne pas avoir à déterminer le seau à jetons de l'utilisateur s'il reste des jetons dans le seau à jetons global, et servir autant de requêtes en rafale des utilisateurs que possible si le service back-end peut fonctionner correctement.
  • En l'absence d'un seau à jetons global, les requêtes ne peuvent pas être rejetées de manière indiscriminée, il est donc nécessaire de déterminer le seau à jetons des utilisateurs individuels et de rejeter les requêtes des utilisateurs avec plus de requêtes en rafale. De cette façon, il est assuré que les requêtes des autres utilisateurs ne sont pas affectées.

Évidemment, les seaux à jetons sont plus résilients que les seaux percés, permettant des situations où des pics de trafic sont transmis aux services back-end. Mais, bien sûr, ils ont tous deux leurs avantages et inconvénients, et vous pouvez choisir de les utiliser selon votre situation.

Module de limitation de taux de NGINX

Avec ces deux algorithmes de côté, regardons enfin comment implémenter une limitation de taux dans NGINX. Dans NGINX, le module limit_req est le module de limitation de taux le plus couramment utilisé, et voici une configuration simple :

limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

server {
    location /search/ {
        limit_req zone=one burst=5;
    }
}

Ce code prend l'adresse IP du client comme key, demande un espace mémoire de 10M appelé one, et limite le taux à 1 requête par seconde.

Dans l'emplacement du serveur, la règle de limitation de taux one est également référencée, et le brust est défini à 5. Si le taux dépasse 1r/s, 5 requêtes peuvent être mises en file d'attente simultanément, offrant une certaine zone tampon. Si brust n'est pas défini, les requêtes qui dépassent le taux seront directement rejetées.

Ce module NGINX est basé sur un seau percé, donc il est essentiellement le même que resty.limit.req dans OpenResty, que nous avons décrit ci-dessus.

Résumé

Le plus gros problème de la limitation de taux dans NGINX est qu'elle ne peut pas être modifiée dynamiquement. Après tout, vous devez redémarrer le fichier de configuration après l'avoir modifié pour qu'il prenne effet, ce qui est inacceptable dans un environnement en évolution rapide. Par conséquent, l'article suivant examinera comment implémenter des limites de trafic et de taux dynamiquement dans OpenResty.

Enfin, posons-nous une question. Du point de vue des WAF et des API gateways, existe-t-il une meilleure façon d'identifier ce qui sont des requêtes utilisateur normales et ce qui sont des requêtes malveillantes ? Parce que, pour le trafic en rafale des utilisateurs normaux, nous pouvons rapidement augmenter les services back-end pour augmenter la capacité du service, tandis que pour les requêtes malveillantes, il est préférable de les rejeter directement au niveau de la couche d'accès.

Vous êtes invités à partager cet article avec vos collègues et amis pour apprendre et progresser ensemble.