Serveur Web : tests de charge en Python avec Locust

I. Présentation

Dans cet article, nous allons apprendre à utiliser Locust, un outil de test de charge pour les services et serveurs web. L'idée du test de charge est d'évaluer la réaction et le comportement d'un serveur ou service web, d'un pare-feu, reverse-proxy, load-balancer ou même d'une solution de cache face à la navigation de plusieurs utilisateurs (10, 100, 1000) en même temps. Nous pourrons par exemple découvrir que lorsque 100 utilisateurs accèdent à la même page au même moment, notre serveur web montre des signes de faiblesse (temps de réponse allongé).

Locust se veut simple d'utilisation, sans interface graphique complexe. Il se paramètre et s'utilise majoritairement via du code Python. Ce dernier sert en effet à définir le comportement des utilisateurs que nous voulons simuler et à lancer le test de charge. Une interface web permet ensuite d'ajuster les derniers paramètres puis de suivre le test de charge et surtout d'obtenir des rendus sur les résultats obtenus.

II. Installation de Locust

Nous pouvons utiliser le gestionnaire de module Python pip pour installer locust (Python3) :

$ pip -V
pip 22.2.1 from /home/itc/.local/lib/python3.10/site-packages/pip (python 3.10)

$ pip install locust

Nous pourrons ensuite importer locust dans nos scripts Python :

$ python3
Python 3.10.5 [GCC 11.3.0] on linux
>>> from locust import *

Nous voilà prêts à réaliser notre premier script de test de charge avec Locust !

III. Premier test de charge d'un service web

Pour commencer, faisons simple. Je vais ouvrir un mini serveur web HTTP avec Python dans un terminal :

python3 -m http.server

Cela me permettra d'avoir une cible pour mon script Locust, mais aussi de rapidement voir les journaux HTTP et constater si mon script se comporte comme je le souhaite. Créons un script simple, que je nomme /tmp/loc.py :

from locust import HttpUser, between, task

class WebsiteUser(HttpUser):
wait_time = between(3, 6)

def on_start(self):
self.client.get("/")

@task
def task1(self):
self.client.get("/")

@task
def task2(self):
self.client.get("/operation.php")

Première chose, pas besoin de spécifier l'URL/domaine cible dans le script, ce paramètre est géré plus tard via l'interface web. Nous allons à présent détailler notre premier script :

from locust import HttpUser, between, task

Importation du module Python locust est des fonctions utiles.

class WebsiteUser(HttpUser):

Création d'une classe WebsiteUser provenant du module Locust. Une instance de cette classe représente un utilisateur, qui pourra réaliser plusieurs requêtes/actions au cours du test.

wait_time = between(3, 6)

Délai d'attente de l'utilisateur entre chaque action, la valeur est ici sélectionnée aléatoirement entre 3 et 6 secondes. Au cours du test, nos utilisateurs seront créés tous en même temps ou au fur et à mesure et réaliseront des actions en boucle, avec un délai d'attente aléatoire entre chaque action.

def on_start(self):
self.client.get("/")

Définition du comportement de l'utilisateur lors de sa création, il ne réalisera cette action (ici, se rendre sur la racine de la cible web) qu'une fois. 

@task
def task1(self):
self.client.get("/")

@task
def task2(self):
self.client.get("/operation.php")

Création de deux tâches, qui, elles, seront exécutées en boucle :

  • aller sur la racine du site web (requête GET)
                  OU
  • aller sur la page /operation.php (requête GET)

Ces tâches sont identifiées par le décorateur @task. Il faut bien noter qu'à la suite de la tâche réalisée, le script observe un temps d'attente (paramétré entre 3 et 6 secondes ici), puis sélectionne une autre tâche et la réalise.

Maintenant que nous avons un script, assez simple, il nous suffit de le lancer. Si vous avez installé Locust via pip, il y a de bonnes chances pour que le binaire locust se situe dans votre dossier bin/ personnel (~/.local/bin/locust). Si vous l'avez installé via apt/yum, il doit être dans le dossier des binaires standard (/usr/bin) donc déjà dans votre PATH :).

