Performance · 13

Scaling & Observability

Autoscaling is a delayed feedback loop, and a delayed loop reacts to the past. This lab simulates a traffic ramp against five scaling policies to show why CPU-based scaling with a three-minute alarm can leave users on a saturated fleet for minutes, then turns to the other half of the story: you cannot tune what you cannot see. It builds a hand-rolled histogram/timer, shows why averaging p99s across instances lies, reads a trace waterfall to find the 650 ms, and turns SLOs into burn-rate alerts. The scaling numbers are a simulation with stated, illustrative parameters; the metrics and tracing numbers come from a real plain-Java run on a 12-core laptop. No AWS services were available in the sandbox, so AWS behaviour is described, not measured.

14
Chapters
13
Diagrams
2
Real programs
314 s
Load rise to warm capacity (CPU policy)
01
Chapter One - Why This Matters

Scaling Reacts to the Past

The campaign from Capacity & Failure is on. Traffic to OrderService ramps from 600 to 1800 requests/s over two minutes. The ECS service autoscaling policy is textbook: target tracking on average CPU at 60 %. The dashboard shows what looks like a bug: CPU is at 100 % for minutes, p99 is 4+ seconds, yet the service "is scaling". It is - but each step of the loop takes time, and the delays add up to more than the ramp lasts.

Why scaling "reacts too late": delays stack up before any new capacity exists
Timeline of one scale-out decision (simulation parameters; see Lab 1) fleet OK (5 inst) alarm needs 3 x 1-min datapoints instance start + register 60 s load passes 750 rps: CPU above 60 % target scale-out decision new tasks take traffic full capacity (t=389 s) ramp saturates 5 instances (t=125) From "load crosses the target" to "full capacity" = 314 s in this model - longer than the 120 s ramp. During the lag the fleet runs above 100 % demand: queues grow, p99 reaches seconds (model, Lab 1).

Observability is the other half of the same problem. The first symptom in this incident was not an alarm but a user complaint, because the dashboard plotted average latency and average CPU across instances. This page covers both halves: how to make capacity arrive before the load (Chapters 2-5), and how to see - fast and truthfully - what the service is doing (Chapters 6-11).

Key insight: A thermostat with a five-minute-old thermometer will overshoot in both directions. Autoscaling is that thermostat, and the room is your p99. The cure is to measure something faster and closer to the cause, to act on a forecast where you can, and to keep headroom so that a late reaction is still an acceptable one.
02
Chapter Two - Mental Model

Autoscaling Is a Feedback Loop with Dead Time

Metric period: 60 s (standard CloudWatch resolution)
▶
The datapoint for minute N is only complete at the end of minute N; the autoscaler never sees "now", it sees an average of the last minute (up to a minute late).
Alarm evaluation: M of N datapoints
▶
Application Auto Scaling target tracking creates alarms that need several consecutive breaching datapoints for scale-out (documented as roughly 3 x 1 min) and many more for scale-in. This is deliberate damping - and it is a 3-minute delay.
Scaling math: desired = ceil(current x metric / target)
▶
Proportional, based on the metric of ready capacity. CPU saturates at 100 %: at 100 % you cannot tell 2x overload from 5x, so the loop under-reacts and needs several rounds.
Provisioning: image pull, JVM + Spring start, health checks, registration
▶
60-120 s for a Spring Boot task is common (JIT, Startup & Native); EC2 instances add minutes. Cold starts of Lambda are different (not covered here).
Warm-up: C1/C2 compilation, caches, connection pools
▶
A new JVM serves slower for the first minute (we assume 50 % capacity rising to 100 % over 60 s - an assumption, not a measurement; see JIT page for measured warm-up).
Scale-in cooldown and the opposite risk
▶
If scale-in is too eager, the next wave arrives to a shrunken fleet; scale-in is intentionally slow (e.g. 15 x 1-min datapoints).
The autoscaling feedback loop with dead time
Traffic Fleet capacity Metric (CPU) Alarm Scale decision avg of last 60 s 3 datapoints ceil(n x m / t) Launch tasks Start (90 s) Warm-up (60 s) capacity arrives Every red box is a pure delay: the loop acts on old data. High gain plus dead time = oscillation. Feed-forward (scheduled scaling, queue depth, request rate) shortens the loop; headroom absorbs what remains.
03
Chapter Three - Lab 1 - Scaling Lag

Five Policies Against the Same Ramp

1 Problem
2 Hypothesis
3 Measurement
4 Diagnosis
5 Optimization
6 Verification
1 Problem
▶
Traffic ramps 600 → 1800 rps in 120 s (t = 60 to 180). With ECS target tracking on CPU 60 %, p99 is above one second for roughly four minutes and about 10 % of requests are dropped or time out.
2 Hypothesis
▶
Capacity arrives only after metric delay + three datapoints + start time + warm-up. Using a faster signal or lower datapoint count should shorten the lag; extra headroom or scheduled capacity should remove it for a known ramp.
3 Measurement
▶
A one-second-step simulation in plain Java (Scale.java, Chapter 12): per-instance capacity 250 rps; new instance takes 90 s to start then runs at 50 % capacity rising to 100 % over 60 s; metrics are 1-minute averages evaluated on minute boundaries; latency model p99 = 20 ms x ln(100) / (1 - ρ) plus queue wait, queue capped at 2000 requests (overflow counted as dropped). Five policies A-E.
4 Diagnosis
▶
A needs t = 239 s for the first scale decision because the CPU breach needs three consecutive minutes; the fleet is saturated from t ≈ 125 to t ≈ 340. Using 1 datapoint (B) or requests-per-target (D) decides at t = 119 s. Lower target (C) starts later but ends with far more instances. Only the scheduled pre-scale (E) avoids the incident.
5 Optimization
▶
One variable per policy: A baseline; B datapoints 3 → 1; C CPU target 60 → 40 %; D signal CPU → requests per target (150); E 12 instances pre-provisioned at t = 0 (scheduled scaling for a known event).
6 Verification
▶
Seconds degraded (p99 > 1 s or drops): A 243, B 173, C 156, D 161, E 0. Dropped: A 10.2 %, B 5.4 %, C 6.6 %, D 5.4 %, E 0 %. Instance-seconds (cost proxy): A 6120, B 6780, C 11400, D 7200, E 7193.
Scale.java output (excerpt: summary lines; SERIES lines omitted; full run reproducible with the source in Chapter 12)
traffic 600 -> 1800 rps over t=60..180 s; instance start 90 s + 60 s warm-up at 50% capacity; capacity 250 rps/instance A CPU 60%, 3 datapoints first scale-out at t=239s peak inst=17 sec rho>85%: 267 sec degraded: 243 max p99(model)=4670 ms dropped=10.2% inst-seconds=6120 B CPU 60%, 1 datapoint first scale-out at t=119s peak inst=16 sec rho>85%: 201 sec degraded: 173 max p99(model)=4670 ms dropped=5.4% inst-seconds=6780 C CPU 40% (more headroom), 3 dp first scale-out at t=179s peak inst=31 sec rho>85%: 162 sec degraded: 156 max p99(model)=4670 ms dropped=6.6% inst-seconds=11400 D requests/target 150, 1 datapoint first scale-out at t=119s peak inst=16 sec rho>85%: 177 sec degraded: 161 max p99(model)=4670 ms dropped=5.4% inst-seconds=7200 E scheduled pre-scale to 12 at t=0 first scale-out at t=0s peak inst=12 sec rho>85%: 0 sec degraded: 0 max p99(model)=320 ms dropped=0.0% inst-seconds=7193
PolicyFirst scale decisionPeak instancesSeconds degradedDroppedInstance-secondsVerdict
A CPU 60 %, 3 datapointst = 239 s1724310.2 %6120Cheap, and it hurts
B CPU 60 %, 1 datapointt = 119 s161735.4 %6780Faster; risk of flapping on noise
C CPU 40 %, 3 datapointst = 179 s311566.6 %11400Over-provisions after the fact
D requests/target 150, 1 datapointt = 119 s161615.4 %7200Faster signal; still late
E scheduled pre-scale (12)t = 01200 %7193Wins for a known ramp
Simulated p99 during the ramp: the CPU policy is slow for minutes; a pre-scale avoids it (model, illustrative parameters)
0 1000 2000 3000 4000 5000 0 s 60 s 120 s 180 s 240 s 300 s 360 s 420 s 480 s 540 s 600 s 1 s ramp starts ramp ends time (s); traffic ramps 600 to 1800 rps during t = 60..180 model p99 latency (ms, capped 5000) A CPU 60 %, 3 dp D requests/target, 1 dp E scheduled pre-scale
Policy A: capacity needed vs launched vs actually serving - the distance between the lines is the incident (model)
0 3 6 9 12 15 18 0 s 60 s 120 s 180 s 240 s 300 s 360 s 420 s 480 s 540 s 600 s gap between launched and ready = start + warm-up time (s) instances instances needed at 60 % CPU launched (policy A) ready and serving (policy A)
⚠ Production gotchas
  • Symptom: CPU 100 % on all tasks, ECS desired count rising, p99 in seconds. Reading it: "scaling" is not "scaled". Compare desired vs running vs healthy-and-warm counts.
  • CPU alone is a weak signal for IO-bound services. A thread-pool-saturated, DB-waiting service can have 30 % CPU and a dead user experience; use request rate per target, in-flight requests, queue depth or latency (Chapter 4).
  • Target tracking saturates at 100 %. The metric cannot show how far over capacity you are; a 2x overload and a 5x overload both read 100 %, so the controller needs several rounds.
  • Model caveat. The simulation uses the parameters stated above (90 s start, 60 s warm-up at 50 %, 3 datapoints). Your values differ: measure your own task start-to-healthy time and warm-up, then re-run Scale.java with them.
