Files
spring-boot-demo/kubernetes-deployment/docs/02-probes.md
T
asmhatreandClaude Opus 5 a065696478 Add kubernetes-deployment: probes, shutdown, JVM ergonomics, HPA
Companion code for "Deploying Spring Boot 4 on Kubernetes: Probes, Graceful
Shutdown, Limits and JVM Ergonomics". A dependency outage under three
probe-group setups, a rolling restart under load four ways (three runs
each), the JVM's ergonomic choices for nine pod shapes, one GC-heavy load
under five CPU limits with throttling counters, and an HPA driven by a
Micrometer gauge through prometheus-adapter. Measured on k3s v1.36.4.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
2026-09-11 17:12:10 +00:00

105 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2. Probes: which checks belong in which probe
[← 1. The lab](01-the-lab.md) · [Index](../README.md) · Next: [3. JVM ergonomics →](03-jvm-ergonomics.md)
Spring Boot exposes two health groups for Kubernetes, `/actuator/health/liveness` and
`/actuator/health/readiness`. They are added automatically when Boot detects Kubernetes (the
`*_SERVICE_HOST` / `*_SERVICE_PORT` variables), and explicitly here with
`management.endpoint.health.probes.enabled=true` so they also exist on a laptop. By default each
contains exactly one thing - the application's `LivenessState` and `ReadinessState` - and **nothing
else**, not the database, not `diskSpace`, not your own indicators.
The three probes answer different questions, and the kubelet does something different on failure:
| Probe | Question | On failure |
|---|---|---|
| startup | has it finished starting? | kill after `failureThreshold × periodSeconds`; liveness/readiness wait until it passes |
| liveness | is this process broken beyond repair? | **kill and restart the container** |
| readiness | should it get traffic right now? | remove from the Service; no restart |
## One dependency outage, three configurations
[`DownstreamHealthIndicator`](../src/main/java/com/ankurm/k8s/health/DownstreamHealthIndicator.java)
calls a `downstream` service. [`demo-probes.sh`](../scripts/demo-probes.sh) scales the downstream to
zero for 60 s and watches the two `orders` pods, the ready endpoints, and whether `GET /work` - an
endpoint that **does not use the downstream at all** - still works through the Service.
**In the liveness group** ([`probes-liveness-includes-downstream.txt`](output/probes-liveness-includes-downstream.txt)):
```
t= 17s 42sgx ready=true restarts=0 running | x8bjd ready=true restarts=0 running | serving endpoints=2 GET /work via Service: ok
t= 23s 42sgx ready=false restarts=1 running | x8bjd ready=false restarts=1 running | serving endpoints=0 GET /work via Service: FAIL
-------- downstream restored --------
t= 69s 42sgx ready=true restarts=1 running | x8bjd ready=true restarts=1 running | serving endpoints=2 GET /work via Service: ok
```
Both replicas were killed and restarted together - three failed liveness probes, 15 s - and the
restarted containers could not pass their startup probe (it checks the same group) until the
downstream came back. A dependency outage became a full outage of a service that did not need the
dependency, and the JVMs lost their warm caches and JIT state on top. Had the outage outlasted the
startup probe's 120 s budget, the pods would have been restarted again, into `CrashLoopBackOff`.
**In the readiness group** ([`probes-readiness-includes-downstream.txt`](output/probes-readiness-includes-downstream.txt)):
```
t= 6s 8mf9m ready=false restarts=0 running | btcl4 ready=false restarts=0 running | serving endpoints=0 GET /work via Service: FAIL
-------- downstream restored --------
t= 69s 8mf9m ready=true restarts=0 running | btcl4 ready=true restarts=0 running | serving endpoints=2 GET /work via Service: ok
```
No restarts, and recovery within seconds of the downstream returning. **But still zero serving
endpoints within 6 s** - every replica checks the same shared dependency, so every replica leaves
the Service at once. Clients get connection refused instead of a meaningful error, including for
the requests that never needed the downstream.
**In neither group** - Spring Boot's default ([`probes-default-groups.txt`](output/probes-default-groups.txt)):
```
t= 23s 7ltj7 ready=true restarts=0 running | cpnkk ready=true restarts=0 running | serving endpoints=2 GET /work via Service: ok
```
The pods stay in service. The requests that need the downstream fail - with whatever error your
code returns, which can be a precise 503 - and the rest keep working.
## The rule this gives
- **Liveness**: only things a restart would fix. Boot's default `livenessState` is usually exactly
right. A shared dependency in liveness restarts every replica at once.
- **Readiness**: only things specific to *this* instance - warming a local cache, a full local
queue. A shared dependency in readiness takes every replica out at once.
- **Shared dependencies** belong in the request path: timeouts, a circuit breaker, a fast 503.
Keep them in `/actuator/health` for dashboards - just not in a probe group.
## Slow starts: the startup probe
[`demo-startup.sh`](../scripts/demo-startup.sh) makes the context take ~45 s to refresh
(`DEMO_STARTUP_DELAY=40s`, a sleep in a `@PostConstruct`) and rolls it out to one replica, first
without a startup probe ([`startup-probe.txt`](output/startup-probe.txt)):
```
t= 44s [gqllk ready=false restarts=0] [7ltj7 ready=true restarts=0]
t= 50s [gqllk ready=false restarts=1] [7ltj7 ready=true restarts=0]
t= 88s [gqllk ready=false restarts=2] [7ltj7 ready=true restarts=0]
t=118s [gqllk ready=false restarts=2] [7ltj7 ready=true restarts=0]
```
Liveness (every 5 s, three failures) starts failing the moment the container starts, because
Tomcat is not listening yet (`connect: connection refused` in the events). The new pod is killed
before it can ever finish starting, over and over. The first kill only *shows* as a restart at
t=50 s because the kill runs the 5 s `preStop` sleep and then waits for the JVM to exit.
The old pod `7ltj7` keeps serving throughout: with `maxUnavailable: 0` the rollout simply stalls
until `progressDeadlineSeconds` (600 s) marks it failed. With more replicas and a
`maxUnavailable` above zero, the same change takes capacity away with every attempt.
The same rollout with a startup probe:
```
t= 45s [7ltj7 ready=true restarts=0] [jthjg ready=false restarts=0]
t= 51s [jthjg ready=true restarts=0]
```
Ready at ~51 s, no restarts, and liveness only begins once the startup probe has passed. Size the
startup budget (`failureThreshold × periodSeconds`, 120 s here) for your slowest start - a cold
node, a slow config server - and keep liveness tight.