Load Testing on Scale mit Grafana k6
Warum Lasttests sinnvoll sind
Dass eine Webseite unter einem Besucheransturm zusammenbricht, kann zahlreiche Ursachen haben — mangelnde Hardwarekapazitäten (CPU, RAM, Bandbreite) sind nur eine davon. Engpässe können auch durch Eigenarten der jeweiligen Softwarearchitektur eines Projekts entstehen (beispielsweise eine seltsame Datenbankabfrage, die aus irgendeinem Grund einen exklusiven Lock auf die gesamte Tabelle benötigt), ineffiziente Programmierung oder Serverfehlkonfigurationen (192 Kerne bringen beispielsweise wenig, wenn PHP-FPM noch mit der standardmäßig eingestellten Limitierung auf 5 gleichzeitige Kindprozesse läuft... alles schon vorgekommen 🙄)
All diese möglichen Ursachen sind Grund genug, sich nicht auf Aussagen wie “192 Kerne werden schon reichen” zu verlassen. Man sollte besser mit einem Lasttest verifizieren, dass ein Projekt den erwarteten Szenarien standhält.
Damit die Ergebnisse so eines Lasttests belastbar sind, müssen natürlich (zumindest halbwegs) realistische Testszenarien simuliert werden, die in der Realität auch so zu erwarten sind. In einem E-Commerce-Projekt beispielsweise ist es unrealistisch, einfach nur tausende Aufrufe auf die Startseite zu simulieren (jeder beliebige Shopware-Store mit einem gut konfigurierten HTTP-Cache kommt damit ohnehin mühelos zurecht); stattdessen sollte ein realitätsnaher Lasttest auch die Suche und den Checkout-Prozess beinhalten.
Das Testszenario
Für eine Festival-Webseite wie das Parookaville ist die Feuerprobe der Ticketverkauf, der in unserem Fall auch die Grundlage für das Testszenario (eines von mehreren) bot. Aus Analysen vorheriger Ticketverkäufe des Festivals war bekannt, dass innerhalb eines 15-Minuten-Zeitraums die Seite von ca. 50.000 Besuchern aufgerufen wurde — die ersten 25.000 davon gleich zu Beginn, innerhalb der ersten fünf Minuten.
Anhand dieser Daten konstruierten wir das Testszenario in der folgenden Abbildung: Nach einem sehr kurzen Ramp-Up wird über einen Zeitraum von fünf Minuten mit einer konstanten Ankunftsrate von 5.000 Iterationen pro Minute gearbeitet (fünf Minuten mal 5.000 Iterationen/Minute macht die ersten 25.000 Besuche). Die verbleibenden 25.000 Besuche verteilen sich mit abnehmender Ankunftsrate über die nächsten 10 Minuten.
Da es die meisten Interessenten im Regelfall eilig haben, gingen wir davon aus, dass der Klickweg der meisten Besucher von der Startseite direkt zur Ticketseite führte (der eigentliche Ticketshop und die Buchungsstrecke wurde von einem anderen Service Provider angeboten und war nicht im Scope des Lasttests). Einen geringen (zufälligen) Anteil der Besucher führte das Testszenario jedoch auch über andere Seiten der Website.
Szenarien mit Grafana k6 implementieren
Als Test-Tool entschieden wir uns für Grafana k6. k6 steht mit der AGPL unter einer Open-Source-Lizenz und ermöglicht die Implementierung komplexer Testszenarien, indem diese in JavaScript programmiert werden.
Ein einfacher Testfall könnte beispielsweise wie im folgenden Codebeispiel aussehen:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 10,
duration: '30s',
};
export default function() {
const res = http.get('https://my-loadtest.example');
check(res, {
'is status 200': r => r.status === 200,
});
}Jedes k6-Szenario simuliert “Virtuelle User” (kurz “VU”); das Verhalten jedes VUs wird von einer JavaScript-Funktion beschrieben (im einfachsten Fall wie oben der Default-Export der Datei). Im obigen Beispiel besteht das Test-Szenario lediglich daraus, dass eine einzelne Seite aufgerufen und sichergestellt wird, dass diese mit dem erwarteten HTTP-Statuscode antwortet. Das Testszenario wird mit 10 virtuellen Usern für 30 Sekunden ausgeführt.
Die Testfunktion in diesem Beispiel ist ein ganz einfaches Beispiel; für ein realistisches Testszenario könnte auch ein komplexer Klickweg quer über die ganze Webseite implementiert werden.
So ein Testszenario wird als einfache JavaScript-Datei gespeichert (kann dementsprechend auch einfach in einem Git-Repository versioniert zu werden), und kann über einen Konsolenbefehl ausgeführt werden:
$ k6 run script.js
Über die Umgebungsvariable K6_WEB_DASHBOARD kann zudem gleichzeitig eine Web-Oberfläche im Browser geöffnet werden, die relevante Messwerte während des laufenden Tests in Echtzeit grafisch darstellt:
$ K6_WEB_DASHBOARD=true k6 run script.js
Nach einem Testlauf stellt k6 alle möglichen Messwerte auf der Konsole da. Relevant sind hier beispielsweise die Werte checks (für die Anzahl der erfolgreich durchgeführten Tests; im Beispiel etwa auf den HTTP-Antwortcode) oder http_req_duration für die Antwortzeiten des Webservers.
Service-Level Objectives und Thresholds
Was auch zu einem Lasttest dazugehört, ist die Überlegung, ab wann dieser denn eigentlich als "bestanden" gilt. Das ist insbesondere dann relevant, wenn man Lasttests vollautomatisiert (beispielsweise als Teil einer CI-Pipeline) ausführen möchte.
k6 bietet hierzu die Möglichkeit, auf alle erhobenen Messwerte sogenannte "Thresholds", also Schwellwerte, zu definieren. In der Definition des Testszenarios könnte das wie folgt aussehen:
export const options = {
// ...
thresholds: {
checks: ['rate>0.98'],
http_req_duration: ['p(95)<150']
}
}; Die Schwellwerte aus dem Beispiel lassen sich übersetzen: Der Test gilt als bestanden, wenn mindestens 98% aller benutzerdefinierten Checks erfüllt wurden (im Beispiel also, dass 98% aller HTTP-Anfragen mit dem erwarteten Statuscode beantwortet wurden), und die Antwortzeit in 95% aller Fälle weniger als 150ms betrug.
Jetzt aber mal reinfolgen!
Abonniere Tipps für CMS und Dev-Tools, Hosting Hacks und den ein oder anderen Gag. Unsere schnellen Takes für dich per E-Mail.
When to Browser
"Einfache" HTTP-Anfragen sind nur die halbe Miete für einen realitätsnahen Lasttest – denn die echten Besucher einer Seite stellen ja auch keine HTTP-Anfragen direkt ans CMS, sondern verwenden einen Browser, der in der Regel noch viele weitere Ressourcen nachlädt.
k6 bietet auch hierfür eine Lösung, denn Test-Szenarien können auch in einem echten Browser ausgeführt werden. Ein Test-Szenario dafür könnte beispielsweise wie folgt aussehen:
import { browser } from 'k6/browser';
import { check } from 'k6';
export const options = {
scenarios: {
ui: {
executor: 'constant-vus',
vus: '20',
duration: '15min',
options: {
browser: {
type: 'chromium',
},
},
},
},
};
export default async function () {
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto('https://my-loadtest.example');
await Promise.all([
page.waitForNavigation(),
page.locator('a[title="Linktext"]').click(),
]);
const header = await page.locator('h1').textContent();
check(header, {
"expected heading is found": (h) => h == 'Foo',
});
} finally {
await page.close();
}
}Die Programmierschnittstelle sieht in diesem Fall anders aus als in den herkömmlichen Testszenarien. Wer jedoch schon einmal Frontend-Tests mit Playwright geschrieben hat, dem mag die API womöglich bekannt vorkommen.
Der offensichtliche Haken bei Browser-Tests ist, dass diese prinzipbedingt wesentlich mehr Ressourcen benötigen als die herkömmlichen Tests, die "einfache" HTTP-Anfragen an den Server stellen. Während für einfache "Protocol-level Tests" mit einem RAM-Verbrauch von 3-5MB pro Virtuellem User gerechnet werden sollte, sind es bei browserbasierten Tests eher 300-500MB pro Virtuellem User.
Aus diesem Grund ist es meistens unpraktikabel, wirklich große Tests (mit tausenden oder zehntausenden gleichzeitiger User) rein browserbasiert auszuführen. In der Praxis setzt man daher in der Regel auf einen hybriden Ansatz: Mit einfachen Protocol-level Tests wird der größte Teil der simulierten Last verursacht (k6 ist hier sehr effizient, und kann mit geringem Ressourceneinsatz tausende virtueller User simulieren), und die relevanten Messwerte werden dann mit Browsertests erhoben, die mit wesentlich geringerer Parallelität ausgeführt werden.
Core Web Vitals als Service-Level Objective
Lasttests im Browser auszuführen bietet noch ganz andere Messwerte, auf die man in seinen Lasttests achten kann. Denn tatsächlich ist die reine http_request_duration (also die Zeit, innerhalb derer eine einfache HTTP-Anfrage beantwortet wurde) für den Besucher einer Seite gar nicht so interessant. Aus Nutzersicht ist viel relevanter, wie schnell sich die Seite tatsächlich soweit aufgebaut hat, dass sie benutzbar ist.
Als Messwerte sind aus diesem Grund einige der Core Web Vitals-Metriken sehr interessant. In unseren Tests legten wir beispielsweise besonderes Augenmerk auf den "Largest Contentful Paint". Das bezeichnet die Zeit, die vom ursprünglichen Seitenaufruf bis zum (prozentual im Verhältnis zum Browser-Viewport gesehen) Aufbau des größten Seiteninhalts vergeht. Generell gelten hier alle Werte von weniger als 2,5 Sekunden als "gut" und alles unterhalb von 4 Sekunden als "verbesserungsbedürftig".
k6 erhebt die relevanten Core Web Vitals-Metriken (wenn wir einen Browser-basierten Test machen) automatisch, sodass wir im Testszenario ganz normale Thresholds darauf definieren können:
export const options = {
// ...
thresholds: {
checks: ['rate>0.98'],
browser_web_vital_ttfb: ['p(95)<100'],
browser_web_vital_lcp: ['p(95)<4000']
}
};Core Web Vitals wie den Largest Contentful Paint als Ziel-Metrik zu verwenden, ist sinnvoll, da das die Metriken sind, die für einen Endanwender der Seite tatsächlich relevant sind. So kann durch den Lasttest sichergestellt werden, dass auch unter einem Lastszenario die Seite für einen Besucher auch wirklich noch benutzbar ist.
“Große” Tests mit k6
Auch wenn k6 sehr effizient darin ist, mit geringen Ressourcen große Mengen virtueller Nutzer zu simulieren, muss man sich natürlich auch über das Sizing der Testumgebung Gedanken machen, da auch diese ansonsten schnell zu einem Flaschenhals werden kann.
Für den Lasttest verwendeten wir zunächst eine einzelne m7i.16xlarge AWS-Instanz (ja, auch wir nutzen manchmal AWS) mit 64 CPU-Kernen und 256 GiB RAM. In unserem Test konnten wir diese mit 1.500 virtuellen Nutzern zu 100% auslasten.
Um große Testmaschinen effizient ausnutzen zu können, müssen ein paar Sachen beachtet werden. Beispielsweise mussten auf den Testmaschinen diverse Kernel-Parameter gesetzt werden, um die große Anzahl an TCP-Verbindungen effizient bearbeiten zu können:
$ sysctl -w net.ipv4.ip_local_port_range="1024 65535"
$ sysctl -w net.ipv4.tcp_tw_reuse=1
$ sysctl -w net.ipv4.tcp_timestamps=1
$ ulimit -n 250000Sollte eine einzelne Testmaschine für einen großen Lasttest nicht ausreichend sein, kann k6 große Tests auch in Form einzelner "Execution Segments" über mehrere Maschinen aufteilen. In diesem Fall benötigt man lediglich eine Lösung, um die während der Tests erhobenen Messdaten am Ende wieder zusammen zu führen.
Diese Zusammenführung haben wir uns in diesem Fall einfach gemacht: Die einzelnen Execution Segments schrieben ihre Messwerte in einfache CSV-Dateien, die wir anschließend über ein Jupyter-Notebook für die Auswertung wieder zusammenführten.
Work smarter, not harder
Im Rahmen unserer Tests stellten wir tatsächlich fest, dass die Maschine für den simulierten Benutzeransturm nicht ausreichend dimensioniert war. Die untenstehende Abbildung zeigt beispielsweise die Auswertung eines Lasttests, bei dem nach ca. 2-3 Minuten ein Prozess-Limit des Apache-Webservers erreicht wurde. Dies sorgte kurzzeitig für eine enorm erhöhte Antwortzeit des Servers, und im Anschluss für eine dauerhaft stark erhöhte Fehlerrate.
Natürlich kann man in solchen Situationen einfach noch mehr Hardware-Ressourcen auf ein Problem werfen. Gleichzeitig kann man durch geschickte Architekturentscheidungen aber auch dafür sorgen, dass der Server die Anfragen effizienter verarbeiten kann.
Im vorliegenden Beispiel entschieden wir uns nach den ersten (nicht allzu ermutigenden) Tests beispielsweise dazu, der TYPO3-Seite einen Varnish-Cache voranzuschalten, der einen Großteil der Anfragen aus dem (nach wie vor mehr als ausreichendem) RAM des Servers bedienen konnte. Im Anschluss waren die 1.500 gleichzeitigen Virtuellen Nutzer kein Problem mehr.
Falls du selber einen Varnish Cache auf unserer Plattform betreiben möchtest, lies unseren Guide zur Bereistellung von Caching Proxies. Speziell für TYPO3 bieten wir außerdem die varnishcache-Erweiterung, die beispielsweise automatisiert den Varnish-Cache leeren kann, wenn auch der TYPO3-Seitencache geleert wird.
Fazit
Lasttests sind kein Nice-to-have, sondern eine Notwendigkeit, wenn man sicherstellen will, dass eine Webseite einem Besucheransturm auch tatsächlich standhält. Wie das Beispiel zeigt, lassen sich mit Tools wie Grafana k6 realitätsnahe Testszenarien effizient implementieren und ausführen – und die Ergebnisse liefern wertvolle Erkenntnisse, die weit über die bloße Hardware-Dimensionierung hinausgehen. Ob ineffiziente Datenbankabfragen, Serverfehlkonfigurationen oder schlicht fehlende Caching-Strategien: Probleme, die unter Volllast zum Zusammenbruch führen würden, lassen sich im Lasttest identifizieren und beheben, bevor es zum Ernstfall kommt.
Am Ende lohnt sich die Zeit, die man in einen Lasttest investiert, eigentlich immer: nicht nur durch konkrete Optimierungen, sondern vor allem durch das gute Gefühl, mit dem man dem nächsten Ticketverkauf (oder der nächsten Höhle-der-Löwen-Ausstrahlung, dem nächsten DDoS-Angriff, oder was auch immer) entgegensehen kann. Statt einfach darauf zu hoffen, dass alles gut geht, weiß man genau, dass das System die erwartete Last bewältigen kann. Und sollte doch etwas Unvorhergesehenes passieren, hat man durch die Tests bereits ein tiefes Verständnis des Systems aufgebaut und weiß, wo man ansetzen muss. Dieses Sicherheitsgefühl ist unbezahlbar – gerade, wenn es um Events geht, bei denen tausende Besucher gleichzeitig auf den "Kaufen"-Button drücken.