Tests de montée en charge : Gatling "Tips & Tricks"

Lorsque vous travaillez sur une application à fort traffic, ou que vous allez simplement déployer une nouvelle application en production, il faut pouvoir identifier la charge que votre application et votre infrastructure sont prêtes à absorber.

Il est aussi très important de bien tester le comportement de votre application : en effet, si une partie de celle-ci possède par exemple du cache, il est important d'en prendre compte et d'essayer de simuler une charge "réelle".Gatling Load Test Pour tester cette charge, différents outils existent sur le marché vous permettant de "scripter" des scénarios, en fonction du langage que vous affectionnez, parmis eux : Locust si vous souhaitez développer vos scénarios en Python, ou encore Vegeta développé en Go mais permettant de créer des scénarios assez simplement.

Présentation de Gatling

J'ai de mon côté choisi d'utiliser l'outil https://gatling.io/ pour mes tests de montée en charge car je le trouve assez complet à la fois dans les différents besoins des scénarios mais aussi dans ses rapports de sortie.

Gatling est un outil écrit en https://www.scala-lang.org/, qui nécessite une JVM pour son exécution, vous aurez donc besoin des outils Java pour son exécution. Il s'agit bien sûr d'un produit open-source même si une offre d'entreprise (et donc payante) est disponible dans le cas ou vous avez besoin d'obtenir rapidement et simplement des rapports sur une application robuste.

L'objectif de cet article est de vous démontrer techniquement les avantages qu'apporte Gatling dans la gestion de vos scénarios.

Facteur de requête par seconde / Facteur de durée

Lorsque vous allez exécuter un scénario, vous voudrez certainement pouvoir adapter rapidement et simplement le nombre d'utilisateurs que vous souhaitez simuler ainsi que la durée du scénario. Pour cela, vous pouvez simplement mettre en place dans votre scénario deux variables qui vous permettront d'effectuer un facteur multiplicateur. Par exemple :

val rps_factor = Integer.getInteger("rpsFactor", 1).toInt
val time_factor  = Integer.getInteger("timeFactor", 1).toInt

Puis, affectez à vos scénarios de test les valeurs de charge que vous souhaitez en passant le coéfficient multiplicateur :

  setUp(
    scn.inject(
      rampUsers(50 * rps_factor) over(5 * time_factor seconds),
      rampUsers(100 * rps_factor) over(10 * time_factor seconds),
    )
    .protocols(httpConf)
  )

