Skip to Content
How-toLoad test (k6)

Как запустить load-test

Пошаговый рецепт для нагрузочного тестирования backend-сервиса. Делается перед go-live, после крупных изменений и регулярно для SLO-регрессии. Reference observability — ../conventions/observability. Profile running сервиса — profile-service.

Содержание

Зачем

Load-test нужен в трёх случаях:

  1. Перед go-live нового сервиса. Без нагрузочного теста production- ready чеклист не закрывается (см. ../checklists/production-ready).
  2. После крупных изменений. Новый hot path, новая интеграция с downstream, поменяли DB-схему — проверь, что p95/p99 не ухудшились.
  3. Регулярно, для SLO-регрессии. Weekly load-test на staging, результаты складываются в Grafana. Если p95 вырос на 20% за неделю — разбираемся, что изменилось.

Load-test без baseline бесполезен. Цифры «1000 RPS, p95 150ms» осмысленны только если ты знаешь, что «на прошлой неделе было 1200 RPS, p95 100ms».

Инструмент — k6

Стандарт — grafana/k6. Причины выбора:

  • JavaScript для сценариев — читабельнее, чем YAML или кастомные DSL.
  • Хорошая интеграция с Grafana / Prometheus (результаты стримятся в remote-write endpoint).
  • k8s-operator для cluster-нагрузок, если нужно давить с нескольких нод.
  • Активно поддерживается, bug-fix’ы быстрые.

Альтернативы (JMeter, Gatling, vegeta) — не используем. Если появилась причина, почему k6 не подходит для конкретного кейса — обсуждение в команде, issue в docs-репо, тогда добавим вариант сюда.

Установка локально

brew install k6 # macOS winget install k6 # Windows # Linux — через repo, см. k6.io/docs/get-started/installation

Проверка:

k6 version # k0.xx.x (...)

Простой сценарий — HTTP

Создай файл load-tests/review-create.js в сервис-репо:

