Часть 2 из 4: построение настоящего набора тестов k6 для живого приложения на Kubernetes
В первой части я рассказал о философии k6 и разобрал анатомию первого теста. Теперь переходим к делу — продакшн-уровневый набор тестов (production-grade test suite), запущенный против живого микросервисного приложения на домашнем Kubernetes-кластере (homelab Kubernetes cluster). Включая то, что пошло не так при первом запуске, и как я это отладил. Весь код можно найти здесь: https://github.com/mwimpelberg28/k6-playground
Цель тестирования: Online Boutique
Вместо того чтобы тестировать заглушку или игрушечный API, мне хотелось чего-то похожего на настоящую продакшн-систему. Google’s Online Boutique — это демонстрационное микросервисное приложение из 11 сервисов, покрывающее реалистичный стек электронной коммерции: фронтенд, корзина, оформление заказа, каталог товаров, конвертация валют, рекомендации и многое другое.
Развернуть его заняло около двух минут:
kubectl create namespace boutique
kubectl apply -n boutique -f \
https://raw.githubusercontent.com/GoogleCloudPlatform/microservices-demo/main/release/kubernetes-manifests.yaml
Мой домашний кластер работает на kubeadm поверх Ubuntu с MetalLB для балансировки нагрузки. Через 30 секунд MetalLB назначил реальный внешний IP, и приложение начало обслуживать трафик по адресу http://10.4.20.2.
kubectl get svc -n boutique frontend-external
# NAME TYPE EXTERNAL-IP PORT(S)
# frontend-external LoadBalancer 10.4.20.2 80:xxxxx/TCP
Архитектурное решение, которое важнее всего
Прежде чем написать хоть один тест, я спроектировал многоуровневую структуру проекта. Именно она отличает набор тестов от папки со скриптами.
k6-boutique/
├── src/
│ ├── config/ ← опции тестов в JSON, выбираются во время запуска
│ │ ├── smoke.config.json
│ │ ├── load.config.json
│ │ ├── stress.config.json
│ │ └── browser.config.json
│ ├── scenarios/ ← пользовательские сценарии: цепочки скриптов + sleep
│ │ ├── browseFlow.js
│ │ ├── shopperFlow.js
│ │ ├── currencyFlow.js
│ │ └── stressFlow.js
│ ├── scripts/ ← отдельные действия на страницах: один group() на файл
│ │ ├── home.js
│ │ ├── product.js
│ │ ├── cart.js
│ │ ├── checkout.js
│ │ └── currency.js
│ ├── pages/ ← классы Page Object Model для браузерных тестов
│ │ ├── HomePage.js
│ │ └── ProductPage.js
│ ├── lib/ ← общий HTTP-клиент и проверочные утверждения
│ │ ├── client.js
│ │ └── checks.js
│ ├── main.js ← единственная точка входа для всех HTTP-тестов
│ └── browser.js ← точка входа для браузерных тестов
├── webpack.config.js
└── package.json
Представьте это как конструктор лего. Слой lib/ умеет общаться с приложением. Слой scripts/ оборачивает каждое действие в именованный group(). Слой scenarios/ связывает эти действия в пользовательские сценарии. Слой config/ задаёт профиль нагрузки и пороговые значения для каждого типа теста. Ни один слой не обращается дальше, чем на один уровень вниз.
Общий клиент
src/lib/client.js знает, как разговаривать с приложением: базовый URL, вспомогательные функции для запросов, идентификаторы товаров, тело запроса для оформления заказа. Все слои импортируют из него. Изменить целевой URL достаточно один раз — и всё подхватит изменение автоматически.
Один нюанс, на который стоит обратить внимание: каждый запрос несёт тег name.
// src/lib/client.js
function params(name) {
return { headers: baseHeaders, tags: { service: 'frontend', name } };
}
export function getProduct(productId) {
return http.get(`${BASE_URL}/product/${productId}`, params('get-product'));
}
export function addToCart(productId, quantity = 1) {
return http.post(`${BASE_URL}/cart`, { product_id: productId, quantity: quantity.toString() }, params('add-to-cart'));
}
Без тега name k6 будет отслеживать /product/0PUK6V6EV0 и /product/1YMWWN1N4O как отдельные метрические ряды. При 10 идентификаторах товаров и большом количестве виртуальных пользователей (VU) вы быстро упрётесь в лимит Grafana Cloud «слишком много рядов» (too many series). Тег name сворачивает все запросы к страницам товаров в единый ряд get-product — вне зависимости от конкретного ID в URL.
Общие проверки
src/lib/checks.js знает, как должен выглядеть правильный ответ для каждой страницы:
// src/lib/checks.js
export function checkHome(res) {
return check(res, {
'status 200': (r) => r.status === 200,
'shows products': (r) => r.body.includes('Hot Products'),
'response < 2s': (r) => r.timings.duration < 2000,
});
}
Определить один раз — использовать везде. Когда приложение изменится, достаточно поправить в одном месте.
Слой скриптов
Каждый файл в scripts/ оборачивает одно действие в именованный group() и запускает соответствующую проверку. Это единица повторного использования — сценарии вызывают именно их, а не сырые HTTP-запросы.
// src/scripts/product.js
export function browseProduct(productId) {
let ok;
group('browse product', () => {
ok = checkProductPage(getProduct(productId));
});
return ok;
}
export function viewProduct(productId) {
let ok;
group('view product', () => {
ok = checkProductPage(getProduct(productId));
});
return ok;
}
Разные имена групп важны — group_duration{group:::browse product} и group_duration{group:::view product} являются отдельными метриками, поэтому можно устанавливать разные SLA для случайного просмотра и для потока «намерен купить».
Конфиг-файлы управляют всем
Вместо того чтобы жёстко прописывать профили нагрузки прямо в тестовых файлах, каждый тип теста получает JSON-конфиг, который передаётся во время запуска. Единственная точка входа читает тот конфиг, на который вы её направите:
// src/main.js
const CONFIG_FILE = __ENV.CONFIG_FILE || '../src/config/smoke.config.json';
const testConfig = JSON.parse(open(CONFIG_FILE));
export const options = Object.assign({ insecureSkipTlsVerify: false }, testConfig);
export function setup() {
getHome(); // прогрев соединения до старта VU
sleep(2);
}
// Именованные экспорты, чтобы поле exec в JSON-конфиге могло на них ссылаться
export { browseFlow, shopperFlow, currencyFlow, stressFlow };
Шаг сборки (webpack) упаковывает всё в dist/test.main.js. JSON-конфиги остаются снаружи бандла и открываются во время выполнения, поэтому менять их можно без пересборки.
npm run build
# локальный запуск
k6 run dist/test.main.js -e CONFIG_FILE=../src/config/load.config.json
# запуск в облаке
k6 cloud run dist/test.main.js -e CONFIG_FILE=../src/config/load.config.json
Сначала — дымовой тест
Дымовой конфиг (smoke config) — это 1 VU, 5 итераций shopperFlow: главная страница → товар → добавить в корзину → оформить заказ. Единственная его задача — убедиться, что приложение работает и критические пути отвечают корректно. Если дымовой тест упал, дальше ничего не запускается.
// src/config/smoke.config.json
{
"scenarios": {
"smoke": {
"executor": "per-vu-iterations",
"vus": 1,
"iterations": 5,
"exec": "shopperFlow",
"gracefulStop": "30s"
}
},
"thresholds": {
"http_req_failed": ["rate<0.05"],
"http_req_duration": ["p(95)<2000"],
"checks": ["rate>0.90"],
"group_duration{group:::homepage}": ["avg<500"],
"group_duration{group:::view product}": ["avg<500"],
"group_duration{group:::add to cart}": ["avg<1000"],
"group_duration{group:::checkout}": ["avg<5000"]
}
}
Пороги group_duration требуют пояснения. http_req_duration показывает, насколько быстро выполняются отдельные запросы. group_duration — сколько занимает весь именованный шаг целиком: группа может содержать один запрос или несколько. Задать SLA для group_duration{group:::checkout} гораздо ближе к реальному бизнес-SLO, чем порог на сырой запрос, — ведь оформление заказа включает несколько последовательных вызовов.
Синтаксис выглядит непривычно — group:::checkout использует три двоеточия. Так k6 форматирует тег для встроенной метрики group_duration. Каждая группа, определённая в коде, автоматически получает соответствующий ряд в этой метрике.
k6 run dist/test.main.js -e CONFIG_FILE=../src/config/smoke.config.json
# или: npm run smoke
Что обнаружил первый запуск
Первый дымовой запуск: 10% ошибок, два нарушенных порога. Время ответа было отличным — p95 в 87 мс — то есть проблема была не в производительности. Что-то функционально не работало.
Шаг отладки 1 — проверить текст, который ищет проверка:
curl -s http://10.4.20.2/product/0PUK6V6EV0 | grep -i "add to cart"
# <button type="submit" class="cymbal-button-primary">Add To Cart</button>
Текст совпал точно. Значит, проверка была написана правильно — но часть запросов возвращала не 200-е ответы ещё до того, как проверка вообще срабатывала.
Шаг отладки 2 — посмотреть, что реально возвращает POST в корзину:
curl -v -X POST http://10.4.20.2/cart \
-d "product_id=0PUK6V6EV0&quantity=1" \
-H "Content-Type: application/x-www-form-urlencoded"
< HTTP/1.1 302 Found
< Location: /cart
< Set-Cookie: shop_session-id=51779754-8ac6-4ac9-bbd9-1f062a8dc1b4
POST в корзину возвращает 302 и устанавливает сессионную куку. При всего нескольких итерациях шум холодного старта до установки сессий доминировал в результатах. Исправление: увеличить количество итераций, добавить прогрев в setup() в main.js и слегка ослабить пороги — дымовой тест должен ловить катастрофические сбои, а не применять жёсткие SLO.
Вот в чём ценность тестирования против реального приложения, а не заглушки: вы обнаруживаете настоящее поведение системы.
Два бага, найденных во время нагрузочного теста
Прогон полного набора тестов выявил ещё два дефекта.
Баг 1 — успешность оформления заказов: 0%. Все 79 попыток оформить заказ завершились и вернули 200, но ни одна не нашла ожидаемый текст. Одна команда curl прояснила ситуацию:
curl -s [checkout flow with cookies] | grep -i "order\|confirm\|thank"
# Your order is complete!
Проверка в src/lib/checks.js ожидала Your order is placed. Исправлено в одном месте, подхвачено везде:
export function checkCheckout(res) {
return check(res, {
'order placed': (r) => r.status === 200 && r.body.includes('Your order is complete!'),
'response < 3s': (r) => r.timings.duration < 3000,
});
}
Баг 2 — браузерная проверка «заголовок страницы присутствует» падала во всех 41 итерации. В браузерном API k6 page.title() возвращает Promise и требует await. Исправление находится в src/scenarios/browserFlow.js:
// сломанный вариант
'page title present': () => page.title().length > 0,
// исправленный вариант
'page title present': async () => (await page.title()).length > 0,
Оба исправления — хорошее напоминание о том, что проверки ровно настолько хороши, насколько верны заложенные в них допущения. Тестовый фреймворк сделал своё дело — он немедленно обнажил несоответствия.
Пользовательские сценарии: три конкурентных потока
После прохождения дымового теста пришло время для нагрузочного. Вместо бесконечного обращения к одному эндпоинту три разных типа пользователей работают одновременно в виде отдельных сценариев k6. Все три определены в load.config.json; функции сценариев живут в src/scenarios/.
// src/config/load.config.json (секция scenarios)
{
"scenarios": {
"browsers": { "executor": "ramping-vus", "exec": "browseFlow", "stages": [{"duration":"1m","target":20}, {"duration":"3m","target":20}, {"duration":"1m","target":0}], "tags": {"journey":"browser"} },
"shoppers": { "executor": "ramping-vus", "exec": "shopperFlow", "stages": [{"duration":"1m","target":5}, {"duration":"3m","target":5}, {"duration":"1m","target":0}], "tags": {"journey":"shopper"} },
"currencyUsers": { "executor": "constant-arrival-rate", "exec": "currencyFlow", "rate": 2, "timeUnit": "1s", "duration": "5m", "preAllocatedVUs": 5, "maxVUs": 10, "tags": {"journey":"currency"} }
}
}
Browsers (Браузеры) — случайные посетители, только чтение, до 20 VU. Сценарий связывает visitHome() и несколько вызовов browseProduct() из слоя скриптов:
// src/scenarios/browseFlow.js
export function browseFlow() {
let pagesViewed = 0;
visitHome();
pagesViewed++;
sleep(randSleep(2, 5));
const numProducts = Math.floor(Math.random() * 3) + 2;
for (let i = 0; i < numProducts; i++) {
browseProduct(randomProduct());
pagesViewed++;
sleep(randSleep(1, 4));
}
browseDepth.add(pagesViewed);
}
Shoppers (Покупатели) — полный поток оформления заказа, до 5 VU. Скрипт оформления заказа возвращает { ok, duration }, чтобы сценарий мог фиксировать пользовательские метрики без доступа к сырому ответу:
// src/scenarios/shopperFlow.js
export function shopperFlow() {
visitHome();
sleep(randSleep(2, 4));
viewProduct(productId);
sleep(randSleep(1, 3));
const cartOk = addItemToCart(productId, 1);
if (!cartOk) { cartErrors.add(1); return; }
viewCart();
sleep(randSleep(2, 4));
const { ok, duration } = doCheckout();
checkoutDuration.add(duration);
checkoutSuccess.add(ok);
}
Currency switchers (Переключатели валют) — нагружает микросервис валют с постоянной частотой запросов — 2 RPS. Исполнитель constant-arrival-rate управляет пропускной способностью, а не количеством конкурентных пользователей: ровно 2 итерации в секунду вне зависимости от того, сколько каждая из них занимает. Именно так ведёт себя настоящий продакшн-трафик.
Пороги запросов по сценариям
Поскольку тег каждого сценария задаётся в JSON-конфиге ("tags": {"journey":"browser"}), можно назначать независимые пороги на длительность запросов для каждого сценария:
"http_req_duration{journey:browser}": ["p(95)<2000"],
"http_req_duration{journey:shopper}": ["p(95)<4000"],
"http_req_duration{journey:currency}": ["p(95)<2000"]
Пользовательские метрики как бизнес-SLO
Пользовательские метрики (custom metrics) определяются в файлах сценариев по месту использования. shopperFlow.js владеет метриками оформления заказа; browseFlow.js — метрикой глубины просмотра:
// src/scenarios/shopperFlow.js
const checkoutDuration = new Trend('boutique_checkout_duration', true);
const checkoutSuccess = new Rate('boutique_checkout_success');
const cartErrors = new Counter('boutique_cart_errors');
Пороги в load.config.json выражают реальные бизнес-требования:
"boutique_checkout_duration": ["p(95)<5000"],
"boutique_checkout_success": ["rate>0.80"]
Это и есть переход от инфраструктурных SLO к бизнес-SLO — задокументированных в коде, хранящихся в системе контроля версий и автоматически проверяемых в CI.
Результаты по всем четырём типам тестов
После исправления обоих багов и повторного прогона полного набора:
Результаты складываются в чёткую картину.
Время ответа под нормальной нагрузкой — отличное. Дымовой тест показал p95 в 89 мс, нагрузочный — p95 в 273 мс. Приложение уверенно справляется с реалистичным трафиком на домашнем железе.
Оформление заказов: 0% → 100% после исправления. Все 80 попыток оформить заказ завершились успешно, p95 составил 224 мс при пороге 5000 мс. Баг был целиком в проверочном утверждении, а не в самом приложении.
Браузерные Web Vitals в хорошей форме. LCP — 335 мс, FCP — 255 мс: оба показателя укладываются в целевые значения Core Web Vitals. TTFB — 36 мс — превосходный результат. CLS — 0,117, чуть выше цели в 0,10: стоит держать под наблюдением, но не критично. Примечание: в браузерных тестах намеренно нет вызовов group() — в браузерном контексте существует давно известный баг k6 с группами.
Страница товара первой начала сбоить под стрессом. Ошибки на странице товара стали накапливаться примерно с отметки 100 VU, тогда как главная страница держалась вплоть до пика в 150 VU — 9907 успешных проверок, ноль ошибок с кодом 500. Страница товара в итоге набрала 2037 сбоев. Это архитектурно обоснованно: страница товара разворачивает веерный вызов к каталогу товаров, сервису рекомендаций и сервису валют одновременно. Под нагрузкой эти нижестоящие вызовы начинают выстраиваться в очередь. Граф вызовов главной страницы проще, поэтому она деградирует позже.
Средняя глубина просмотра — 4,0 страницы за сессию — случайный просмотр товаров в сценарии browser работает как задумано и генерирует реалистичные паттерны чтения.
Что дальше
Третья часть посвящена стресс-тесту в деталях: чтение сигналов деградации, архитектурное объяснение паттерна отказов на странице товара и модуль k6 Browser для измерения Web Vitals. А также все четыре типа пользовательских метрик и способы применять их как SLO, автоматически проверяемые в CI через Grafana Cloud.