Einführung in Gatling

Der Software-Test gehört zur Agilen Softwareentwicklung. Auch wenn viele Softwareentwickler automatische (J)Unit-Tests als "Testen" ansehen, liegt der Schwerpunkt beim Testen im eigentlichen Sinne nicht nur im Verhindern von Regressionen durch automatische Checks, sondern auch im Gewinnen von Informationen über die entwickelte Software. Wertvolle und zeitnahe Informationen sind essenziell, um fundierte Entscheidungen zu treffen. Neben vielen anderen Test-Arten ist der Leistungstest ein wichtiger Bestandteil einer umfassenden Test-Strategie.
Performance-Testing
Ziel des Performance-Tests ist herauszufinden, ob die entwickelte Software mit den verfügbaren Ressourcen die Leistungsanforderungen erfüllen kann. Die Leistungsanforderungen sind dabei von der Art des Systems abhängig. Schon seit Grossrechnerzeiten unterscheidet man zwischen Stapelverarbeitungs- und Online-Systemen.
Bei der Stapelverarbeitung gilt es, die Ressourcen so effizient wie möglich auszunutzen, um eine definierte Datenmenge in einer möglichst kurzen Zeit zu verarbeiten. Solche Systeme sind datengetrieben und das Ziel ist, das System für möglichst hohen Durchsatz zu optimieren.
Bei Online-Systemen gilt es, genügend Ressourcen, d. h. mit Leistungsreserven, bereitzustellen, sodass auf eintretende Ereignisse in einer möglichst kurzen Zeit geantwortet werden kann. Im Regelfall sind diese Ereignisse eintreffende Benutzer bzw. deren Requests, aber auch andere Ereignisse sind nicht unüblich, z. B. eintreffende Sensordaten oder auch Nachrichten jeglicher Art. Solche Systeme sind Ereignis-getrieben und das Ziel ist, das System so zu optimieren, dass Antwortzeiten für einen Großteil der Requests unterhalb einer Toleranzschwelle liegen.
Zur Durchführung von Lasttests benötigt man Werkzeuge – z. B. Gatling.
Was ist Gatling?
Gatling [1] ist ein moderner Lastgenerator, der – anders als Thread-basierte Lastgeneratoren wie Apache JMeter [2] oder verschiedene kommerzielle Werkzeuge – auf einer non-blocking, eventbasierten Lastgenerierung beruht. Gatling ist in Scala geschrieben und bietet eine Scala DSL zum Entwickeln von Lasttests an. Anders als mit grafischen Werkzeugen wie dem JMeter workbench werden so die Lasttests als Code geschrieben, was eine große Flexibilität mit sich bringt, jedoch grundlegende Programmierkenntnisse erfordert. Zum Einstieg in die Entwicklung bietet Gatling jedoch auch einen Recorder an, mit dem Zugriffe auf eine Seite mitgeschnitten werden könnnen. Der Recorder erzeugt daraus den Scala-Code für Test, der dann in einer IDE nachbearbeitet werden kann. Aber selbst ohne Recorder ist die DSL einfach zu verstehen und gut dokumentiert, sodass nach einer kurzen Einarbeitungszeit einfach Lasttests erstellt werden können.
Der Recorder wird im Abschnitt weiter unten etwas ausführlicher beschrieben, doch zunächst möchte ich auf die Besonderheit in der Lastgenerierung in Gatling eingehen.
Event-basierte Lastgenerierung
Gatling erzeugt die Last nicht mit Hilfe von Threads, sondern einem Timer, der Events auslöst. Dadurch können reale User leichter mit virtuellen Usern abgebildet werden, zudem besteht keine Rückkopplung vom zu testenden System. Um den Unterschied genauer zu verstehen, schauen wir uns zunächst die Lastgenerierung mit einem Thread-basierten Generator an.
Bei Thread-basierten Lastgeneratoren werden virtuelle User als Sequenz von Requests abgebildet, die durch einen oder mehrere Threads abgearbeitet werden. Mehrere virtuelle User "teilen" sich dabei einen Thread. Zwischen einzelnen Requests wartet der Thread – die sogenannte Think Time. Über die Think Time können virtuelle User in reale User umgerechnet werden. In Thread-basierten Lastgeneratoren gibt es zwei Verschränkungen:
- Virtuelle User, die sich einen Thread teilen, sind voneinander abhängig. Folgende virtuelle User müssen auf vorhergehende warten.
- Requests eines virtuellen Users, die von einem Thread gesendet werden, sind von der Antwort des zu testenden Systems abhängig. Bevor ein neuer Request gesendet werden kann, muss der vorherige beantwortet werden. Wenn das System durch die höhere Belastung Requests langsamer beantwortet, sinkt durch die Rückkopplung die effektive Last und mit der Zeit pendeln sich Lastgenerator und zu testendes System auf ein gesättigtes Niveau ein.
Mit dem asynchronen, nicht-blockierenden Ansatz von Gatling besteht diese Verschränkung nicht, da Ereignisse nur an den Timer gebunden sind und lediglich durch die Kapazität des Event Loops, d. h. der CPU beschränkt werden. Virtuelle User sowie Requests sind so unabhängig voneinander.
Der Recorder
Bevor wir zum Aufbau von Lasttests eingehen, möchte ich noch kurz auf den Recorder von Gatling eingehen. Der Recorder von Gatling ist eine Standalone-Applikation und die einzige Komponente mit einer grafischen Bedienoberfläche. Er kennt zwei Betriebsmodi: Proxy und HAR. Arbeitet er als HTTP Proxy, müssen im Browser die Proxy-Settings auf den Recorder verweisen, andernfalls können keine Requests aufgenommen werden. Sofern man als Entwickler (hoffentlich) die nötigen Berechtigungen dafür hat, kann man so sehr schnell und einfach Skripte aufnehmen.
Etwas umständlich wird es jedoch, wenn die Kommunikation ausschliesslich über https erfolgt und der Browser dem Zertifikat des Recorders nicht vertraut. An dieser Stelle kommt jedoch der zweite Betriebsmodus des Recorders zum Tragen, der meines Erachtens nach die stärkere Funktionalität darstellt – der Import von HAR-Dateien.
HAR steht für HTTP ARchive und ist ein Format, in dem Browser wie Firefox, Chrome aber auch Edge, HTTP-Requests protokollieren und abspeichern. Mithilfe der Entwicklertools des jeweiligen Browsers und deren Netzwerk-Sicht können die HTTP-Requests einer Seite – auch seitenübergreifend – aufgenommen und als HAR exportiert werden.
Der Gatling-Recorder kann diese HAR-Dateien dann direkt in ein Gatling-Szenario umwandeln. Der Vorteil dieser Methode gegenüber dem Proxy-Modus ist, dass man die Request-Sequenzen ohne besondere Einstellungen aus jedem Browser erzeugen kann, keine besonderen Proxy-Einstellungen vornehmen muss, HTTPs-Verbindungen ohne weiteres aufgenommen werden und die Archive zur Wiederverwendung aufbewahrt werden können.
Bestandteile eines Performance-Tests
Hat man eine Sequenz von Requests aufgenommen, geht es an die Nachbearbeitung, da oftmals auch unnötige Requests mit aufgezeichnet wurden. Dazu zählt vor allem das Bereinigen von Anfragen zu Content von anderen Quellen, wie z. B. JavaScript, aber auch statischer Content wie Bilder von CDNs. Schließlich wollen wir beim Lasttest nur ein System testen.
Auch wenn man ein aufgenommenes Script mit Gatling ohne weiteres ausführen kann, empfiehlt es sich, für Wiederverwendung und bessere Wart- und Erweiterbarkeit das Script zu strukturieren.
Ein Lasttest besteht aus drei Hauptelementen:
- Seitenskripte, die Abfolge von Requests für eine Seite definieren. Auch dynamisch generierte Requests, z. B. für eine Echtzeitsuche, zählen hierzu.
- Szenarien, die beschreiben, wie die Benutzermenge sich durch die Applikation navigiert. Dazu gehören neben Eintritts- und Austrittspunkten auch Verzweigungen mit Wahrscheinlichkeiten.
- Lastprofile, die beschreiben, mit welcher Rate pro Zeitintervall die Nutzer am System antreffen.
Alle drei Elemente werden in einer Simulation zu einem Lasttest zusammengeführt.
Die Simulation
Die Simulation bildet den Rahmen um den gesamten Test. Neben den auszuführenden Szenarien sowie das angewandte Lastprofil werden hier die Grundeinstellungen zum verwendeten Protokoll vorgenommen, z. B. das Zielsystem, Verbindungsparameter, Default-Header oder Connection Handling.
Eine Simulation muss zwingend die setUp-Methode der abstrakten Gatling-Simulation-Klasse aufrufen, da darüber das Szenario und die Last-Konfiguration definiert wird. Für einfache Simulationen können zudem Protokoll-Konfigurationen (HTTP) sowie Szenarien definiert werden.
class Example extends Simulation{ val httpProtocol = http //base URL für relative Requests im Szenario .baseURL("http://localhost:8080") val mySzenario = Szenario("my Szenario").exec(...) setUp( mySzenario.inject( constantUsersPerSec(40) during (20 minutes) ) ).protocols(httpProtocol) }
Die Protokoll-Definition erlaubt hierbei das Setup der Verbindungseigenschaften für das HTTP-Protokoll. Normalerweise enthält es mindestens die Base-URL, die zur Auflösung von relativen URLs in den später erklärten Seitenaufrufen verwendet wird. Auf diesem Weg lässt sich das Zielsystem zentral ändern. Außerdem lassen sich Connection-Pooling, Default Header, Resource Management & Caching konfigurieren.
Page Scripts
Page Scripts definieren für jede einzelne Seite, welche HTTP-Requests zum Server geschickt werden. Die einfachste Methode, ein Seiten-Skript zu erzeugen, ist der oben beschriebene Recorder, jedoch ist die von Gatling bereitgestellte DSL auch intuitiv genug, um Seiten-Skripte manuell zu erstellen.
Ein Seiten-Skript fängt entweder mit exec() oder group() an. Während group vor allem dazu dient – wie der Name schon sagt – Requests zu gruppieren, sodass diese später im Report in einer logischen Einheit dargestellt werden, wird mit exec ein eigentlicher Request abgesetzt, eingeleitet durch http() mit einer URL relativ zum Basispfad der Applikation sowie der HTTP-Method.
def page = exec( http("ExamplePage").get("/example") )
Dem so definerten Request lassen sie noch weitere Parameter wie z. B. HTTP-Header oder Ressourcen hinzufügen. Ressourcen sind Folgerequests, die im Kontext der Seite und nach dem initialen Requests abgesetzt werden. Anders als die Seite selbst, werden Ressourcen zudem parallel abgerufen, wobei der Parallelitätsgrad im Simulations-Setup konfigurierbar ist. Für verschiedene Browser gibt es mitgelieferte Defaults.
Eine vollständige Seite mit Ressourcen und einer Reihe von Headern sieht dann so aus:
def page = exec( http("ExamplePage") .get("/example") .headers(Map( "Accept" -> "*/*", "User-Agent" -> "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0")) .resources( http("Stylesheet") .get("/css/stylesheet.css") .headers( Map("Accept" -> "text/css") ), http("JavaScript") .get("/js/app.js") .headers( Map("Accept" -> " application/javascript")) ) )
Hier wird dann schon die erste Stärke der Verwendung einer Programmiersprache sichtbar. Wiederkehrende Elemente, wie z. B. die Header-Map lassen sich auslagern und seitenübergreifend wiederverwenden:
def defaultHeaders = Map( "Accept" -> "*/*", "User-Agent" -> "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0") def page1 = exec( http("ExamplePage 1") .get("/example1") .headers(defaultHeaders) ) def page2 = exec( http("ExamplePage 2") .get("/example2") .headers(defaultHeaders)
Natürlich kann man auch andere HTTP-Requests absetzen. So können einem POST Request Payload aus einer externen Datei oder dynamische Form-Parameter beigefügt werden.
def submitForm = exec( http("Submit Form") .post("/submit") .headers(formHeaders) .formParam("formField 1", "value 1") ) def createResource = exec( http("Create Resource") .post("/resource") .body(StringBody("{\"data\":123}")) )
Eine weitere Stärke von Gatling bzw. der Verwendung einer Programmiersprache zur Entwicklung der Tests ist die Möglichkeit, Seiten-Skripte auch dynamisch erzeugen zu lassen. Für ein Kundenprojekt musste zum Beispiel die Live-Suche getestet werden. Bei der Live-Suche wird vom Browser während der Sucheingabe, bei jedem eingegebenen Zeichen, ein Such-Request mit der bisher eingetippten Suchfolge abgesetzt.
Die Suche nach einem Wort mit 10 Zeichen hatte also 10 Requests zur Folge, wobei der zeitliche Abstand zwischen den Requests der Tippgeschwindigkeit entsprach. Mit der Funktion im folgenden Beispiel kann eine solche Such-Sequenz anhand eines beliebigen Suchbegriffs erzeugt werden, wobei die erste Suchanfrage ab 3 Zeichen abgesetzt wird (searchThreshold) und die Wartezeit zwischen jedem Request mit 1 Sekunde einer eher langsamen Tippgeschwindigkeit entspricht (paceTime).
import java.net.URLEncoder import io.gatling.core.Predef._ import io.gatling.http.Predef._ import scala.concurrent.duration._ def searchRealTime(query: String, paceTime: Duration = 1 second, searchThreshold: Int = 3) = { group("Search RealTime") { def feederData = Range(searchThreshold, query.length + 1) .map(i => query.substring(0, i)) .map(word => Map("rtquery" -> URLEncoder.encode(word, "UTF-8"))) .toList group("Search (RealTime)") { foreach(feederData, "rtquery", "counter") { exec(flattenMapIntoAttributes("${rtquery}")) .exec( http("${counter}:query='${rtquery}'") //lesbarer name im Report .get("/search/?query=${rtquery}")) //generierter Query .pace(paceTime) } } } }
Szenarios
Szenarien beschreiben die Navigationspfade, die eine Benutzergruppe durch das System verfolgt. Eine Benutzergruppe trifft an einem Einstiegspunkt der Webseite an. Dies ist oftmals die Startseite, kann aber auch ein Direkteinsprung auf einer Unterseite sein, z. B. durch Bookmarks oder Marketing-Kampagnen. Die Benutzergruppe kann sich im Verlauf des Besuchs aufteilen und unterschiedliche Pfade nehmen. Beispielsweise können in einem Webshop einzelne Nutzer nur stöbern, während andere auch etwas kaufen. Schließlich verlassen die Benutzer das System wieder über bestimmte Austrittspunkte. In Gatling lassen sich solche Scenarien über die Ausführung der einzelnen Page Scripte beschreiben.
def Szenario = Szenario("Browse shop").exec(page1).exec(page2)
Werden bestimmte Seitenabfolgen häufig wiederverwendet, so ist eine vorherige Definition der Abfolge sinnvoll:
def pageChain = exec(page1).exec(page2) def Szenario1 = scnario("Browse shop").exec(pageChain).exec(page3) def Szenario2 = scnario("Register").exec(pageChain).exec(page4)
Ein Szenario lässt sich auch aufsplitten. Eine gängige Method ist randomSwitch, bei dem sich die Verzweigung prozentual aufteilen lässt. Wichtig ist hierbei, dass die Prozente von 0 bis 100 angegeben werden und 100 nicht überschreiten dürfen. Alternative Verzweigungsmodi sind uniformRandomSwitch oder roundRobinSwitch.
def Szenario = scnario("Browse shop") .exec(chain) .randomSwitch( 90 -> anonymousBrowsing, 10 -> shopProducts )
Neben der Modellierung von einmaligen Abläufen in Kombination mit Ankunftsraten bietet Gatling auch die "klassische" Modellierung von wiederkehrenden Abfragen an. Dabei wird eine Abfolge von Seitenaufrufen im Szenario selbst wiederholt und im Lastprofil lediglich die Anzahl der Benutzer definiert. Dies wird durch eine Reihe von Loop-Konstrukten ermöglicht:
- repeat – für eine definierte Anzahl von Wiederholungen,
- foreach – zum Iterieren über eine Sequenz,
- during – zur Wiederholung in einem bestimmten Zeitraum,
- asLongAs – Wiederholung bis eine Bedingung nicht mehr erfüllt ist und
- forever – zur unbegrenzten Wiederholung.
Zum Beispiel:
def Szenario = scnario("Browse shop") .during(20 minutes){ exec(page1).exec(page2) }
Lastprofile
Im Last-Profil wird definiert, mit welcher Rate bzw. wie viele Benutzer in einem bestimmten zeitlichen Verlauf ein Szenario durchlaufen. Dies wird im setup-Block der Simulation über die inject-Methode des Szenarios definiert, der eine Liste von InjectionSteps mitgegeben wird.
setUp( mySzenario.inject( ... //injection steps ) )
Die Gatling-DSL bietet zahlreiche Injection Steps an. Die wichtigsten für den Alltagsgebrauch dürften der lineare Anstieg der User, sowie konstante oder steigende Ankunftsrate pro Sekunde sein, welche durch die folgend Steps abgebildet werden.
- rampUsers – zur Abbildung einer fixen Benutzeranzahl, insbesondere zur Modellierung geschlossener Benutzergruppen in Kombination mit einem Loop-Konstrukt wie during (20 minutes),
- constantUsersPerSec – zur Abbildung einer konstanten Ankunftsrate. Die dadurch erzeugten Nutzer sind unabhängig voneinander und durchlaufen das Szenario exakt einmal, und
- rampUsersPerSec – zur Abbildung einer sich linear verändernden Ankunftsrate.
Darüber hinaus gibt es noch ein paar weitere Funktionen für speziellere Szenarien, hier verweise ich jedoch auf die Dokumentation [3].
Ein typisches Lastszenario, bestehend aus einem linearem Ramp-up der User und einer konstanten Last über einen gewissen Zeitraum, sieht dann wie folgt aus:
setUp( mySzenario.inject( rampUsersPerSec(1) to (40) during (4 minutes), constantUsersPerSec(40) during (20 minutes) ) ).protocols(httpProtocol)
Im setUp lassen sich auch mehrere Szenarien definieren, die dann nebeneinander abgearbeitet werden. Die Liste an Injection Steps kann aber auch programmatisch generiert werden, um z. B. eine mathematische Funktion durch eine Reihe von rampUsersPerSec-Steps anzunähern, was ein sehr mächtiges Werkzeug für dynamische Lasten ist.
Integration in Maven
Wie auch JMeter bietet Gatling ein Maven-Plugin, womit es sich in Continous Integration-Pipelines integrieren lässt, aber auch bei der lokalen Entwicklung hilft, da sich die Simulationen leider nicht direkt über die IDE starten lassen. Ohne weitere Parameter geht das Plugin davon aus, dass es nur eine Simulation – die Scala-Klasse mit dem Simulation-Setup – im Projekt gibt und startet diese. Hat man mehrere Simulationen, lassen sich die Simulationen über den simulationClass- bzw. sc-Parameter der Plugin-Konfiguration bestimmen. Über verschiedene Executions lassen sich so auch mehrere Simulationen in einem Maven-Durchlauf ausführen.
<plugin> <groupId>io.gatling</groupId> <artifactId>gatling-maven-plugin</artifactId> <executions> <execution> <id>moderateLoad</id> <goals> <goal>execute</goal> </goals> <configuration> <sc>example.ModerateLoadSimulation</sc> </configuration> </execution> <execution> <id>highLoad</id> <goals> <goal>execute</goal> </goals> <configuration> <sc>example.HighLoadSimulation</sc> </configuration> </execution> </executions> </plugin>
Die Simulationen können dann gezielt mit dem @-Operator in Maven gestartet werden:
mvn gatling:execute@moderateLoad
Auswertung
Gatling generiert nach Ausführung aus dem Request-Log einen sehr anschaulichen Report. In diesem Report sind für die einzelnen Requests sowie Request-Gruppen Antwortzeiten mit Min/Max sowie 75-, 95- und 99-Prozent-Perzentilen sowie die jeweiligen Fehlerraten aufgeführt. Die Statistiken werden optisch ansprechend mit Diagrammen zum zeitlichen Verlauf der aktiven Nutzer, Antwortzeiten, Request- und Response-Anzahl sowie einem Histogramm der Antwortzeiten ergänzt.
Die Reports stehen als interaktive HTML-Seiten zur Verfügung und können so z. B. direkt publiziert oder vom Build Server abgerufen werden. Insbesondere die Diagramme sind anschaulich genug, um selbst Laien im Performance-Test die Ergebnisse verständlich visuell zu kommunizieren. So habe ich beispielsweise in einem Projekt nach einer initialen Erläuterung zur Interpretation der Diagramme täglich lediglich die Diagramme an die Projektleitung rapportiert, wodurch sie sich, ohne mit Details auseinandersetzen zu müssen, schnell einen Überblick über den Stand der Performance-Verbessererungen einer Web-Applikation verschaffen konnte.
Da die Reports aus den Logfiles ausgelesen werden, ist es auch ohne weiteres möglich, mit einem eigenen Parser die Logs zu verarbeiten und detailliertere Auswertungen zu betreiben.
Zusammenfassung
Gatling ist ein Open Source-Werkzeug zur Entwicklung und Ausführung von Lasttests, die in einer Scala DSL entwickelt werden. Damit lassen sich Szenarien besser modularisieren und wiederverwenden als z. B. in JMeter. Ein wesentlicher Unterschied zu JMeter ist zudem, dass die Last nicht-blockierend und asynchron über Ereignisse generiert wird und nicht auf Threads basiert. Damit lassen sich mit gleichem Ressourcenaufwand ungleich höhere Lasten erzeugen. Dem gegenüber steht eine geringere Funktionsvielfalt als bei JMeter, vor allem was die Konnektivität angeht, bei der Gatling lediglich HTTP(s) und JMS unterstützt.
Ich persönlich bevorzuge Gatling, weil die Entwicklung mit einer IDE für mich als Programmierer natürlicher von der Hand geht, als die grafisch unterstützte Entwicklung bei JMeter, und weil ich mit Gatling mit dem programmatischen und event-basierten Ansatz mehr Möglichkeiten habe, Lastkurven und Requestabfolgen zu definieren.
Neuen Kommentar schreiben