$ /~/.local/bin/locust -f /tmp/loc.py
[2022-10-01 15:29:52,466] itc-test/INFO/locust.main: Starting web interface at http://0.0.0.0:8089 (accepting connections from all network interfaces)
[2022-10-01 15:29:52,481] itc-test/INFO/locust.main: Starting Locust 2.12.1

Nous pouvons à présent nous rendre sur l'interface web http://0.0.0.0:8089 :

Locust

Ici, on peut paramétrer le nombre total d'utilisateurs que nous souhaitons simuler et le nombre de créations d'utilisateurs par seconde. Par exemple, si je souhaite simuler 200 utilisateurs avec une création de 20 par seconde, j'aurai tous mes utilisateurs au bout de 10 secondes et chacun d'entre eux feront des boucles avec un délai d'attente de 3 a 6 secondes, puis une requête sur / ou /operation.php, tel que défini dans mon script.

Dans Advanced Options, le paramètre Run time permet de paramétrer une durée du test, utile pour faire des tests d'une durée équivalente entre plusieurs sites ou pour comparer des paramétrages différents côté service web.

Nous pouvons ensuite cliquer sur Start swarming, un tableau apparaîtra et détaillera le nombre de requêtes faites par point d'entrée, le nombre de fails, et différentes données sur les tailles et durée de réponse obtenue (à noter que RPS signifie request per second) :

Serveur Web - Test de charge Locust

Pour avoir des résultats parlant et contourner les limitations de capacité de mon environnement (voir chapitre "Limitations") j'ai donc monté une VM Linux avec 200 Mo de RAM, qui aura du mal à répondre à de multiples requêtes sur un réseau local d'hyperviseur. Également, j'ai créé sur ce service web une page index.html standard, et une page operation.php qui fait des opérations de chiffrement. Cela afin de simuler l'activité d'un serveur qui effectue des opérations dans une base de données afin de générer une réponse pour chaque requête. Je vous partage ici mon script pour information :

<?php

$s = "Welcome";

$ciphering = "AES-128-CTR";
$iv_length = openssl_cipher_iv_length($ciphering);
$options = 0;
$encryption_iv = '1234567891011121';
$round = rand(30,50);
for ($i =0; $i < $round; $i++)
{
$encryption_key = rand(1000000000,10000000000000);
$s = openssl_encrypt($s, $ciphering, $encryption_key, $options, $encryption_iv);
}
echo "s : $s\n";
?>

III. Analyse des résultats

Pendant et à la fin de l'exécution d'un test de charge, il est possible de visualiser les données dans le tableau exposé plus haut, mais également dans trois graphiques (menu Charts).

Le dernier graphique montre la montée en charge des tests avec le nombre d'utilisateurs simulés en même temps (ici 5000 utilisateurs avec une création des utilisateurs par lot 50/seconde).

Le premier graphique indique en vert le nombre de requêtes par seconde et en rouge, nombre d'échecs par seconde (erreur HTTP/500 ou timeout). Le graphique du milieu décrit le temps de réponse médian (ligne verte) et pour le 95eme centile (ligne jaune). C'est le graphique le plus intéressant, car sans causer d'erreur, une surcharge du serveur web va entraîner des temps de réponse de plus en plus longs. Ce que l'on observe dans mon graphique ci-dessus.