De cette façon, vous pourrez passer la valeur 1 pour vos tests de compilation de scénario (afin que les tests ne s'exécutent pas trop longtemps), et des valeurs plus élevées pour vos tests de production.

Les feeders

C'est en général un des premiers concepts duquel vous aurez besoin afin d'exécuter vos tests mais nous allons tout de même en parler : les feeders, le fait de pouvoir fournir à votre scénario de test un ensemble de valeurs. Prenons un cas simple : vous souhaitez tester la charge d'une API en production et pour éviter le cache, vous souhaitez faire des appels pour obtenir une ressource différente à chaque fois. Dans ce cas, créez simplement un fichier CSV avec une liste d'identifiants valables.

Créons donc le fichier user-files/data/my-identifiers.csv :

myIdentifiers
e9fbd24b-31f8-498f-ba03-7d758d4d2a17
2a012137-ec39-4d37-b2b7-0fc3186f78a0
507a036e-a946-4e82-ae52-305306981694

Chargez ensuite ce fichier dans votre code et donnez le à votre scénario afin de l'utiliser de la façon suivante :

val myIdentifiersFeeder = csv("my-identifiers.csv").random

val scn = scenario("FTPPubSimulation")
  .feed(feeder)
  .exec(http("Call to obtain my ressource")
    .get("/my-ressource/${myIdentifiers}")
    .queryParam("id_diffusion", "${metaId}"))

Jusque là, rien de compliqué et cela est déjà expliqué dans la documentation. Voyons maintenant d'autres astuces qui, je l'espère, vous seront utiles si vous ne maîtrisez pas très bien Gatling et le langage Scala.

Condition en fonction d'une requête précédente

Imaginez que vous fassiez une requête à une API, et que vous souhaitiez faire une seconde requête uniquement dans le cas ou une valeur est définie dans votre première requête. Ceci est faisable avec la syntaxe suivante :

.exec(
    http("GET /api/conditionner/{conditionID}")
    .get("/api/conditionner/${conditionID}")
    .check(
        jsonPath("$..purchaseId").findAll.saveAs("purchaseID")
    )
    .doIf("${purchaseID.exists()}") {
        exec(http("GET /api/purchase/{purchaseID}")
        .get("/api/purchase/${purchaseID(0)}")
      )
    }
)

Ici, la seconde requête (/api/purchase/...) sera exécutée uniquement dans le cas ou un champ "purchaseId" est retourné par la première requête. Pour plus d'informations sur les diverses conditions que vous pouvez mettre en place, je vous invite à vous rendre sur la page suivante : https://gatling.io/docs/2.3/general/scenario/#conditional-statements.

Polling : attendre une tâche asynchrone

Si vous avez des workers ou simplement des tâches traitez de façon asynchrone dans votre architecture, vous comprendrez que vous allez devoir attendre que ceux-ci soient exécutés. Gatling vous permet aussi de gérer ce cas grâce aux fonctions tryMax et check :

.tryMax(100) {
    pause(1)
    .exec(http("GET /api/registration/{registrationID}")
        .get("/api/registration/${registrationID(0)}")
        .check(
            jsonPath("$..purchaseId").findAll.saveAs("purchaseID")
        )
    )
}
...

Ici, nous allons appeler la requête HTTP toutes les secondes (car nous effectuons une pause d'une seconde) et ce jusqu'à 100 fois, à moins que le champ "purchaseID" soit trouvé dans la réponse, et auquel cas le check sera noté comme positif et le scénario passera à l'exécution suivante.

Requête aléatoire

Il est possible que vous souhaitiez répartir de la charge sur deux types de requêtes différentes : par exemple, l'acceptation ou le refus d'une inscription (aléatoirement). Pour ce faire, vous allez pouvoir générer un nombre aléatoire (0 ou 1) et utiliser doIfEqualsOrElse afin d'appeler la requête correspondante :

exec(session => {
    val rnd = new scala.util.Random
    session.set("randomInt", 0 + rnd.nextInt((1 - 0) + 1))
})
.doIfEqualsOrElse("${randomInt}", 1) {
    exec(
        http("PUT /api/contract/{contractID}/accept")
        .put("/api/contract/" + contractID + "/accept")
        .header("Cookie", "_token=" + token)
        .check(status.not(404), status.not(500))
    )
} {
    exec(
        http("PUT /api/contract/{contractID}/refuse")
        .put("/api/contract/" + contractID + "/refuse")
        .header("Cookie", "_token=" + token)
        .check(status.not(404), status.not(500))
    )
}

Ainsi, plutôt que de ne tester qu'une seule API, vous pouvez simuler une charge de requêtes plus cohérente avec la réalité de votre application.

Boucler sur une liste d'éléments

Le mot clé foreach vous permettra de boucler sur une liste d'éléments afin d'éffectuer des actions sur ces éléments :

exec(
    http("GET /api/contract/{contractID}/users")
    .get("/api/contract/${contractID}/users")
    .queryParam("limit", "10")
    .check(
        jsonPath("$.results[*].id").findAll.saveAs("userID")
    )
)
.foreach("${userID}", "elementID") {
    exec(
        http("PUT /api/user/{elementID}")
        .put("/api/user/${elementID}")
        .header("Cookie", "_token=" + token)
        .header("Content-Type", "application/json")
        .body(StringBody("""{"status": "status_has_contract"}""")).asJSON
        .check(status.not(404), status.not(500))
    )
}

Dans cet exemple, la requête PUT sera donc exécutée sur tous les éléments utilisateurs retournés par la première requête.

Jouer les tests sur plusieurs noeuds

Dans le cas ou vous auriez besoin de générer une forte charge, une seule machine ne vous suffira peut-être pas, que ce soit en terme de ressources disponibles (CPU, RAM) ou en terme de bande passante. Gatling permet de pouvoir aggréger les données de plusieurs rapports de test. Ainsi, l'idée est de jouer les rapports sur plusieurss machines en même temps et d'aggréger les rapports pour en obtenir qu'un seul. Bien sûr, il faut tenir compte de ce fait dans les valeurs d'utilisateur et le temps de vos scénarios.

Pour automatiser cela, j'ai pris le temps de faire le script bash suivant qui lance un de mes scénarios et qui se connecte sur les différents serveurs pour les exécuter en simultané. Commençons par préparer les diverses variables de notre script :

#!/bin/bash

# Assuming we use this user for all hosts
USER_NAME='root'

# Remote hosts list
HOSTS=( 1.1.1.1 2.2.2.2 3.3.3.3 4.4.4.4 )

# Assuming all Gatling installation are in the same path (with write permissions)
GATLING_HOME=/opt/gatling/my-project
GATLING_SIMULATIONS_DIR=$GATLING_HOME/user-files/simulations
GATLING_RUNNER=$GATLING_HOME/bin/gatling.sh

# Simulation class name
SIMULATION_NAME='mynamespace.MyTestSimulation'

GATLING_REPORT_DIR=$GATLING_HOME/results/
GATHER_REPORTS_DIR=/gatling/reports/

Avant de commencer, nous allons également cleaner l'éventuel rapport de test précédent qui pourrait subsister en local et sur les serveurs distants :

echo "Cleaning previous runs from localhost"
rm -rf reports
rm -rf $GATHER_REPORTS_DIR
mkdir $GATHER_REPORTS_DIR
rm -rf $GATLING_REPORT_DIR

for HOST in "${HOSTS[@]}"
do
  echo "Cleaning previous runs from host: $HOST"
  ssh -n -f $USER_NAME@$HOST "sh -c 'rm -rf $GATLING_REPORT_DIR'"
done

Maintenant, il est temps de mettre à jour les scénarios sur les serveurs et d les exécuter :

for HOST in "${HOSTS[@]}"
do
  echo "Copying simulations to host: $HOST"
  scp -r $GATLING_SIMULATIONS_DIR/* $USER_NAME@$HOST:$GATLING_SIMULATIONS_DIR
done

for HOST in "${HOSTS[@]}"
do
  echo "Running simulation on host: $HOST"
  ssh -n -f $USER_NAME@$HOST "sh -c 'nohup $GATLING_RUNNER -nr -s $SIMULATION_NAME > /gatling/run.log 2>&1 &'"
done

Une fois les tests terminés, il ne vous reste plus qu'à récupérer les fichiers de logs et à les agréger :

for HOST in "${HOSTS[@]}"
do
  echo "Gathering result file from host: $HOST"
  ssh -n -f $USER_NAME@$HOST "sh -c 'ls -t $GATLING_REPORT_DIR | head -n 1 | xargs -I {} mv ${GATLING_REPORT_DIR}{} ${GATLING_REPORT_DIR}report'"
  scp $USER_NAME@$HOST:${GATLING_REPORT_DIR}report/simulation.log ${GATHER_REPORTS_DIR}simulation-$HOST.log
done

mv $GATHER_REPORTS_DIR $GATLING_REPORT_DIR
echo "Aggregating simulations"
$GATLING_RUNNER -ro reports

Et voilà, votre rapport est maintenant disponible. Vous pouvez aggréger toutes ces commandes afin de vous fabriquer un script bash exécutant automatiquement ces différentes étapes.

Conclusion

Gatling est un outil complet qui vous permet de réaliser des scénarios de test sur mesure pour votre application. Le langage Scala n'est pas très compliqué à prendre en main dans ce cas et vous pourrez écrire vos scénarios assez simplement. Aussi, les rapports fournis par Gatling sont plutôt agréables à lire et son fonctionnement par fichier de log permet d'aggréger des logs venant de plusieurs sources, il est donc possible de l'exécuter sur plusieurs noeuds afin de simuler une forte charge.