Load Testing on Scale mit Grafana k6

|
Foto von Martin Helmich. Dazu der Text: Load Testing on Scale mit Grafana

Die TYPO3-Agentur zdrei aus Köln betreibt die Webseite des Parookaville-Festivals auf einem mittwald-Server. Sie wollten wissen, ob der Server dem Besucheransturm standhält, der während des Ticketverkaufs stattfindet. Der Server war (zu dem Zeitpunkt) mit 192 Kernen und 512 GiB RAM großzügig dimensioniert. Dennoch macht es in solchen Situationen Sinn, sich nicht einfach auf seine Hardwarekapazitäten zu verlassen, sondern stattdessen über einen Lasttest zu verifizieren, dass eine Umgebung diesen Szenarien standhält. Wie Agenturen gemeinsam mit mittwald diese Tests durchführen, liest du in diesem Artikel. 

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. 

Schaubild, dass die zeitliche Architektur des Load Testings zeigt

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.

Schaubild, dass die Trafficverteilung bei diesem Load Test zeigt

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. 

Screenshot der Konsole wenn man k6 startet

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.

Wie dürfen wir dich ansprechen?

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. 

Schaubild, dass das Verhältnis von HTTP Protocol Level Tests und Browser Tests zeigt

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". 

Schaubild, das Largest Contentful Paint erklärt

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. 

Screenshot des Environments

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 250000

Sollte 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. 

Schaubild, dass  zeigt, welche Ergebnisse in Jupiter gesammelt werden

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. 

Schaubild der Auslastung während des Tests

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. 

Ähnliche Artikel:

Weißer Text auf blauem Hintergrund: Auf Phishing geklickt - was jetzt zu tun ist
Weißer Text auf blauem Hintergrund: Auf Phishing geklickt - was jetzt zu tun ist

Was tun, wenn du auf Phishing hereingefallen bist?

Schnelles Handeln zählt. Du kannst den Schaden begrenzen und dich künftig besser absichern. Wir zeigen dir, was zu tun ist.

Maik spricht über AI Hosting
Maik spricht über AI Hosting

Vom Zettel zur KI: Wie ein Bioladen mit AI Hosting die Organisation smarter macht

Ein Bioladen. Zettel und Stift. Ein Teil der Kollegschaft steht kurz vor der Rente. Der andere besteht aus Studierenden im Nebenjob. Das digitale Knowhow könnte nicht unterschiedlicher...

SSL-Zertifikate: Neue Laufzeiten & Wildcard Let’s Encrypt

SSL-Zertifikate laufen bald kürzer. Erfahre, was sich 2026–2029 ändert und wie du mit Let’s Encrypt & Wildcard im mStudio entspannt bleibst.

Mit Automationen Entlastung beim Kunden schaffen

Im letzten Interview mit Markus Liermann hat er von seinem Umzug zu mittwald erzählt und wie er mit dem Container- und dem AI Hosting seine eigene Infrastruktur neu aufgesetzt...

Im Interview: Wie Freelancer Markus seine gesamte Infrastruktur mit Container- und AI Hosting umstellt

Markus Liermann ist seit 22 Jahren selbständiger TYPO3-Entwickler. Heute sieht er sich mehr als Product-Owner. Vor Kurzem war er auf der Suche nach einem Hoster,...