04
Chapter Four - Choosing the Signal

Scale on What Moves First, Not on CPU Alone

If the loop has dead time, shorten it where you can and compensate where you cannot. Three levers: choose a signal that moves before CPU does, lower the number of confirmations you need, and use feed-forward (schedules, predictions, queue depth) for known patterns.

Lead time of the first scale decision per policy (simulation; the load crosses the 60 % CPU target at t = 75 s)
0 s 60 s 120 s 180 s 240 s 239 s A CPU 60 % 3 datapoints 119 s B CPU 60 % 1 datapoint 179 s C CPU 40 % 3 datapoints 119 s D requests/target 1 datapoint 0 s E scheduled pre-scale seconds until first scale decision
SignalLeads CPU?Good forWeaknessHow (AWS / Kubernetes)
CPU utilisationNo (it is the consequence)CPU-bound servicesIO-bound services stay low while queues grow; saturates at 100 %ECS ECSServiceAverageCPUUtilization; HPA Resource metric
Requests per targetYesStateless HTTP behind ALBNeeds a per-target capacity number from load testsECS/ASG ALBRequestCountPerTarget
In-flight requests / thread-pool busyYesBlocking MVC servicesCustom metric pipelineMicrometer tomcat.threads.busy → Prometheus adapter / CloudWatch
Queue depth / ageYesWorkers (SQS, Kafka consumers)Depth alone without age can misleadKEDA aws-sqs-queue / Kafka lag trigger; CloudWatch ApproximateNumberOfMessagesVisible
p95/p99 latencyPartly (already user-visible)SLO-drivenNoisy; latency also rises from non-capacity causes (DB slow) and scaling out then makes it worseCustom metric; use as guardrail not driver
Schedule / predictionFeed-forwardKnown daily/weekly/campaign patternsWrong forecast = waste or missScheduled actions; predictive scaling (EC2 ASG)
Shell - ECS target tracking on requests-per-target (AWS CLI; how-to, not run in this sandbox; the ResourceLabel comes from your ALB and target group)
aws application-autoscaling register-scalable-target \ --service-namespace ecs --resource-id service/prod/order-service \ --scalable-dimension ecs:service:DesiredCount --min-capacity 6 --max-capacity 30 aws application-autoscaling put-scaling-policy --policy-name rps-per-target \ --service-namespace ecs --resource-id service/prod/order-service \ --scalable-dimension ecs:service:DesiredCount --policy-type TargetTrackingScaling \ --target-tracking-scaling-policy-configuration '{ "TargetValue": 150.0, "PredefinedMetricSpecification": { "PredefinedMetricType": "ALBRequestCountPerTarget", "ResourceLabel": "app/orders-alb/50dc6c495c0c9188/targetgroup/orders/73e2d6bc24d8a067" }, "ScaleOutCooldown": 60, "ScaleInCooldown": 300 }' # max-capacity is derived from the DB connection budget (Capacity & Failure, chapter 5)
YAML - Kubernetes HPA v2 with fast scale-up and slow scale-down, plus KEDA queue-depth scaling for a worker (real field names)
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: { name: order-service } spec: scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: order-service } minReplicas: 6 maxReplicas: 30 metrics: - type: Pods pods: metric: { name: http_requests_per_second } # from a metrics adapter target: { type: AverageValue, averageValue: "150" } behavior: scaleUp: stabilizationWindowSeconds: 0 policies: [ { type: Percent, value: 100, periodSeconds: 30 } ] # may double every 30 s scaleDown: stabilizationWindowSeconds: 300 --- apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: { name: order-events-worker } spec: scaleTargetRef: { name: order-events-worker } minReplicaCount: 2 maxReplicaCount: 20 triggers: - type: aws-sqs-queue metadata: queueURL: https://sqs.eu-west-1.amazonaws.com/123456789012/order-events queueLength: "100" # target messages per replica awsRegion: eu-west-1
⚠ Production gotchas
  • Scaling on average CPU of a mixed fleet where new tasks (warming, low CPU) dilute the average and delay the next scale-out; use the instance-warmup / cooldown settings so warming tasks are accounted for.
  • max-capacity larger than the downstream budget. Scaling out an app tier into an already saturated database makes the incident worse (Capacity & Failure, connection budget).
  • Scale-in thrash. Short scale-in windows remove capacity just before the next wave; keep scale-in slow, scale-out fast (the HPA behaviour block above).
  • Queue-depth scaling for consumers capped by partitions. More Kafka consumers than partitions sit idle (see Distributed Systems, Chapter 10).
05
Chapter Five - Warm-up & Probes

Ready Is Not Warm

A new task is not new capacity until it is ready and warm. Three mechanisms decide how soon: health probes (when does traffic arrive?), load balancer slow start (how fast does it ramp?), and graceful shutdown (what happens to in-flight requests on scale-in and deploys).