Pour rappel, la moyenne est la moyenne arithmétique d'une série de chiffres. la médiane est une valeur numérique qui sépare la moitié supérieure de la moitié inférieure d'un ensemble (50% des requêtes obtiennent un temps de réponse supérieur au temps médian, 50% un temps de réponse inférieur

En statistique le quatre-vingt-quinzième centile est la valeur telle que 95 % des valeurs mesurées sont en dessous et 5 % sont au-dessus.

Le menu Failures permet d'avoir une idée du nombre de message d'erreur reçu et de la réponse du serveur :

Ici, on peut identifier des échecs de réponse côté serveur (pas de réponse/timeout).

IV. Scripting "avancé" avec locust

Nous avons fait un script assez simple contenant deux tâches et des requêtes GET. En parcourant la documentation Locust. Vous remarquez qu'il est possible de faire des choses plus complexes.

A. Prioriser le comportement de l'utilisateur :

Si l'on souhaite orienter le comportement de nos utilisateurs, il est possible de mettre un "poids" assigné à chaque tâche :

class WebsiteUser(HttpUser):
wait_time = between(3, 6)

def on_start(self):
self.client.get("/")

@task(1)
def task1(self):
self.client.get("/")

@task(3)
def task2(self):
self.client.get("/operation.php")

Avec ces valeurs (@task(1), @task(3)), les utilisateurs auront trois fois plus de chance de sélectionner la tâche 2 plutôt que la tâche 1, ce qui permet de simuler une tendance des utilisateurs à aller plus sur une page que sur une autre.

B. Envoyer des données POST

Si vous souhaitez simuler les comportements d'utilisateurs envoyant des données (par exemple : authentification), cela est également possible

@task
def task3(self):
self.client.post("/login", json={"username":"foo", "password":"bar"})

C. Répartition de la charge de travail

Il est également possible de répartir la charge de travail lors du test à l'aide de nœuds esclaves. Il faut pour cela lancer une instance en mode master :

$ ~/.local/bin/locust -f loc.py --master
[2022-10-01 10:27:47,041] itc-test/INFO/locust.main: Starting web interface at http://0.0.0.0:8089 (accepting connections from all network interfaces)
[2022-10-01 10:27:47,056] itc-test/INFO/locust.main: Starting Locust 2.12.1

Les autres instances doivent être démarrées en mode worker et indiquer l'adresse IP du master:

$ /~/.local/bin/locust -f loc.py --worker --master-host=192.168.56.3

Lorsqu'un worker se connecte au master, le message suivant apparaît au niveau du master :

[2022-10-01 10:29:22,021] itc-test/INFO/locust.runners: Worker debian_77b92f24a7a54ac09ceeae99588e984b (index 0) reported as ready. 1 workers connected.

Attention cependant, dans ce cas, le master n'effectuera plus de requête lui-même. Il vous faudra donc au minimum deux workers pour avoir un gain de performance.

D. Pour aller plus loin

Je vous invite à consulter la documentation de Locust pour découvrir toutes ses possibilités : https://docs.locust.io/. Il est par exemple possible de lire le contenu des réponses puis d'afficher un message spécifique quand les résultats (menu Failures) afin d'avoir un tri plus personnalisé des messages d'erreurs et réponses du serveur.

Sans avoir testé toutes ses fonctionnalités, l'outil me paraît facilement personnalisable tant sur la partie test (comportement des utilisateurs) que sur la présentation des résultats.

V. Limitations

A. Capacité système

Il est important de noter que des limitations peuvent exister dans vos environnements de test et empêcher Locust d'atteindre les charges demandées. Votre système n'est à lui seul pas en capacité de lutter contre un serveur digne de ce nom (à moins de trouver une vulnérabilité permettant de décupler votre force de frappe, type amplification). Ce dernier comporte des limitations comme le nombre de fichiers pouvant être ouverts en même temps, ou le nombre de connexions en attente pouvant être active en même temps. Par exemple, voici ce que j'obtiens si je souhaite simuler 10 0000 utilisateurs avec Locust sur une machine virtuelle (4 Go de RAM) :

À première vue, mon test a permis de faire tomber le serveur web, le nombre de "fail" ne fait qu'augmenter ! Cependant, si je regarde le menu Failures pour avoir les détails des messages d'erreurs obtenus, j'obtiens cela :


C'est mon système qui limite les tests (OSError - Too many open files), il ne parvient plus à ouvrir de fichier (il crée certainement un fichier socket par connexion ou utiliser un fichier pour stocker le résultat de chaque requête de façon temporaire). Mon système ici n'est tout simplement pas calibré pour simuler autant de connexions. D'ailleurs, Locust nous prévient rapidement de cette limitation :

# /home/itc/.local/bin/locust -f /home/itc/Documents/loc.py 
WARNING/locust.main: System open file limit '1024' is below minimum setting '10000'.
It's not high enough for load testing, and the OS didn't allow locust to increase it by itself.
See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-number-of-open-files-limit for more info.

En effet, vous pouvez toujours essayer de créer 2000 requêtes par seconde, si votre système ou l'infrastructure réseau n'est pas fait pour supporter une telle charge, Locust ne parviendra au maximum des capacités paramétrées et cela faussera vos tests.

B. Capacité réseau

Les limitations de vos tests peuvent également provenir de votre environnement réseau. Par exemple, j'ai fait mes premiers tests depuis une VM VirtualBox dont l'interface était paramétrée en mode NAT, ce qui impacte très durement les capacités réseau (Locust ne parvenait qu'à faire 9 requêtes par seconde). Le passage en bridge m'a permis de passer à 60 requêtes par seconde. Le tout pour un site situé sur Internet, chemin par lequel d'autres éléments peuvent freiner mon test (mon routeur, mon architecture réseau, etc.).

Également, si votre poste est en Wifi, il risque d'être rapidement limité par les capacités du point d'accès qui acheminera vos paquets jusqu'au serveur web testé.

Il faut donc être conscient de tout cela lorsque l'on fait un test.

C. Contourner les limitations locales

Pour contourner ces limitations techniques, le plus simple semble de louer un serveur à haute capacité (réseau et système) chez un hébergeur cloud (OVHcloud, Azure, Google Cloud Platforme, AWS, etc.). Directement exposé sur Internet et profitant d'infrastructures musclées, ce serveur ne subira les freins et ralentissements éventuels de vos réseaux internes et vos tests ne perturberont pas vos utilisateurs.

Une deuxième option lorsque vous hébergez vos serveurs web dans votre propre Datacenter est de se brancher directement au serveur en question pour avoir un test qui sera le plus "violent" possible.

Dans un contexte réaliste, il ne faut pas oublier de tester non seulement le serveur web, mais aussi tous les composants qui peuvent y mener dans le cadre de l'utilisation de normale de ce dernier. Par exemple, lorsqu'un utilisateur parcourt votre site web depuis internet, il passe par votre routeur périphérique, votre pare-feu, potentiellement un système anti-DDOS, un reverse-proxy, etc. Ces éléments peuvent chacun constituer un point faible au niveau du test de charge : qu'est-ce qu'un SPOF ? 

N'hésitez pas à poser vos questions et partagez vos expériences dans les commentaires ou sur notre Discord.

Partagez cet article Partager sur Twitter Partager sur Facebook Partager sur Linkedin Partager sur Google+ Envoyer par mail

Mickael Dorigny

Co-fondateur d'IT-Connect.fr. Auditeur/Pentester chez Orange Cyberdéfense.

Nombre de posts de cet auteur : 526.Voir tous les posts

One thought on “Serveur Web : tests de charge en Python avec Locust

  • Bonjour dans la mise en place du test avec locust, j’ai un soucis d’installation. En fait, je lance le serveur, s’affiche donc :
    green-liveconsole8/INFO/locust.main: Starting web interface at http://0.0.0.0:8089 (accepting connections from all network interfaces)
    [2023-08-18 15:17:58,272] green-liveconsole8/INFO/locust.main: Starting Locust 2.16.1

    Ceci est censé fonctionner, sauf qu’en lançant Safari avec localhost:8089 cela ne fonctionne pas, auriez-vous potentiellement une solution ?

    Bien à vous,

    Répondre

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.