Summary
Spring Boot 2.3 introduced server.shutdown: graceful, liveness/readiness probe
endpoints, and SmartLifecycle-based shutdown ordering. These cover HTTP-level draining
well. However, there is no standard Actuator endpoint that allows a Kubernetes preStop
hook to poll and determine when all application-level in-flight work (async tasks,
Kafka consumer processing, custom thread pools, etc.) has completed before SIGTERM is sent
to the JVM.
Current Behaviour
Spring Boot provides:
/health/liveness — signals whether the application context is alive
/health/readiness — signals whether the pod should receive traffic; Spring
automatically sets ReadinessState.REFUSING_TRAFFIC on shutdown, which causes this to
return 503
server.shutdown: graceful — Tomcat waits for in-flight HTTP requests to complete
SmartLifecycle — allows beans to participate in ordered shutdown
None of these can be used as a simple HTTP 200/503 polling endpoint for a Kubernetes
preStop hook to determine when async, non-HTTP work is finished.
The Gap
The Kubernetes pod termination sequence is:
- Pod removed from Service endpoints
- preStop hook executes: Spring is fully alive here; no shutdown has started
- SIGTERM sent to JVM process
- Spring shutdown begins (SmartLifecycle, Tomcat drain, etc.)
- SIGKILL after terminationGracePeriodSeconds
The preStop hook runs before SIGTERM reaches the JVM. Spring's own graceful shutdown
therefore cannot participate in this phase. Teams are left implementing their own drain
endpoint, typically:
- A custom
@RestController returning 200 OK when idle, 503 while busy
- A custom
SmartLifecycle bean that polls registered "drainable" components
- Wiring of
ThreadPoolTaskExecutor.getActiveCount() and similar metrics by hand
This is a common enough pattern that every team deploying Spring Boot on Kubernetes
implements it independently.
Proposed Solution
Introduce a Drainable SPI (or reuse SmartLifecycle) and a built-in Actuator
endpoint — e.g. /actuator/drain or a new drain health group — that:
- Aggregates the state of all registered
Drainable beans (or SmartLifecycle beans
that have not yet completed their stop())
- Returns
200 OK when all components report no in-flight work
- Returns
503 Service Unavailable with a JSON body listing the still-busy component
names while work is in progress
The endpoint should be exposed under the management port and require no authentication by
default (consistent with /health/liveness and /health/readiness), so that a minimal
preStop script can use it:
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- until wget -qO- http://localhost:8080/actuator/drain; do sleep 2; done
/**
* Register a bean of this type to participate in the built-in drain endpoint.
* Spring Boot will expose aggregate drain state at /actuator/drain.
*/
public interface Drainable {
/** Returns true when this component has no more in-flight work. */
boolean isDrained();
/** Human-readable name used in the /actuator/drain response body. */
String name();
}
Summary
Spring Boot 2.3 introduced
server.shutdown: graceful, liveness/readiness probeendpoints, and
SmartLifecycle-based shutdown ordering. These cover HTTP-level drainingwell. However, there is no standard Actuator endpoint that allows a Kubernetes
preStophook to poll and determine when all application-level in-flight work (async tasks,
Kafka consumer processing, custom thread pools, etc.) has completed before SIGTERM is sent
to the JVM.
Current Behaviour
Spring Boot provides:
/health/liveness— signals whether the application context is alive/health/readiness— signals whether the pod should receive traffic; Springautomatically sets
ReadinessState.REFUSING_TRAFFICon shutdown, which causes this toreturn 503
server.shutdown: graceful— Tomcat waits for in-flight HTTP requests to completeSmartLifecycle— allows beans to participate in ordered shutdownNone of these can be used as a simple HTTP 200/503 polling endpoint for a Kubernetes
preStophook to determine when async, non-HTTP work is finished.The Gap
The Kubernetes pod termination sequence is:
The
preStophook runs before SIGTERM reaches the JVM. Spring's own graceful shutdowntherefore cannot participate in this phase. Teams are left implementing their own drain
endpoint, typically:
@RestControllerreturning200 OKwhen idle,503while busySmartLifecyclebean that polls registered "drainable" componentsThreadPoolTaskExecutor.getActiveCount()and similar metrics by handThis is a common enough pattern that every team deploying Spring Boot on Kubernetes
implements it independently.
Proposed Solution
Introduce a
DrainableSPI (or reuseSmartLifecycle) and a built-in Actuatorendpoint — e.g.
/actuator/drainor a newdrainhealth group — that:Drainablebeans (orSmartLifecyclebeansthat have not yet completed their
stop())200 OKwhen all components report no in-flight work503 Service Unavailablewith a JSON body listing the still-busy componentnames while work is in progress
The endpoint should be exposed under the management port and require no authentication by
default (consistent with
/health/livenessand/health/readiness), so that a minimalpreStopscript can use it: