Education › Site Reliability › Stage 4: Engineering for resilience

Capacity planning & load testing

Find the knee of the curve before your users do; forecasting and headroom.

Advanced ~35 min read Module 14 of 16

Systems rarely fail gently as load rises. They behave well up to a point, and then latency climbs steeply and errors appear, often within a few percent of extra traffic. The job of capacity planning is to know where that point is, keep a deliberate distance from it, and see demand coming early enough to act. This module covers the small amount of queueing theory that explains the behaviour, how to load test honestly, how much headroom to keep, and how to forecast.

After this module you can
  • Explain why latency rises non-linearly with utilisation, and identify the knee of the curve
  • Use Little's Law to relate throughput, latency and concurrency when sizing pools and replicas
  • Design and run load, stress, soak and spike tests with a realistic, open workload model
  • Calculate headroom for peak load and for N+1 or N+2 redundancy
  • Forecast demand from organic growth and planned events, and configure autoscaling with sensible limits

Why there is a knee

Any resource that serves requests, such as a CPU, a worker pool, a database connection or a disk, behaves like a queue. While it is mostly idle, a new request is served at once. As it gets busier, a new request increasingly arrives to find others ahead of it, and has to wait. Queueing theory gives a simple approximation for a single server with random arrivals.

text
response time = service time / (1 - utilisation)

service time 20 ms:
  utilisation 50%  ->  20 / 0.50 =   40 ms   (2x)
  utilisation 80%  ->  20 / 0.20 =  100 ms   (5x)
  utilisation 90%  ->  20 / 0.10 =  200 ms   (10x)
  utilisation 95%  ->  20 / 0.05 =  400 ms   (20x)
  utilisation 99%  ->  20 / 0.01 = 2000 ms   (100x)

The curve is nearly flat at first, then bends sharply upward. That bend is the knee. Going from 50% to 80% utilisation costs a little latency. Going from 90% to 95% doubles it. Real systems have many servers and less random arrivals, so the exact numbers differ, but the shape always holds. Past the knee, a small rise in traffic, a slightly slower dependency or the loss of one instance pushes you up a cliff.

Two consequences follow. First, running hot is a false economy: a fleet at 90% utilisation looks efficient, and it has no ability to absorb anything. Second, averages hide the knee. Utilisation averaged over five minutes can read 60% while one-second bursts reach 100% and queues form. Look at high percentiles of latency and at saturation signals such as queue depth and CPU throttling, not only at average utilisation.

Little's Law is the other tool worth carrying. For any stable system:

text
L = lambda x W

L       average number of requests in the system (concurrency)
lambda  average arrival rate (requests per second)
W       average time a request spends in the system (seconds)

Example: 400 requests/s, each taking 250 ms
  L = 400 x 0.25 = 100 requests in flight at any moment

  Each pod handles 16 concurrent requests -> 100 / 16 = 6.25 -> at least 7 pods,
  before any headroom.

  If a dependency slows and W doubles to 500 ms, L doubles to 200:
  the same traffic now needs twice the concurrency.

That last line explains many outages. Traffic did not rise, but a dependency slowed, so requests stayed in flight longer and concurrency doubled. Thread pools and connection pools sized for the old concurrency ran out, and the cascading failure from the previous module began. Size pools from Little's Law using a pessimistic latency, not the happy-path figure.

Load testing honestly

You find the knee by measurement. Different tests answer different questions.

TestShapeQuestion answered
LoadRamp to expected peak and holdDo we meet the SLO at the load we expect?
StressKeep increasing until it breaksWhere is the limit, what breaks first, and does it recover?
SoakModerate load for hours or daysAre there leaks: memory, connections, disk, file handles?
SpikeA sudden jump, many times normalCan we absorb a surge, and does autoscaling react in time?

The most important and least understood choice is the workload model. In a closed model, a fixed number of virtual users each send a request, wait for the response, then send another. When the system slows, the users slow with it, so the offered load falls just as the system gets into trouble, and the test flatters you. In an open model, requests arrive at a set rate regardless of how fast responses come back, which is how real internet traffic behaves: users do not stop arriving because your site is slow. Test with an open model, specifying an arrival rate.

