ловить регрессии производительности в CI до продакшена

Тестирование производительности инфраструктуры: k6 и Gatling в CI/CD

13 минут

Ежемесячный нагрузочный прогон не ловит регрессии с каждого merge. Разделите дымовые, сценарные и стресс-уровни на k6 и Gatling, сделайте пороги воротами CI и во время нагрузки смотрите HPA, CPU throttling и пулы соединений в Kubernetes.

Почему поздние нагрузочные тесты пропускают регрессии инфраструктуры

Многие команды делают проверку производительности чекбоксом перед релизом: один длинный прогон на стенде, пара правок — и надежда. Инциденты в продакшене всё равно случаются, когда merge незаметно исчерпывает пул к базе, ужесточает лимит ingress или упирается в CPU throttling при отставании HPA. Складываются три сбоя: staging неделями отстаёт от топологии продакшена; полный набор нагрузки идёт полчаса, и его пропускают; тесты бьют только по коду приложения и игнорируют DNS, TLS, пулы соединений и автомасштабирование. Нужны ворота под скорость пайплайна — быстрые на каждый pull request, богаче перед релизом — и наблюдение за инфраструктурой, пока работает генератор нагрузки.

Три уровня: дымовые, сценарные и стресс

Делите работу по скорости обратной связи. Дымовые тесты производительности укладываются в минуту на каждом PR: немного виртуальных пользователей и жёсткие пороги — k6 завершается с ненулевым кодом (99 при нарушении threshold), и CI падает без самописного разбора JSON. Сценарные тесты моделируют реалистичные пути — вход, каталог, оформление заказа — на staging с топологией как в продакшене; Gatling удобен для читаемых многошаговых симуляций на Scala или Java с assertions. Стресс и ёмкость гоняйте еженедельно или на release candidate, чтобы найти потолок, реакцию HPA и насыщение. Берите k6 для JS-гейтов и облачного CI; Gatling — когда важнее сложные последовательности протоколов и подробные отчёты, а не сверхкороткий PR-джоб.

Дымовой гейт k6, который блокирует merge

Держите дымовые скрипты короткими и детерминированными. Цель — стабильный staging URL из секретов, не продакшен. Заложите SLO в options.thresholds, чтобы код выхода процесса был воротами — не изобретайте pass/fail по потоку JSON-точек. Выгружайте summary как артефакт для трендов; сам threshold уже должен валить job.

JavaScript · дымовой тест k6 с порогами для CI
import http from "k6/http";
import { check, sleep } from "k6";

const BASE_URL = __ENV.BASE_URL || "https://staging-api.example.com";

export const options = {
  vus: 5,
  duration: "30s",
  thresholds: {
    http_req_duration: ["p(95)<300"],
    http_req_failed: ["rate<0.01"],
    checks: ["rate>0.99"],
  },
};

export default function () {
  const res = http.get(`${BASE_URL}/health`);
  check(res, {
    "status is 200": (r) => r.status === 200,
    "body not empty": (r) => r.body && r.body.length > 0,
  });
  sleep(1);
}
JavaScript · профиль стресса k6 с нарастанием VU
export const options = {
  scenarios: {
    stress: {
      executor: "ramping-vus",
      startVUs: 0,
      stages: [
        { duration: "2m", target: 100 },
        { duration: "5m", target: 100 },
        { duration: "2m", target: 200 },
        { duration: "5m", target: 200 },
        { duration: "2m", target: 300 },
        { duration: "5m", target: 300 },
        { duration: "10m", target: 0 },
      ],
    },
  },
  thresholds: {
    http_req_duration: ["p(99)<1000"],
    http_req_failed: ["rate<0.05"],
  },
};

Сценарии Gatling для реалистичных пользовательских путей

Gatling уместен, когда ворота должны пройти многошаговый поток с паузами и перцентилями по всему пути. Запускайте после выката на staging в main или по ночному расписанию — не на каждый коммит. Учётные данные — в секретах CI, каталог сидируйте так, чтобы SKU существовали, и валите сборку при нарушении assertion так же, как при упавшем unit-тесте.

Scala · симуляция оформления заказа в Gatling
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class CheckoutSimulation extends Simulation {

  val httpProtocol = http
    .baseUrl("https://staging-api.example.com")
    .acceptHeader("application/json")

  val checkoutScenario = scenario("Checkout Flow")
    .exec(
      http("Login")
        .post("/auth/login")
        .body(StringBody(s"""{"user":"loadtest","pass":"${sys.env.getOrElse("LOGIN_PASSWORD", "changeme")}"}"""))
        .check(status.is(200))
    )
    .pause(2, 5)
    .exec(http("Browse Catalog").get("/products").check(status.is(200)))
    .pause(1, 3)
    .exec(
      http("Add to Cart")
        .post("/cart/items")
        .body(StringBody("""{"product_id":"sku-100","qty":1}"""))
        .check(status.is(201))
    )
    .pause(1, 2)
    .exec(http("Checkout").post("/orders").check(status.is(200)))

  setUp(
    checkoutScenario.inject(
      rampUsers(50).during(30.seconds),
      constantUsersPerSec(10).during(60.seconds),
    )
  ).protocols(httpProtocol)
    .assertions(
      global.responseTime.percentile3.lt(500),
      global.successfulRequests.percent.gt(99.0),
    )
}

