Масштабирование генераторов нагрузки и k6 в Kubernetes
Признаки, что нужна горизонталь
dropped_iterationsпоявляется при росте нагрузки.- CPU/Network на генераторе > 85%, а сервису еще есть запас.
- Нужно сымитировать multi-region трафик (latency важна).
Способы масштабирования
- Несколько машин под один тест: запускаем несколько k6 с одинаковым сценарием и различными
--tag shard=1/2, результаты складываем в Prom/Influx/Grafana Cloud. - k6 Cloud: готовый managed-генератор, multi-region. Хорош для быстрого старта, но помните про стоимость и приватные сети.
- Kubernetes + k6-operator: Kubernetes Job/CRD, автоматизация ресурсов и артефактов.
Пример k6-operator
# k6-test.yaml
apiVersion: k6.io/v1alpha1
kind: K6
metadata:
name: checkout-load
spec:
parallelism: 4
script:
configMap:
name: k6-script
file: checkout.js
runner:
image: grafana/k6:latest
env:
- name: BASE_URL
value: https://stage.shopstack.ioСоздаем ConfigMap с checkout.js, применяем манифест, собираем результаты из Prometheus/Influx.
Лимиты и емкость генератора
- k6 VU — горутина. Тысячи VU помещаются на 2–4 vCPU и 4–8GB RAM при HTTP-тестах, но все зависит от размера ответов и TLS.
- Следите за network throughput: большой payload или WebSocket может забить сетевой интерфейс раньше CPU.
- Для
constant-arrival-rateзакладывайтеmaxVUsс запасом (x1.5–2 кrate).
Экономика и расчеты
- Прикидка: HTTP тест ~1–5MB RAM на VU? Считайте общую нагрузку: 20k VU → февральский тест требует ~64–96GB RAM и 16–32 vCPU на несколько подов (реально меряйте на стенде).
- Разносите генераторы по отдельным нодам (noisy neighbor из-за kubelet/sidecars может портить RPS).
- Централизуйте метрики: Prometheus remote-write или InfluxDB, чтобы агрегировать десятки генераторов.
- K6 Cloud/Grafana Cloud: удобно для multi-region без возни с сетью, но учитывайте стоимость и приватность трафика; для приватных API — peering/VPN.
Архитектура k6-operator
k6-operator создает параллельные поды по параметру parallelism. Каждый под —
независимый генератор с собственным shard тегом. Метрики агрегируются в
Prometheus через remote-write, затем в Grafana для анализа.
- CRD
K6→ Runner-поды поparallelism, скрипт монтируется из ConfigMap/Secret. - Добавляйте
resources.requests/limits,nodeSelector/taints для выделенных нод (минимум фонового шума). - Метрики: через ServiceMonitor → Prometheus → remote write в общую TSDB (VictoriaMetrics/Thanos).
- Для stateful тестов: переменные окружения/Secrets с токенами; при необходимости — sharding данных через env
SHARD_IDи логику в JS.
Расчет ресурсов и пример 50k VU
- Грубая прикидка: ~1000–2000 VU на 1 vCPU для легких HTTP-тестов, 50–150 VU на 1 vCPU для Browser/тяжелых ответов. Меряйте на своих payload/RTT.
- 50k VU под HTTP: 25–50 vCPU → 10 подов по 5 vCPU + 8–12GB RAM каждый (проверяйте сетку).
- Сетевые лимиты: учитывайте bandwidth узлов; крупные ответы или TLS могут упереться в сеть раньше CPU.
Сбор метрик в масштабе
- Отмечайте поды тегом
shard:--tag shard=1для разных экземпляров, чтобы фильтровать в Grafana. - Prometheus remote-write с каждого генератора в один Prom/VM; не тащите трафик через общий Ingress, используйте ClusterIP/подсетку.
- Альтернатива: InfluxDB/VM single endpoint, либо Grafana Cloud с peering/VPN.
Multi-region
- Генерируйте трафик из географий, где ваши пользователи, чтобы увидеть латентность сети.
- В Grafana помечайте
tag:region, строите p95/p99 по регионам и принимайте решения по CDN/edge/batching.
Архитектурная схема (словами)
- Несколько Runner-подов k6 за Service/Headless Service, каждый с тегом
shard. - Prometheus Operator собирает метрики с Runner'ов и пишет remote write в центральный TSDB.
- Grafana читает из TSDB, дашборды фильтруют по
shard/region/release. - Тестируемое приложение в кластере/за LB; для multi-region — несколько генераторов в разных ЛОЦ/облаках, агрегация метрик единая.
Troubleshooting: проблемы масштабирования
Причина: Недостаточно памяти для генерации заданного количества VU.
Расчет памяти:
- ~1-5 MB на VU для простых HTTP-тестов
- ~10-50 MB на VU для Browser/тяжелых payload
- Большие response body увеличивают потребление памяти
Решение:
# 1. Увеличьте memory limits в манифесте
spec:
runner:
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi" # Увеличили с 2Gi
cpu: "2000m"
# 2. Уменьшите VU на под, увеличьте parallelism
spec:
parallelism: 8 # Было 4, стало 8
# Теперь 50k VU / 8 pods = 6250 VU на под (вместо 12500)Как отладить:
# Посмотрите память перед крашем
kubectl top pod k6-runner-xyz
# Посмотрите события
kubectl describe pod k6-runner-xyz | grep -A 5 Events
# Если видите OOMKilled:
# - Увеличьте memory limit
# - Или уменьшите VU на подПроблема: Ожидали 4 пода × 300 RPS = 1200 RPS, а видим только 800 RPS.
Причина 1: Поды конкурируют за ресурсы node
- Поды запущены на одной ноде
- Упираемся в CPU/Network ноды, а не пода
Решение — разнесите на разные ноды:
# Добавьте pod anti-affinity
spec:
runner:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution: - labelSelector:
matchLabels:
app: k6-runner
topologyKey: kubernetes.io/hostname
# Или используйте nodeSelector для выделенных нод
spec:
runner:
nodeSelector:
workload: k6-load-testingПричина 2: Недостаточно maxVUs
# Для constant-arrival-rate нужен запас VU
scenarios: {
load: {
executor: "constant-arrival-rate",
rate: 300, // Целевой RPS
maxVUs: 600, // Запас x2 на пики латентности
}
}
# Если maxVUs мал, k6 не сможет генерировать нужный RPSПричина 3: Network bandwidth исчерпан
- Проверьте network throughput pod/node
- Крупные response body (MB) быстро забивают сеть
- TLS handshake тоже ест bandwidth
Причина 1: Недостаточно ресурсов в кластере
# Проверьте доступные ресурсы
kubectl describe nodes | grep -A 5 "Allocated resources"
# Если CPU/Memory Pressure:
# - Добавьте ноды в кластер
# - Уменьшите resources.requests в K6 манифесте
# - Используйте меньше parallelismПричина 2: ConfigMap со скриптом не найден
# Проверьте существование ConfigMap
kubectl get configmap k6-script
# Если не существует — создайте:
kubectl create configmap k6-script \
--from-file=test.js=./my-test.js
# В K6 манифесте:
spec:
script:
configMap:
name: k6-script
file: test.js # Должно совпадать с ключом в ConfigMapПричина 3: RBAC / ServiceAccount проблемы
# Проверьте права k6-operator
kubectl get clusterrole k6-operator
kubectl get clusterrolebinding k6-operator
# Логи оператора покажут RBAC ошибки
kubectl logs -n k6-operator-system deployment/k6-operator-controller-managerПричина 4: nodeSelector не найдено нод
# Если указан nodeSelector:
spec:
runner:
nodeSelector:
workload: k6
# Проверьте, есть ли ноды с таким label:
kubectl get nodes --show-labels | grep workload=k6
# Если нет — либо удалите nodeSelector, либо добавьте label:
kubectl label nodes node-1 workload=k6Причина: Каждый под отправляет метрики с собственными labels, и Grafana не объединяет их автоматически.
Решение — добавьте общие labels:
# В k6 скрипте добавьте теги:
export const options = {
tags: {
test_id: "checkout-load", // Общий для всех подов
release: __ENV.RELEASE_TAG, // Передайте через env
}
};
# В Kubernetes передайте env:
spec:
runner:
env: - name: RELEASE_TAG
value: "v1.2.3"
# В Grafana query используйте фильтр:
http_req_duration{test_id="checkout-load"}Для агрегации по shard:
# В k6 добавьте shard tag из env
export const options = {
tags: {
shard: __ENV.K6_POD_NAME, // Уникальный для каждого пода
}
};
# В Kubernetes прокиньте pod name:
spec:
runner:
env: - name: K6_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
# В Grafana можете фильтровать:
# - По всем shards: test_id="checkout-load"
# - По одному shard: shard="k6-runner-1"Проверьте Prometheus targets:
# Откройте Prometheus UI → Status → Targets
# Убедитесь что все k6 поды в UP
# Если DOWN — проверьте network policy / ServiceMonitorЗолотое правило: Если генератор здоров, проблема в сервисе. Если генератор под нагрузкой — выводы недостоверны.
Метрики генератора (должны быть зелёными):
dropped_iterations = 0— критично!CPU k6 pod < 80%Memory k6 pod < 80% limitNetwork throughput < 80% bandwidthhttp_req_connecting стабильная— если растет, проблема в сети
Если генератор НЕ здоров:
- Увеличьте ресурсы (CPU/RAM/parallelism)
- Пересоздайте тест
- Только ПОСЛЕ этого делайте выводы о сервисе
Dashboard в Grafana:
# Создайте панель "Generator Health":
- CPU k6 pods (target: < 80%)
- Memory k6 pods (target: < 80%)
- dropped_iterations (target: = 0)
- http_req_connecting p95 (target: stable)
# Если хотя бы один показатель красный:
# → Нельзя доверять результатам теста!Сравнение генератор vs сервис:
| Симптом | Генератор | Сервис |
|---|---|---|
| http_req_waiting растет | ❌ | ✅ (БД/API медленные) |
| http_req_connecting растет | ✅ (сеть) | ❌ |
| dropped_iterations > 0 | ✅ (нет ресурсов) | ❌ |
| CPU генератора > 85% | ✅ | ❌ |
| CPU сервиса > 85% | ❌ | ✅ |
Причина: k6-operator оставляет завершенные Jobs для инспекции логов.
Поведение по умолчанию:
- Job остается в состоянии Completed
- Поды остаются для просмотра логов
- Это нормально — можно удалить вручную
Ручная очистка:
# Удалить конкретный тест
kubectl delete k6 checkout-load
# Удалить все завершенные k6 Jobs
kubectl delete k6 --field-selector=status.stage=finished
# Удалить поды старше 1 дня
kubectl delete pods --field-selector=status.phase=Succeeded \
-l app=k6-runner --older-than=24hАвтоматическая очистка (TTL):
# В манифесте K6 добавьте cleanup:
apiVersion: k6.io/v1alpha1
kind: K6
metadata:
name: checkout-load
spec:
parallelism: 4
script:
configMap:
name: k6-script
file: test.js
cleanup: "post" # Удалить после завершения
# Опции cleanup:
# - "post" — удалить сразу после завершения
# - "pre" — удалить перед запуском (очистить старые)
# - "never" — не удалять (по умолчанию)CronJob для автоочистки:
apiVersion: batch/v1
kind: CronJob
metadata:
name: k6-cleanup
spec:
schedule: "0 2 * * *" # Каждый день в 2:00
jobTemplate:
spec:
template:
spec:
serviceAccountName: k6-cleanup
containers: - name: kubectl
image: bitnami/kubectl:latest
command: - /bin/sh - -c - |
kubectl delete k6 --field-selector=status.stage=finished
kubectl delete pods -l app=k6-runner --field-selector=status.phase=Succeeded
restartPolicy: OnFailure📊 Реальный кейс: Gaming platform и "один генератор не справляется"
Контекст: Mobile gaming platform (стиль Fortnite/PUBG), готовились к season launch с прогнозом 100K одновременных игроков. WebSocket connections для real-time game state.
Проблема: Нужно протестировать 100K concurrent WebSocket connections с realistic game traffic (updates 20/sec per player).
Что пошло не так (первая попытка):
-
Запустили k6 на одной мощной машине:
// test-single-instance.js export const options = { scenarios: { websocket_load: { executor: "constant-vus", vus: 100000, // 100K concurrent connections duration: "30m", }, }, };Машина: AWS c5.24xlarge (96 vCPU, 192GB RAM) = $4.08/hour
-
Результаты (провал):
- 🔴 Upup только до 28K VUs:
- CPU k6-генератора: 98% (все 96 cores загружены)
- RAM: 145GB / 192GB
- Network bandwidth: 9.8 Gbps / 10 Gbps (saturated!)
- 🔴 dropped_iterations: 15K/sec (генератор не успевает)
- 🔴 http_req_sending: растет до 2.5s (network bottleneck)
- ❌ Вывод: Единичный генератор физически не может создать 100K connections
- 🔴 Upup только до 28K VUs:
-
Root cause:
- k6 — single-process Go приложение (GIL нет, но scheduler ограничен)
- Network bandwidth: 100K connections × 20 updates/sec × 1KB = 2GB/sec (16 Gbps) — превышает NIC limit
- TCP ephemeral ports: ограничение ОС ~65K concurrent connections с одного IP
Что сделали (distributed k6 в Kubernetes):
-
Установили k6-operator:
helm repo add grafana https://grafana.github.io/helm-charts helm install k6-operator grafana/k6-operator -n k6-system --create-namespace -
Создали TestRun с parallelism:
# k6-testrun-distributed.yaml apiVersion: k6.io/v1alpha1 kind: TestRun metadata: name: season-launch-load spec: parallelism: 10 # 10 генераторов script: configMap: name: k6-test-websocket file: test.js arguments: --vus=10000 --duration=30m # 10K per instance = 100K total separate: true # Агрегация метрик в один summary runner: image: grafana/k6:latest resources: requests: cpu: "8" memory: "16Gi" limits: cpu: "12" memory: "24Gi" env: - name: K6_OUT value: prometheus-remote-write=http://prometheus:9090/api/v1/push -
k6 тест (WebSocket load):
// test.js import ws from "k6/ws"; import { check } from "k6"; export const options = { scenarios: { websocket: { executor: "constant-vus", vus: __ENV.K6_VUS || 10000, duration: __ENV.K6_DURATION || "30m", }, }, thresholds: { ws_connecting: ["p(95)<500"], ws_session_duration: ["p(95)>1800000"], // > 30 min (sustained connection) dropped_iterations: ["count==0"], // ОБЯЗАТЕЛЬНО: генератор должен справляться }, }; export default function () { const url = `${__ENV.WS_URL}/game/connect`; const params = { tags: { name: "GameSession" } }; ws.connect(url, params, function (socket) { socket.on("open", () => { // Join game room socket.send( JSON.stringify({ action: "join", roomId: "season-launch" }) ); // Simulate game updates: 20/sec socket.setInterval(() => { socket.send( JSON.stringify({ action: "update", position: { x: Math.random() * 1000, y: Math.random() * 1000 }, timestamp: Date.now(), }) ); }, 50); // 50ms = 20 updates/sec }); socket.on("message", (data) => { check(data, { "server responds": (d) => d !== null, }); }); socket.on("error", (e) => { console.error("WebSocket error:", e); }); socket.setTimeout(() => { socket.close(); }, 1800000); // 30 minutes }); } -
Результаты (success):
- ✅ 100K concurrent WebSocket connections (10 pods × 10K each)
- ✅ Генераторы:
- CPU per pod: 72% (8 cores)
- RAM per pod: 14GB / 24GB
- Network per pod: 1.6 Gbps (в пределах нормы)
- dropped_iterations: 0 ✅
- ✅ Метрики приложения:
- p95 message latency: 85ms (SLO: <150ms)
- Error rate: 0.08%
- Server CPU: 58% (3000 cores Kubernetes cluster)
- ✅ Стоимость:
- 10 pods × c5.4xlarge (16 vCPU, 32GB) × 1 hour = $1.70/hour
- vs single c5.24xlarge: $4.08/hour
- Экономия: 58% + лучшее распределение нагрузки
Optimization insights:
-
Network bandwidth distribution:
- Single instance: 16 Gbps needed, но NIC limit 10 Gbps → bottleneck
- Distributed: 10 pods × 1.6 Gbps = 16 Gbps total → no bottleneck
-
TCP connection limits:
- Single instance: 65K ephemeral ports limit (ОС) → невозможно 100K connections
- Distributed: 10 pods × 10K connections = 100K, каждый pod < 65K → ok
-
Auto-scaling генераторов:
# HPA для k6 генераторов (если нагрузка растет) apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: k6-runner-hpa spec: scaleTargetRef: apiVersion: k6.io/v1alpha1 kind: TestRun name: season-launch-load minReplicas: 10 maxReplicas: 25 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 75
Season launch результат:
- Peak concurrent players: 118K (превысили прогноз на 18%)
- p95 message latency: 92ms
- Error rate: 0.12%
- Zero infrastructure incidents
- k6 distributed test дал уверенность: система выдержит пик
ROI:
- Стоимость: k6-operator setup + distributed tests = $25K
- Избежали: potential downtime на season launch ($500K+ убытки + reputation)
- Инфраструктура оптимизирована: нашли bottleneck в message queue (Kafka partitions scaling)
- ROI: 20x
Bonus: Multi-region testing:
Позже команда расширила distributed k6 на несколько AWS regions для тестирования global latency:
# k6-multi-region.yaml
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
name: global-latency-test
spec:
parallelism: 15 # 5 per region
script:
configMap:
name: k6-test-websocket
runner:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: topology.kubernetes.io/region
# Распределить pods по разным regions
nodeSelector:
k6-generator: "true"War story:
Performance Engineer: "Раньше мы думали: 'Куплю самую мощную машину — и все решится'. Но network и OS limits не купишь. Distributed k6 в Kubernetes — единственный способ тестировать > 50K concurrent connections. Plus, cheaper: 10 small instances < 1 huge instance".
CTO: "k6-operator превратил нагрузочное тестирование в commodity: один YAML — и мы тестируем 100K connections. Раньше на это уходили недели настройки JMeter clusters. Теперь — 1 час".
Урок: Единичный генератор k6 ограничен физическими пределами: CPU, RAM, network bandwidth, TCP ephemeral ports. Для high-scale нагрузки (>50K VUs, >10K RPS) обязательно используйте distributed execution:
Когда нужен distributed k6:
- > 50K concurrent connections (WebSocket, long-polling)
- > 10K RPS с realistic payloads (>1KB)
- Network-intensive tests (video streaming, file uploads)
- Multi-region latency testing
- Генератор CPU > 85% или
dropped_iterations > 0
Решения:
- k6-operator (Kubernetes) — для production-grade distributed testing
- k6 Cloud — managed solution, multi-region out-of-the-box
- Manual script (SSH + tmux) — для быстрых экспериментов без K8s
✅ Чек-лист завершения урока
После этого урока вы должны уметь:
Горизонтальное масштабирование:
- Понимать, когда генератор упирается в CPU/RAM/Network
- Знать признаки: CPU > 85%, dropped_iterations > 0, http_req_sending растет
- Запускать несколько экземпляров k6 вручную с разделением нагрузки
k6-operator в Kubernetes:
- Установить k6-operator через Helm
- Создать TestRun CRD с parallelism для распределенной нагрузки
- Настроить separateStdout для агрегации метрик
- Мониторить pods генераторов: CPU, RAM, dropped_iterations
Grafana k6 Cloud:
- Понимать отличие локального k6 vs k6 Cloud
- Знать, когда использовать Cloud (multi-region, managed execution)
- Запускать тесты через k6 Cloud CLI
- Просматривать результаты в web UI
Практическое задание:
- Запустите тест локально и найдите, при какой нагрузке генератор упирается
- Если есть K8s — установите k6-operator и запустите distributed test
- Сравните single instance vs parallelism: как изменился RPS и CPU генераторов?
Если чек-лист пройден — переходите к уроку 16: изучим продвинутые паттерны и финальный чек-лист внедрения.