The related measurement error is coordinated omission. If a tool waits for each response before sending the next request, then during a ten-second stall it records one slow request instead of the hundreds that should have been sent and would have been slow as well. The reported percentiles look far better than what users experienced. Tools that schedule requests at a constant rate, and measure from the intended send time, avoid it.

k6: an open-model test that ramps the arrival rate and enforces the SLO
javascript
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-arrival-rate',   // open model: a request rate, not a user count
      startRate: 50,
      timeUnit: '1s',
      preAllocatedVUs: 200,
      maxVUs: 2000,
      stages: [
        { target: 200, duration: '5m' },  // ramp to expected peak
        { target: 200, duration: '10m' }, // hold
        { target: 600, duration: '10m' }, // keep going to find the knee
      ],
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.001'],      // under 0.1% errors
    http_req_duration: ['p(99)<500'],     // p99 under 500 ms
  },
};

export default function () {
  const res = http.get('https://staging.example.com/api/products?page=1');
  check(res, { 'status is 200': (r) => r.status === 200 });
}
  • Use a realistic mix of endpoints, payload sizes and data. Hitting one cached URL measures your cache, not your system.
  • Test an environment like production, in size and in data volume. A query that is instant on a thousand rows may be a full scan on a hundred million.
  • Watch the system, not only the tool. Record which resource saturates first: CPU, memory, a connection pool, a lock, a downstream rate limit. That is your bottleneck, and the thing to fix or scale next.
  • Check recovery. After a stress test, does latency return to normal when the load drops, or does the system stay broken until restarted? The second is a serious finding.
  • Do not load test a dependency you do not own without agreement. Stub third-party APIs, or you will be testing someone else's rate limiter.
  • Make it routine. Run a short load test in the pipeline to catch performance regressions, and a full one before every expected peak.

Headroom and redundancy

Capacity is not sized for the average. It is sized for the peak, plus room for failure, plus room for growth and error. Work through it in order.

text
1. Peak demand            measured peak, e.g. 1,200 requests/s on the busiest day
2. Per-instance capacity  from a load test, at the SLO: e.g. 150 requests/s per pod
                          (the load where p99 still meets the target, NOT the breaking point)
3. Instances for peak     1,200 / 150 = 8
4. Redundancy             N+1: survive the loss of one   -> 9
                          N+2: one in maintenance AND one failing -> 10
5. Zone failure           3 zones; losing one must leave >= 8
                          -> 12 pods, 4 per zone (8 remain)
6. Growth buffer          expected growth until the next review, e.g. +20%

Step five is often the largest term, and it surprises people. With instances spread across three zones, surviving the loss of a zone means the remaining two thirds must carry the full peak, so you run at no more than about 67% of capacity in normal operation. With two zones it is 50%. That arithmetic is the real price of zone redundancy, and it is why target utilisation figures of 50 to 65% are common and sensible, not wasteful.

Remember what happens at the moment of failure. The survivors take on extra load and are moved up the latency curve towards the knee. If the fleet was at 85% before one of four instances died, the remaining three face 113% and collapse. This is the load-redistribution failure from the previous module, and headroom is the defence.

Tip

Express capacity in the unit the business understands, such as orders per minute or concurrent viewers, and track the ratio of current peak to tested capacity. "We are at 62% of what we have proven we can handle" is a statement that product managers and finance can plan with.

Forecasting demand

Demand comes from two sources, and they need different methods. Organic growth is the natural trend from adoption and usage. It can be estimated from history. Inorganic growth comes from events that history cannot predict: a product launch, a marketing campaign, a large customer being onboarded, a seasonal sale. It can only be learned by talking to the people planning those events, which makes capacity planning partly a communication job.

python
import numpy as np

