Skip to main content
Back to course
k6: нагрузочное тестирование как система
15 / 1788%

Масштабирование генераторов нагрузки и k6 в Kubernetes

25 минут

Признаки, что нужна горизонталь

  • 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% limit
  • Network throughput < 80% bandwidth
  • http_req_connecting стабильная — если растет, проблема в сети

Если генератор НЕ здоров:

  1. Увеличьте ресурсы (CPU/RAM/parallelism)
  2. Пересоздайте тест
  3. Только ПОСЛЕ этого делайте выводы о сервисе

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).

Что пошло не так (первая попытка):

  1. Запустили 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

  2. Результаты (провал):

    • 🔴 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
  3. 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):

  1. Установили k6-operator:

    helm repo add grafana https://grafana.github.io/helm-charts
    helm install k6-operator grafana/k6-operator -n k6-system --create-namespace
  2. Создали 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
  3. 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
      });
    }
  4. Результаты (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:

  1. Network bandwidth distribution:

    • Single instance: 16 Gbps needed, но NIC limit 10 Gbps → bottleneck
    • Distributed: 10 pods × 1.6 Gbps = 16 Gbps total → no bottleneck
  2. TCP connection limits:

    • Single instance: 65K ephemeral ports limit (ОС) → невозможно 100K connections
    • Distributed: 10 pods × 10K connections = 100K, каждый pod < 65K → ok
  3. 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:

  1. > 50K concurrent connections (WebSocket, long-polling)
  2. > 10K RPS с realistic payloads (>1KB)
  3. Network-intensive tests (video streaming, file uploads)
  4. Multi-region latency testing
  5. Генератор 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: изучим продвинутые паттерны и финальный чек-лист внедрения.

Масштабирование генераторов нагрузки и k6 в Kubernetes — k6: нагрузочное тестирование как система — Potapov.me