Graceful Shutdown & Readiness Gating
See also: Health / Readiness / Liveness Probes — the probe surface (
/healthz,/readyz,/livez) this page gates during shutdown.
Starting with 10.0.0, both embedded-server microservice flavors — juneau-microservice-jetty and juneau-microservice-tomcat — perform a zero-downtime graceful shutdown suitable for rolling Kubernetes deployments. The two servers share one identical shutdown contract.
When a microservice begins shutting down it:
- Flips readiness out of service first. A shared
ReadinessStateflag is flipped before the listening connector stops, soGET /readyzimmediately returns503 Service Unavailable(state=OUT_OF_SERVICE) while a load balancer / Kubernetes is still routing.GET /livezandGET /healthzstay healthy so the orchestrator does not kill the pod mid-drain. - Optionally settles. An optional
shutdownSettleDelaypause between the readiness flip and the connector stop gives the load balancer a window to observe the503before draining begins. - Drains in-flight requests. The connector stops accepting new connections and the server waits up to a bounded
stopTimeout(default30s) for in-flight requests to complete before it fully stops.
This sequencing is what lets Kubernetes shift traffic away from a terminating pod before its in-flight work is interrupted.
Shutdown sequence
shutdown begins
│
├─▶ ReadinessState.markOutOfService() /readyz → 503 (OUT_OF_SERVICE)
│ /livez → 200 (still LIVE)
│ /healthz→ 200 (still UP)
│
├─▶ sleep(shutdownSettleDelay) LB observes the 503 (default 0ms)
│
├─▶ drain in-flight requests up to stopTimeout (default 30s)
│ Jetty: server.setStopTimeout(...) then server.stop()
│ Tomcat: connector.pause() then wait for active count → 0, then stop()
│
└─▶ server fully stopped
/readyz vs /livez vs /healthz during shutdown
| Probe | Normal | During shutdown | Why |
|---|---|---|---|
GET /readyz | 200 (when no component is DOWN) | 503 OUT_OF_SERVICE | Tells the load balancer / k8s readiness gate to stop routing new traffic to this pod. |
GET /livez | 200 | 200 (unaffected) | A 503 here would make k8s kill the pod mid-drain — deliberately not gated. |
GET /healthz | 200 / 503 per indicators | unaffected | The aggregate (null-probe) health surface is not gated by readiness. |
The readiness gate is implemented in HealthAggregator: when the shared ReadinessState is out of service it forces the READY probe DOWN and adds a readiness component with {"state": "OUT_OF_SERVICE"} to the payload. Liveness and the aggregate health surface are untouched.
{
"status": "DOWN",
"components": {
"readiness": {
"status": "DOWN",
"details": {
"state": "OUT_OF_SERVICE"
}
}
}
}
Tuning knobs
Two knobs control the drain behavior, both expressible programmatically (preferred), via the config file, or left at their defaults.
| Knob | Default | Meaning |
|---|---|---|
stopTimeout | 30s | Bounded time the server waits for in-flight requests to drain before stopping the connector. |
shutdownSettleDelay | 0 (none) | In-process pause between the /readyz 503 flip and the connector stop. The recommended Kubernetes pattern uses a preStop hook sleep instead (see below). |
Precedence
For both servers the resolved value follows this precedence:
programmatic settings > config-file entry > (Jetty only: existing jetty.xml value) > 30s default
Builder examples
Contribute a @Bean JettySettings (or @Bean TomcatSettings) from your own @Configuration class to override the defaults:
import java.time.Duration;
import org.apache.juneau.commons.inject.Configuration;
import org.apache.juneau.commons.inject.Bean;
import org.apache.juneau.microservice.jetty.JettySettings;
@Configuration
public class MyAppConfig {
@Bean
JettySettings jettySettings() {
return JettySettings.create()
.stopTimeout(Duration.ofSeconds(45))
.shutdownSettleDelay(Duration.ofSeconds(2))
.build();
}
}
The Tomcat flavor is identical — swap JettySettings for TomcatSettings:
import java.time.Duration;
import org.apache.juneau.commons.inject.Configuration;
import org.apache.juneau.commons.inject.Bean;
import org.apache.juneau.microservice.tomcat.TomcatSettings;
@Configuration
public class MyAppConfig {
@Bean
TomcatSettings tomcatSettings() {
return TomcatSettings.create()
.stopTimeout(Duration.ofSeconds(45))
.shutdownSettleDelay(Duration.ofSeconds(2))
.build();
}
}
Any value left null on the settings bean falls back to the config-file entry, then to the default.
juneau.cfg examples
Both knobs are documented in the bundled juneau.cfg and read from the server's section ([Jetty] or [Tomcat]) in milliseconds:
[Jetty]
# Graceful-shutdown drain timeout in milliseconds. Defaults to 30000 (30s) when unset.
stopTimeout = 30000
# Settle delay in milliseconds applied between the /readyz 503 flip and the connector stop.
# Defaults to 0 when unset.
shutdownSettleDelay = 0
For the Tomcat flavor the keys live under the [Tomcat] section instead (Tomcat/stopTimeout, Tomcat/shutdownSettleDelay).
Jetty vs Tomcat parity
Both servers expose the same knobs, the same defaults, and the same observable readiness contract. They differ only in the underlying mechanism used to drain:
| Jetty | Tomcat | |
|---|---|---|
| Apply drain timeout | server.setStopTimeout(...) applied at startup; server.stop() drains | connector pause(), then wait up to stopTimeout for the protocol-handler executor's active count to reach zero, then stop() / destroy() |
| Config section | [Jetty] | [Tomcat] |
jetty.xml override | honored if set (precedence above the default) | n/a |
Default stopTimeout | 30s | 30s |
| Readiness flip / settle | identical | identical |
The bundled jetty.xml deliberately does not set stopTimeout — it is applied in code so the 30s default and the precedence rules apply uniformly. A comment in jetty.xml documents this.
Kubernetes deployment guidance
The recommended production pattern pairs the in-process readiness flip with a preStop hook and a generous terminationGracePeriodSeconds, rather than relying on the in-process shutdownSettleDelay:
- Point the readiness probe at
/readyzso k8s stops routing to the pod as soon as it flips to503. - Point the liveness probe at
/livezso k8s does not kill the pod while it is draining. - Add a
preStophook sleep to give the cluster's endpoint/Service propagation time to remove the pod from rotation before the JVM begins draining. - Set
terminationGracePeriodSecondsto comfortably exceed(preStop sleep + shutdownSettleDelay + stopTimeout)so Kubernetes does notSIGKILLthe pod mid-drain.
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
# Must exceed preStop sleep + shutdownSettleDelay + stopTimeout.
terminationGracePeriodSeconds: 60
containers:
- name: my-juneau-microservice
readinessProbe:
httpGet:
path: /readyz
port: 10000
periodSeconds: 5
livenessProbe:
httpGet:
path: /livez
port: 10000
periodSeconds: 10
lifecycle:
preStop:
exec:
# Give Service/endpoint propagation time to drop this pod before draining.
command: ["sh", "-c", "sleep 10"]
With this setup: the preStop sleep + readiness 503 keep new traffic away, stopTimeout drains the in-flight requests, and terminationGracePeriodSeconds guarantees the JVM gets enough time to finish before a hard kill.