# weekly peak requests/s for the last 12 weeks
peaks = np.array([810, 835, 850, 870, 905, 915, 940, 975, 990, 1020, 1050, 1075])
weeks = np.arange(len(peaks))

slope, intercept = np.polyfit(weeks, peaks, 1)          # straight-line fit
capacity = 2000                                         # tested capacity, requests/s
threshold = 0.65 * capacity                             # act before 65% of capacity

weeks_until_threshold = (threshold - intercept) / slope - weeks[-1]
print(f"growth: {slope:.0f} req/s per week")
print(f"threshold of {threshold:.0f} reached in about {weeks_until_threshold:.0f} weeks")
  • Forecast the peak, not the mean. Use weekly or daily maxima, and respect seasonality: compare this December with last December, not with November.
  • Compare the forecast with lead time. If adding capacity takes six weeks, because of procurement, quota increases or a database migration, you must act when the threshold is more than six weeks away. Cloud quotas and account limits are a classic hidden lead time.
  • Track more than CPU. Storage, database connections, IP addresses in a subnet, licence seats, API rate limits with third parties and message queue partitions all run out, often less visibly.
  • Alert on the trend. predict_linear in Prometheus turns a forecast into an early-warning ticket, such as a disk that will fill in four days.
  • Revisit regularly. A forecast is wrong the day after it is made. A short monthly review of forecast against actual keeps it useful.
promql
# disk will be full within 4 days at the current rate of growth (ticket, not page)
predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[3d], 4 * 86400) < 0

# weekly peak request rate, for the capacity dashboard
max_over_time(sum(rate(http_requests_total{job="checkout"}[5m]))[7d:5m])

Autoscaling, and its limits

Autoscaling matches capacity to demand automatically, which saves money in quiet periods and absorbs ordinary peaks. It complements capacity planning and does not replace it, because it has limits you must design around.

yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: checkout
  namespace: shop
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: checkout
  minReplicas: 6                 # enough for normal load with a zone lost
  maxReplicas: 30                # a ceiling that protects dependencies and the budget
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 60      # may double every minute
    scaleDown:
      stabilizationWindowSeconds: 300   # scale in slowly, to avoid flapping
  • It reacts, so it lags. Metrics must be scraped, a decision made, pods scheduled, perhaps a node provisioned, images pulled, the application started and its caches warmed. That takes from tens of seconds to several minutes. A spike that arrives in ten seconds is served by the capacity you already had, which is why the target is 60% and not 90%.
  • Scale up fast and down slowly. Rapid scale-in followed by another spike causes flapping and needless cold starts.
  • Choose the metric that reflects the bottleneck. CPU suits compute-bound services. For I/O-bound services, scale on requests per second, queue depth or in-flight requests, using custom or external metrics.
  • minReplicas is a reliability setting, not a cost setting. It must cover normal load with a zone lost, without waiting for the autoscaler.
  • maxReplicas protects what is downstream. Thirty pods with twenty database connections each is six hundred connections. If the database allows five hundred, the autoscaler has just caused your outage. Scale the whole chain, or put a connection pooler in between.
  • Pre-scale for known events. Before a campaign or a sale, raise the minimum in advance. Do not make the autoscaler discover the launch from a wall of traffic.
  • A scaled-to-zero or freshly started fleet is cold. Test how the service behaves in its first minute under load.
Watch out

Stateless application tiers scale easily, which means that capacity problems migrate to the parts that do not: the primary database, a shared cache, a third-party API. Know which of your components cannot scale horizontally, know their limits, and plan those by hand.

Hands-on practice