import http from 'k6/http'; import { check, sleep } from 'k6'; export const options = { stages: [ { duration: '2m', target: 100 }, // ramp up { duration: '5m', target: 100 }, // sustained { duration: '1m', target: 0 }, // ramp down ], thresholds: { http_req_duration: ['p(95)<200', 'p(99)<500'], http_req_failed: ['rate<0.01'], }, }; export default function () { const res = http.post( `${__ENV.BASE_URL}/v1/reviews`, JSON.stringify({ place_id: 42, rating: 5, text: 'test' }), { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${__ENV.TOKEN}`, }, }, ); check(res, { 'status 201': (r) => r.status === 201 }); sleep(1); }

Разберёмся по кусочкам:

  • stages — профиль нагрузки: 2 минуты ramp’уемся до 100 VU (virtual users), 5 минут держим 100 VU, 1 минута ramp-down до 0. Итого 8 минут. Короткий тест для smoke-проверки.
  • thresholds — автоматические проверки. Если p95 > 200ms или p99 > 500ms, k6 завершится с ненулевым exit code — CI падает.
  • http_req_failed: ['rate<0.01'] — error-rate меньше 1%.
  • __ENV.BASE_URL, __ENV.TOKEN — передаются через env при запуске, не хардкодятся в скрипте.
  • sleep(1) — think-time между iteration’ами, 1 секунда.

Запуск

BASE_URL=https://staging.example \ TOKEN=<test-token> \ k6 run load-tests/review-create.js

Для CI (self-hosted runner) — тот же вызов, дополнительно с --out experimental-prometheus-rw=http://prometheus-pushgateway:9090/api/v1/write для стрима метрик в Prometheus.

Полезные флаги:

  • --vus 50 --duration 30s — override stages для быстрой ad-hoc проверки.
  • --summary-export=summary.json — выгрузка результатов в JSON для пост-обработки.
  • --http-debug=full — логирование каждого HTTP-вызова (только для отладки сценария; не для нагрузки).

Интерпретация результата

Типовой output в конце прогона:

http_req_duration.........: avg=42.3ms p(95)=120ms p(99)=250ms http_reqs.................: 15000 500/s iterations................: 14850 http_req_failed...........: 0.34%

Что смотреть:

  • p(95) < 200ms — хорошо, threshold пройден.
  • http_req_failed > 1% — плохо. Ищи причину: таймауты (timeouts в downstream), 5xx от backend’а, rate-limit Gateway’ем. Смотри логи сервиса за период теста.
  • 500/s — throughput. Сравни с expected (если одна реплика должна держать 1000 RPS, а у тебя 500 — узнай, почему).
  • p(99) - p(95) разница большая (например, 250ms vs 120ms) — tail latency, обычно от GC pauses, медленного SQL или замедлившегося downstream’а.

Grafana-dashboard для k6 — отдельная страница: показывает RPS по времени, p50/p95/p99, error rate, и это синхронизировано с backend- метриками (CPU, RAM, DB pool usage) по времени. Так видно, что началось раньше — spike RPS или spike CPU.

Сценарии для разных workload’ов

Write-heavy (POST)

export default function () { http.post(`${__ENV.BASE_URL}/v1/reviews`, payload, opts); sleep(1); }

Осторожно с:

  • DB locks. FOR UPDATE / advisory locks под высоким write load могут сериализовать запросы и обрушить latency.
  • FK-constraints и триггеры. Вставка в таблицу с 10 FK и CHECK-triggers — каждый INSERT дорогой.
  • Outbox-row. Каждый write генерирует outbox-row → нагрузка на forwarder → проверь outbox_forwarder_lag_seconds.

Read-heavy (GET)

export default function () { const id = Math.floor(Math.random() * 10000); http.get(`${__ENV.BASE_URL}/v1/places/${id}/details`); sleep(0.5); }

Смотри:

  • Cache hit ratio в Prometheus — если hit rate 99%, load почти не доходит до БД. Если 50% — кэш плохо работает, проверь TTL и invalidation policy.
  • Uniform distribution id’ов — если все запросы на один id=1, измеряешь только кэш. Если хочешь real-world — используй zipf-distribution (90% запросов на 10% популярных id).

Mixed (80/20 read/write)

import { group } from 'k6'; export const options = { scenarios: { reads: { executor: 'constant-vus', vus: 80, duration: '5m', exec: 'readScenario', }, writes: { executor: 'constant-vus', vus: 20, duration: '5m', exec: 'writeScenario', }, }, }; export function readScenario() { /* ... */ } export function writeScenario() { /* ... */ }

Два сценария стартуют одновременно, нагрузка ближе к prod-паттерну.

Что мерить в Prometheus

k6 показывает только client-side timing. Serverside-проблемы смотри в Prometheus:

МетрикаЧто значит
http_request_duration_seconds per-endpointp95/p99 на backend (без net latency между k6 и сервисом)
process_cpu_seconds_total rateCPU usage подов под нагрузкой
process_resident_memory_bytesRAM, растёт ли со временем (memory leak)
pgx_pool_idle_connections / pgx_pool_total_connectionsDB pool usage; near-limit = bottleneck
outbox_forwarder_publish_duration_secondsKafka publish latency
outbox_forwarder_lag_secondsoutbox lag — растёт = forwarder не успевает
redis_command_duration_secondsRedis latency
http_requests_total{status=~"5.."} rateserver-side error rate

Наблюдение: если client видит p95=200ms, а server видит p95=50ms — 150ms это network + client-side overhead. Норма для internet-staging; аномально для in-cluster.

Настройка дешбордов — ../conventions/observability и add-metric-and-alert.

Нагрузка Kafka consumer

Для consumer-сайда k6-HTTP не подходит напрямую. Сценарий:

  1. Отдельный publisher-скрипт наливает N сообщений в Kafka topic (Go-утилита в cmd/load-producer/main.go или kcat).
  2. Consumer обрабатывает, замеряем:
    • throughput (messages/sec — из consumer log’ов или метрики messages_processed_total).
    • lag (Kafka consumer lag через kafka_consumer_lag из Prometheus JMX exporter).
    • p95 handler duration — метрика handler’а.

Цель: throughput > input rate с запасом (input 500 msg/s, consumer держит 2000 msg/s → запас 4×).

Если lag растёт монотонно при стабильном input rate — consumer не справляется (либо медленный handler, либо забита downstream- зависимость, либо pool connection’ов переполнен).

Baseline SLA

Первый load-test на staging — это baseline. Дальше каждый тест сравнивается с ним.

Метрики, которые фиксируются в baseline:

  • p95 latency per endpoint (в Grafana-annotation).
  • Throughput per pod (RPS одного pod’а при target CPU ~70%).
  • Resource usage per RPS — CPU% и RAM MB на 100 RPS. Это цифра для capacity planning.

Правило: любой PR с регрессией >10% по baseline требует обоснования. В PR-description — почему регрессия приемлема, или как чинить.

Acceptance criteria для go-live

Production-ready чеклист (см. ../checklists/production-ready) требует «1 час sustained load» — это формула без цифр. Без acceptance criteria тест превращается в cargo-cult: «прогнали, график нарисовался, ОК».

Для каждого сервиса acceptance criteria задаются заранее и проверяются на staging-прогоне. Ниже — минимум для HTTP-сервиса; значения зафиксируй в docs/slo.md сервиса (см. ../conventions/slo-and-budget#документирование-slo) — load-test должен проверять ровно эти числа.

Минимальный прогон: 1 час, sustained target RPS

Target RPS — 2× ожидаемого prod-пикового трафика. Это headroom на непредсказуемые всплески (viral content, ретраи клиентов во время инцидента).

Hard-gate пороги (CI валит при нарушении)

МетрикаПорогОбоснование
HTTP error rate (5xx / total)≤ 0.1% sustained за часSLO 99.9% + запас. Ни одного спайка 5xx на старте прогона — он тоже считается.
p95 latency per-endpoint≤ SLO-target (см. docs/slo.md)SLO для read-endpoint’а обычно 500ms; при нагрузке не больше.
p99 latency per-endpoint≤ 2× p95 SLO-targetTail не деградирует сильнее в 2 раза.
RAM growth за час≤ 20% от baseline по окончании ramp-up’аЛюбой persistent growth = memory leak, не принимаем без фикса (см. ../troubleshooting/memory-leak).
Goroutine count growth≤ 10% после stabilizationGoroutine leak обычно даёт устойчивый рост; даже 10% за час — red flag.
DB pool idle connections≥ 20% от POOL_MAXЗапас на спайки. 0 idle в пике — pool исчерпан, запросы в очереди.
Outbox lag p99≤ 5s sustainedForwarder freshness SLO (см. ../conventions/slo-and-budget#outbox-forwarder).
Consumer lag (если есть)≤ 1000 messages sustainedConsumer держит input rate с запасом.
CPU per pod≤ 70% sustained в target RPSHeadroom на GC + всплески. 90% = pod’ы близки к throttling’у.

Любой Hard-gate порог нарушен → load-test не зачёт, сервис не выпускается в prod. Не договариваемся, не ослабляем. Либо оптимизация + повторный прогон, либо уменьшение target RPS с документированием в docs/slo.md.

Soft-gate (требуют review, но не блокируют автоматически)

МетрикаПорогДействие
Cache hit ratio< 70% на read-heavy endpointReview: ожидаемый trade-off или bug cache-policy?
Кол-во retry’ев через CB> 0.5% от total на один downstreamReview: downstream медленный, ожидаем, или CB-параметры неправильные?
4xx rate> 5%Review: ошибка в load-test сценарии (неправильные payload’ы) или баг в валидации?

Soft-gate не валит CI, но owner load-test’а документирует в отчёте прогона, почему это ок.

Шаблон отчёта прогона

После каждого go-live load-test’а — документ в сервис-репо docs/load-test-reports/YYYY-MM-DD.md:

# Load test report — YYYY-MM-DD **Service:** review-service v1.4.0 **Environment:** staging (3 replicas, 500m CPU / 512Mi limit) **Target RPS:** 600 (2× prod-peak 300) **Duration:** 1h sustained + 5m ramp ## Hard-gate | Metric | Target | Observed | Pass | |---|---|---|---| | 5xx rate | ≤ 0.1% | 0.04% | ✓ | | p95 GET /v1/reviews | ≤ 500ms | 180ms | ✓ | | p99 GET /v1/reviews | ≤ 1000ms | 310ms | ✓ | | RAM growth | ≤ 20% | +8% | ✓ | | Goroutine growth | ≤ 10% | +3% | ✓ | | DB pool idle | ≥ 20% | 35% min | ✓ | | Outbox p99 lag | ≤ 5s | 1.2s | ✓ | | CPU per pod | ≤ 70% | 55% | ✓ | ## Soft-gate notes - Cache hit ratio 91% — выше ожидаемого; invalidation policy ok. - Retry через CB: 0.1% на user-service — штатный фон. ## Issues / follow-ups - Ни одной регрессии. Sign-off для go-live.

Документ прикладывается к production-ready чеклисту. Без него go-live не подписывается.

Periodic re-run

После go-live — load-test раз в квартал на staging с тем же сценарием. Новые acceptance criteria сверяются со старыми: если деградация > 10% на любом hard-gate — issue с приоритетом P1.

Где запускать

ОкружениеUse-case
Local (один pod в docker-compose)Smoke-test: проверить, что скрипт не сломан, endpoint отвечает ожидаемо. 1–2 минуты, 10 VU.
Staging (полный stack в кластере)Основной load-test. Реальный Postgres, Kafka, Redis. Реплики, ресурсы как в проде или уменьшенные пропорционально.
CI (self-hosted runner или k6 cloud)Pre-merge для critical paths. Короткий сценарий (1–2 мин), проверка threshold’ов. Если не проходит — PR блокируется.
ProdТолько read-only сценарии, очень осторожно, с предупреждением команды SRE. Write на прод load-тест’ом — запрещено.

Red flags во время теста

  • Растущий response time при постоянной нагрузке. p95 начал 100ms, через 5 минут — 300ms, RPS не менялся. Признак resource exhaustion: memory leak, pool fullness, connection leak. Снять heap-profile, смотреть в pprof.
  • Stepwise увеличение 5xx. Error rate прыжком с 0.5% до 30% — cascading failure: один downstream упал, timeout’ы поехали на upstream, handler’ы забились.
  • Spike в p99 без роста p50. Tail-latency от GC pauses или slow query. Смотри go_gc_duration_seconds и pg_stat_statements за окно теста.
  • Kafka consumer lag растёт монотонно. throughput < input rate. Нужно либо увеличить параллельность handler’ов, либо оптимизировать handler, либо добавить реплики.

Если видишь red flag — останови тест и разберись, не продолжай «чтобы добить до конца». Дальше результат уже искажён (cascading effects).

Chaos + load

Во время sustained load — осознанно ломай что-нибудь:

  • Kill одного pod’а consumer’а / api’а → проверь, что трафик перераспределился. Цель: ≤ 30 секунд на восстановление стабильного state’а (зависит от конфигурации readiness probe и rebalance timings).
  • Drop Redis на 30 секунд → fail-open работает? (см. ../patterns/idempotent-consumer).
  • DB connection spike: PGHBA-level заблокировать 50% connection’ов → pgxpool retry работает, сервис деградирует изящно, не падает в panic.

Chaos-тест раз в релиз или раз в квартал — это зрелость системы. Сервис, который падал при убийстве одного pod’а, не production-ready.

Anti-patterns

Load-test первый раз на проде

Load на прод без предыдущего тестирования на staging = высокий риск инцидента. Порядок: local → staging → (если нужно и согласовано) prod read-only.

Load-test без baseline

«RPS 1000, p95 200ms» — числа в вакууме. Без сравнения с прошлым тестом непонятно, плохо это или хорошо. Первый тест → baseline, все последующие → сравнение.

Игнорирование Prometheus-метрик

k6 показал p95=50ms, команда обрадовалась. А в Prometheus за тот же период pgx_pool_idle=0 постоянно — pool на пределе, следующий тест уже упрётся. Клиентская сторона всегда оптимистичнее серверной.

Запуск с локальной машины на prod-endpoint

Net latency до staging/prod из дома — 50–200ms. Твои p95 — это в основном твой интернет, не backend. Run from in-cluster или close-by runner.

Не предупредить SRE перед load на staging

Staging alert’ы срабатывают на load-test (CPU spike, error rate) → SRE разбирается, «что сломалось», тратит время. Всегда в чат #ops: «в X часов запускаю load-test на staging, duration 15 минут».

Load-test с одним id’ом

http.get(`${__ENV.BASE_URL}/v1/places/42/details`);

Все запросы в одну cache-key. Real-world hit rate ≈ 0% или 100%, не измеряет distribution. Randomize id, используй Zipf или uniform, зависит от сценария.

Включил load, ушёл пить кофе

k6 может нагружать downstream, который не твой — внешний FCM, SMS-gateway, S3. Нагрузка на чужие сервисы — это инцидент с их стороны и счёт с твоей. Sanity-check сценария на 1 RPS перед ramp-up’ом.

Связанные разделы

Last updated on