Task lifecycle from launch to serving to draining
Pendingpull + schedule StartingJVM + Spring Ready?readiness OK Warmingslow start / JIT Servingfull capacity DrainingSIGTERM, drain startupProbe protects slow boot readinessProbe gate for traffic slow_start.duration ramps request share server.shutdown=graceful finish in-flight livenessProbe restarts a stuck process; readiness removes it from rotation without killing it. Do not use the same endpoint for both. In this lab "Starting" is 90 s and "Warming" is 60 s at 50 % rising to 100 % capacity (assumed).
YAML + properties - probes, graceful shutdown and a connection-drain window (Spring Boot 3, Kubernetes shown; ECS uses container health checks and the target group deregistration delay)
# application.properties management.endpoint.health.probes.enabled=true # /actuator/health/liveness and /readiness management.endpoint.health.group.readiness.include=readinessState,db server.shutdown=graceful # stop accepting, finish in-flight requests spring.lifecycle.timeout-per-shutdown-phase=30s # Deployment container spec startupProbe: { httpGet: { path: /actuator/health/liveness, port: 8080 }, failureThreshold: 30, periodSeconds: 5 } readinessProbe: { httpGet: { path: /actuator/health/readiness, port: 8080 }, periodSeconds: 5 } livenessProbe: { httpGet: { path: /actuator/health/liveness, port: 8080 }, periodSeconds: 10 } lifecycle: { preStop: { exec: { command: ["sleep", "10"] } } } # let the load balancer deregister first terminationGracePeriodSeconds: 45 # > preStop + shutdown phase
MechanismWhat it fixesSetting to know
Readiness probeTraffic to a JVM that is still bootingProbe period x failure threshold = worst-case delay to mark ready
Startup probeLiveness killing a slow-starting appfailureThreshold x periodSeconds must exceed worst-case start time
ALB slow start modeNew target receives its full share instantly and drowns while JIT-coldTarget group attribute slow_start.duration_seconds (30-900 s; ALB, round-robin); ramps a new target's share linearly
Deregistration delayIn-flight requests cut on scale-in/deployTarget group deregistration_delay.timeout_seconds (default 300 s; lower it to your p99.9)
Graceful shutdownConnection resets and 502s during rolloutsserver.shutdown=graceful, spring.lifecycle.timeout-per-shutdown-phase
Pre-warming / CDS / native imageSlow start and cold JITSee JIT, Startup & Native Image
⚠ Production gotchas
  • Readiness that never fails. A readiness endpoint that returns 200 as soon as Tomcat is up sends traffic to a cold JVM: p99 spikes on every deploy. Include warm-up work (a few synthetic requests, pool initialisation) in the readiness gate.
  • 502s on every deploy or scale-in. Symptom: ALB 502/504 bursts at rollout time. Cause: task killed before the load balancer stopped sending (no preStop delay / graceful shutdown), or app keep-alive shorter than the ALB idle timeout (Spring, HTTP & Network).
  • Health check hitting the database. A slow DB flips all tasks unhealthy simultaneously and the service goes to zero - exactly the failure the readiness group choice (db above) should be a deliberate decision about.
  • Slow start with CPU target tracking. Warm-up lowers measured CPU, delaying the next scale-out; set instance warm-up so the policy ignores starting tasks.
06
Chapter Six - Metrics to Watch

RED, USE and the Micrometer Names Behind Them

You need a small, fixed set of metrics on every service dashboard. The classic frames are the RED method (Rate, Errors, Duration) for request-driven services and USE (Utilisation, Saturation, Errors) for resources, both introduced in Measure First. With Micrometer in Spring Boot the names below are what you get.

AreaMicrometer metric (Prometheus name)Look forMaps to
HTTP (RED)http.server.requests (http_server_requests_seconds_*) tags uri, method, status, outcomerate, 5xx ratio, p95/p99 from histogram bucketsRate, Errors, Duration
JVM GCjvm.gc.pause, jvm.gc.live.data.size, jvm.gc.memory.allocated/promotedpause p99 > 100 ms; live-data trending up (leak)Saturation (memory)
JVM memoryjvm.memory.used/max by area and idold gen after GC rising; non-heap + direct buffers vs container limitUtilisation
Threadsjvm.threads.live, jvm.threads.states (blocked/waiting), executor.queuedblocked count rising; executor queue non-zeroSaturation
Tomcattomcat.threads.busy, tomcat.threads.config.max (needs server.tomcat.mbeanregistry.enabled=true)busy = max → thread starvationSaturation
Hikarihikaricp.connections.active/pending/timeout, hikaricp.connections.acquire, .usagepending > 0; timeouts > 0; acquire p99Saturation (DB pool)
CPUprocess.cpu.usage, system.cpu.usage, system.load.average.1m; container CFS throttling from cAdvisorthrottled periods > 0 with low average CPUUtilisation
Downstreamhttp.client.requests (RestClient/WebClient), resilience4j.circuitbreaker.stateper-dependency p99; breaker openDuration, Errors
YAML - Spring Boot settings that make the metrics useful (real property names; Boot 3)
management: endpoints.web.exposure.include: health,info,prometheus metrics: distribution: percentiles-histogram.http.server.requests: true # publishes _bucket series: aggregatable across instances slo.http.server.requests: 50ms,100ms,250ms,500ms,1s # buckets AT your SLO thresholds # percentiles.http.server.requests: 0.95,0.99 # client-side percentiles: NOT aggregatable, avoid for dashboards tags: application: order-service server.tomcat.mbeanregistry.enabled: true # tomcat.threads.* metrics
RED plus saturation: nine panels answer "is it slow, why, and is it getting worse"
A one-screen dashboard: RED for the service, saturation for what limits it (layout sketch, not real data) Rate req/s per uri Errors 5xx ratio + SLO line Duration p50/p95/p99 from buckets Tomcat threads busy vs max Hikari active / pending / timeouts GC + heap pause p99, old-gen after GC Downstream p99 per dependency, breaker state Queue / lag SQS age, Kafka lag Fleet desired / running / ready tasks
⚠ Production gotchas
  • Dashboards of averages. The mean hid a 9x tail on one instance in Lab 2. Use p95/p99 from histograms, plus the rate of requests above the SLO threshold.
  • High-cardinality tags. A uri tag built from raw paths (/orders/123) explodes series count; Spring uses the route template (/orders/{id}) - keep it that way, and never tag by user/order id.
  • No saturation metrics. Rate and latency say "slow"; thread, pool and queue metrics say "why". Without hikaricp.connections.pending the Capacity lab's DB incident is invisible until timeouts.
  • Scrape interval vs spikes. A 15-30 s scrape misses 5 s spikes in gauges; histograms and counters survive coarse scraping, gauges do not.
07
Chapter Seven - Lab 2 - Histograms & Timers

Why the Dashboard Disagreed with the Customers