Find the knee of a real service

  1. Deploy a service you can safely overload, with the dashboards from the observability stage showing request rate, error ratio, latency percentiles, CPU and any connection pools.
  2. Install k6, or another tool that supports a constant arrival rate. Write an open-model test that ramps the request rate in steps, holding each step for a few minutes.
  3. Run it and record p50 and p99 latency, error ratio and CPU at each step. Plot latency against request rate and mark the knee. Note which resource saturated first.
  4. Define per-instance capacity as the highest rate at which the latency SLO is still met. Using Little's Law, check it against your worker or connection pool size.
  5. Repeat with a closed-model test using a fixed number of virtual users, and compare the results. Explain why the closed model looks better.
  6. Calculate the replicas needed for your peak with N+1 redundancy and the loss of one of three zones, and state the resulting normal-operation utilisation.
  7. Add a HorizontalPodAutoscaler. Run a spike test and measure the time from the spike to the new pods serving traffic. Decide whether your utilisation target leaves enough headroom for that delay.
  8. Run a two-hour soak test at moderate load and look for steadily rising memory, connections or file descriptors.
Cheat sheet

Capacity planning & load testing — at a glance

Main things to focus on

  • Latency rises non-linearly with utilisation: roughly service time / (1 - utilisation). Stay below the knee.
  • Average utilisation hides bursts. Watch p99 latency and saturation signals such as queue depth and throttling.
  • Little's Law: concurrency = arrival rate x latency. A slower dependency raises concurrency at the same traffic.
  • Load test with an open model (an arrival rate). Closed models and coordinated omission flatter the system.
  • Per-instance capacity is the rate at which the SLO is still met, not the rate at which it breaks.
  • Size for peak, plus N+1 or N+2, plus the loss of a zone, plus growth. Three zones means about 67% utilisation at most.
  • Forecast peaks, include planned events, and compare the forecast with the lead time for adding capacity.
  • Autoscaling lags by minutes. Scale up fast, down slowly, pre-scale for events, and cap the maximum to protect dependencies.

Formulas

R = S / (1 - U)Response time from service time and utilisation (single server)
L = lambda x WLittle's Law: concurrency = arrival rate x time in system
instances = peak rate / per-instance capacityRound up, before redundancy
N+1, N+2Spare instances for one failure, or failure plus maintenance
max utilisation = (zones - 1) / zones2 zones 50%, 3 zones 67%, 4 zones 75%
load on survivors = U x n / (n - 1)85% across 4 becomes 113% across 3
act when: time to threshold <= lead timeProcurement, quotas and migrations take weeks

Latency multiplier by utilisation

50%2x service time
70%3.3x
80%5x
90%10x
95%20x
99%100x

Test types

LoadExpected peak, held: is the SLO met?
StressBeyond the limit: what breaks first, and does it recover?
SoakHours at moderate load: leaks and slow degradation
SpikeSudden surge: absorption and autoscaler reaction time
Open modelRequests arrive at a set rate; realistic for internet traffic
Closed modelFixed users wait for replies; load falls as the system slows

k6 essentials

k6 run script.jsRun a test locally
executor: 'ramping-arrival-rate'Open model with a changing request rate
executor: 'constant-arrival-rate'Open model at a fixed request rate
stages: [{ target: 200, duration: '5m' }]Ramp to a target over a duration
thresholds: { http_req_duration: ['p(99)<500'] }Fail the run if the SLO is missed
thresholds: { http_req_failed: ['rate<0.001'] }Fail the run on too many errors

Autoscaling and queries

minReplicasCovers normal load with a zone lost; a reliability setting
maxReplicasCeiling that protects downstream limits and the budget
averageUtilization: 60Target as a percentage of the pod's CPU request
behavior.scaleDown.stabilizationWindowSeconds: 300Scale in slowly to avoid flapping
predict_linear(metric[3d], 4 * 86400) < 0Will run out within four days
max_over_time(EXPR[7d:5m])Weekly peak of an expression, via a subquery

Common pitfalls

  • Running the fleet at 85 to 90% utilisation because it looks efficient, leaving no room for a failure or a spike.
  • Load testing with a fixed number of virtual users, so that offered load falls as the system slows.
  • Quoting the breaking point as capacity, instead of the rate at which the SLO is still met.
  • Testing against a small dataset or a single cached endpoint, and measuring nothing real.
  • Letting the autoscaler grow the application tier until it exhausts database connections.
  • Forecasting from history alone and being surprised by a launch that marketing had planned for months.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →