Перейти к содержанию

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

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

Содержание

Зачем

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

  1. Перед go-live нового сервиса. Без нагрузочного теста production- ready чеклист не закрывается (см. ../checklists/production-ready.md).
  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-endpoint p95/p99 на backend (без net latency между k6 и сервисом)
process_cpu_seconds_total rate CPU usage подов под нагрузкой
process_resident_memory_bytes RAM, растёт ли со временем (memory leak)
pgx_pool_idle_connections / pgx_pool_total_connections DB pool usage; near-limit = bottleneck
outbox_forwarder_publish_duration_seconds Kafka publish latency
outbox_forwarder_lag_seconds outbox lag — растёт = forwarder не успевает
redis_command_duration_seconds Redis latency
http_requests_total{status=~"5.."} rate server-side error rate

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

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

Нагрузка Kafka consumer

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

  1. Отдельный publisher-скрипт наливает N сообщений в Kafka topic (Go-утилита в cmd/load-producer/main.go или kcat).
  2. Consumer обрабатывает, замеряем:
  3. throughput (messages/sec — из consumer log'ов или метрики messages_processed_total).
  4. lag (Kafka consumer lag через kafka_consumer_lag из Prometheus JMX exporter).
  5. 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 — почему регрессия приемлема, или как чинить.

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

Окружение 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.md).
  • 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'ом.

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