Realistische Lasttests mit k6: offene und geschlossene Modelle, Ankunftsraten und Coordinated Omission
Warum die meisten Lasttests Latenzen zu niedrig ausweisen und wie Sie das mit Arrival-Rate-Executors in k6, sinnvollen Thresholds, Denkzeit und sauberer Korrelation beheben.
Die meisten Lasttests beantworten eine etwas andere Frage als die, die das Team eigentlich gestellt hat. Das Team möchte wissen: Was passiert mit unseren Nutzern, wenn das Dreifache des normalen Traffics eintrifft? Das Skript misst: Was passiert, wenn 200 Roboter so schnell auf die API einhämmern, wie diese es zulässt? Das klingt ähnlich. Sobald das System langsamer wird, liefern beide Fragen jedoch sehr unterschiedliche Antworten.
Dieser Artikel geht die Entscheidungen durch, die einen k6-Test realistisch machen: das Lastmodell, den Executor, Thresholds, Denkzeit und Korrelation. Außerdem behandelt er die Messfalle namens Coordinated Omission. Alle Beispiele verwenden die JavaScript-API von k6 und laufen wie gezeigt.
Geschlossene und offene Lastmodelle
Ein Lastgenerator folgt einem von zwei Modellen, ob Sie es bewusst gewählt haben oder nicht.
In einem geschlossenen Modell durchlaufen eine feste Anzahl virtueller Nutzer (VUs) eine Schleife. Jeder VU sendet eine Anfrage, wartet auf die Antwort, pausiert optional und wiederholt das Ganze. Der Durchsatz hängt von der Antwortzeit ab: Mit 50 VUs, 1 Sekunde Pause und 100 ms Antwortzeit erhalten Sie etwa 50 / 1,1 ≈ 45 Anfragen pro Sekunde. Steigen die Antwortzeiten auf 2 Sekunden, erzeugen dieselben 50 VUs nur noch 50 / 3 ≈ 17 Anfragen pro Sekunde. Die Last sinkt genau dann, wenn das System Probleme hat.
In einem offenen Modell trifft neue Arbeit nach einem Zeitplan ein, der nicht vom System abhängt. Pro Sekunde kommen zehn neue Nutzer, egal ob die letzten zehn in 100 ms oder in 10 Sekunden bedient wurden. So verhält sich echter Traffic auf die meisten öffentlichen APIs und Web-Apps: Ihre Kunden wissen nicht, dass der Checkout gerade langsam ist, und warten nicht höflich, bevor sie klicken.
In k6 sind constant-vus, ramping-vus, per-vu-iterations und shared-iterations Executors für das geschlossene Modell. constant-arrival-rate und ramping-arrival-rate sind Executors für das offene Modell. Hier ein typisches Skript im geschlossenen Modell:
import http from 'k6/http';
import { sleep } from 'k6';
// Closed model: a fixed pool of 50 VUs. Each VU sends the next request
// only after the previous one has returned (plus 1 s of sleep).
export const options = {
scenarios: {
closed: { executor: 'constant-vus', vus: 50, duration: '10m' },
},
};
export default function () {
http.get(`${__ENV.BASE_URL}/v1/products`);
sleep(1);
}Es ist nicht falsch, misst aber etwas Bestimmtes: das Verhalten eines Systems, das eine feste Population bedient, die sich zurückhält, sobald es langsam wird. Das passt zu manchen Systemen, etwa zu einer festen Gruppe von Back-Office-Mitarbeitern oder einem Batch-Consumer mit fester Thread-Anzahl. Für eine öffentliche API mit Internet-Traffic ist es meist das falsche Modell.
Arrival-Rate-Executors in der Praxis
Arrival-Rate-Executors planen Iterationen, nicht VUs. Sie geben an, wie viele Iterationen pro timeUnit starten sollen, und k6 nimmt für jede Iteration einen freien VU aus einem Pool. Ist kein VU frei, legt k6 weitere an, bis höchstens maxVUs. Kann eine Iteration trotzdem nicht pünktlich starten, wird sie übersprungen und die Metrik dropped_iterations erhöht.
import http from 'k6/http';
import { check } from 'k6';
const BASE = __ENV.BASE_URL;
// Open model: k6 starts new iterations at a fixed schedule,
// regardless of how fast the system under test responds.
export const options = {
scenarios: {
create_orders: {
executor: 'ramping-arrival-rate',
startRate: 10, // iterations per timeUnit at t = 0
timeUnit: '1s',
preAllocatedVUs: 100, // initialised before the test starts
maxVUs: 1000, // ceiling; beyond it, iterations are dropped
stages: [
{ target: 50, duration: '5m' }, // ramp to normal peak (50 orders/s)
{ target: 50, duration: '15m' }, // hold
{ target: 150, duration: '5m' }, // ramp to 3x peak
{ target: 150, duration: '10m' }, // hold
{ target: 0, duration: '2m' }, // ramp down
],
},
},
thresholds: {
'http_req_duration{name:create_order}': ['p(95)<500', 'p(99)<1200'],
http_req_failed: ['rate<0.01'],
checks: ['rate>0.99'],
// Any dropped iteration means the schedule was not kept: treat as a failed run.
dropped_iterations: ['count==0'],
},
};
export default function () {
const res = http.post(
`${BASE}/v1/orders`,
JSON.stringify({ sku: 'SKU-1001', qty: 1 }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'create_order' } },
);
check(res, { 'status is 201': (r) => r.status === 201 });
}Einige Details sind wichtig:
preAllocatedVUswerden vor Testbeginn initialisiert. Alle weiteren VUs entstehen während des Tests, und die Initialisierung eines VU kostet CPU und Speicher auf dem Lastgenerator. Legen Sie ungefähr so viele vorab an, wie Sie erwartungsgemäß brauchen.maxVUsist eine Sicherheitsgrenze, kein Zielwert. Erreicht der Test diese Grenze, ist entweder das System sehr langsam geworden (jede Iteration belegt einen VU länger) oder die Grenze ist zu niedrig.dropped_iterationsist das wichtigste Signal in einem Test mit offenem Modell. Ein Wert über null bedeutet, dass k6 den Zeitplan nicht einhalten konnte. Die tatsächlich erzeugte Last war also geringer als angenommen. Der Thresholdcount==0macht daraus einen harten Fehlschlag.
Wie viele VUs brauchen Sie? Nutzen Sie Little's Law: Nebenläufigkeit = Ankunftsrate × Verweildauer. Bei 50 Iterationen pro Sekunde und einer mittleren Iterationsdauer von 0,4 s sind im Schnitt etwa 20 VUs beschäftigt. Verschlechtert sich die Latenz unter Stress auf 4 s, brauchen Sie 200. Dimensionieren Sie preAllocatedVUs für den Normalfall und maxVUs für den degradierten Fall, den Sie beobachten möchten.
Das Lastmodell aus echtem Traffic ableiten
Ein Executor ist nur so gut wie die Zahlen, die Sie ihm geben. Bevor Sie das Skript schreiben, werten Sie eine Woche Access-Logs oder APM-Daten aus und ermitteln:
- Spitzen-Ankunftsrate pro Endpunkt: nicht den Tagesdurchschnitt, sondern die stärkste Minute bzw. die stärksten fünf Minuten, und wann sie auftreten (Zahltag, Montagmorgen, Monatsabschluss).
- Den Anfragemix: Bestehen 70 % des Traffics aus Lesezugriffen und 2 % aus Zahlungsanlagen, ist ein Test, der nur Zahlungen erzeugt, ein Stresstest eines einzelnen Endpunkts und kein Lasttest des Systems.
- Sitzungen: wie viele Anfragen eine typische User Journey auslöst und wie lange Nutzer zwischen den Schritten pausieren.
- Datenprofil: wie viele Konten, Produkte oder Mandanten aktiv sind. Caches verhalten sich völlig anders, wenn jede Anfrage denselben Datensatz trifft.
Modellieren Sie verschiedene Traffic-Klassen in k6 als getrennte Szenarien, jeweils mit eigenem Executor, eigener Rate und eigener exec-Funktion. Ein typischer Mix könnte ein Szenario für anonymes Browsen mit 200 Iterationen/s enthalten, eines für angemeldete Journeys mit 20/s und eines für Webhooks eines Partners mit 5/s. Thresholds lassen sich dann über das automatische Tag scenario pro Szenario festlegen.
Thresholds mit Aussagekraft
Thresholds machen aus einem Lasttest statt eines Diagramms eine Bestanden/Nicht-bestanden-Prüfung, die in der CI laufen kann. Drei Regeln helfen:
- Thresholds auf Perzentile, pro Endpunkt. Ein globales
p(95)über alle Anfragen vermischt Health-Checks mit 5 ms und Berichtsgenerierung mit 800 ms und verdeckt beides. Versehen Sie Anfragen mit einem stabilenname-Tag und setzen Sie Thresholds aufhttp_req_duration{name:create_order}. Bei URLs mit IDs im Pfad setzen Sietags.nameselbst oder verwenden das Tagged Template Literalhttp.url, das diese automatisch gruppiert. - Leiten Sie die Werte aus SLOs ab, nicht aus dem letzten Lauf. Lautet das Produktversprechen „Zahlungen werden für 99 % der Nutzer in unter einer Sekunde bestätigt“, ist der Threshold
p(99)<1000auf diesem Endpunkt. Ein Threshold aus dem gestrigen Ergebnis sagt nur aus, ob sich etwas verändert hat. - Kombinieren Sie Latenz immer mit Fehlern. Ein System, das jede Anfrage mit einem schnellen 503 beantwortet, hat hervorragende Latenzen. Ergänzen Sie
http_req_failedund, für fachliche Korrektheit, einen Rate-Threshold aufchecks.
Setzen Sie abortOnFail mit Bedacht ein. In CI-Smoke-Tests spart es Zeit. In einem Kapazitätstest entstehen die wertvollsten Daten jedoch meist nach der ersten Threshold-Verletzung.
Denkzeit und realistische Journeys
Denkzeit ist die Pause zwischen den Aktionen eines Nutzers. Im geschlossenen Modell steuert sie faktisch die Anfragerate, weshalb Skripte im geschlossenen Modell so oft mit sleep(1) enden. Im offenen Modell bestimmt die Ankunftsrate, wie viele Journeys starten, und die Denkzeit, wie lange jede Journey dauert. Das verändert die Nebenläufigkeit, also die Zahl gleichzeitig aktiver Sitzungen, und wirkt sich auf Session Stores, Connection Pools und Caches aus.
Verwenden Sie Denkzeit also, um echte Pausen innerhalb einer Journey abzubilden (etwa das Lesen einer Kontoseite vor einer Überweisung), randomisieren Sie sie, und fügen Sie am Ende einer Arrival-Rate-Iteration kein sleep „zur Taktung“ ein. Die Taktung ist Aufgabe des Executors, und ein abschließendes Sleep blockiert nur unnötig einen VU.
Korrelation und Testdaten
Echte Nutzer senden keine fest codierte Anfrage. Sie melden sich an, erhalten ein Token, listen ihre Konten auf und arbeiten mit IDs, die der Server zurückgegeben hat. Ein Skript, das dies überspringt, lässt jeden VU mit demselben Token auf denselben gecachten Datensatz zugreifen. Der Test misst dann Ihren Cache, nicht Ihr System.
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import exec from 'k6/execution';
const BASE = __ENV.BASE_URL;
const RUN_ID = __ENV.RUN_ID || `${Date.now()}`;
// Loaded once in the init context and shared read-only between VUs.
// users.json: [{ "email": "...", "password": "...", "payee_id": "..." }, ...]
const users = new SharedArray('users', () => JSON.parse(open('./users.json')));
export const options = {
scenarios: {
banking_journey: {
executor: 'constant-arrival-rate',
rate: 20, // 20 new journeys per second
timeUnit: '1s',
duration: '20m',
preAllocatedVUs: 250, // ~ rate x mean journey duration (see Little's Law)
maxVUs: 800,
},
},
thresholds: {
'http_req_duration{name:login}': ['p(95)<300'],
'http_req_duration{name:get_account}': ['p(95)<200'],
'http_req_duration{name:create_transfer}': ['p(95)<600'],
http_req_failed: ['rate<0.01'],
dropped_iterations: ['count==0'],
},
};
function params(name, token, extraHeaders) {
const headers = Object.assign({ 'Content-Type': 'application/json' }, extraHeaders || {});
if (token) headers.Authorization = `Bearer ${token}`;
return { headers: headers, tags: { name: name } };
}
// Randomised think time between min and max seconds.
function think(min, max) {
sleep(min + Math.random() * (max - min));
}
export default function () {
// A different user per iteration, not per VU: spreads load across accounts.
const user = users[exec.scenario.iterationInTest % users.length];
const login = http.post(`${BASE}/v1/auth/login`,
JSON.stringify({ email: user.email, password: user.password }), params('login'));
if (!check(login, { 'login: 200': (r) => r.status === 200 })) {
return; // no token: abandon this journey instead of producing junk 401s
}
const token = login.json('access_token'); // correlation #1
think(2, 5);
const list = http.get(`${BASE}/v1/accounts`, params('list_accounts', token));
const accountId = list.json('data.0.id'); // correlation #2
const account = http.get(`${BASE}/v1/accounts/${accountId}`, params('get_account', token));
check(account, { 'account: 200': (r) => r.status === 200 });
think(3, 8);
const transfer = http.post(`${BASE}/v1/transfers`,
JSON.stringify({ from: accountId, to: user.payee_id, amount: '10.00', currency: 'EUR' }),
params('create_transfer', token,
{ 'Idempotency-Key': `${RUN_ID}-${exec.scenario.iterationInTest}` }));
check(transfer, { 'transfer: 201': (r) => r.status === 201 });
}Was dieses Skript richtig macht:
- Korrelation: Token und Konto-ID stammen aus vorherigen Antworten (
res.json()akzeptiert einen Pfad im GJSON-Stil wiedata.0.id). - Testdaten in großem Umfang:
SharedArraylädt die Nutzerdatei einmal und teilt sie schreibgeschützt zwischen den VUs. OhneSharedArrayhielte jeder VU eine eigene Kopie im Speicher. Die Auswahl überexec.scenario.iterationInTestverteilt Iterationen auf die Nutzer, statt jedem VU fest einen Nutzer zuzuordnen. - Frühzeitiger Abbruch innerhalb einer Journey: Schlägt die Anmeldung fehl, endet die Iteration. Andernfalls scheitern die folgenden Anfragen mit 401, was die Fehlerzahlen aufbläht und die Latenzen dieser Endpunkte verfälscht.
- Idempotenzschlüssel sind pro Iteration und pro Lauf eindeutig. Wiederholungen innerhalb des Systems verhalten sich so wie in Produktion, und wiederholte Läufe kollidieren nicht.
Achten Sie darauf, dass die Kardinalität der Testdaten der Produktion entspricht. Fünf Testnutzer hinter einem Cache sind nicht fünftausend echte Nutzer. Planen Sie auch, wie Sie den Zustand zurücksetzen: Ein 20-minütiger Lauf mit 20 Überweisungen pro Sekunde erzeugt 24.000 Überweisungen.
Coordinated Omission: die Messfalle
Gil Tene prägte den Begriff Coordinated Omission für einen Fehler in der Art, wie viele Lasttools Latenz messen. Stellen Sie sich einen Tester im geschlossenen Modell vor, der eine Anfrage pro Sekunde sendet. Der Server hängt 10 Sekunden lang. Der Tester sendet Anfrage Nr. 1, wartet 10 Sekunden auf die Antwort und erfasst einen langsamen Messwert von 10 s. Die neun Anfragen, die während des Hängers gesendet worden wären, wurden nie gesendet und daher nie gemessen. Wären echte Nutzer weiterhin im Sekundentakt eingetroffen, hätten sie 9 s, 8 s, 7 s usw. gewartet. Das Tool hat sich mit dem getesteten System „koordiniert“ und genau die Messwerte ausgelassen, auf die es ankam.
Das Ergebnis sind Perzentile, die deutlich besser aussehen als die Realität, vor allem ab p99. Während eines Hängers kann der Unterschied zwischen ausgewiesenem und tatsächlich erlebtem p99 eine Größenordnung betragen.
Wo k6 hilft und wo nicht:
- Arrival-Rate-Executors starten Iterationen weiterhin planmäßig, solange freie VUs vorhanden sind, auch wenn frühere Iterationen hängen. Langsame Phasen erzeugen viele langsame Messwerte, wie es bei echten Nutzern der Fall wäre.
- k6 misst jede Anfrage ab dem Zeitpunkt, an dem sie tatsächlich gesendet wird. Gehen dem Executor die VUs aus, startet er die Iteration nicht verspätet und tut so, als wäre sie pünktlich. Er verwirft sie und zählt sie in
dropped_iterations. Deshalb muss diese Metrik für einen gültigen Lauf null sein: Verworfene Iterationen sind ausgelassene Messungen. - Executors im geschlossenen Modell sind konstruktionsbedingt anfällig für Coordinated Omission. Verwenden Sie sie nur, wenn Sie tatsächlich eine geschlossene Population modellieren.
Auch der Lastgenerator selbst kann Coordinated Omission verursachen. Ist seine CPU ausgelastet, gehen Anfragen verspätet raus und die Messwerte werden verrauscht. Überwachen Sie daher auch CPU und Netzwerk des Generators, halten Sie ihn deutlich unter der Sättigung und verteilen Sie den Test bei Bedarf auf mehrere Maschinen.
Checkliste, bevor Sie den Zahlen trauen
- Der Executor entspricht der Realität: Arrival-Rate für offenen Traffic, VU-basiert nur für tatsächlich geschlossene Populationen.
- Raten und Mix stammen aus Produktionsdaten, einschließlich der echten Spitze statt eines Durchschnitts.
dropped_iterationsist null, und die CPU des Lastgenerators blieb deutlich unter der Sättigung.- Thresholds gelten pro Endpunkt, basieren auf Perzentilen, sind an SLOs gekoppelt und mit Fehlerraten kombiniert.
- Anfragen sind korreliert, Testdaten haben eine produktionsnahe Kardinalität, und Caches sind weder künstlich warm noch künstlich kalt.
- Die Umgebung ist dokumentiert (Instanzgrößen, Datenvolumen, Versionen), damit der Lauf wiederholt und verglichen werden kann.
- Rohdaten werden aufbewahrt, nicht nur die Zusammenfassung am Testende:
# Run against staging, tag the run, and keep raw results for later analysis
k6 run \
-e BASE_URL=https://staging.example.test \
-e RUN_ID="$(date +%Y%m%d-%H%M)" \
--summary-trend-stats="avg,min,med,p(90),p(95),p(99),p(99.9),max" \
--out json=results.json \
journey.jsDie Standardzusammenfassung am Testende ist ein guter Anfang, fasst aber den gesamten Lauf in einem Satz von Zahlen zusammen. Eine zweiminütige Latenzspitze mitten in einem 30-minütigen Test verändert das Gesamt-p95 kaum. Bewahren Sie die rohe Zeitreihe auf (JSON-Ausgabe oder ein Stream nach Prometheus oder Grafana Cloud k6), damit Sie sehen, wann etwas schiefging, und nicht nur, ob. Wie Sie diese Ergebnisse richtig lesen, ist ein eigenes Thema, das wir in unserem Artikel zu Perzentilen, Histogrammen und Sättigung behandeln.
Möchten Sie einen zweiten Blick auf Ihr System?
Fordern Sie den kostenlosen 20-Minuten-Check an: kurze schriftliche Befunde, nicht-invasiv, unverbindlich.