LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Capacity Planning for Engineers: Forecasting Growth, Load Testing, and Avoiding Surprise Scaling Events

srecapacity-planningperformanceload-testingk6scalinginfrastructurereliability

Capacity Planning for Engineers: Forecasting Growth, Load Testing, and Avoiding Surprise Scaling Events

Few things feel worse operationally than a service falling over because traffic doubled during a product launch you knew was coming for weeks. Capacity planning is the discipline that turns “I hope we have enough” into “I know we have enough — and here’s the data.”

It’s also deeply undervalued. Most teams treat capacity as an afterthought until a scaling event causes an incident. Then they over-provision reactively, spend too much money, and repeat the cycle when the next surprise comes. Systematic capacity planning breaks this cycle: you measure your system’s limits, model your expected growth, provision with explicit headroom, and validate your assumptions with load tests before real traffic arrives.

This guide covers the full practice: understanding bottlenecks and limits, demand forecasting, load testing with k6, Kubernetes autoscaling, cost-aware rightsizing, and the operational cadence that keeps you ahead of growth.


The Capacity Planning Mental Model

Capacity planning answers three questions:

  1. What are my system’s limits? — At what traffic level does each component break?
  2. When will I hit those limits? — Given observed growth, how much runway do I have?
  3. What do I do about it? — Provision more resources, optimize, or change the architecture?

The output is not a spreadsheet. It’s a set of decisions with lead times: “We need to add database read replicas by June or we’ll hit connection limits at current growth rate.”

The three types of capacity constraints

Resource limits — CPU, memory, disk, network bandwidth. Hard ceilings that cause failures when hit.

Throughput limits — Requests per second a service can handle before latency degrades. Soft limits that degrade before failing.

Dependency limits — External constraints: database connections, API rate limits, third-party SLAs. Often the first thing you hit.


Step 1: Understand Your Current Performance Envelope

Before forecasting, you need to know where your system breaks today.

Identify your critical path

Map the request flow for your most important user journeys. For an e-commerce checkout:

User Browser
    │
    ▼
CDN / Edge Cache (Cloudflare)
    │
    ▼
API Gateway (rate limiting, auth)
    │
    ▼
Checkout Service ──► Inventory Service
    │                     │
    ▼                     ▼
Payment Service      PostgreSQL (read replica)
    │
    ▼
Stripe API (external)
    │
    ▼
Order Service ──► PostgreSQL (primary)
                      │
                      ▼
                  Redis (session/cart)

Each node in this graph has a capacity. The bottleneck is the node with the lowest capacity relative to its current load.

Measure current utilization

Pull current resource utilization across the critical path:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Kubernetes resource utilization
kubectl top pods -n production --sort-by=cpu
kubectl top nodes

