Zielone E2E, puste pytanie o pojemność
Jest 15 września. Mój zestaw funkcyjny na Playwrighcie jest zielony, logowanie przechodzi w każdym przebiegu, a na jedno pytanie nadal nie umiem odpowiedzieć: co się stanie, kiedy zaloguje się naraz pięćdziesiąt osób. Zielony test E2E mówi, że ścieżka działa dla jednego użytkownika w jednym kontekście przeglądarki. Ile takich ścieżek aplikacja uniesie naraz i jak długo każdy będzie przy tym czekał, tego test E2E nie mówi i mówić nie powinien.
We wrześniu 2022 rozdzieliłem testy na trzy koszyki: pull request, main i noc. Ten podział zostaje bez zmian. Dzisiaj dokładam do niego Grafana k6, a ten wpis jest o tym, gdzie k6 stoi obok Playwrighta. Nie przepisuję piramidy, nie ruszam bramki na pull requeście i nie wymieniam suite funkcyjnej.
Moja teza na wrzesień 2024 jest wąska. k6, zarówno w warstwie protocol, jak i w stabilnym od czerwca module k6/browser, mierzy czas i obciążenie. Playwright 1.47.1 zostaje właścicielem asercji funkcyjnych. API przeglądarkowe k6 jest zgrubnie podobne do Playwrighta, ale to nie jest drop-in: skryptu nie przeniosę z jednego narzędzia do drugiego bez przepisania, a k6 nie zastępuje zestawu E2E na pull requeście.
Stan warsztatu, żeby za rok było wiadomo, o czym mówię. k6 OSS to 0.53.0 z 13 sierpnia, pierwsze wydanie z natywnymi modułami ES. Silnikiem JavaScriptu jest w nim Sobek, fork goja, na który k6 przeszedł w czerwcu. Moduł k6/browser wyszedł z fazy eksperymentalnej w 0.52.0 z 25 czerwca, od razu z w pełni asynchronicznym API. Stary import k6/experimental/browser jeszcze działa, ale ma wsparcie tylko do 23 września i w tym wpisie nie jest pinem do niczego. Testy przeglądarkowe w Grafana Cloud k6 są ogólnie dostępne od 4 lipca, a demo w tym wpisie jest w całości OSS. Playwright to 1.47.1 z piątku 13 września, łatka do 1.47.0 z 5 września, z przypiętym do tej wersji Chromium 129.0.6668.29.
Czego tu nie będzie. Nie będzie zdania, że k6 zastępuje @playwright/test. Nie będzie nowego YAML-a od zera, bo triggery opisałem dwa lata temu, a dziś dokładam do nich dwa joby. Nie będzie liczby żądań na sekundę z produkcji, nazwy klienta ani cennika Grafana Cloud. Gatlinga ani JMetera tu nie uczę. Będzie syntetyczna aplikacja z logowaniem, skrypt protocol na jej API z progiem na p95, jeden skrypt k6/browser, który przeklikuje formularz, i test Playwrighta na tej samej ścieżce.
Wszystkie liczby w tym wpisie pochodzą z jednej maszyny z czterema rdzeniami, na której stały naraz aplikacja, k6 i Chromium. To jest ilustracja mechanizmu, a nie benchmark. Do tego zastrzeżenia wrócę w pułapkach, bo samo w sobie jest jedną z nich.
Dwie warstwy k6
Syntetyczna aplikacja jest celowo mała. Strona /login/ ma formularz z polami “E-mail” i “Hasło” oraz przyciskiem “Zaloguj”. Formularz woła POST /api/login, który sprawdza hasło i ustawia ciasteczko sesji, a po sukcesie przeglądarka przechodzi na /konto/ z nagłówkiem “Witaj”. Hasło jest sprawdzane funkcją PBKDF2 i tu siedzi cały eksperyment, bo aplikacja ma dwa tryby. W pierwszym liczy skrót w puli wątków (crypto.pbkdf2). W drugim liczy go synchronicznie (crypto.pbkdf2Sync), w tym samym wątku, który obsługuje wszystkie żądania. Drugi tryb to regres wstawiony z premedytacją, taki, jaki w prawdziwym kodzie przychodzi razem z drobnym refaktorem. Funkcjonalnie oba tryby są identyczne: to samo hasło, ten sam status, to samo ciasteczko.
Pierwsza warstwa to protocol. k6 wysyła żądania HTTP bez przeglądarki, więc wirtualny użytkownik, czyli VU, jest tani: to pętla w JavaScripcie, a nie osobny proces. Cały perf/login-api.js:
import http from 'k6/http'
import { check, sleep } from 'k6'
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'
const profiles = {
smoke: { executor: 'constant-vus', vus: 2, duration: '30s' },
load: {
executor: 'ramping-vus',
stages: [
{ duration: '30s', target: 50 },
{ duration: '1m', target: 50 },
{ duration: '15s', target: 0 },
],
},
}
export const options = {
scenarios: { login: profiles[__ENV.PROFILE || 'smoke'] },
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<300'],
},
}
const body = JSON.stringify({ email: 'qa@example.test', password: 'Haslo123!' })
const params = { headers: { 'Content-Type': 'application/json' } }
export default function () {
const res = http.post(`${BASE_URL}/api/login`, body, params)
check(res, { 'status 200': r => r.status === 200 })
sleep(1)
}Kilka rzeczy jest tu celowych. Profil ruchu wybieram zmienną PROFILE: smoke to dwóch użytkowników przez 30 sekund, a load to rampa do pięćdziesięciu, minuta na płaskim i zejście do zera. sleep(1) to czas namysłu między logowaniami, bez którego pięćdziesiąt VU zamienia się w pięćdziesiąt pętli bez oddechu. O wyniku przebiegu decydują progi z options.thresholds, a nie check. Przy przekroczonym progu k6 kończy przebieg z kodem wyjścia 99, czyli czerwonym krokiem w CI. check na status 200 jest w tym skrypcie tylko licznikiem w raporcie i w pułapkach pokażę, dlaczego to rozróżnienie ma znaczenie.
Smoke na wariancie synchronicznym przechodzi bez zadyszki: p95 42,55 ms, 58 żądań, wszystkie z kodem 200, kod wyjścia 0. Dwóch użytkowników regresu nie widzi, tak samo jak nie widzi go test E2E. Load na tym samym wariancie wygląda już inaczej:
✓ status 200
checks.........................: 100.00% ✓ 2471 ✗ 0
✗ http_req_duration..............: avg=683.18ms min=35.72ms med=871.42ms max=2.83s p(90)=905.8ms p(95)=915.45ms
✓ http_req_failed................: 0.00% ✓ 0 ✗ 2471
http_reqs......................: 2471 23.473169/s
vus_max........................: 50 min=50 max=50Przebieg kończy się komunikatem thresholds on metrics 'http_req_duration' have been crossed i kodem wyjścia 99. Każde z 2471 żądań dostało 200, żadne nie padło, a p95 wyszło 915,45 ms przy progu 300 ms. Na wariancie z pulą wątków ten sam profil daje p95 40,08 ms i kod wyjścia 0. W wariancie synchronicznym każde logowanie zajmuje na kilkadziesiąt milisekund jedyny wątek obsługujący żądania. Przy dwóch użytkownikach tego nie widać, a przy pięćdziesięciu żądania ustawiają się w kolejce.
Druga liczba z tego raportu jest równie ważna jak p95: 2471 żądań w tym samym czasie, w którym szybki wariant obsłużył 4003. Rampa VU to w k6 model zamknięty. Każdy wirtualny użytkownik czeka na odpowiedź, zanim wyśle następne żądanie, więc wolniejsza aplikacja dostaje mniej ruchu i sama sobie obniża obciążenie. Na pytanie o pięćdziesiąt równoległych sesji to jest uczciwa odpowiedź. Na pytanie, czy aplikacja wytrzyma zadaną liczbę żądań na sekundę, potrzebny jest model otwarty, czyli w k6 executor constant-arrival-rate, który wysyła żądania w stałym tempie niezależnie od tego, jak szybko system odpowiada. Ten wpis nie jest o RPS, więc zostaję przy VU.
Druga warstwa to przeglądarka. k6/browser steruje prawdziwym Chromium i zbiera metryki frontendu: Web Vitals, takie jak browser_web_vital_lcp, browser_web_vital_fcp i browser_web_vital_ttfb, oraz czasy i błędy żądań wysyłanych przez samą przeglądarkę. Tutaj VU tani już nie jest, bo każdy dostaje własną instancję Chromium. Jedyny skrypt k6/browser w tym wpisie, perf/login-browser.js:
import { browser } from 'k6/browser'
import { check } from 'k6'
import { Trend } from 'k6/metrics'
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'
const loginDuration = new Trend('login_ui_duration', true)
export const options = {
scenarios: {
ui: {
executor: 'constant-vus',
vus: 2,
duration: '1m',
options: { browser: { type: 'chromium' } },
},
},
thresholds: {
checks: ['rate==1.0'],
browser_web_vital_lcp: ['p(95)<1000'],
login_ui_duration: ['p(95)<1500'],
},
}
export default async function () {
const page = await browser.newPage()
let landed = false
try {
await page.goto(`${BASE_URL}/login/`)
await page.locator('#email').fill('qa@example.test')
await page.locator('#password').fill('Haslo123!')
const start = Date.now()
await Promise.all([
page.waitForNavigation(),
page.locator('button[type="submit"]').click(),
])
loginDuration.add(Date.now() - start)
landed = page.url().endsWith('/konto/')
} finally {
check(landed, { 'iteracja doszła do /konto/': ok => ok })
await page.close()
}
}Web Vitals mierzą ładowanie stron, a mnie interesuje jeszcze czas od kliknięcia “Zaloguj” do wylądowania na /konto/, razem z wywołaniem API. Dlatego dokładam własną metrykę login_ui_duration typu Trend, z progiem na p95. Nawigację po kliknięciu łapię przez Promise.all z page.waitForNavigation(), bo k6 nie ma asercji, która sama czeka na adres, jak expect(page).toHaveURL w Playwrighcie. check stoi w finally i ma próg rate==1.0. Dlaczego akurat tam, pokażę w pułapkach, bo to najdroższa lekcja tego tygodnia.
k6 0.53.0 nie przynosi własnej przeglądarki. Szuka Chrome’a albo Chromium w systemie, a ścieżkę można nadpisać zmienną K6_BROWSER_EXECUTABLE_PATH. Lokalnie wskazuję nią Chromium 129.0.6668.29, które Playwright 1.47.1 instaluje w swoim katalogu przeglądarek, żeby oba narzędzia chodziły na tym samym buildzie silnika. Dwóch użytkowników przez minutę na szybkim wariancie daje 174 iteracje, wszystkie zakończone na /konto/, LCP p95 38,49 ms, login_ui_duration p95 257 ms i kod wyjścia 0. Na tak prostych stronach LCP i FCP wychodzą identyczne, bo największy element pojawia się już przy pierwszym renderowaniu treści.
Gdybym potrzebował więcej przeglądarek, niż uniesie jedna maszyna, Grafana Cloud k6 ma testy przeglądarkowe z wieloma VU, osią czasu przeglądarki i zrzutami ekranu. Od 0.53.0 ten sam skrypt wysyła się tam komendą k6 cloud run. Cennika nie podaję, a do tego wpisu OSS wystarcza w zupełności.
Dlaczego nie zlewam z Playwright
Na tym samym wariancie synchronicznym, na którym k6 przekroczył próg, Playwright 1.47.1 przechodzi bez uwag. Oba testy z tests/login.spec.ts:
import { test, expect } from '@playwright/test'
test('logowanie przenosi na konto', async ({ page }) => {
await page.goto('/login/')
await page.getByLabel('E-mail').fill('qa@example.test')
await page.getByLabel('Hasło').fill('Haslo123!')
await page.getByRole('button', { name: 'Zaloguj' }).click()
await expect(page).toHaveURL(/\/konto\/$/)
await expect(page.getByRole('heading', { name: 'Witaj' })).toBeVisible()
})
test('złe hasło zostaje na logowaniu z komunikatem', async ({ page }) => {
await page.goto('/login/')
await page.getByLabel('E-mail').fill('qa@example.test')
await page.getByLabel('Hasło').fill('zle-haslo')
await page.getByRole('button', { name: 'Zaloguj' }).click()
await expect(page.getByRole('alert')).toHaveText('Błędny e-mail lub hasło')
await expect(page).toHaveURL(/\/login\/$/)
})Running 2 tests using 1 worker
✓ 1 [chromium] › login.spec.ts:3:5 › logowanie przenosi na konto (241ms)
✓ 2 [chromium] › login.spec.ts:13:5 › złe hasło zostaje na logowaniu z komunikatem (211ms)
2 passed (866ms)Oba narzędzia mówią prawdę i odpowiadają na różne pytania. Playwright sprawdza, czy ścieżka działa: czy po dobrym haśle jestem na koncie, a po złym widzę komunikat i zostaję na logowaniu. k6 sprawdza, ile ta ścieżka kosztuje pod ruchem. Gdybym jeden wynik zastąpił drugim, w każdą stronę straciłbym informację.
Playwright jest moją suite funkcyjną od decyzji z kwietnia 2022 i od pustego projektu z maja tamtego roku. Powody się nie zmieniły. Asercje same czekają na stan strony, lokatory idą po rolach i etykietach i na nich stoją moje page objecty, trzy silniki mają jedno API, a trace z CI pokazuje przy czerwonym teście krok, DOM, sieć i konsolę. Trace Viewer jest przy tym żywym narzędziem, a nie dodatkiem: 1.47.0 dodał filtry w zakładce sieci, a piątkowa 1.47.1 naprawiła w tej zakładce podgląd treści odpowiedzi. Jeden scenariusz z tej suite chodzi na pull requeście jako smoke, pełna macierz w nocy, dokładnie tak, jak to ustaliłem w 2022 roku.
k6 ma inne mocne strony i inne granice. Skrypt nie chodzi w Node, tylko na silniku Sobek, więc k6 nie rozwiązuje paczek z node_modules tak jak Node, a import @playwright/test kończy się, zanim cokolwiek wystartuje:
level=error msg="GoError: The moduleSpecifier \"@playwright/test\" couldn't be recognised as something k6 supports.\n" hint="script exception"To jest kod wyjścia 107, czyli błąd skryptu. Potem wkleiłem kroki pierwszego testu, bez asercji, do szkieletu k6 z browser.newPage() na początku i page.close() w finally, zmieniając tylko adres w page.goto na pełny. k6 otworzył przeglądarkę, załadował stronę i padł na pierwszym lokatorze z błędem TypeError: Object has no member 'getByLabel'. Osobny wariant z page.locator i asercją w stylu Playwrighta skończył się na ReferenceError: expect is not defined. Oba przebiegi zakończyły się kodem 0, bo skrypt nie miał progów, a w podsumowaniu i tak stało LCP 24,89 ms, bo page.goto zdążyło się wykonać.
Dokumentacja modułu dla wersji 0.53 mówi to uczciwie: API przeglądarki celuje w zgrubną zgodność z Playwrightem dla Node.js, a ponieważ k6 nie chodzi w Node.js, będzie się od niego trochę różnić. W praktyce te różnice to brak getByRole, getByLabel i pozostałych metod getBy* na stronie i lokatorze, brak wbudowanego expect i jeden silnik, bo type: 'chromium' w opcjach scenariusza jest wymagane i jest jedyną dostępną wartością. Asercje w stylu Chai daje biblioteka k6chaijs z jslib, ale jej expect nie czeka na stan strony tak jak expect z Playwrighta. W drugą stronę k6 ma rzeczy, których Playwright nie daje pod ręką, na przykład page.throttleCPU() i page.throttleNetwork().
Najbardziej kusi strona dokumentacji k6 o Page Object Model, według której istniejących page objectów łatwo użyć ponownie. Przykład z tej strony stoi na page.locator z atrybutem data-testid. Moje page objecty z Playwrighta stoją na getByRole i getByLabel, więc łatwe ponowne użycie znaczy u mnie przepisanie każdego lokatora. Dlatego skrypt k6 z tego wpisu ma selektory CSS po identyfikatorach z HTML-a, a test Playwrighta ma role i etykiety. Składnia wygląda znajomo: page.goto, locator, fill, click. Skryptu 1:1 nie kopiuję i nie udaję, że utrzymuję jeden kod w dwóch narzędziach.
Słowo “równolegle” też znaczy tu co innego niż w październiku 2021. Tam równoległość to wątki frameworka, procesy VSTest i joby pipeline’u, a strategy: parallel w Azure czy max-parallel w macierzy GitHub Actions mówi, na ile maszyn tnę zestaw, żeby skończył się szybciej. VU w k6 to symulowani użytkownicy w jednym procesie na jednej maszynie. Ich liczba jest częścią pytania, a nie sposobem na szybszą odpowiedź. W 2021 roku ograniczałem max-parallel, kiedy testy uderzały we wspólne środowisko, które nie zniosłoby dwudziestu klientów. k6 zamienia to zdanie w pomiar: ilu klientów środowisko znosi i jak długo każdy z nich czeka.
Rok temu pisałem, że AI jest taśmą produkcyjną, a nie nowym szczeblem piramidy. Z k6 jest tak samo. Nie spłaszcza piramidy i nie dokłada do niej piętra, bo piramida jest rysunkiem o cenie wykonania i szybkości sprzężenia zwrotnego, a load to osobne pytanie zadawane obok niej, na innym triggerze.
Gdzie w triggerach
Triggery są z września 2022 i nie zmieniam w nich ani słowa. Odpowiadam tylko na pytanie, do którego koszyka trafia każda z warstw k6.
Pull request nie dostaje nic. pr-gate zostaje jedynym wymaganym checkiem i ma ten sam skład co dwa lata temu: jednostki, kontrakty konsumenta i jeden smoke aplikacji. Do tego koszyka wpuszczam tylko to, co jest deterministyczne, a pomiar czasu na współdzielonym runnerze deterministyczny nie jest. Bramka, która czasem kłamie, przestaje być bramką, a zaczyna być formalnością, i dwa lata temu nie po to ją odchudzałem.
Na main po merdżu stawiam smoke protocol: ten sam perf/login-api.js z profilem smoke, dwóch użytkowników przez 30 sekund, te same progi. Aplikację podnoszę w tym jobie tak samo jak w jobie e2e z sierpnia 2022, plikiem Compose i pętlą po /health. Ten smoke nie złapałby regresu z synchronicznym skrótem i nie ma go łapać, bo przy dwóch użytkownikach tego regresu nie widać. Łapie dwie inne rzeczy. Pierwsza to skrypt, który się zestarzał: zmieniona ścieżka endpointu albo inny kształt żądania, na które API odpowie błędem, a każdą z nich zatrzyma próg na http_req_failed. Druga to regres widoczny już przy jednym użytkowniku, na przykład zapytanie, które nagle trwa pół sekundy. Bez tego joba o zepsutym skrypcie dowiedziałbym się od nocnego loadu, a nocny load czerwony przez literówkę w skrypcie uczy zespół, że czerwony nocny load nic nie znaczy.
Load i przeglądarka idą w nocy, w osobnym jobie obok nocnej macierzy E2E, nie w jej środku, i ten job nie jest wymagany. Skrypt nocny łączy obie warstwy tak, jak dokumentacja k6 opisuje podejście hybrydowe: dużo VU protocol generuje obciążenie, a kilka VU przeglądarki mierzy, co w tym czasie widzi użytkownik. Dokumentacja radzi zaczynać od małego udziału przeglądarki, rzędu dziesięciu procent VU albo mniej. U mnie to dwa na pięćdziesiąt dwa. Dzięki natywnym modułom z 0.53.0 plik perf/nightly.js tylko reeksportuje funkcje z dwóch skryptów i składa z nich scenariusze:
export { default as api } from './login-api.js'
export { default as ui } from './login-browser.js'
export const options = {
scenarios: {
api: {
executor: 'ramping-vus',
exec: 'api',
stages: [
{ duration: '30s', target: 50 },
{ duration: '1m', target: 50 },
{ duration: '15s', target: 0 },
],
},
ui: {
executor: 'constant-vus',
exec: 'ui',
vus: 2,
startTime: '30s',
duration: '1m',
options: { browser: { type: 'chromium' } },
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<300'],
'checks{scenario:ui}': ['rate==1.0'],
browser_web_vital_lcp: ['p(95)<1000'],
login_ui_duration: ['p(95)<1500'],
},
}k6 czyta options wyłącznie z pliku, który uruchamiam, dlatego progi są tu powtórzone. Próg na checks zawęziłem do scenariusza ui, czyli do strażnika z finally. Status odpowiedzi API pilnuje http_req_failed z budżetem poniżej jednego procenta, a próg rate==1.0 na wszystkich checks unieważniłby ten budżet przy pierwszej odpowiedzi 500 na kilka tysięcy. Przeglądarka startuje po 30 sekundach, kiedy rampa dochodzi do pięćdziesięciu VU, i mierzy całą minutę na płaskim.
Na szybkim wariancie wszystkie progi przechodzą, ale liczby i tak się ruszają: p95 API rośnie z 40,08 ms bez przeglądarki do 74,06 ms, LCP z 38,49 do 63,98 ms, a login_ui_duration z 257 do 298,95 ms. Część różnicy to obciążenie, a część to dwa Chromium, które na tej samej maszynie zabierają aplikacji procesor. Na wariancie synchronicznym ten sam plik daje:
✓ status 200
✓ iteracja doszła do /konto/
✗ browser_web_vital_lcp..........: avg=1.68s min=643.1ms med=1.57s max=3.71s p(90)=2.41s p(95)=2.47s
browser_web_vital_ttfb.........: avg=1.66s min=630.8ms med=1.55s max=3.69s p(90)=2.39s p(95)=2.45s
checks.........................: 100.00% ✓ 2489 ✗ 0
✓ { scenario:ui }..............: 100.00% ✓ 20 ✗ 0
✗ http_req_duration..............: avg=684.87ms min=36.15ms med=874.23ms max=2.32s p(90)=941.25ms p(95)=953.73ms
✓ http_req_failed................: 0.00% ✓ 0 ✗ 2469
http_reqs......................: 2469 23.438002/s
✗ login_ui_duration..............: avg=3.45s min=2.93s med=3.49s max=3.64s p(90)=3.58s p(95)=3.6sPrzebieg kończy się komunikatem thresholds on metrics 'browser_web_vital_lcp, http_req_duration, login_ui_duration' have been crossed i kodem 99. To jest dla mnie sedno całego wpisu. Strażnik przeglądarki ma 100 procent, bo każda z 20 iteracji doszła do /konto/, a z 2469 żądań API nie padło żadne. Funkcjonalnie wszystko działa, tak samo jak w Playwrighcie. Jednocześnie strony /login/ i /konto/, obie statyczne, mają LCP p95 2,47 s, a logowanie od kliknięcia do konta trwa w p95 3,6 s. Statyczne strony czekają, bo synchroniczny skrót blokuje jedyny wątek obsługujący żądania, a wszystko inne stoi za nim w kolejce. TTFB p95 2,45 s mówi to wprost: przeglądarka czeka na pierwszy bajt, a nie na rendering.
W GitHub Actions oba joby dopisuję do szkicu z 2022 roku, pod istniejące jobs:. Tak jak wtedy, bez checkoutu i publikacji wyników, żeby było widać same triggery. Instalację k6 zostawiam, bo niesie pin.
jobs:
# pr-gate, integration i nightly bez zmian
perf-smoke:
if: github.event_name == 'push'
runs-on: ubuntu-22.04
steps:
- run: docker compose -f docker-compose.e2e.yml up -d
# petla po /health jak w jobie e2e z sierpnia 2022
- uses: grafana/setup-k6-action@v1.1.0
with:
k6-version: '0.53.0'
- run: k6 run -e PROFILE=smoke perf/login-api.js
env:
BASE_URL: http://localhost:5000
load:
if: github.event_name == 'schedule'
runs-on: ubuntu-22.04
steps:
- uses: grafana/setup-k6-action@v1.1.0
with:
k6-version: '0.53.0'
browser: true
- run: k6 run perf/nightly.js
env:
BASE_URL: ${{ vars.PERF_BASE_URL }}Runner jest przypięty do ubuntu-22.04, a nie do ubuntu-latest, z tego samego powodu co dwa lata temu. k6-version też jest przypięte, bo bez niego akcja instaluje najnowsze k6 i nocny job sam przeskoczyłby na następne wydanie, to, w którym znika stary moduł przeglądarki. Z przeglądarką jest gorzej. browser: true w grafana/setup-k6-action 1.1.0 sprawdza na Linuksie google-chrome --version i instaluje google-chrome-stable z repozytorium Google tylko wtedy, gdy Chrome’a nie ma. Na hostowanym runnerze Ubuntu Chrome jest w obrazie, więc k6 mam przypięte, a przeglądarka jedzie z obrazem runnera i zmieni się bez mojego commita. Jeśli chcę przypiąć także ją, jest obraz grafana/k6:0.53.0-with-browser, w którym Chromium przychodzi razem z k6.
Nocny load celuje w stałe środowisko z vars.PERF_BASE_URL, a nie w aplikację podniesioną na tym samym runnerze. Smoke na main może dzielić maszynę z aplikacją, bo dwóch użytkowników protocol prawie nic nie kosztuje. Pięćdziesiąt VU i dwa Chromium kosztują już sporo: na moim pomiarze dołożenie dwóch VU przeglądarki do tego samego profilu podniosło p95 API z 40,08 do 74,06 ms. Generator obciążenia i aplikacja na jednej maszynie mierzą siebie nawzajem.
W 2022 roku pisałem, że nocny koszyk bez nazwiska w kalendarzu umiera w trzy tygodnie. Nocny load umiera szybciej, bo jego czerwień nie wygląda jak błąd, tylko jak liczba, którą łatwo uznać za szum. Dlatego ten job ma właściciela, który rano czyta progi, zanim ktokolwiek powie “pewnie runner”.
Pułapki września
Trzy skreślenia z tego tygodnia. W każdym k6 pokazał mi wynik, który wyglądał lepiej, niż powinien.
Skreślenie pierwsze: dwadzieścia przeglądarek na jednym runnerze. Żeby wiedzieć, ile VU przeglądarki uniesie maszyna, puściłem sam skrypt k6/browser na szybkim wariancie aplikacji, po 30 sekund dla 1, 5, 10 i 20 VU. Co dwie sekundy próbkowałem zajętość procesora całej maszyny i liczbę procesów Chromium.
| VU | Procesor maszyny | Procesy Chromium (szczyt) | LCP p95 | login_ui_duration p95 |
Iteracje |
|---|---|---|---|---|---|
| 1 | ok. 24% | 10 | 28,81 ms | 254,75 ms | 46 |
| 5 | ok. 79% | 43 | 81,75 ms | 311 ms | 152 |
| 10 | ok. 95% | 100 | 181,75 ms | 501 ms | 176 |
| 20 | ok. 96% | 200 | 366,82 ms | 954,09 ms | 180 |
Każdy VU to mniej więcej dziesięć procesów Chromium. Od dziesięciu VU liczba iteracji stoi w miejscu: podwojenie VU z dziesięciu do dwudziestu dało 180 iteracji zamiast 176, za to podwoiło p95 LCP i logowania, a powtórka pokazała to samo. Aplikacja obsługuje w tym czasie około sześciu logowań na sekundę, co zajmuje jej mniej niż ćwierć jednego rdzenia. Wąskim gardłem jest Chromium na generatorze, a nie aplikacja. Pamięć nie była problemem: przy dwudziestu VU ubyło około 2,2 GB dostępnej pamięci, czyli mniej więcej 110 MB na przeglądarkę. Procesor skończył się dużo wcześniej.
Najgorsze w tym wyniku jest to, że wygląda wiarygodnie. Wszystkie progi przeszły, LCP 366,82 ms to rozsądna liczba i nic w raporcie nie mówi, że mierzę maszynę z k6. Przy ostrzejszych progach ten przebieg byłby czerwony, a winna byłaby przeglądarka na generatorze, a nie aplikacja. Dlatego protocol zostaje tani i szeroki, a przeglądarka wąska: kilka VU, które mierzą doświadczenie użytkownika, a nie generują ruch.
Skreślenie drugie: asercja funkcyjna w skrypcie loadu. Rozróżniam dwa rodzaje sprawdzeń. Asercja funkcyjna pyta, czy aplikacja robi to, co powinna: czy po złym haśle jest komunikat, czy nagłówek mówi “Witaj”, czy odpowiedź ma właściwy kształt. Strażnik pomiaru pyta tylko, czy iteracja przeszła ścieżkę, której czas mierzę. Pierwsze mieszka w Playwrighcie i w testach kontraktowych. Drugie musi być w k6, z progiem.
Asercja funkcyjna w k6 przegrywa w obu wariantach. Dopisałem do skryptu API check, że odpowiedź ma token. Aplikacja nie zwraca tokena w treści, tylko ustawia ciasteczko, więc sprawdzenie padło dwadzieścia razy na dwadzieścia. checks pokazał 50 procent, progi przeszły, kod wyjścia 0. Bez progu na checks asercja w k6 jest licznikiem w raporcie, który ktoś przeczyta albo nie. Z progiem ten sam przebieg byłby czerwony, tylko z powodu, który nie ma nic wspólnego z obciążeniem: rano zobaczyłbym czerwony nocny load i szukałbym wąskiego gardła tam, gdzie zmienił się kontrakt API. Dwa pytania w jednym przebiegu dają czerwień, której nie umiem przypisać.
Strażnik pomiaru jest potrzebny z powodu, który znalazłem przypadkiem. Pierwsza wersja skryptu przeglądarkowego miała check na końcu bloku try, po nawigacji. Żeby zobaczyć, co się stanie, kiedy skrypt się wysypie, podmieniłem w niej jeden lokator na page.getByLabel('E-mail'), którego w k6 nie ma. Każda z 18 iteracji skończyła się wyjątkiem w logu, a podsumowanie wyglądało tak:
✓ browser_web_vital_lcp.......: avg=24.31ms min=19.5ms med=23.39ms max=34.7ms p(90)=28.66ms p(95)=31.29ms
✓ checks......................: 0.00% ✓ 0 ✗ 0
iterations..................: 18 3.539449/s
✓ login_ui_duration...........: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0sKod wyjścia 0 i wszystkie trzy progi zielone, bo w 0.53.0 próg na metryce bez żadnej próbki przechodzi: checks nie dostał ani jednego wyniku, a Trend bez próbek ma p95 równe zero. LCP jest prawdziwy, bo page.goto zdążyło się wykonać, zanim skrypt padł. Sam wyjątek w iteracji k6 tylko loguje i idzie dalej, bez wpływu na kod wyjścia. Po przeniesieniu check do finally każda iteracja zostawia wynik, także ta, która się wysypała, i ten sam zepsuty skrypt kończy się wynikiem checks 0 na 18, komunikatem o przekroczonym progu na checks i kodem 99. Dlatego w skrypcie z tego wpisu strażnik stoi w finally.
W 2019 roku pisałem, że zestaw dziesięciu testów, na które wszyscy mogą liczyć, jest lepszy niż dwieście czy trzysta testów, którym nikt nie ufa. Nocny load ma ten sam problem w ostrzejszej postaci. Wystarczy jeden zielony przebieg z zerem próbek, żeby nikt już nie ufał zielonemu.
Skreślenie trzecie: stary import k6/experimental/browser. Skrypt w stylu z wiosny, z importem k6/experimental/browser, synchronicznym browser.newPage() i bez await przed lokatorami, na 0.53.0 nadal przechodzi. Dostaję tylko ostrzeżenie, że moduł zniknie po 23 września, z odnośnikiem do przewodnika migracji. Pokusa jest oczywista: zmienić jedną linię importu i mieć spokój. Zrobiłem dokładnie to i dostałem TypeError: Object has no member 'close'. W stabilnym module browser.newPage() zwraca obietnicę, więc bez await zmienna page nie jest stroną. Pierwszy błąd poleciał z page.goto, ale wyjątek z page.close() w bloku finally go przykrył, więc log wskazuje złą linię. Kod wyjścia znowu 0, bo skrypt nie miał progów.
Migracja to nie zmiana importu, tylko await przed newPage, goto, fill, click i close, czyli przed prawie każdym wywołaniem. Przewodnik migracji mówi to wprost i dodaje dwie rzeczy, które łatwo przeoczyć: rdzeniowy check nie czeka na obietnice, a group nie działa z asynchronicznym API. Pierwszą sprawdziłem. Asynchroniczny predykat w rdzeniowym check kończy na 0.53.0 iterację błędem check() does not support async functions as arguments i nie zostawia w checks żadnej próbki, czyli prowadzi prosto w pułapkę z poprzedniego skreślenia. Dlatego w moim skrypcie najpierw liczę landed, a do check podaję zwykłą funkcję. Asynchroniczny check jest od sierpnia w jslib k6-utils.
Według zapowiedzi następne wydanie k6 usunie stary moduł. Na 0.53.0 skrypt z wiosny jeszcze chodzi, a od nocnej aktualizacji dzieli go tylko pin k6-version w CI. Kanonem jest k6/browser z await przed prawie każdym wywołaniem.
OpenAI o1-preview z 12 września nie pisze mi scenariusza loadu: profil ruchu i progi biorę z pomiaru, nie z czatu.
Podsumowanie
We wrześniu 2024 k6 mierzy pojemność, a Playwright mówi, czy ścieżka działa. Oba chodzą na swoich jobach i żaden nie udaje drugiego.
Trzy zdania, które chcę móc przeczytać za rok. Na syntetycznym logowaniu regres z synchronicznym skrótem hasła był niewidoczny dla Playwrighta i dla smoke’a na dwóch VU, a widoczny dla rampy do pięćdziesięciu VU: p95 915,45 ms przy progu 300 ms i kod wyjścia 99, przy stu procentach odpowiedzi 200. k6/browser w 0.53.0 jest zgrubnie podobny do Playwrighta, ale nie ma getBy* ani expect i zna tylko Chromium, więc skrypt piszę osobno i trzymam w nim strażnika w finally, bo próg na metryce bez próbek przechodzi. k6 nie wchodzi na pull request: smoke protocol chodzi na main, a load z dwoma VU przeglądarki w nocy, w osobnym jobie poza wymaganymi checkami.
Czego z tego nie wynika. Nie wynika, że kasuję testy Playwrighta albo przenoszę je do k6. Nie wynika, że p95 z jednej czterordzeniowej maszyny mówi cokolwiek o produkcji: mówi o mechanizmie. Nie wynika, że potrzebuję Grafana Cloud k6, bo całe demo jest OSS. I nie wynika, że smoke na main chroni przed regresem pojemności, bo właśnie pokazał, że nie chroni.
Warunek, po którym nocny load przestanie być u mnie eksperymentem i stanie się pełnoprawną częścią nocnego koszyka, formułuję tak. Generator i aplikacja stoją na różnych maszynach. Dwie noce pod rząd na tej samej wersji aplikacji dają ten sam werdykt na każdym progu. Celowo wstawiony regres, taki jak synchroniczny skrót, robi noc czerwoną, podczas gdy Playwright zostaje zielony. A ktoś ma ten job w kalendarzu i rano czyta progi, zanim powie “to pewnie runner”. Do tego czasu czerwony nocny load jest dla mnie zgłoszeniem do zbadania, a nie wyrokiem.

