Skip to main content

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:

  1. Flips readiness out of service first. A shared ReadinessState flag is flipped before the listening connector stops, so GET /readyz immediately returns 503 Service Unavailable (state=OUT_OF_SERVICE) while a load balancer / Kubernetes is still routing. GET /livez and GET /healthz stay healthy so the orchestrator does not kill the pod mid-drain.
  2. Optionally settles. An optional shutdownSettleDelay pause between the readiness flip and the connector stop gives the load balancer a window to observe the 503 before draining begins.
  3. Drains in-flight requests. The connector stops accepting new connections and the server waits up to a bounded stopTimeout (default 30s) 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​

ProbeNormalDuring shutdownWhy
GET /readyz200 (when no component is DOWN)503 OUT_OF_SERVICETells the load balancer / k8s readiness gate to stop routing new traffic to this pod.
GET /livez200200 (unaffected)A 503 here would make k8s kill the pod mid-drain — deliberately not gated.
GET /healthz200 / 503 per indicatorsunaffectedThe 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.

KnobDefaultMeaning
stopTimeout30sBounded time the server waits for in-flight requests to drain before stopping the connector.
shutdownSettleDelay0 (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:

JettyTomcat
Apply drain timeoutserver.setStopTimeout(...) applied at startup; server.stop() drainsconnector 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 overridehonored if set (precedence above the default)n/a
Default stopTimeout30s30s
Readiness flip / settleidenticalidentical

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 /readyz so k8s stops routing to the pod as soon as it flips to 503.
  • Point the liveness probe at /livez so k8s does not kill the pod while it is draining.
  • Add a preStop hook sleep to give the cluster's endpoint/Service propagation time to remove the pod from rotation before the JVM begins draining.
  • Set terminationGracePeriodSeconds to comfortably exceed (preStop sleep + shutdownSettleDelay + stopTimeout) so Kubernetes does not SIGKILL the 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.