Skip to content

AKS Platform: Load Testing

This page describes how the platform is load tested and what the test measures. The test has two goals: find out how much traffic a workload on the non-production cluster can take before it breaks, and prove that the alerting chain (Prometheus rule, Alertmanager, PagerDuty) fires and clears when it should.

Reference design, your deployment may differ

The test runs against the non-production cluster only. The target application, thresholds and routes are those of the reference setup. A longer design document describes a wider test programme with another tool. This page covers what is implemented and lists the rest as planned.

How a test run works

A manually started pipeline creates a short-lived job in the cluster, drives traffic in three stages, watches the effects and removes everything again. The cluster carries no load generator when idle.

flowchart TB
    start["Pipeline started by hand<br/>(rates, durations, failure trigger)"] --> login["OIDC login to Azure,<br/>cluster credentials"]
    login --> infra["Apply namespace, RBAC,<br/>network policy, dashboard"]
    infra --> mark1["Annotate Grafana: test start"]
    mark1 --> job["Vegeta job in the load-testing namespace"]
    job --> s1["Stage 1: warm-up"]
    s1 --> s2["Stage 2: sustained"]
    s2 --> s3["Stage 3: overload"]
    s3 --> report["Vegeta report in the run summary"]
    report --> mark2["Annotate Grafana: test end"]
    mark2 --> clean["Delete job and config map,<br/>reset the failure rate"]
Part Detail
Tool Vegeta, a constant-rate HTTP attacker, run as a Kubernetes job (one pod, no retries)
Where Inside the cluster in a dedicated load-testing namespace, so traffic goes through the cluster network and DNS
Trigger A manual GitHub Actions workflow with the parameters below
Identity The pipeline's federated identity with the Azure role Azure Kubernetes Service RBAC Admin, scoped to the cluster
Footprint A service account with a namespaced role, a network policy for the generator pods, and a Grafana dashboard config map. The job and its script config map are deleted at the end (if: always()); the job also has a time-to-live
Generator size Requests 500m CPU and 1 GiB, limits 2 CPU and 4 GiB. The worker pool is capped at 8000 connections, which is needed to hold 3000 requests per second when latency rises
Pre-flight The script waits up to 60 seconds for the target's health endpoint before it attacks

Stages and parameters

Stage Default rate Default duration Purpose
1. Warm-up 60 requests per second 30 s Healthy baseline
2. Sustained 400 requests per second 1 min Load that makes the autoscaler react
3. Overload 3000 requests per second 5 min Push past the capacity of the workload

All six values are workflow inputs. A seventh input, trigger_failure (default on), automates the failure story: the pipeline raises the application's failure rate at the start of stage 2 and resets it in the last minute of stage 3, so an error spike is guaranteed without hand-tuning rates. Without it you drive the two changes by hand with kubectl set env.

What is measured

The application exports its own Prometheus metrics (the generator itself exports none). The run is judged on the application's and the cluster's side.

Area Signal Used for
Request rate Requests per second by response code Offered load and achieved throughput
Errors Ratio of 5xx responses over 5 minutes Availability and the fast-burn alert
Latency p50, p90, p95, p99 from the request duration histogram Latency objective
Autoscaling HPA desired replicas against current replicas Does the workload scale out, and how fast
Capacity Pod CPU against requests, pending pods, restarts, out-of-memory kills Where the workload saturates
Alerts Prometheus alert state with the load test label, Alertmanager receiver, PagerDuty incident Whether the chain works
Client view The Vegeta report: totals per stage and combined, with latency percentiles and success ratio Cross-check from the client side

A Grafana dashboard for the test is provisioned from a config map. A helper script can print a summary from Prometheus after the run (total requests, peak rate, peak error rate, peak p95 and p99, maximum replicas, restarts, alert state).

Pass and fail

The job does not pass or fail on Vegeta's success ratio, because stage 3 is meant to overload the workload. The workflow fails only if the job errors (for example the target is unreachable) or does not finish in time. The real result is whether the alerts fire and resolve and an incident is created and cleared.