1 Problem
2 Hypothesis
3 Measurement
4 Diagnosis
5 Optimization
6 Verification
1 Problem
▶
The fleet dashboard shows p99 = 242 ms (the average of per-instance p99 values). Users on the unlucky instance experience 400 ms and the SLO alert never fires. Customers disagree with the dashboard.
2 Hypothesis
▶
Percentiles cannot be averaged. The fleet p99 must be computed from the merged distribution (merged histogram buckets), not from per-instance percentiles. The accuracy of the estimate depends on bucket boundaries.
3 Measurement
▶
Instrument a plain-Java handler with a hand-written histogram timer (Obs.java, Chapter 12): fixed buckets 1 ms ... 2.5 s, counters, an in-flight gauge, and a Prometheus text /metrics renderer. Two instances: A gets 3000 requests (p99 ~ 44 ms), B 1000 requests with a fatter tail (p99 ~ 400 ms). Compute: the average of the two p99s, histogram_quantile on merged buckets, and the exact p99 over all 4000 raw samples (ground truth).
4 Diagnosis
▶
Average of p99s = 242.3 ms; merged-bucket estimate = 303.9 ms; truth = 349.1 ms. The average of percentiles is 31 % too low; the merged histogram is 13 % low because linear interpolation inside the 250-500 ms bucket assumes a uniform distribution inside the bucket.
5 Optimization
▶
One variable: how the fleet percentile is computed (average of per-instance p99 → merge histograms). Second, orthogonal fix: add bucket boundaries around the SLO threshold (e.g. 250, 300, 350, 400, 500 ms) so the interpolation error shrinks where it matters.
6 Verification
▶
Error vs truth: 31 % (average of p99) → 13 % (merged histogram, coarse buckets). The p50 is almost exact in the histogram (20.7 vs 20.4 ms) because it falls in a narrow bucket; p95 is 45.8 vs 40.3 ms. The mean (A 21.3 ms, B 43.2 ms) would have hidden the problem: B's p99 is 9x its mean.
Obs.java - scrape of instance A (trimmed to the first 21 lines of /metrics text)
=== scrape of instance A (/metrics text, trimmed) === # TYPE http_server_requests_seconds histogram http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.001"} 0 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.002"} 0 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.005"} 0 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.01"} 0 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.02"} 1887 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.03"} 2794 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.05"} 2983 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.075"} 2988 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.1"} 2988 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.15"} 2988 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.25"} 2988 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="0.5"} 3000 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="1"} 3000 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="2.5"} 3000 http_server_requests_seconds_bucket{instance="a",uri="/orders/{id}",le="+Inf"} 3000 http_server_requests_seconds_count{instance="a",uri="/orders/{id}"} 3000 http_server_requests_seconds_sum{instance="a",uri="/orders/{id}"} 64.021 http_server_requests_errors_total{instance="a",status="5xx"} 4 http_server_requests_inflight{instance="a"} 0
Obs.java - per-instance and fleet percentiles (real run; the load is random so numbers vary slightly from run to run)
instance A (3000 req): exact p99 = 43.7 histogram p99 = 48.6 instance B (1000 req): exact p99 = 399.0 histogram p99 = 435.9 WRONG average of the two p99s = 242.3 RIGHT histogram_quantile on merged buckets = 303.9 TRUTH exact p99 over all 4000 requests = 349.1 p50: merged histogram 20.7, exact 20.4 ; p95: merged 45.8, exact 40.3 mean latency A 21.3 ms, B 43.2 ms (the mean hides the tail: p99 is 9x the mean on B)
The histogram behind instance A's /metrics output (measured)
0 500 1000 1500 2000 <=1 2 5 10 1887 20 907 30 189 50 5 75 100 150 250 12 500 1000 2500 bucket upper bound (ms); counts are per bucket (the Prometheus text shows them cumulative) Instance A: 3000 requests, 17 above 50 ms: the 99th percentile sits in the 30-50 ms bucket
Three ways to compute the fleet p99 from the same 4000 requests (measured)
0 ms 100 ms 200 ms 300 ms 400 ms 242 ms average of p99s (WRONG) 304 ms merged histogram 349 ms exact p99 (ground truth) fleet p99 (ms)
Java - the core of the hand-rolled timer (full source in Chapter 12): observe into fixed buckets, estimate quantiles like histogram_quantile
void record(double ms) { int i = 0; while (i < BOUNDS_MS.length && ms > BOUNDS_MS[i]) i++; // first bucket whose upper bound >= ms buckets.incrementAndGet(i); count.increment(); sumMicros.add((long) (ms * 1000)); } static double quantileOf(long[] b, double q) { long total = 0; for (long v : b) total += v; double rank = q * total; long cum = 0; for (int i = 0; i < b.length; i++) { long prev = cum; cum += b[i]; if (cum >= rank) { // the bucket that contains the rank double lo = i == 0 ? 0 : BOUNDS_MS[i - 1], hi = BOUNDS_MS[i]; return lo + (hi - lo) * (rank - prev) / b[i]; // linear interpolation inside the bucket } } return Double.NaN; }
⚠ Production gotchas
  • Averaging p99s, or taking "max of p99s" as the fleet number. Neither is the fleet percentile. In Prometheus: histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri="/orders/{id}"}[5m]))) - sum buckets first, then take the quantile.
  • Client-side percentiles (percentiles.http.server.requests) are published per instance and cannot be merged; use histograms for anything you aggregate or alert on.
  • Bucket boundaries nowhere near the SLO. The estimate near the boundary of the SLO threshold is only as good as the bucket there; put slo boundaries at the thresholds you alert on.
  • The timer measures the handler, not the user. Time before the handler (accept queue, TLS, ALB) is missing - compare with ALB TargetResponseTime and client-side measurements (coordinated omission, Benchmarking).
08
Chapter Eight - Distributed Tracing

Reading a Waterfall to Find the 650 ms

Metrics tell you that p99 is bad; a trace tells you where one slow request spent its time. A trace is a tree of spans (name, start, duration, parent) joined by a trace id that travels in HTTP headers (W3C traceparent) or message headers. In Spring Boot 3 the pieces are Micrometer Observation (instrumentation API), Micrometer Tracing (bridge) and OpenTelemetry (exporter).

Maven + properties - Boot 3 tracing with an OTLP exporter (artifact and property names as in the Boot reference; check the exact property for your Boot 3.x version)
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency> <dependency><groupId>io.micrometer</groupId><artifactId>micrometer-tracing-bridge-otel</artifactId></dependency> <dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-exporter-otlp</artifactId></dependency> # application.properties management.tracing.sampling.probability=0.1 # head sampling: 10 % of requests (default is 0.1) management.otlp.tracing.endpoint=http://otel-collector:4318/v1/traces logging.pattern.correlation=[${spring.application.name:},%X{traceId:-},%X{spanId:-}] # trace id in every log line

Below is a real trace from the hand-rolled tracer in Obs.java (spans are explicit; the durations are real Thread.sleep times of a slow request modelled on the 800 ms OrderService request from Measure First): one slow GET /orders/42.

Obs.java - trace of one request (text waterfall truncated to the span list)
=== trace of one slow GET /orders/42 (hand-rolled spans) === GET /orders/42 start 0.7 dur 767.3 ms filters + auth start 1.1 dur 3.1 ms OrderService.load start 5.3 dur 638.5 ms orders by customer (1 query) start 10.0 dur 31.1 ms order_lines x8 (N+1) start 41.7 dur 200.7 ms order_lines x8 (N+1) start 242.4 dur 200.8 ms order_lines x8 (N+1) start 443.2 dur 200.6 ms PaymentGateway.status (HTTP) start 644.4 dur 62.1 ms InventoryService.check (HTTP) start 707.2 dur 38.1 ms Jackson serialize start 745.9 dur 22.1 ms
The same trace as a waterfall: one root, three repeated spans, then two sequential HTTP calls (measured spans)
0 ms 200 ms 400 ms 600 ms 800 ms GET /orders/42 767.3 ms filters + auth 3.1 ms OrderService.load 638.5 ms orders by customer (1 query) 31.1 ms order_lines x8 (N+1) 200.7 ms order_lines x8 (N+1) 200.8 ms order_lines x8 (N+1) 200.6 ms PaymentGateway.status (HTTP) 62.1 ms InventoryService.check (HTTP) 38.1 ms Jackson serialize 22.1 ms
Where the time wentSpansmsShare of the requestWhat it tells you
Database1 query + 3 x "order_lines x8 (N+1)"63383 %Repeated identical spans = N+1: Database Performance
Payment call1 sequential HTTP span628 %Sequential with Inventory: could run in parallel (fan-out page)
Inventory call1 sequential HTTP span385 %Candidate for cache or parallelisation
SerializationJackson223 %Payload size / DTO shape
Everything else (filters, glue)-122 %Application CPU is not the problem