# For each service, check utilization vs requests/limits
kubectl get pods -n production -o json | jq -r '
  .items[] |
  .metadata.name + " | CPU req: " +
  (.spec.containers[0].resources.requests.cpu // "none") + " | Mem req: " +
  (.spec.containers[0].resources.requests.memory // "none")
'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# CPU utilization per service (as % of requested)
sum(rate(container_cpu_usage_seconds_total{namespace="production"}[5m])) by (pod)
/
sum(kube_pod_container_resource_requests{namespace="production", resource="cpu"}) by (pod)

# Memory utilization per service
sum(container_memory_working_set_bytes{namespace="production"}) by (pod)
/
sum(kube_pod_container_resource_requests{namespace="production", resource="memory"}) by (pod)

# Database connection pool utilization
# (this metric name varies by driver — pg-specific example)
db_pool_connections_in_use / db_pool_connections_max

# Current RPS per service
sum(rate(http_requests_total{namespace="production"}[5m])) by (service)

Build a capacity baseline table:

Service              | Current RPS | CPU Util | Mem Util | DB Conns | Headroom
---------------------|-------------|----------|----------|----------|----------
checkout-service     | 450 rps     | 62%      | 71%      | 28/50    | ~38%
payment-service      | 90 rps      | 38%      | 44%      | 12/25    | ~60%
inventory-service    | 520 rps     | 78%      | 52%      | 45/50    | DB conn!
order-service        | 95 rps      | 25%      | 38%      | 8/20     | ~70%
postgres-primary     | 800 qps     | 71%      | 82%      | 53/100   | ~20%
redis                | 12k ops/s   | 34%      | 61%      | N/A      | ~50%

The inventory service is already at 90% DB connection utilization — a ticking clock that needs immediate attention.


Step 2: Determine Your Limits Through Load Testing

Utilization numbers tell you where you are. Load tests tell you where the ceiling is.

k6: The Modern Load Testing Tool

k6 is a developer-friendly load testing tool with JavaScript scripting, built-in metrics, and excellent Kubernetes integration.

1
2
3
brew install k6
# or
docker pull grafana/k6

Finding the breaking point: ramp-up test

The goal is to find the RPS at which your service starts failing SLOs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// breaking-point-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

const errorRate = new Rate('errors');
const checkoutDuration = new Trend('checkout_duration_ms', true);

export const options = {
  // Ramp up until something breaks
  stages: [
    { duration: '2m', target: 100 },   // Warm up
    { duration: '3m', target: 200 },
    { duration: '3m', target: 400 },
    { duration: '3m', target: 600 },
    { duration: '3m', target: 800 },
    { duration: '3m', target: 1000 },  // Keep going until SLO breaks
    { duration: '3m', target: 1200 },
    { duration: '2m', target: 0 },     // Cool down
  ],
  thresholds: {
    // Test "passes" but we observe where it starts degrading
    'http_req_duration': ['p(99)<2000'],
    'errors': ['rate<0.05'],
  },
};

const BASE_URL = __ENV.BASE_URL || 'https://api.example.com';

export default function () {
  const start = Date.now();

  // Simulate a checkout flow
  const cartRes = http.post(
    `${BASE_URL}/api/cart/add`,
    JSON.stringify({ product_id: 'test-product-1', quantity: 1 }),
    { headers: { 'Content-Type': 'application/json',
                 'Authorization': `Bearer ${__ENV.TEST_TOKEN}` } }
  );

  const cartOk = check(cartRes, {
    'cart add: 200': r => r.status === 200,
    'cart add: < 500ms': r => r.timings.duration < 500,
  });

  if (!cartOk) {
    errorRate.add(1);
    return;
  }

  sleep(0.5);

  const checkoutRes = http.post(
    `${BASE_URL}/api/checkout`,
    JSON.stringify({ cart_id: cartRes.json('cart_id'), payment: 'test_visa' }),
    { headers: { 'Content-Type': 'application/json',
                 'Authorization': `Bearer ${__ENV.TEST_TOKEN}` } }
  );

  const checkoutOk = check(checkoutRes, {
    'checkout: 200': r => r.status === 200,
    'checkout: has order_id': r => r.json('order_id') !== undefined,
  });

  const duration = Date.now() - start;
  checkoutDuration.add(duration);
  errorRate.add(!checkoutOk ? 1 : 0);

  sleep(Math.random() * 2 + 0.5);
}
1
2
3
4
5
6
# Run the breaking-point test
k6 run \
  -e BASE_URL=https://staging.example.com \
  -e TEST_TOKEN=$TEST_API_TOKEN \
  --out json=results/breaking-point-$(date +%Y%m%d).json \
  breaking-point-test.js

Read the results to find where SLOs degraded:

1
2
3
4
5
# Parse results to find the RPS at which P99 exceeded 500ms
cat results/breaking-point-*.json | \
  jq 'select(.type=="Point" and .metric=="http_req_duration") |
      {time: .data.time, value: .data.value}' | \
  # Plot or analyze to find the inflection point

Typical breaking-point test findings:

100 VUs  →  ~380 RPS  →  P99: 140ms, Error rate: 0.01%  ✅
200 VUs  →  ~720 RPS  →  P99: 180ms, Error rate: 0.02%  ✅
400 VUs  →  ~1,100 RPS → P99: 290ms, Error rate: 0.05%  ✅
600 VUs  →  ~1,350 RPS → P99: 680ms, Error rate: 0.4%   ⚠️  P99 SLO breached
800 VUs  →  ~1,400 RPS → P99: 2,100ms, Error rate: 8%   ❌  Service degraded
1000 VUs →  ~1,380 RPS → P99: 4,800ms, Error rate: 31%  ❌  Overloaded

Conclusion: service breaks at ~1,350 RPS.
Current load: 450 RPS.
Headroom: 3x current load.

Soak test: sustained load over time

Ramp tests find immediate limits. Soak tests find slow degradation — memory leaks, connection pool exhaustion, cache drift:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// soak-test.js — run at 70% of identified breaking point for 2 hours
export const options = {
  stages: [
    { duration: '5m',  target: 700 },  // Ramp to 70% of limit (~950 RPS)
    { duration: '2h',  target: 700 },  // Hold for 2 hours
    { duration: '5m',  target: 0 },
  ],
  thresholds: {
    'http_req_duration': ['p(99)<500'],
    'errors': ['rate<0.01'],
  },
};

Watch for metrics that drift upward over time during a soak test:

  • Memory usage creeping up → memory leak
  • P99 latency slowly increasing → connection pool or cache issue
  • GC pause frequency increasing → JVM heap pressure
  • Open file descriptors growing → file handle leak

Spike test: sudden traffic burst

Validate that autoscaling responds fast enough and the system survives sudden bursts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// spike-test.js
export const options = {
  stages: [
    { duration: '1m',  target: 100 },   // Normal baseline
    { duration: '10s', target: 1000 },  // Sudden 10x spike
    { duration: '3m',  target: 1000 },  // Hold the spike
    { duration: '10s', target: 100 },   // Back to baseline
    { duration: '2m',  target: 100 },   // Recovery validation
  ],
};

Key questions the spike test answers:

  • Does the service return errors during the first 60 seconds before autoscaling kicks in?
  • Does the autoscaler provision new pods fast enough?
  • Does the database connection pool saturate during the spike?
  • Does the system recover cleanly when the spike ends?

Running k6 in Kubernetes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# k6-configmap.yaml — store test scripts in a ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: k6-tests
  namespace: loadtesting
data:
  breaking-point.js: |
    // test script here

---
# k6-job.yaml — run k6 as a Job with results streamed to InfluxDB/Prometheus
apiVersion: batch/v1
kind: Job
metadata:
  name: k6-breaking-point-test
  namespace: loadtesting
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: k6
          image: grafana/k6:latest
          args:
            - run
            - --out
            - experimental-prometheus-rw=http://prometheus-pushgateway:9091
            - /scripts/breaking-point.js
          env:
            - name: BASE_URL
              value: "https://staging.example.com"
            - name: TEST_TOKEN
              valueFrom:
                secretKeyRef:
                  name: load-test-secrets
                  key: token
            - name: K6_PROMETHEUS_RW_SERVER_URL
              value: "http://prometheus:9090/api/v1/write"
          volumeMounts:
            - name: scripts
              mountPath: /scripts
      volumes:
        - name: scripts
          configMap:
            name: k6-tests

Step 3: Demand Forecasting

Now that you know your limits, you need to know when you’ll hit them.

Time-series forecasting with historical data

Pull your key metrics history and fit a growth model:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
#!/usr/bin/env python3
"""
capacity_forecast.py — forecast when you'll hit capacity limits
"""
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import requests
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures

PROMETHEUS_URL = "http://prometheus:9090"
BREAKING_POINT_RPS = 1350  # From load test
HEADROOM_FACTOR = 0.70     # Provision capacity before hitting 70% of limit

def fetch_metric_history(query: str, days: int = 90) -> pd.DataFrame:
    """Fetch time series from Prometheus."""
    end = datetime.now()
    start = end - timedelta(days=days)

    resp = requests.get(f"{PROMETHEUS_URL}/api/v1/query_range", params={
        "query": query,
        "start": start.timestamp(),
        "end": end.timestamp(),
        "step": "1h",
    })
    resp.raise_for_status()

    results = resp.json()["data"]["result"]
    if not results:
        return pd.DataFrame()

    values = results[0]["values"]
    df = pd.DataFrame(values, columns=["timestamp", "value"])
    df["timestamp"] = pd.to_datetime(df["timestamp"], unit="s")
    df["value"] = df["value"].astype(float)
    return df


def fit_and_forecast(df: pd.DataFrame, days_ahead: int = 180) -> dict:
    """Fit linear and polynomial models, return forecast."""
    # Convert timestamps to numeric (days since start)
    df["day"] = (df["timestamp"] - df["timestamp"].min()).dt.total_seconds() / 86400

    X = df["day"].values.reshape(-1, 1)
    y = df["value"].values

    # Linear model
    linear = LinearRegression().fit(X, y)

    # Polynomial (degree 2) for accelerating growth
    poly = PolynomialFeatures(degree=2)
    X_poly = poly.fit_transform(X)
    poly_model = LinearRegression().fit(X_poly, y)

    # Forecast
    current_day = df["day"].max()
    future_days = np.arange(current_day, current_day + days_ahead, 1).reshape(-1, 1)

    linear_forecast = linear.predict(future_days)
    poly_forecast = poly_model.predict(poly.transform(future_days))

    # Find when each model hits the headroom threshold
    threshold = BREAKING_POINT_RPS * HEADROOM_FACTOR

    linear_breach = next(
        (current_day + i for i, v in enumerate(linear_forecast) if v >= threshold),
        None
    )
    poly_breach = next(
        (current_day + i for i, v in enumerate(poly_forecast) if v >= threshold),
        None
    )

    today = df["timestamp"].max()

    return {
        "current_rps": float(df["value"].iloc[-1]),
        "growth_rate_daily": float(linear.coef_[0]),
        "growth_rate_pct_weekly": float(linear.coef_[0] * 7 / df["value"].mean() * 100),
        "threshold_rps": threshold,
        "linear_breach_date": (today + timedelta(days=linear_breach - current_day)).date()
            if linear_breach else "Beyond forecast window",
        "poly_breach_date": (today + timedelta(days=poly_breach - current_day)).date()
            if poly_breach else "Beyond forecast window",
    }


# Forecast for checkout service
df = fetch_metric_history(
    'sum(rate(http_requests_total{service="checkout-service"}[1h]))'
)
forecast = fit_and_forecast(df)

print(f"""
Checkout Service Capacity Forecast
====================================
Current RPS:          {forecast['current_rps']:.0f}
Breaking point (load test): {BREAKING_POINT_RPS} RPS
Action threshold (70%):     {forecast['threshold_rps']:.0f} RPS

Growth rate:          {forecast['growth_rate_daily']:.1f} RPS/day
                      ({forecast['growth_rate_pct_weekly']:.1f}% per week)

Linear model breach:  {forecast['linear_breach_date']}
Polynomial breach:    {forecast['poly_breach_date']}

Recommendation: Provision additional capacity before {forecast['linear_breach_date']}
""")

Incorporating known events into forecasts

Historical growth rates don’t account for planned events. Adjust your model for:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Upcoming events that affect capacity
EVENTS = [
    {
        "name": "Black Friday",
        "date": "2026-11-27",
        "expected_multiplier": 4.5,  # 4.5x normal peak traffic
        "duration_hours": 24,
    },
    {
        "name": "Product Launch v3.0",
        "date": "2026-05-15",
        "expected_multiplier": 2.0,
        "duration_hours": 6,
    },
    {
        "name": "Marketing Campaign Q2",
        "date": "2026-04-01",
        "expected_multiplier": 1.8,
        "duration_hours": 168,  # 1 week
    },
]

# For Black Friday at 4.5x normal:
# Current peak: 650 RPS
# BF peak estimate: 650 * 4.5 = 2,925 RPS
# Breaking point: 1,350 RPS
# NEED: 2,925 / 1,350 = 2.2x current capacity minimum before BF

Seasonality analysis

Many services have weekly and daily patterns. Don’t forecast from average RPS — use peak RPS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def analyze_seasonality(df: pd.DataFrame) -> dict:
    """Identify weekly and daily peak patterns."""
    df["hour"] = df["timestamp"].dt.hour
    df["day_of_week"] = df["timestamp"].dt.dayofweek  # 0=Monday

    # Find peak hour and day
    hourly_avg = df.groupby("hour")["value"].mean()
    daily_avg = df.groupby("day_of_week")["value"].mean()

    peak_hour = hourly_avg.idxmax()
    peak_day = daily_avg.idxmax()

    # Peak-to-average ratio
    peak_ratio = hourly_avg.max() / hourly_avg.mean()

    days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

    return {
        "peak_hour_utc": peak_hour,
        "peak_day": days[peak_day],
        "peak_to_average_ratio": peak_ratio,
        "effective_peak_rps": df["value"].quantile(0.99),  # 99th percentile
    }

Step 4: Headroom Targets and Capacity Rules

Headroom is the buffer between your current peak load and your breaking point. Without explicit headroom targets, teams unconsciously provision to 90%+ utilization — one traffic spike away from an incident.

Component          | Max utilization | Rationale
-------------------|-----------------|------------------------------------------
Stateless services | 60–70%          | Room for autoscaling lag and traffic spikes
Databases (CPU)    | 50–60%          | Query bursts are unpredictable; recovery time
DB connection pool | 60–70%          | Connection storms during reconnect events
Memory             | 70–80%          | OOM killer is catastrophic; leave headroom
Disk               | 70–75%          | Sudden writes (logs, dumps) can fill fast
Network bandwidth  | 50–60%          | Bursty; leave room for large transfers
External API limits| 50%             | Rate limits reset slowly; don't camp the ceiling

The 3x rule for traffic spikes

Your system should be able to handle 3x its current average peak RPS without degrading. This covers:

  • Normal day-to-day variance (typically 1.5–2x)
  • Unexpected media coverage or viral moments (2–3x)
  • Bug-induced retry storms from clients (2–4x)

If your breaking point is only 1.5x current peak, you have an imminent capacity risk.


Step 5: Kubernetes Autoscaling Done Right

Kubernetes autoscaling is your operational safety net, but it needs to be configured correctly to act fast enough to matter.

Horizontal Pod Autoscaler (HPA)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: checkout-service
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: checkout-service
  minReplicas: 3
  maxReplicas: 50

  metrics:
    # Scale on CPU (most common)
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60  # Scale up when CPU > 60% (not 80%!)

    # Scale on custom metric: RPS per pod
    - type: Pods
      pods:
        metric:
          name: http_requests_per_second
        target:
          type: AverageValue
          averageValue: "200"  # Target 200 RPS/pod; scale when exceeded

  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0      # Scale up immediately — no delay
      policies:
        - type: Percent
          value: 100                     # Double pods at a time if needed
          periodSeconds: 15
        - type: Pods
          value: 4                       # Or add 4 pods every 15 seconds
          periodSeconds: 15
      selectPolicy: Max                  # Use whichever adds more pods

    scaleDown:
      stabilizationWindowSeconds: 300    # Wait 5 minutes before scaling down
      policies:
        - type: Percent
          value: 10                      # Remove at most 10% of pods per minute
          periodSeconds: 60

Why 60% CPU target, not 80%?

If you set CPU target at 80%, by the time the HPA fires and new pods start (30–90 seconds), you may already be at 100% and shedding requests. Setting the target at 60% gives the autoscaler a running start — it begins provisioning before you’re in trouble.

Vertical Pod Autoscaler (VPA) for rightsizing

VPA automatically adjusts CPU/memory requests based on observed usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: checkout-service-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: checkout-service
  updatePolicy:
    updateMode: "Off"    # "Off" = recommendations only (don't auto-apply in prod)
                         # "Auto" = applies changes (use in staging first)
  resourcePolicy:
    containerPolicies:
      - containerName: checkout-service
        minAllowed:
          cpu: 100m
          memory: 128Mi
        maxAllowed:
          cpu: 4
          memory: 4Gi
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Check VPA recommendations (in "Off" mode)
kubectl describe vpa checkout-service-vpa -n production

# Output shows:
# Recommendation:
#   Container Recommendations:
#     Container Name: checkout-service
#     Lower Bound:    cpu: 250m, memory: 512Mi
#     Target:         cpu: 620m, memory: 890Mi   ← Apply this
#     Upper Bound:    cpu: 1200m, memory: 1800Mi
#
# If you're currently requesting 200m CPU, you're under-resourced.
# If you're requesting 2 CPU, you're wasting $$.

KEDA: scale on external metrics

KEDA extends HPA to scale on any metric — queue depth, database lag, Kafka consumer offset:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Scale checkout workers based on order queue depth
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-processor-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: order-processor
  minReplicaCount: 2
  maxReplicaCount: 100

  triggers:
    # Scale based on SQS queue depth
    - type: aws-sqs-queue
      metadata:
        queueURL: https://sqs.us-east-1.amazonaws.com/123456789/order-queue
        queueLength: "50"    # Target: 50 messages per pod
        awsRegion: us-east-1

    # Also scale on Kafka consumer lag
    - type: kafka
      metadata:
        bootstrapServers: kafka:9092
        consumerGroup: order-processors
        topic: orders
        lagThreshold: "100"  # Scale when consumer lag > 100 messages/pod

  advanced:
    restoreToOriginalReplicaCount: true
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown:
          stabilizationWindowSeconds: 180

Cluster autoscaler

Pods can only scale if nodes have capacity. Configure cluster autoscaler to add nodes proactively:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# cluster-autoscaler Helm values
autoDiscovery:
  clusterName: production-cluster

# Scale up aggressively, scale down conservatively
extraArgs:
  scale-down-delay-after-add: 10m
  scale-down-unneeded-time: 10m
  scale-down-utilization-threshold: "0.5"  # Scale down nodes at < 50% utilization
  max-node-provision-time: 15m
  skip-nodes-with-local-storage: "false"
  balance-similar-node-groups: "true"
  expander: least-waste  # Choose node group that wastes least resources

# Node groups — use spot/preemptible for worker nodes, on-demand for critical
nodeGroups:
  - name: on-demand-general
    minSize: 3
    maxSize: 20
  - name: spot-workers
    minSize: 0
    maxSize: 100

Proactive scaling: scale before the event

For known events (product launches, marketing campaigns), don’t rely on reactive autoscaling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pre-scale 2 hours before a known traffic event
# Add to your deployment runbook for major launches

# Scale up manually
kubectl scale deployment checkout-service --replicas=20 -n production
kubectl scale deployment payment-service --replicas=10 -n production

# Update HPA min replicas temporarily
kubectl patch hpa checkout-service -n production \
  --patch '{"spec":{"minReplicas":15}}'

# After event, restore normal minimums
kubectl patch hpa checkout-service -n production \
  --patch '{"spec":{"minReplicas":3}}'

Step 6: Database Capacity Planning

Databases are stateful and can’t be autoscaled like stateless services. They need the most careful planning.

PostgreSQL capacity metrics to track

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-- Connection utilization
SELECT
  count(*) as active_connections,
  (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') as max_connections,
  round(count(*) * 100.0 /
    (SELECT setting::int FROM pg_settings WHERE name = 'max_connections'), 1) as pct_used
FROM pg_stat_activity
WHERE state != 'idle';

-- Table bloat (dead tuples accumulating → I/O waste)
SELECT
  schemaname || '.' || tablename as table,
  n_dead_tup,
  n_live_tup,
  round(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 1) as dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

-- Slow queries (> 1 second)
SELECT
  query,
  calls,
  round(mean_exec_time::numeric, 2) as avg_ms,
  round(total_exec_time::numeric, 2) as total_ms
FROM pg_stat_statements
WHERE mean_exec_time > 1000
ORDER BY total_exec_time DESC
LIMIT 20;

-- Index hit rate (should be > 99%)
SELECT
  sum(idx_blks_hit) / nullif(sum(idx_blks_hit) + sum(idx_blks_read), 0) * 100
  as index_cache_hit_rate,
  sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100
  as heap_cache_hit_rate
FROM pg_statio_user_tables;

Connection pooling: PgBouncer

Direct PostgreSQL connections are expensive (~2–5MB each). With 100 application pods each opening 10 connections, you’re at 1,000 connections — consuming 2–5GB of RAM just for connections.

PgBouncer pools connections, allowing hundreds of application connections to share a small pool of server connections:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# pgbouncer.ini
[databases]
production = host=postgres-primary port=5432 dbname=myapp

[pgbouncer]
listen_port = 5432
listen_addr = 0.0.0.0
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

# Transaction pooling: most aggressive, highest compatibility issues
# Session pooling: safest, but less efficient
pool_mode = transaction

# Server connections (to actual PostgreSQL)
max_client_conn = 1000    # App connections to PgBouncer
default_pool_size = 25    # Actual PostgreSQL connections per database
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3

# Timeouts
server_connect_timeout = 5
query_timeout = 30
client_idle_timeout = 300

# Stats
stats_period = 60

With PgBouncer: 1,000 app connections → 25 PostgreSQL connections. Massive reduction in database memory pressure.

Read replicas for read-heavy workloads

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# Python — route reads to replicas, writes to primary
import psycopg2
from contextlib import contextmanager
import random

PRIMARY_DSN = "postgresql://user:pass@postgres-primary:5432/myapp"
REPLICA_DSNS = [
    "postgresql://user:pass@postgres-replica-1:5432/myapp",
    "postgresql://user:pass@postgres-replica-2:5432/myapp",
]

@contextmanager
def get_read_connection():
    """Use a random replica for reads."""
    dsn = random.choice(REPLICA_DSNS)
    conn = psycopg2.connect(dsn)
    try:
        yield conn
    finally:
        conn.close()

@contextmanager
def get_write_connection():
    """Always use primary for writes."""
    conn = psycopg2.connect(PRIMARY_DSN)
    try:
        yield conn
    finally:
        conn.close()

# Usage
def get_product(product_id: str):
    with get_read_connection() as conn:  # Replica
        cur = conn.cursor()
        cur.execute("SELECT * FROM products WHERE id = %s", (product_id,))
        return cur.fetchone()

def update_inventory(product_id: str, quantity: int):
    with get_write_connection() as conn:  # Primary
        cur = conn.cursor()
        cur.execute(
            "UPDATE products SET stock = stock - %s WHERE id = %s",
            (quantity, product_id)
        )
        conn.commit()

Step 7: The Capacity Review Cadence

Capacity planning isn’t a one-time exercise. It needs a regular cadence to stay useful.

Monthly capacity review

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
## Monthly Capacity Review — March 2026

### Traffic growth (last 30 days)
| Service          | March Avg RPS | Feb Avg RPS | Growth |
|------------------|---------------|-------------|--------|
| checkout-service | 480           | 450         | +6.7%  |
| payment-service  | 96            | 90          | +6.7%  |
| inventory-service| 555           | 520         | +6.7%  |

### Current headroom vs targets
| Service          | Current Util | Target Max | Headroom | Status |
|------------------|-------------|------------|----------|--------|
| checkout-service | 65% CPU     | 70%        | 5%       | ⚠️ Low  |
| payment-service  | 40% CPU     | 70%        | 30%      | ✅ OK   |
| inventory DB conn| 92%         | 70%        | -22%     | 🔴 Over |
| postgres-primary | 74% CPU     | 60%        | -14%     | 🔴 Over |

### Forecast: when do we hit 70% threshold?
| Service          | At current growth | Adjusted (incl. May launch) |
|------------------|-------------------|-----------------------------|
| checkout-service | June 15           | April 28                    |
| postgres-primary | Already over      | Already over                |

### Action items
1. Add PgBouncer to reduce DB connections immediately (Owner: @dbteam, Due: Apr 5)
2. Add read replica for inventory reads (Owner: @dbteam, Due: Apr 15)
3. Upgrade checkout-service pod resources (1 → 2 CPU) before May launch (Owner: @platteam, Due: Apr 30)
4. Run load test against staging after resource changes (Owner: @sre, Due: May 1)

Pre-launch capacity checklist

Run this 2 weeks before any major traffic event:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
## Pre-Launch Capacity Checklist — v3.0 Launch (May 15)

### Traffic estimation
- [ ] Expected peak RPS documented (with source/confidence)
- [ ] Expected multiplier vs current baseline: ___x
- [ ] Duration of peak: ___ hours

### Load testing
- [ ] Breaking-point test run on staging with new code
- [ ] Spike test run (simulating launch day traffic profile)
- [ ] Soak test run at expected peak for 2+ hours
- [ ] Results reviewed; no regressions vs previous tests

### Infrastructure readiness
- [ ] HPA min replicas pre-scaled to handle expected peak
- [ ] DB connection pools validated at expected connection count
- [ ] CDN cache rules reviewed for new endpoints
- [ ] External API rate limits confirmed sufficient
- [ ] Rollback plan documented and tested

### Monitoring and alerting
- [ ] Alert thresholds reviewed (not too tight for expected traffic increase)
- [ ] Dashboards updated for new services/endpoints
- [ ] On-call engineer briefed on launch and expected traffic
- [ ] Runbooks updated

### Communication
- [ ] Engineering team notified of launch date
- [ ] Customer support briefed on new features
- [ ] Status page incident template prepared

Cost-Aware Capacity Planning

More capacity costs more money. Right-sized systems spend less and perform better.

Identifying over-provisioned services

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Find pods with low utilization vs their requests (candidates for downsizing)
kubectl top pods -n production --no-headers | \
  awk '{print $1, $2, $3}' | \
  while read pod cpu mem; do
    requested_cpu=$(kubectl get pod $pod -n production -o jsonpath='{.spec.containers[0].resources.requests.cpu}')
    echo "$pod: using $cpu, requested $requested_cpu"
  done

# Or use Goldilocks (VPA recommendation viewer)
helm install goldilocks fairwinds-stable/goldilocks --namespace goldilocks
kubectl label namespace production goldilocks.fairwinds.com/enabled=true
kubectl port-forward -n goldilocks svc/goldilocks-dashboard 8080:80
# Open http://localhost:8080 — see VPA recommendations for all workloads

Spot/preemptible instances for stateless workloads

Stateless services that can handle a 2-minute interruption are perfect candidates for spot instances (60–90% cheaper):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Node affinity: prefer spot, fall back to on-demand
affinity:
  nodeAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 80
        preference:
          matchExpressions:
            - key: node.kubernetes.io/capacity-type
              operator: In
              values: [spot]
      - weight: 20
        preference:
          matchExpressions:
            - key: node.kubernetes.io/capacity-type
              operator: In
              values: [on-demand]

# Tolerate spot node interruption
tolerations:
  - key: "node.kubernetes.io/spot"
    operator: "Exists"
    effect: "NoSchedule"

PodDisruptionBudget: prevent over-aggressive scale-downs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Ensure at least 2 pods are always available during node drains/scaling
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: checkout-service-pdb
  namespace: production
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: checkout-service

Quick Reference: Capacity Planning Toolbox

Tool Purpose
k6 Load testing, breaking-point discovery, spike tests
kubectl top Real-time pod/node resource utilization
VPA Automatic resource request recommendations
HPA Horizontal autoscaling on CPU/custom metrics
KEDA Event-driven autoscaling (queues, Kafka, etc.)
Goldilocks VPA recommendations dashboard
PgBouncer PostgreSQL connection pooling
Cluster Autoscaler Node-level autoscaling
Prometheus Time-series metrics for forecasting
pg_stat_statements PostgreSQL query performance analysis

Summary

Capacity planning is not glamorous work, but it’s what separates teams that are constantly fighting fires from teams that sleep soundly through traffic spikes. The practice rests on three pillars:

  1. Know your limits — load test to find your breaking points before production traffic finds them for you. A breaking-point test is a few hours of work that could prevent a multi-hour incident.

  2. Watch your trends — model demand growth with historical data, adjust for known events, and maintain explicit headroom targets. Utilization above 70% for stateless services or 60% for databases is a yellow flag.

  3. Automate your safety net — configure HPA with aggressive scale-up behavior (60% CPU target, not 80%), use KEDA for queue-driven workloads, and pre-scale before events you can predict. Autoscaling is not a substitute for planning, but it’s essential insurance.

The teams that do this well don’t have fewer incidents because they’re luckier. They have fewer incidents because they’ve done the work to know what their systems can handle — and they’ve built the headroom to handle more.

Comments