catch performance regressions in CI before they reach production

Infrastructure performance testing with k6 and Gatling in CI/CD

13 min read

Monthly load tests miss regressions that land on every merge. Split smoke, scenario, and stress tiers with k6 and Gatling, gate CI on thresholds, and watch Kubernetes HPA, CPU throttling, and connection pools while the load runs.

Why late load tests miss infrastructure regressions

Many teams run performance testing as a pre-release checklist: one long staging run, a few fixes, then hope. Production pages still arrive when a merge quietly exhausts a database pool, tightens an ingress rate limit, or hits CPU throttling under HPA lag. Three failures stack: staging drifts from production topology for weeks; full load suites take half an hour so developers skip them; tests hit only application code and ignore DNS, TLS, connection pools, and autoscaling. You need gates that match pipeline speed—fast on every pull request, richer before release—and that observe the infrastructure while the load generator runs.

Three tiers: smoke, scenario, and stress

Split work by feedback speed. Smoke performance tests run under a minute on every PR with a handful of virtual users and hard thresholds—k6 exits non-zero (code 99 on threshold breach) so CI fails without custom parsing. Scenario tests model realistic journeys—login, browse, checkout—on a staging topology that mirrors production; Gatling shines for readable multi-step Scala or Java simulations with assertions. Stress and capacity tests ramp weekly or on release candidates to find breaking points, HPA reaction, and saturation. Prefer k6 for scriptable JS gates and cloud-native CI; prefer Gatling when complex protocol sequences and rich reports matter more than ultra-short PR jobs.

k6 smoke gate that blocks the merge

Keep smoke scripts tiny and deterministic. Point at a stable staging URL from secrets, not production. Put SLOs into options.thresholds so the process exit code is the gate—avoid reinventing pass/fail from raw JSON point streams. Export a summary artifact for trends; the threshold itself should already fail the job.

JavaScript · k6 smoke test with CI thresholds
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 ramping stress profile
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 scenarios for realistic journeys

Use Gatling when the gate must exercise multi-step flows with think time and percentile assertions across the whole journey. Run after deploy-to-staging on main or on a nightly schedule—not on every commit. Keep credentials in CI secrets, seed catalogs so product IDs exist, and fail the build on assertion breach the same way you fail unit tests.

Scala · Gatling checkout journey simulation
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),
    )
}

Wire gates into GitHub Actions

Make smoke a required check on pull requests. Let k6 thresholds fail the step via exit code; upload --summary-export for history. Schedule Gatling after staging deploy on main. Never load-test production from CI without an explicit, approved window and traffic shaping.

YAML · GitHub Actions k6 smoke and Gatling scenario
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

Load-test Kubernetes infrastructure, not only the app

Run generators from a dedicated perf-testing namespace with ResourceQuota so staging tenants stay protected. Watch the target namespace during the run: HPA replica count, CPU throttling, OOMKilled events, ingress 429s, and pool wait times. On cgroup v2 read cpu.stat from /sys/fs/cgroup/cpu.stat inside the app pod. Correlate k6 or Gatling latency spikes with those signals—otherwise you optimize the wrong layer.

YAML · k6 Job and ResourceQuota for isolated load
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 · watch HPA and CPU throttle during the run
#!/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

Operational practices that keep gates trusted

Baseline every critical service before changing thresholds; store summaries in Prometheus, Grafana Cloud k6, or object storage so you spot a five-percent crawl across twenty merges. Align smoke p95 and error rate with published SLOs—not aspirational numbers. Use production-like payloads and skewed traffic, not uniform synthetic noise. Isolate external dependencies with stubs when flakiness would burn trust. Automate trend alerts separate from pass/fail. After stress finds a ceiling, feed capacity numbers into HPA and PodDisruptionBudget reviews. Performance in CI is a feedback loop at development speed: smoke on every PR, journeys before release, stress for capacity—and infrastructure metrics in the same window as the load.

Align p95 and error-rate gates with objectives from our SLO, SLI, and error budget guide for platform teams.

After a green stress gate, progressive delivery still needs automated canary analysis and SLO-based rollback.