Встраивание ворот в GitHub Actions

Сделайте дымовой прогон обязательной проверкой на pull request. Пусть пороги k6 валят шаг кодом выхода; загружайте --summary-export для истории. Gatling планируйте после деплоя на staging в main. Не гоняйте нагрузку на продакшен из CI без явного согласованного окна и ограничения трафика.

YAML · GitHub Actions: дымовой k6 и сценарий Gatling
jobs:
  performance-smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run k6 smoke gate
        uses: grafana/[email protected]
        env:
          BASE_URL: ${{ secrets.STAGING_BASE_URL }}
        with:
          filename: tests/performance/smoke.js
          flags: --summary-export=results/smoke-summary.json
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: k6-smoke-summary
          path: results/

  performance-scenario:
    if: github.ref == 'refs/heads/main'
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Gatling scenario
        env:
          LOGIN_PASSWORD: ${{ secrets.LOADTEST_PASSWORD }}
        run: |
          ./gradlew gatlingRun -Dgatling.simulationClass=CheckoutSimulation
      - name: Fail on Gatling assertion report
        run: |
          python scripts/validate-gatling.py \
            --report build/reports/gatling \
            --max-p95 500 \
            --min-success 99

Нагружайте инфраструктуру Kubernetes, а не только приложение

Генераторы запускайте из отдельного namespace perf-testing с ResourceQuota, чтобы не давить соседей на staging. Во время прогона смотрите целевой namespace: число реплик HPA, CPU throttling, события OOMKilled, 429 на ingress и ожидание в пулах. На cgroup v2 читайте cpu.stat из /sys/fs/cgroup/cpu.stat внутри пода приложения. Сопоставляйте скачки задержки k6 или Gatling с этими сигналами — иначе оптимизируете не тот слой.

YAML · Job k6 и ResourceQuota для изолированной нагрузки
apiVersion: v1
kind: ResourceQuota
metadata:
  name: perf-test-quota
  namespace: perf-testing
spec:
  hard:
    requests.cpu: "4"
    requests.memory: 8Gi
    limits.cpu: "8"
    limits.memory: 16Gi
    pods: "20"
---
apiVersion: batch/v1
kind: Job
metadata:
  name: perf-test-stress
  namespace: perf-testing
spec:
  backoffLimit: 1
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: k6
          image: grafana/k6:0.54.0
          args: ["run", "/scripts/stress.js"]
          env:
            - name: BASE_URL
              valueFrom:
                configMapKeyRef:
                  name: perf-config
                  key: target-url
          resources:
            requests:
              cpu: 500m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi
          volumeMounts:
            - name: scripts
              mountPath: /scripts
      volumes:
        - name: scripts
          configMap:
            name: k6-scripts
Bash · наблюдение HPA и CPU throttle во время прогона
#!/usr/bin/env bash
set -euo pipefail
kubectl get hpa -n production --watch &
kubectl top pods -n production --watch &
# cgroup v2 (Kubernetes 1.25+ nodes typically)
kubectl exec -n production deploy/api -- cat /sys/fs/cgroup/cpu.stat

Практика, при которой воротам доверяют

Снимите базовый профиль каждого критичного сервиса до смены порогов; храните summary в Prometheus, Grafana Cloud k6 или object storage, чтобы заметить ползучий рост на 5% за двадцать merge. Согласуйте дымовые p95 и долю ошибок с опубликованными SLO — не с желаемыми цифрами. Берите полезную нагрузку и перекосы трафика как в продакшене, а не равномерный синтетический шум. Внешние зависимости изолируйте заглушками, если флаки сожгут доверие. Алерты по тренду отделяйте от pass/fail. Когда стресс нашёл потолок, перенесите цифры в обзор HPA и PodDisruptionBudget. Производительность в CI — петля обратной связи на скорости разработки: дым на каждый PR, сценарии перед релизом, стресс для ёмкости — и метрики инфраструктуры в том же окне, что и нагрузка.

Согласуйте пороги p95 и доли ошибок с целями из гайда по SLO, SLI и error budget для платформенных команд.

После зелёного стресс-гейта progressive delivery всё равно нуждается в автоматическом анализе canary и откате по SLO.