ловить регрессии производительности в 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.
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);
}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-тесте.
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 без явного согласованного окна и ограничения трафика.
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 с этими сигналами — иначе оптимизируете не тот слой.
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#!/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.
