k6-fleet: verteilte k6-Tests ohne Kubernetes

Ein Controller und ein Agent, die einen k6-Test über ssh oder Docker auf mehreren Maschinen ausführen und Thresholds, abortOnFail und die Zusammenfassung auf den zusammengeführten Daten auswerten.

Das Problem

Ein k6-Prozess läuft auf einer Maschine. Braucht ein Test mehr Last, als diese Maschine erzeugen kann, bietet k6 Execution Segments an, die einen Test exakt auf mehrere Instanzen aufteilen. Was fehlt, ist etwas, das diese Instanzen als einen Test behandelt. Issue #140, "Clustering and distributed execution", ist seit März 2017 offen und hat 103 Daumen hoch. Ein Maintainer schrieb dort 2021, verteilte Tests gingen mit Segmenten, aber "unfortunately, without thresholds and a centralized summary". Zwei Proofs of Concept für eine native Lösung wurden ungemergt geschlossen, der zweite im Mai 2026.

Die vorhandenen Wege sind der Kubernetes-Operator, der gehostete Dienst Grafana Cloud oder eigene Skripte. Eine Codesuche findet Threshold-Logik im Operator nur in seinem Grafana-Cloud-Pfad.

Fehlende Zusammenführung ändert Ergebnisse. Bekommt einer von drei Generatoren die langsamen Requests, bestehen die anderen beiden p(95)<300 auf ihren eigenen Samples, während das zusammengeführte p(95) scheitert.

Wie es arbeitet

k6-fleet ist ein Go-Binary ohne Abhängigkeiten außerhalb der Standardbibliothek. Der Controller ruft k6 archive auf, sodass Skript, Importe und open()-Dateien als eine Datei reisen, und liest die Thresholds mit k6 inspect. Er startet sich selbst über ssh oder Docker als Agent auf jeder Maschine. Die gesamte Kommunikation läuft über stdin und stdout des Agenten, auf einem Generator wird also kein Port geöffnet.

Jeder Agent startet k6 pausiert mit seinem Segment und --no-thresholds. setup() läuft einmal über die REST-API von k6, das Ergebnis geht an die anderen Instanzen. Der Controller misst die Uhrenabweichung und gibt allen Agenten eine gemeinsame Startzeit. Die Agenten lesen den Sample-Strom von k6 aus einem FIFO und senden pro Sekunde einen Frame mit Zählern, Raten und einem zusammenführbaren Quantil-Sketch je Trend. Der Controller führt sie zusammen, wendet abortOnFail an, ruft teardown() einmal auf und endet mit den Exit-Codes von k6.

Ausgabe aus dem CI mit echtem k6 v2.3.0 und drei Agenten. Das Skript trägt jede Iterationsnummer von 0 bis 999 in einen Trend ein, die exakten Werte sind also bekannt (Mittelwert 499,5, p(95) 949,05):

  THRESHOLDS (evaluated on merged data)
    ok   deterministic "avg==499.5"  actual=499.5
    ok   deterministic "max==999"  actual=999
    ok   deterministic "p(95)<960"  actual=948.7

  AGENTS
    name             segment    iterations   requests
    local-0          0:1/3             334        336
    local-1          1/3:2/3           333        333
    local-2          2/3:1             333        333

Wie es getestet ist

Die Orchestrierungsszenarien laufen gegen einen Stub von k6-CLI, REST-API und JSON-Ausgabe. Was echtes k6 tut, konnte der Stub nicht zeigen. Das CI führt dieselben Abläufe deshalb gegen k6 v2.3.0 und v1.8.2 aus, mit einem Ziel, das festhält, was ankommt. Geprüft wird, dass 1000 geteilte Iterationen genau einmal ankommen, dass die Zähler einem einzelnen k6 run entsprechen und die Perzentile höchstens 0,5 Prozent davon abweichen, und dass kill -9 auf einen Agenten den Lauf mit Exit 3 beendet und sein k6 mitnimmt. Die Transporte ssh und Docker laufen in denselben Jobs.

Der erste Lauf gegen echtes k6 fand zwei Dinge, die der Stub verdeckt hatte. k6 2.x hat --no-summary entfernt. Und k6 1.x meldet über seine API keinen Exit-Code, sodass exec.test.abort() auf einer Instanz wie ein sauberes Ende aussah und die übrigen Agenten weiterliefen.

Grenzen

Alle Tests liefen auf einer Maschine pro CI-Job, eine Flotte getrennter Hosts über ein echtes Netz ist also ungetestet. Der JSON-Strom kostet Durchsatz. Mit k6, Ziel und Agent auf einem geteilten Runner mit 4 vCPUs erreichte ein Agent 48 Prozent der Requests pro Sekunde von reinem k6 auf v2.3.0 und 87 Prozent auf v1.8.2, der größte Teil des Verlusts kam allein von --out json. Die Geschwindigkeit der Runner schwankte um mehr als den Faktor zwei, aussagekräftig sind daher nur diese Verhältnisse innerhalb eines Jobs. Perzentile haben bis zu 0,5 Prozent relativen Fehler, und handleSummary() wird nicht aufgerufen.

Der Code liegt unter github.com/Jwrede/k6-fleet.