Why autoscaling reacts too late (a simulated ramp against five policies), scaling signals, probes and warm-up, Micrometer metrics, hand-rolled histograms that show why averaging p99 lies, trace waterfalls, SLO burn-rate alerts and the AWS telemetry pipeline.
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.
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
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).
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
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
Policy
First scale decision
Peak instances
Seconds degraded
Dropped
Instance-seconds
Verdict
A CPU 60 %, 3 datapoints
t = 239 s
17
243
10.2 %
6120
Cheap, and it hurts
B CPU 60 %, 1 datapoint
t = 119 s
16
173
5.4 %
6780
Faster; risk of flapping on noise
C CPU 40 %, 3 datapoints
t = 179 s
31
156
6.6 %
11400
Over-provisions after the fact
D requests/target 150, 1 datapoint
t = 119 s
16
161
5.4 %
7200
Faster signal; still late
E scheduled pre-scale (12)
t = 0
12
0
0 %
7193
Wins for a known ramp
Simulated p99 during the ramp: the CPU policy is slow for minutes; a pre-scale avoids it (model, illustrative parameters)
Policy A: capacity needed vs launched vs actually serving - the distance between the lines is the incident (model)
⚠ 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)
Signal
Leads CPU?
Good for
Weakness
How (AWS / Kubernetes)
CPU utilisation
No (it is the consequence)
CPU-bound services
IO-bound services stay low while queues grow; saturates at 100 %
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
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)
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.
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"
⚠ 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)
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)
Three ways to compute the fleet p99 from the same 4000 requests (measured)
Java - the core of the hand-rolled timer (full source in Chapter 12): observe into fixed buckets, estimate quantiles like histogram_quantile
voidrecord(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));
}
staticdoublequantileOf(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 rankdouble 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
}
}
returnDouble.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)
Sequential with Inventory: could run in parallel (fan-out page)
Inventory call
1 sequential HTTP span
38
5 %
Candidate for cache or parallelisation
Serialization
Jackson
22
3 %
Payload size / DTO shape
Everything else (filters, glue)
-
12
2 %
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)
Alert
Burn rate
Long window
Short window
Budget spent when it fires
Action
Fast burn - page
14.4x
1 h
5 min
2 %
Page now: would exhaust the budget in 2 days
Medium burn - page
6x
6 h
30 min
5 %
Page: exhausts in 5 days
Slow burn - ticket
1x
3 days
6 h
10 %
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)
JSON - an Embedded Metric Format (EMF) log line: CloudWatch extracts custom metrics from a structured log record (field names per the EMF specification)
Higher 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.
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. */
publicclassObs {
// ---------- metrics ----------staticfinaldouble[] BOUNDS_MS = {1, 2, 5, 10, 20, 30, 50, 75, 100, 150, 250, 500, 1000, 2500};
staticclassHistogram {
finalAtomicLongArray buckets = newAtomicLongArray(BOUNDS_MS.length + 1); // per-bucket (non-cumulative) countsfinalLongAdder count = newLongAdder(), sumMicros = newLongAdder();
voidrecord(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 */
doublequantile(double q) { returnquantileOf(snapshot(), q); }
long[] snapshot() { long[] s = newlong[buckets.length()]; for (int i = 0; i < s.length; i++) s[i] = buckets.get(i); return s; }
staticdoublequantileOf(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) returnBOUNDS_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];
}
}
returnDouble.NaN;
}
staticlong[] merge(long[]... all) { long[] m = newlong[all[0].length]; for (long[] a : all) for (int i = 0; i < m.length; i++) m[i] += a[i]; return m; }
}
staticclassMetrics {
finalHistogram http = newHistogram();
finalLongAdder ok = newLongAdder(), err5xx = newLongAdder();
finalAtomicInteger inflight = newAtomicInteger();
finalList<Double> raw = Collections.synchronizedList(newArrayList<>()); // ground truth for the demo onlyStringscrape(String inst) {
StringBuilder sb = newStringBuilder();
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();
}
staticStringtrim(double d) { String s = String.valueOf(d); return s.endsWith(".0") ? s.substring(0, s.length() - 2) : s; }
}
// ---------- the "service": one handler, instrumented ----------staticvoidhandle(Metrics m, double base, double expMean, double slowP, double slowAdd) {
m.inflight.incrementAndGet();
long t0 = System.nanoTime();
SplittableRandom r = newSplittableRandom();
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 histogramif (fail) m.err5xx.increment(); else m.ok.increment();
m.inflight.decrementAndGet();
}
staticvoidload(Metrics m, double base, double expMean, double slowP, double slowAdd, int n, int rps) throws Exception {
CountDownLatch d = newCountDownLatch(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();
}
}
staticdoubleexact(List<Double> l, double p) { List<Double> c = newArrayList<>(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) {}
staticfinalList<Span> TRACE = newArrayList<>();
staticlongT0;
staticint depth;
staticvoidspan(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, newSpan(name, depth, s, e - s));
}
staticvoidsleep(double ms) { try { Thread.sleep((long) ms, (int) ((ms % 1) * 1e6)); } catch (InterruptedException ignored) { } }
publicstaticvoidmain(String[] a) throws Exception {
MetricsA = newMetrics(), B = newMetrics();
Thread t1 = newThread(() -> { try { load(A, 14, 6, 0.005, 300, 3000, 300); } catch (Exception e) { } });
Thread t2 = newThread(() -> { 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 = newArrayList<>(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++) { finalint 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 = newArrayList<>(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 : newdouble[]{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));
}
}
staticStringfmt(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). AMODEL with illustrative parameters:
* traffic ramps 600 -> 1800 rps between t=60 s and t=180 s. Anew 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).
*/
publicclassScale {
staticfinalintT = 600, START_S = 90, WARM_S = 60;
staticfinaldoubleCAP = 250; // rps per fully warm instance at 100 % CPUstaticfinaldoubleWARM_START = 0.5; // capacity fraction right after startstaticfinaldoubleSVC_MS = 20;
staticdoublelambda(int t) {
if (t < 60) return600;
if (t < 180) return600 + (1800 - 600) * (t - 60) / 120.0;
return1800;
}
enum Sig { CPU, RPS_PER_TARGET }
record Policy(String name, Sig sig, double target, int datapoints, int minAtStart, int preScaleAt, int preScaleTo) {}
staticintready0(List<Integer> readyAt, int t) { int n = 0; for (int r : readyAt) if (t >= r) n++; return n; }
staticStringBuilder csv = newStringBuilder();
staticvoidrun(Policy p) {
int initial = p.minAtStart();
List<Integer> readyAt = newArrayList<>(); // second when each instance takes trafficfor (int i = 0; i < initial; i++) readyAt.add(-1000);
double q = 0, maxQ = 2000;
double[] cpuHist = newdouble[T], rpsHist = newdouble[T];
int streak = 0, firstScale = -1, instSec = 0;
double maxP99 = 0, errors = 0, total = 0; int secHot = 0, secErr = 0, peakInst = initial;
StringBuilder series = newStringBuilder();
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-scaleif (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 boundaryif ((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);
}
publicstaticvoidmain(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(newPolicy("A CPU 60%, 3 datapoints", Sig.CPU, 0.60, 3, 5, -1, 0));
run(newPolicy("B CPU 60%, 1 datapoint", Sig.CPU, 0.60, 1, 5, -1, 0));
run(newPolicy("C CPU 40% (more headroom), 3 dp", Sig.CPU, 0.40, 3, 5, -1, 0));
run(newPolicy("D requests/target 150, 1 datapoint", Sig.RPS_PER_TARGET, 150, 1, 5, -1, 0));
run(newPolicy("E scheduled pre-scale to 12 at t=0", Sig.CPU, 0.60, 3, 5, 0, 12));
}
static { Runtime.getRuntime().addShutdownHook(newThread(() -> { 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
Topic
Rule of thumb
Evidence on this page
Scaling lag
Time to capacity = metric delay + datapoints + start + warm-up
314 s from load crossing the target to full capacity (model)
Signal
Prefer request rate / queue depth over CPU; keep CPU as guardrail
first decision t = 239 s (CPU, 3 dp) vs 119 s (rate, 1 dp)
Multiwindow burn rate; page at 14.4x/1 h and 6x/6 h
Chapter 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:
Measure First - RED/USE, percentiles, the 800 ms attribution.