Alert validation

Three load test alert rules mirror the service level objectives of the reference assignment. They carry labels that mark them as test alerts:

Alert Condition For Severity
AvailabilityErrorBudgetBurnFast 5xx ratio over 5 minutes above 10 times the 0.1% error budget 5 min page
AvailabilityErrorBudgetBurnSlow 5xx ratio over 30 minutes above 2 times the budget 12 h ticket
LatencySLOViolation p95 latency over 5 minutes above 300 ms 15 min page

Alertmanager routes alerts with source = load-test to a dedicated receiver. In the implemented setup it uses the same PagerDuty routing key as production and is told apart by a [LOAD-TEST] prefix and fields in the event. The design document proposed a separate PagerDuty service for this.

What you can observe in one run: only the fast-burn alert, because the default stage 3 lasts 5 minutes, as long as the rule's for period. Run stage 3 for 20 minutes or more to see the latency alert fire. The slow-burn alert cannot be observed live.

Evidence to collect for an alert validation:

Check How
Alert reaches firing Prometheus alerts page, filtered on the test label
Routed to the test receiver Alertmanager alerts page
Incident created PagerDuty, title with the [LOAD-TEST] prefix
Incident resolves by itself PagerDuty, after the failure rate is reset and the metrics recover
No effect on production alerting No other incidents on the same service in the window

The target workload

The target is a small public demo web application (Flask, with Prometheus metrics) deployed in the <team>-dev namespace with the shared application chart: autoscaling from 3 to 10 replicas on a 70% CPU target, a disruption budget, a hardened security context, and a service monitor that sets the Prometheus job label so that rules, dashboard and queries all key on one name. The work endpoint simulates 50 to 200 ms of latency and fails randomly at a configurable rate (default 2%). It degrades realistically under load without any artificial stress switch.

Only the dev environment is supported. The test and accp namespaces run a placeholder web server image, not the demo application.

What the numbers mean

In the validated run, throughput levelled off at about 1,800 to 2,260 requests per second (about 220 per pod at ten pods), the autoscaler went from 3 to 10 pods, the fast-burn alert fired about six minutes into the run and resolved shortly after the failure rate was reset. These numbers describe the demo application (a single-process development server), not the capacity of the platform. Raising the CPU limit made no difference. Treat the run as a repeatable method for finding the number for any service.

Known limitations

Limitation Effect
The latency histogram measures only request handling, not time spent queued when the server is saturated The latency alert rarely fires even under severe overload, so the fast-burn alert is the reliable signal
An Argo CD self-heal reverted the failure-rate change within a second Fixed by telling the Argo CD application to ignore that one field
Role assignments for the pipeline and the Graph API issue Use the cicd-aks-rbac target (Deployment)
Results of runs are not stored or compared automatically Capture the run summary and the dashboard yourself
Local runs on a laptop give noisy absolute numbers Use in-cluster numbers

Planned test programme (design only)

The design document describes further test types for the non-production cluster. They are not implemented in the pipeline.

Test Pattern Purpose
Smoke 1 to 5 users, 1 to 2 minutes Check the test setup and the target
Baseline Normal load for about 13 minutes Reference values for release comparison
Load Peak expected load for about 23 minutes All platform objectives hold
Stress Step-wise ramp until degradation Find the breaking point, informational
Spike Sudden burst, then back The autoscaler reacts and the system recovers
Soak Moderate load for 2 to 8 hours Leaks and gradual degradation
Capacity Ramp until one objective breaks Maximum sustainable throughput
Autoscaling Load above the autoscaler target, then to zero Scale-up and scale-down timing
Alerting Load patterns that trigger known alerts Rule to incident chain

The design also named k6 with its Kubernetes operator as the primary tool and an Azure DevOps pipeline. The implementation moved to Vegeta, driven from GitHub Actions, because the k6-based demo runs repeatedly failed to give the expected results.