How to read a waterfall. (1) Find the widest span under the root: that is where time went. (2) Look for repetition - many short identical children means N+1; (3) look at gaps between a parent and its children - time spent in the parent itself or waiting for a thread; (4) look at stairs - sequential children that could be parallel; (5) compare with a fast trace of the same endpoint (is it always slow or only sometimes?). The 650 ms of the OrderService request is the database span group, exactly the attribution exercise from the methodology page.

⚠ Production gotchas
  • Head sampling hides the incident. 10 % random sampling keeps few of the rare slow requests. Use tail-based sampling in the collector (keep all errors and requests slower than the SLO) or raise the rate for the endpoint under investigation.
  • Broken context propagation. A trace that stops at an async boundary (thread pool, @Async, message consumer) loses its parent; use Boot's instrumented executors / context-propagating wrappers and put trace headers in message attributes.
  • Tracing overhead and volume. Per-span cost is small but real; 100 % sampling of a 3000 rps service with 30 spans each is 90,000 spans/s. Sample, and drop noisy spans (health checks, actuator).
  • Only the HTTP spans. The N+1 only shows if JDBC/DB calls are spans (use the datasource/JDBC instrumentation or observation) - otherwise the waterfall shows one fat "OrderService" bar.
  • X-Ray note. X-Ray uses its own trace-id format; through the AWS Distro for OpenTelemetry (ADOT) collector with the X-Ray exporter you need the X-Ray-compatible id generator/propagator in the SDK (or use the collector's translation). Verify against current ADOT docs.
09
Chapter Nine - SLIs, SLOs & Burn Rate

From "Fast Enough" to an Alert You Can Trust

An SLO turns "fast enough" into a number you can alert on. Define a service level indicator (SLI) as a ratio of good events to total events (for example: requests answered successfully in under 300 ms), an SLO as the target (99.9 % over 30 days) and an error budget as the allowed failures (0.1 % - about 43 minutes of full outage). Then alert on how fast you are spending the budget, not on a raw error count.

Obs.java - error-budget arithmetic (SLO 99.9 % over 30 days)
=== error-budget arithmetic: SLO 99.9% over 30 days === error budget = 0.1% of 43,200 min = 43.2 min of total outage-equivalent burn rate error ratio budget gone in budget used per hour 1.0 0.0010 30 days 0.1% 2.0 0.0020 15 days 0.3% 6.0 0.0060 5 days 0.8% 14.4 0.0144 2 days 2.0% 36.0 0.0360 20.0 h 5.0%
Error budget remaining at constant burn rates (arithmetic from the SLO definition)
0 % 25 % 50 % 75 % 100 % 0h 120h 240h 360h 480h 600h 720h 2 % spent: 14.4x for 1 h hours into the 30-day window error budget remaining burn rate 1 burn rate 6 burn rate 14.4 burn rate 36
AlertBurn rateLong windowShort windowBudget spent when it firesAction
Fast burn - page14.4x1 h5 min2 %Page now: would exhaust the budget in 2 days
Medium burn - page6x6 h30 min5 %Page: exhausts in 5 days
Slow burn - ticket1x3 days6 h10 %Ticket: on pace to use the whole budget

The pairs are the multiwindow, multi-burn-rate pattern from the Google SRE workbook: the long window proves it is not a blip, the short window proves it is still happening (so the alert resolves quickly). 14.4x for 1 hour consumes 14.4 x 1/720 = 2 % of a 30-day budget, which is why that number appears in the table.

YAML - a Prometheus burn-rate alert for the latency+availability SLI of OrderService (rule syntax; metric names from Chapter 6)
groups: - name: order-service-slo rules: - record: sli:orders_bad_ratio:rate5m # bad = 5xx or slower than 300 ms expr: | 1 - ( sum(rate(http_server_requests_seconds_bucket{uri="/orders/{id}",le="0.3",status!~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count{uri="/orders/{id}"}[5m])) ) - alert: OrdersFastBurn expr: sli:orders_bad_ratio:rate1h > (14.4 * 0.001) and sli:orders_bad_ratio:rate5m > (14.4 * 0.001) for: 2m labels: { severity: page }
⚠ Production gotchas
  • Alerting on causes (CPU 80 %) instead of symptoms (SLO burn). You get pages that nobody can act on and miss the ones that matter; keep cause metrics as diagnostics.
  • SLI measured at the wrong place. Server-side handler timers miss load-balancer queueing and TLS; measure at the ALB and/or client side too.
  • Average-based SLOs. "Average latency below 200 ms" is satisfied by a service that fails 5 % of users; use a percentile or a good/total ratio.
  • No policy attached to the budget. When the budget is gone, feature work slows and reliability work takes priority - otherwise the SLO is decoration. Link the policy to the failure rehearsals.
10
Chapter Ten - CloudWatch, EMF, X-Ray & ADOT

Getting the Signal to Where Humans and Autoscalers Can Use It

On AWS the same telemetry reaches three families of destinations. None of this could be run in the sandbox (no AWS access); the architecture and formats are what the services document, so check current docs for limits and prices.

Telemetry pipeline on AWS (illustrative architecture)
OrderServiceMicrometer + OTel ADOT / OTel Collectorsidecar or daemon OTLP CloudWatch agent / EMFlog lines with metrics AWS X-Ray (traces) Managed Prometheus CloudWatch metrics CloudWatch Logs Dashboardsand alarms Pipeline sketch (architecture, not a measurement). The collector decouples the app from the backends: tail sampling, batching and routing live there. Cost scales with samples x series x retention; high-cardinality dimensions are the usual bill surprise.
JSON - an Embedded Metric Format (EMF) log line: CloudWatch extracts custom metrics from a structured log record (field names per the EMF specification)
{ "_aws": { "Timestamp": 1735689600000, "CloudWatchMetrics": [ { "Namespace": "OrderService", "Dimensions": [["Service", "Endpoint"]], "Metrics": [ { "Name": "OrderLatency", "Unit": "Milliseconds" }, { "Name": "DbQueries", "Unit": "Count" } ] } ] }, "Service": "order-service", "Endpoint": "GET /orders/{id}", "OrderLatency": 767.3, "DbQueries": 25, "traceId": "4bf92f3577b34da6a3ce929d0e0e4736" // high-cardinality fields stay in the log, NOT in Dimensions }
NeedOption on AWSTrade-off
Custom app metrics without an agent per metricEMF log lines → CloudWatch MetricsOne log line = many metrics; dimensions multiply series (cost)
Prometheus-style scraping + PromQLAmazon Managed Service for Prometheus + Managed Grafana, or self-hostedBest for histograms and burn-rate rules; you own the rules
TracesX-Ray via ADOT, or any OTLP backendSampling decisions in the collector; cross-account setup
Container resource metricsContainer Insights (ECS/EKS) incl. CPU throttling hintsExtra cost per metric; enable selectively
DB internalsPerformance Insights / CloudWatch Database Insights, slow query logsSee Database Performance
High-resolution (1 s) metricsCloudWatch high-resolution custom metricsHigher cost; use for the few signals that drive scaling
Illustrative, not measured: AWS limits, pricing and exact alarm evaluation rules change; treat the descriptions above as the shape of the solution and confirm against the current documentation before relying on them.
11
Chapter Eleven - Runbook

From Alert to Cause in Five Questions

When the SLO alert fires, the order of questions matters more than the tools. This is the shortest path from "burn-rate page" to a cause, using exactly the signals built in this page and the earlier ones.

Alert-to-cause decision flow (process diagram)
SLO burn-rate alertwhich endpoint? since when? RED + saturation panelswhat changed: deploy, traffic, dependency? Rate up, CPU/queue highcapacity: scaling lag? shed? Rate flat, latency updependency or pool: see traces Errors up after a deployroll back, then diagnose Compare desired / ready tasksscale policy, max cap, DB budget Open a slow tracewidest span, repeated spans, gaps Canary and heap trendleak? config? bad query plan? Rule: mitigate first (roll back, shed, scale, fail over), then investigate with traces, profiles and heap dumps.
SymptomFirst lookThenPage
p99 up, rate flat, CPU lowTomcat busy / Hikari pendingTrace: widest span; thread dumpDatabase, Concurrency
p99 up and CPU 100 % after traffic riseDesired vs ready tasks; load per targetScale policy lead time; shedThis page, Chapters 3-5
Latency spikes on a period (30 s, 60 s)GC pause p99, CFS throttling, cron jobsJFR / GC logJVM Memory & GC, AWS Compute
5xx burst at deploysALB 502/504, readiness, graceful shutdownpreStop, keep-alive vs idle timeoutChapter 5, Spring Tuning
Dashboard fine, users unhappyIs the SLI measured at the edge? percentiles from merged buckets?Client-side/RUM, ALB TargetResponseTimeChapter 7
Backlog / lag risingConsumers vs partitions, age metricScale on age, fix poison messageDistributed Systems
12
Chapter Twelve - Try It Yourself

The Plain-Java Source I Ran

Plain Java 21, no dependencies: javac Obs.java && java Obs (about 12 seconds; random load, so numbers vary a little between runs) and javac Scale.java && java Scale (instant; deterministic, writes scale.csv).

Obs.java - histogram timer, Prometheus text output, merged-quantile demo, mini tracer, error-budget table
import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** Hand-rolled metrics (counter, gauge, histogram timer), Prometheus text output, histogram merge vs "average of p99", and a tiny tracer. */ public class Obs { // ---------- metrics ---------- static final double[] BOUNDS_MS = {1, 2, 5, 10, 20, 30, 50, 75, 100, 150, 250, 500, 1000, 2500}; static class Histogram { final AtomicLongArray buckets = new AtomicLongArray(BOUNDS_MS.length + 1); // per-bucket (non-cumulative) counts final LongAdder count = new LongAdder(), sumMicros = new LongAdder(); void record(double ms) { int i = 0; while (i < BOUNDS_MS.length && ms > BOUNDS_MS[i]) i++; buckets.incrementAndGet(i); count.increment(); sumMicros.add((long) (ms * 1000)); } /** same algorithm as PromQL histogram_quantile: linear interpolation inside the bucket that crosses the rank */ double quantile(double q) { return quantileOf(snapshot(), q); } long[] snapshot() { long[] s = new long[buckets.length()]; for (int i = 0; i < s.length; i++) s[i] = buckets.get(i); return s; } static double quantileOf(long[] b, double q) { long total = 0; for (long v : b) total += v; double rank = q * total; long cum = 0; for (int i = 0; i < b.length; i++) { long prev = cum; cum += b[i]; if (cum >= rank) { if (i == BOUNDS_MS.length) return BOUNDS_MS[BOUNDS_MS.length - 1]; double lo = i == 0 ? 0 : BOUNDS_MS[i - 1], hi = BOUNDS_MS[i]; return b[i] == 0 ? lo : lo + (hi - lo) * (rank - prev) / b[i]; } } return Double.NaN; } static long[] merge(long[]... all) { long[] m = new long[all[0].length]; for (long[] a : all) for (int i = 0; i < m.length; i++) m[i] += a[i]; return m; } } static class Metrics { final Histogram http = new Histogram(); final LongAdder ok = new LongAdder(), err5xx = new LongAdder(); final AtomicInteger inflight = new AtomicInteger(); final List<Double> raw = Collections.synchronizedList(new ArrayList<>()); // ground truth for the demo only String scrape(String inst) { StringBuilder sb = new StringBuilder(); sb.append("# TYPE http_server_requests_seconds histogram\n"); long cum = 0; for (int i = 0; i < BOUNDS_MS.length; i++) { cum += http.buckets.get(i); sb.append(String.format("http_server_requests_seconds_bucket{instance=\"%s\",uri=\"/orders/{id}\",le=\"%s\"} %d%n", inst, trim(BOUNDS_MS[i] / 1000), cum)); } cum += http.buckets.get(BOUNDS_MS.length); sb.append(String.format("http_server_requests_seconds_bucket{instance=\"%s\",uri=\"/orders/{id}\",le=\"+Inf\"} %d%n", inst, cum)); sb.append(String.format("http_server_requests_seconds_count{instance=\"%s\",uri=\"/orders/{id}\"} %d%n", inst, http.count.sum())); sb.append(String.format("http_server_requests_seconds_sum{instance=\"%s\",uri=\"/orders/{id}\"} %.3f%n", inst, http.sumMicros.sum() / 1e6)); sb.append(String.format("http_server_requests_errors_total{instance=\"%s\",status=\"5xx\"} %d%n", inst, err5xx.sum())); sb.append(String.format("http_server_requests_inflight{instance=\"%s\"} %d%n", inst, inflight.get())); return sb.toString(); } static String trim(double d) { String s = String.valueOf(d); return s.endsWith(".0") ? s.substring(0, s.length() - 2) : s; } } // ---------- the "service": one handler, instrumented ---------- static void handle(Metrics m, double base, double expMean, double slowP, double slowAdd) { m.inflight.incrementAndGet(); long t0 = System.nanoTime(); SplittableRandom r = new SplittableRandom(); double ms = base + (-Math.log(1 - r.nextDouble())) * expMean + (r.nextDouble() < slowP ? slowAdd * (1 + r.nextDouble() * 0.3) : 0); try { Thread.sleep((long) ms, (int) ((ms % 1) * 1e6)); } catch (InterruptedException ignored) { } boolean fail = r.nextDouble() < 0.002; double took = (System.nanoTime() - t0) / 1e6; m.http.record(took); m.raw.add(took); // the "timer": observe duration into the histogram if (fail) m.err5xx.increment(); else m.ok.increment(); m.inflight.decrementAndGet(); } static void load(Metrics m, double base, double expMean, double slowP, double slowAdd, int n, int rps) throws Exception { CountDownLatch d = new CountDownLatch(n); long start = System.nanoTime(); try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < n; i++) { long at = start + i * (1_000_000_000L / rps); java.util.concurrent.locks.LockSupport.parkNanos(Math.max(0, at - System.nanoTime())); ex.submit(() -> { handle(m, base, expMean, slowP, slowAdd); d.countDown(); }); } d.await(); } } static double exact(List<Double> l, double p) { List<Double> c = new ArrayList<>(l); Collections.sort(c); return c.get(Math.min(c.size() - 1, (int) Math.ceil(p / 100 * c.size()) - 1)); } // ---------- tiny tracer ---------- record Span(String name, int depth, double startMs, double durMs) {} static final List<Span> TRACE = new ArrayList<>(); static long T0; static int depth; static void span(String name, Runnable body) { double s = (System.nanoTime() - T0) / 1e6; int idx = TRACE.size(); TRACE.add(null); depth++; body.run(); depth--; double e = (System.nanoTime() - T0) / 1e6; TRACE.set(idx, new Span(name, depth, s, e - s)); } static void sleep(double ms) { try { Thread.sleep((long) ms, (int) ((ms % 1) * 1e6)); } catch (InterruptedException ignored) { } } public static void main(String[] a) throws Exception { Metrics A = new Metrics(), B = new Metrics(); Thread t1 = new Thread(() -> { try { load(A, 14, 6, 0.005, 300, 3000, 300); } catch (Exception e) { } }); Thread t2 = new Thread(() -> { try { load(B, 20, 10, 0.05, 300, 1000, 100); } catch (Exception e) { } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("=== scrape of instance A (/metrics text, trimmed) ==="); System.out.print(A.scrape("a")); System.out.println("\n=== per-instance and fleet percentiles (ms) ==="); List<Double> all = new ArrayList<>(A.raw); all.addAll(B.raw); double pa = A.http.quantile(0.99), pb = B.http.quantile(0.99); long[] merged = Histogram.merge(A.http.snapshot(), B.http.snapshot()); System.out.printf("instance A (3000 req): exact p99 = %.1f histogram p99 = %.1f%n", exact(A.raw, 99), pa); System.out.printf("instance B (1000 req): exact p99 = %.1f histogram p99 = %.1f%n", exact(B.raw, 99), pb); System.out.printf("WRONG average of the two p99s = %.1f%n", (pa + pb) / 2); System.out.printf("RIGHT histogram_quantile on merged buckets = %.1f%n", Histogram.quantileOf(merged, 0.99)); System.out.printf("TRUTH exact p99 over all 4000 requests = %.1f%n", exact(all, 99)); System.out.printf("p50: merged histogram %.1f, exact %.1f ; p95: merged %.1f, exact %.1f%n", Histogram.quantileOf(merged, .5), exact(all, 50), Histogram.quantileOf(merged, .95), exact(all, 95)); double avgA = A.http.sumMicros.sum() / 1000.0 / A.http.count.sum(), avgB = B.http.sumMicros.sum() / 1000.0 / B.http.count.sum(); System.out.printf("mean latency A %.1f ms, B %.1f ms (the mean hides the tail: p99 is %.0fx the mean on B)%n", avgA, avgB, exact(B.raw, 99) / avgB); System.out.println("\n=== trace of one slow GET /orders/42 (hand-rolled spans) ==="); T0 = System.nanoTime(); span("GET /orders/42", () -> { span("filters + auth", () -> sleep(3)); span("OrderService.load", () -> { sleep(4); span("orders by customer (1 query)", () -> sleep(31)); for (int i = 0; i < 24; i++) { final int k = i; if (k % 8 == 0) { span("order_lines x8 (N+1)", () -> { for (int j = 0; j < 8; j++) sleep(25); }); } } }); span("PaymentGateway.status (HTTP)", () -> sleep(62)); span("InventoryService.check (HTTP)", () -> sleep(38)); span("Jackson serialize", () -> sleep(22)); }); List<Span> sorted = new ArrayList<>(TRACE); sorted.sort(Comparator.comparingDouble(Span::startMs)); for (Span s : sorted) { int off = (int) Math.round(s.startMs() / 8), len = Math.max(1, (int) Math.round(s.durMs() / 8)); System.out.printf("%-34s start %6.1f dur %6.1f ms |%s%s%n", " ".repeat(s.depth()) + s.name(), s.startMs(), s.durMs(), " ".repeat(off), "#".repeat(len)); } System.out.println("\n=== error-budget arithmetic: SLO 99.9% over 30 days ==="); double budgetMin = 30 * 24 * 60 * 0.001; System.out.printf("error budget = 0.1%% of 43,200 min = %.1f min of total outage-equivalent%n", budgetMin); System.out.println("burn rate error ratio budget gone in budget used per hour"); for (double br : new double[]{1, 2, 6, 14.4, 36}) { System.out.printf("%-10.1f %-12.4f %-16s %.1f%%%n", br, br * 0.001, fmt(30 * 24 / br), 100 * br / (30 * 24)); } } static String fmt(double hours) { return hours >= 48 ? String.format("%.0f days", hours / 24) : String.format("%.1f h", hours); } }
Scale.java - autoscaling reaction-time simulation with five policies
import java.util.*; /** * Autoscaling reaction-time simulation (one second per step). A MODEL with illustrative parameters: * traffic ramps 600 -> 1800 rps between t=60 s and t=180 s. A new instance needs START_S before it takes traffic * and then WARM_S of JIT/cache warm-up at reduced capacity. Metrics are 1-minute averages (CloudWatch-style). */ public class Scale { static final int T = 600, START_S = 90, WARM_S = 60; static final double CAP = 250; // rps per fully warm instance at 100 % CPU static final double WARM_START = 0.5; // capacity fraction right after start static final double SVC_MS = 20; static double lambda(int t) { if (t < 60) return 600; if (t < 180) return 600 + (1800 - 600) * (t - 60) / 120.0; return 1800; } enum Sig { CPU, RPS_PER_TARGET } record Policy(String name, Sig sig, double target, int datapoints, int minAtStart, int preScaleAt, int preScaleTo) {} static int ready0(List<Integer> readyAt, int t) { int n = 0; for (int r : readyAt) if (t >= r) n++; return n; } static StringBuilder csv = new StringBuilder(); static void run(Policy p) { int initial = p.minAtStart(); List<Integer> readyAt = new ArrayList<>(); // second when each instance takes traffic for (int i = 0; i < initial; i++) readyAt.add(-1000); double q = 0, maxQ = 2000; double[] cpuHist = new double[T], rpsHist = new double[T]; int streak = 0, firstScale = -1, instSec = 0; double maxP99 = 0, errors = 0, total = 0; int secHot = 0, secErr = 0, peakInst = initial; StringBuilder series = new StringBuilder(); for (int t = 0; t < T; t++) { double lam = lambda(t); double cap = 0; int ready = 0; for (int r : readyAt) { if (t >= r) { double age = t - r; double f = age >= WARM_S ? 1.0 : WARM_START + (1 - WARM_START) * age / WARM_S; cap += CAP * f; ready++; } } if (ready == 0) { cap = 1; } double rho = lam / cap; q = Math.max(0, q + lam - cap); double dropped = 0; if (q > maxQ) { dropped = q - maxQ; q = maxQ; } errors += dropped; total += lam; double wait = q / cap * 1000; double p99 = SVC_MS * Math.log(100) / (1 - Math.min(rho, 0.97)) * (rho < 1 ? 1 : 1) + wait; maxP99 = Math.max(maxP99, p99); if (rho > 0.85) secHot++; if (dropped > 0 || p99 > 1000) secErr++; cpuHist[t] = Math.min(100, 100 * rho); rpsHist[t] = ready == 0 ? 0 : lam / ready; instSec += readyAt.size(); peakInst = Math.max(peakInst, readyAt.size()); if (t % 20 == 0) series.append(String.format("%d:%d/%d/%.0f ", t, readyAt.size(), ready, Math.min(p99, 5000))); csv.append(String.format("%s,%d,%.0f,%d,%d,%.0f%n", p.name().substring(0, 1), t, lam, readyAt.size(), ready, Math.min(p99, 5000))); // scheduled pre-scale if (p.preScaleAt() >= 0 && t == p.preScaleAt()) { while (readyAt.size() < p.preScaleTo()) readyAt.add(t + START_S); if (firstScale < 0) firstScale = t; } // metric evaluation on each minute boundary if ((t + 1) % 60 == 0) { double m = 0; for (int i = t - 59; i <= t; i++) m += p.sig() == Sig.CPU ? cpuHist[i] : rpsHist[i]; m /= 60; boolean breaching = m > p.target() * (p.sig() == Sig.CPU ? 100 : 1); streak = breaching ? streak + 1 : 0; if (streak >= p.datapoints()) { double ratio = m / (p.target() * (p.sig() == Sig.CPU ? 100 : 1)); int desired = (int) Math.ceil(ready0(readyAt, t) * ratio); while (readyAt.size() < desired) readyAt.add(t + START_S); if (desired > readyAt.size() - 0 && firstScale < 0) firstScale = t; if (firstScale < 0) firstScale = t; } } } System.out.printf("%-34s first scale-out at t=%-4s peak inst=%-3d sec rho>85%%: %-4d sec degraded: %-4d max p99(model)=%-6.0f ms dropped=%.1f%% inst-seconds=%d%n", p.name(), firstScale < 0 ? "never" : firstScale + "s", peakInst, secHot, secErr, maxP99, 100 * errors / total, instSec); System.out.println(" SERIES t:launched/ready/p99ms " + series); } public static void main(String[] a) { System.out.println("traffic 600 -> 1800 rps over t=60..180 s; instance start " + START_S + " s + " + WARM_S + " s warm-up at 50% capacity; capacity " + (int) CAP + " rps/instance"); run(new Policy("A CPU 60%, 3 datapoints", Sig.CPU, 0.60, 3, 5, -1, 0)); run(new Policy("B CPU 60%, 1 datapoint", Sig.CPU, 0.60, 1, 5, -1, 0)); run(new Policy("C CPU 40% (more headroom), 3 dp", Sig.CPU, 0.40, 3, 5, -1, 0)); run(new Policy("D requests/target 150, 1 datapoint", Sig.RPS_PER_TARGET, 150, 1, 5, -1, 0)); run(new Policy("E scheduled pre-scale to 12 at t=0", Sig.CPU, 0.60, 3, 5, 0, 12)); } static { Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { java.nio.file.Files.writeString(java.nio.file.Path.of("scale.csv"), csv.toString()); } catch (Exception e) { } })); } }
What to try next: Edit START_S, WARM_S and the ramp to match your own service; in Obs.java add buckets at 300, 350 and 400 ms and watch the merged estimate approach the truth; add a second tracer span around the payment call and run it in parallel to see the waterfall shorten.
13
Chapter Thirteen - Interview Angles

Questions You Should Be Able to Answer

  • Q: Why did autoscaling not save us during the traffic ramp?
    The loop has dead time: 1-minute metric averages, several breaching datapoints, task start (60-120 s), then JIT warm-up. In the simulation CPU target tracking decided at t = 239 s for a ramp that started at t = 60 and left users 243 seconds degraded. Use faster signals, fewer confirmations, headroom or scheduled scaling.
  • Q: Which metric should drive autoscaling for an HTTP service?
    Requests per target (ALBRequestCountPerTarget) or in-flight/busy threads, because they lead CPU; keep CPU as a guardrail. For workers use queue depth/age (KEDA, CloudWatch). Latency is a guardrail, not a driver.
  • Q: Why can you not average p99 across instances?
    Percentiles are not additive. Merge histogram buckets across instances and then compute the quantile; our run: average of p99s 242 ms vs merged 304 ms vs truth 349 ms.
  • Q: Histogram vs summary in Micrometer?
    Histogram buckets (percentiles-histogram / slo) are aggregatable across instances in Prometheus; client-side percentiles are per-instance and cannot be merged. Put bucket boundaries at your SLO thresholds.
  • Q: How do you find the 650 ms in a slow request?
    A trace waterfall: the widest span group (here 3 repeated "order_lines x8" spans + 1 query = ~630 ms, an N+1), sequential HTTP calls that could be parallel, and gaps in the parent. Then confirm with DB-side evidence.
  • Q: Explain burn rate.
    Burn rate = observed error ratio / allowed error ratio (1 - SLO). Burn rate 1 spends exactly the budget over the window; 14.4 for an hour spends 2 % of a 30-day budget. Alert on multiple windows (long to prove, short to confirm).
  • Q: Why readiness vs liveness probes?
    Readiness controls traffic (remove from rotation, e.g. while warming or dependency down); liveness restarts a stuck process. Mixing them causes restarts that make an overload worse.
  • Q: We see 502s only during deploys. Causes?
    No graceful shutdown/preStop delay, deregistration delay too short, in-flight requests cut off, or keep-alive shorter than the ALB idle timeout. Fix with graceful shutdown, preStop sleep, and aligned timeouts.
  • Q: What goes into an EMF record and what must not?
    Metric values plus a small set of low-cardinality dimensions; high-cardinality identifiers (traceId, userId) stay as plain log fields so they do not multiply metric series.
  • Q: How would you tune head vs tail sampling?
    Head sampling is cheap but random; tail sampling in the collector keeps all errors and slow traces. Use head sampling at a low rate plus tail rules for error/latency, and exemplars to link a histogram bucket to a trace.
14
Chapter Fourteen - Summary

Cheat Sheet and Recap

Cheat sheet
TopicRule of thumbEvidence on this page
Scaling lagTime to capacity = metric delay + datapoints + start + warm-up314 s from load crossing the target to full capacity (model)
SignalPrefer request rate / queue depth over CPU; keep CPU as guardrailfirst decision t = 239 s (CPU, 3 dp) vs 119 s (rate, 1 dp)
Known eventsPre-scale (scheduled)0 degraded seconds vs 243
ProbesReadiness gates traffic, startup protects slow boot, liveness restartsChapter 5
PercentilesMerge buckets, then quantile; never average p99242 vs 304 vs 349 ms (measured)
TracingWidest span, repeated spans, gaps, stairs~630 of 767 ms in repeated DB spans
SLO alertsMultiwindow burn rate; page at 14.4x/1 h and 6x/6 hChapter 9 table
∑ Scaling and observability - what to remember
  • Autoscaling is a delayed feedback loop. Add up the delays before you trust it with a ramp.
  • Scale on what moves first (requests per target, queue age); use headroom and scheduled capacity for known peaks.
  • Ready is not warm. Readiness gates, slow start and graceful shutdown decide what users see during every scale event and deploy.
  • Measure with histograms, aggregate buckets, not percentiles; put bucket boundaries at SLO thresholds.
  • A trace waterfall answers "where": widest span, repetition (N+1), gaps, and sequential calls.
  • Alert on SLO burn rate, not on causes; keep cause metrics as diagnostics.
  • Scaling numbers here come from a simulation with stated parameters; metrics numbers from a real plain-Java run; AWS behaviour is documented, not measured.
Related pages: