Graceful Shutdown
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 with tokio 1.53, tokio-util 0.7.19 and tower-http 0.7.1 on Rust 1.99 (edition
2024), checked on 2026-10-06. Every deployment, autoscaling event and node drain stops processes. A service that exits
immediately on SIGTERM cuts off requests in flight, loses queued background work and drops unflushed traces. Graceful
shutdown is the sequence that avoids all three. The Kubernetes side is covered on this site’s
Health Probes and Lifecycle page.
Where it runs in the request
In the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and extractors run, the handler runs, the response travels back out), graceful shutdown works at the outermost two levels and never inside a request:
-
the tokio accept loop inside
axum::servestops callingaccept()when the signal future resolves, so new connections are refused by the kernel (or queued and then reset); -
each hyper connection task is told to shut down gracefully: an idle keep-alive connection is closed at once, a connection with a request in flight finishes that request — through the tower layers, router, extractors, handler and the whole response body — and is then closed.
Nothing in the tower stack, router or extractors knows that shutdown has started unless you tell it. The readiness flag
below is ordinary application state read by an ordinary handler, and the CancellationToken is how handlers that
produce long-lived responses (SSE streams, WebSockets) learn that they should finish.
The shutdown order
The order matters at three points:
-
Readiness fails first. While the instance still serves requests, its
/readyendpoint already returns503 Service Unavailable, so load balancers that route by health checks stop sending new work. -
The server drains before workers stop. Requests in flight may still enqueue jobs; stopping the workers only after
serve(...).awaithas returned guarantees nothing is enqueued into a queue nobody reads. -
Pools and telemetry close last, because draining requests and finishing jobs both use them.
with_graceful_shutdown and a readiness flag
axum::serve(...).with_graceful_shutdown(signal) takes any Future<Output = ()> + Send + 'static; when it resolves,
the server stops accepting new connections, tells hyper to close idle keep-alive connections, waits for in-flight
requests to finish, and then the serve future completes. The signal future listens for both SIGTERM (sent by
orchestrators, docker stop and systemd) and SIGINT (Ctrl+C in a terminal).
The program below also implements the "mark not ready" step of the diagram. An Arc<AtomicBool> in the state starts as
true; GET /ready returns 200 while it is set and 503 once it is cleared. shutdown_signal() clears it as soon
as a signal arrives, then keeps the server running for a short drain delay so health-checking load balancers observe the
failing probe before the listener closes. GET /live (liveness) stays 200 throughout: the process is healthy, just
leaving.
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use axum::{Router, extract::State, http::StatusCode, routing::get};
use tokio::signal;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use tower_http::timeout::TimeoutLayer;
/// How long to keep serving after readiness starts failing.
const DRAIN_DELAY: Duration = Duration::from_secs(2);
#[derive(Clone)]
struct AppState {
ready: Arc<AtomicBool>,
}
/// Readiness: 200 while accepting work, 503 once shutdown has started.
async fn ready(State(state): State<AppState>) -> StatusCode {
if state.ready.load(Ordering::Relaxed) {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
/// Liveness: the process is running; never fails during shutdown.
async fn live() -> StatusCode {
StatusCode::OK
}
/// Resolves on Ctrl+C or SIGTERM, after marking the instance not ready and waiting `DRAIN_DELAY`.
async fn shutdown_signal(ready: Arc<AtomicBool>) {
let ctrl_c = async { signal::ctrl_c().await.expect("failed to install Ctrl+C handler") };
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
ready.store(false, Ordering::Relaxed); // GET /ready now answers 503
println!("shutdown signal received: not ready, draining for {DRAIN_DELAY:?}");
tokio::time::sleep(DRAIN_DELAY).await; // still serving while load balancers notice
}
async fn slow_report() -> &'static str {
tokio::time::sleep(Duration::from_secs(3)).await; // an in-flight request during shutdown
"report finished\n"
}
#[tokio::main]
async fn main() {
let state = AppState { ready: Arc::new(AtomicBool::new(true)) };
let tracker = TaskTracker::new();
let cancel = CancellationToken::new();
// a background worker that stops when told to
let worker_cancel = cancel.clone();
tracker.spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(1));
loop {
tokio::select! {
() = worker_cancel.cancelled() => { println!("worker: finishing"); break; }
_ = tick.tick() => { /* process a batch */ }
}
}
});
let app = Router::new()
.route("/reports/slow", get(slow_report))
// bounds how long any single request can delay shutdown
.layer(TimeoutLayer::with_status_code(StatusCode::SERVICE_UNAVAILABLE, Duration::from_secs(10)))
// probes are added after the timeout layer, so they are not wrapped by it
.route("/ready", get(ready))
.route("/live", get(live))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal(state.ready)).await.unwrap();
println!("server stopped: no request in flight");
cancel.cancel(); // 1. stop background work
tracker.close();
if tokio::time::timeout(Duration::from_secs(5), tracker.wait()).await.is_err() {
println!("workers did not stop in time");
}
// 2. close pools: pool.close().await; 3. flush telemetry: tracer_provider.shutdown()
println!("clean exit");
}
Source: axum: Serve::with_graceful_shutdown, axum example: graceful-shutdown, std: AtomicBool and Tokio: Graceful Shutdown
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/ready
200
$ curl -s localhost:3000/reports/slow & sleep 0.5; kill -TERM $(pgrep bookshelf-web); sleep 0.5
shutdown signal received: not ready, draining for 2s
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/ready # during the drain delay
503
report finished # the in-flight request completed
server stopped: no request in flight
worker: finishing
clean exit
Source: axum: Serve::with_graceful_shutdown and tokio::signal
-
Ordering::Relaxedis enough for the flag: it guards no other data, and a probe that reads the old value a moment late is harmless. UseArc<AtomicBool>(or aCancellationTokenandis_cancelled()) rather than aMutex<bool>; the readiness handler must never wait on a lock. -
The readiness route sits outside the
TimeoutLayerhere only because it never blocks. If it checks dependencies (database, Redis), give it its own short timeout — a probe that hangs is worse than one that fails. -
Without a health-checking load balancer (plain Kubernetes Services route by endpoints, see below),
DRAIN_DELAYcan be zero and thepreStophook does the waiting instead. With both, keep the delay short: it adds to the shutdown budget. -
with_graceful_shutdownwaits indefinitely for in-flight requests. Bound them withTimeoutLayer(and the body deadline layers for slow uploads and downloads, see Request Limits & Timeouts), so a stuck request cannot block shutdown forever. -
Everything after
serve(...).awaitruns only once the server has stopped, which is the right moment to stop workers: requests can no longer enqueue jobs. -
PgPool::close().awaitwaits for checked-out connections to return and closes them, so the database sees clean disconnects; an OpenTelemetrySdkTracerProvidermust be shut down explicitly to export the last batch of spans (see OpenTelemetry & Distributed Tracing).
Long-lived connections: SSE and WebSockets
Server-Sent Events and WebSockets are the two cases where "wait for in-flight requests" goes wrong, in opposite directions:
-
An SSE stream is a response whose body never ends. To hyper the connection is still busy, so graceful shutdown waits for it — forever, until the orchestrator’s
SIGKILL.TimeoutLayerdoes not help: it bounds the time until the handler returns the response head, which an SSE handler does immediately. -
A WebSocket is the opposite. After the
101 Switching Protocolsresponse, hyper hands the connection to the upgrade and its connection task ends, so graceful shutdown does not wait for it. The socket lives in a task thatWebSocketUpgrade::on_upgradespawned, and it is dropped without a close frame whenmainreturns and the runtime shuts down. Clients see an abnormal closure (code 1006) instead of a clean "going away".
Both are solved with one CancellationToken in the state, cancelled inside the signal future — before hyper starts
waiting for connections, not after serve returns (by then an SSE stream would already have blocked it):
use std::{convert::Infallible, time::Duration};
use axum::{
Router,
extract::{
State,
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
},
response::{
IntoResponse, Sse,
sse::{Event, KeepAlive},
},
routing::get,
};
use futures::{Stream, StreamExt};
use tokio_stream::wrappers::IntervalStream;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
#[derive(Clone)]
struct AppState {
shutdown: CancellationToken,
tracker: TaskTracker,
}
async fn events(State(state): State<AppState>) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let ticks = IntervalStream::new(tokio::time::interval(Duration::from_secs(1)))
.map(|_| Ok(Event::default().event("loan").data("tick")));
// the stream -- and with it the response body -- ends when shutdown starts
Sse::new(ticks.take_until(state.shutdown.cancelled_owned())).keep_alive(KeepAlive::default())
}
async fn ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
let tracker = state.tracker.clone();
// track the socket task so main can wait for its close frame
ws.on_upgrade(move |socket| tracker.track_future(echo(socket, state.shutdown)))
}
async fn echo(mut socket: WebSocket, shutdown: CancellationToken) {
loop {
tokio::select! {
() = shutdown.cancelled() => {
let frame = CloseFrame { code: close_code::AWAY, reason: "server shutting down".into() };
let _ = socket.send(Message::Close(Some(frame))).await;
break;
}
msg = socket.recv() => match msg {
Some(Ok(Message::Text(text))) => {
if socket.send(Message::Text(text)).await.is_err() {
break;
}
}
Some(Ok(_)) => {}
Some(Err(_)) | None => break,
},
}
}
}
async fn shutdown_signal() {
let ctrl_c = async { tokio::signal::ctrl_c().await.expect("failed to install Ctrl+C handler") };
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
}
#[tokio::main]
async fn main() {
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
let state = AppState { shutdown: shutdown.clone(), tracker: tracker.clone() };
let app = Router::new().route("/events", get(events)).route("/ws", get(ws)).with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(async move {
shutdown_signal().await;
shutdown.cancel(); // end SSE streams and close WebSockets *before* hyper waits for connections
})
.await
.unwrap();
tracker.close();
tracker.wait().await; // every WebSocket has sent its close frame
println!("clean exit");
}
Source: tokio-util: CancellationToken::cancelled_owned, TaskTracker::track_future, axum: WebSocketUpgrade::on_upgrade and axum: response::sse
-
take_until(fromfutures::StreamExt) ends the stream when the token’s future completes; the response body then finishes normally and hyper can close the connection. A browser’sEventSourcereconnects automatically after the stream ends — by then to another instance, because this one no longer accepts connections. -
Close code
1001(close_code::AWAY, "going away") is the one RFC 6455 defines for a server going down. Clients should treat it as "reconnect with backoff", not as an error to show the user. -
TaskTracker::track_futureis used instead oftracker.spawnbecauseon_upgradealready spawns the task; tracking the future it runs is enough fortracker.wait()to cover it. See WebSockets and Server-Sent Events & Streaming for the handlers themselves.
A hard deadline for the drain
TimeoutLayer bounds each request, but not streaming bodies, slow clients reading a large download, or a handler that
ignores cancellation. A final safety net is a deadline that starts when the signal arrives and stops waiting for the
server when it expires, so cleanup still runs before the orchestrator’s SIGKILL:
use std::time::Duration;
use axum::{Router, routing::get};
use tokio_util::sync::CancellationToken;
const DRAIN_DEADLINE: Duration = Duration::from_secs(20);
async fn stream_forever() -> axum::body::Body {
// a body that never ends and ignores shutdown -- the case the deadline is for
let stream = futures::stream::pending::<Result<axum::body::Bytes, std::io::Error>>();
axum::body::Body::from_stream(stream)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/stuck", get(stream_forever));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
let started = CancellationToken::new();
let signal_started = started.clone();
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
tokio::signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
signal_started.cancel(); // starts the deadline clock
});
let deadline = async {
started.cancelled().await;
tokio::time::sleep(DRAIN_DEADLINE).await;
};
tokio::select! {
result = server => result.expect("server error"),
() = deadline => eprintln!("drain deadline exceeded: abandoning open connections"),
}
// cleanup runs in both cases: stop workers, close pools, flush telemetry
println!("exiting");
}
Source: tokio::select! and axum: WithGracefulShutdown
When the deadline branch wins, select! drops the server future; the connection tasks it spawned are cancelled when
the runtime shuts down at the end of main. Choose the deadline so that preStop + drain deadline + cleanup stays below
the orchestrator’s grace period.
Kubernetes
When Kubernetes terminates a pod it removes the pod from Service endpoints and runs the preStop hook (then sends
SIGTERM) at about the same time — not one after the other. For a few seconds, kube-proxy rules, ingress controllers
and external load balancers may still send new requests to a process that is shutting down. The usual configuration:
spec:
terminationGracePeriodSeconds: 30 # SIGKILL after this; must exceed preStop + drain + cleanup
containers:
- name: bookshelf-web
lifecycle:
preStop:
sleep:
seconds: 5 # keep serving while endpoints are updated (sleep action, Kubernetes 1.30+)
readinessProbe:
httpGet: { path: /ready, port: 3000 }
periodSeconds: 2
failureThreshold: 1
livenessProbe:
httpGet: { path: /live, port: 3000 }
periodSeconds: 10
-
The
preStopsleep delaysSIGTERMuntil traffic has been routed away; with distroless images the built-insleepaction avoids needing a shell (exec: ["sleep", "5"]fails when there is nosleepbinary). -
terminationGracePeriodSecondscounts from the start of termination and includes thepreStophook. Budget: preStop (5 s) + drain delay (2 s) + request drain (up to theTimeoutLayer, 10 s) + workers and flush (5 s) < 30 s. Raise the grace period rather than shortening the request timeout below what real requests need. -
/readyfailing is what an ingress or cloud load balancer with its own health checks reacts to; the Service endpoint removal is what kube-proxy reacts to. Implementing both covers both kinds of routing. -
Never make liveness depend on readiness: a liveness probe that fails during shutdown makes the kubelet restart a container that is merely draining.
-
A rolling update with
maxUnavailable: 0only stays error-free if old pods drain cleanly; graceful shutdown is what makes "zero-downtime deploys" true.
See Health Probes and Lifecycle and Deployment for probes and manifests.
Docker, systemd and PID 1
-
docker stopsendsSIGTERM, waits 10 seconds by default (--stop-timeout, orstop_grace_periodin Compose), then sendsSIGKILL. Use the exec formCMD ["/app/bookshelf-web"](orENTRYPOINT): the shell formCMD /app/bookshelf-webruns the binary under/bin/sh -c, which does not forwardSIGTERM, so the server never hears it and is killed after the timeout. -
The process that is PID 1 in a container gets no default signal actions: without a handler,
SIGTERMis simply ignored. Installing the tokio handler above fixes that; images that run a wrapper script shouldexecthe binary or use an init such astini(docker run --init). -
systemd sends
SIGTERMto the main process (KillSignal=), waitsTimeoutStopSec=(90 s by default) and then sendsSIGKILL. Set it to match your drain budget.
Common errors
| Symptom | Cause and fix |
|---|---|
The process ignores |
Only Ctrl+C ( |
Shutdown hangs until |
An SSE stream, a streaming download or a request without a timeout is still in flight. End streams with a
|
A second Ctrl+C does not kill the process |
Once tokio registers a handler for a signal, the platform default is never restored for the life of the process.
Treat a second signal as "force quit" explicitly: wait for it in the deadline branch and |
WebSocket clients report code 1006 (abnormal closure) on every deploy |
Upgraded connections are not drained. Track the socket tasks and send a |
Requests fail with |
The pod stops accepting before endpoints are updated. Add the |
Jobs are lost on deploy; the queue shows work that was never processed |
Workers were stopped (or the runtime dropped them) before draining finished. Cancel workers only after |
The last spans or metrics before a restart never reach the backend |
The tracer or meter provider was not shut down. Call |
Warning |
|
std::process::exit inside a handler is never a shutdown mechanism: it skips destructors, pool closing and telemetry
flushing. When main returns, the tokio runtime is dropped and every task still running is cancelled at its next
.await; work spawned with plain tokio::spawn and not tracked is lost silently.
Version notes
| Version | Change |
|---|---|
axum 0.6 |
Shutdown went through hyper 0.14: |
axum 0.7.0 |
|
axum 0.7.3 |
|
axum 0.8.0 |
|
axum 0.8.4 |
Fixed a leaked tokio task when |
tower-http 0.6.7 / 0.7 |
|
tokio-util 0.7.10 |
|
Upcoming in axum 0.9
The unreleased axum 0.9, listed under "Unreleased" on the axum repository’s main branch and not yet published,
changes the output type of the serve future (and of with_graceful_shutdown), so code that names those types or
matches on the result needs adjusting when upgrading. It also applies a header-read timeout by default, adds
Serve::with_executor for custom executors, ListenerExt::limit_connections and connection-age limits. The shutdown
sequence described on this page is unchanged; re-check the examples against the 0.9 release notes when it ships (see
Migration & Framework Comparison).
Where the books are outdated
Yoon (Beginning Axum, chapter 6) handles only Ctrl+C. In containers the signal is SIGTERM, which a Ctrl+C handler
never sees — the process is then killed after the grace period with requests still in flight. Material written for
axum 0.6 uses hyper::Server::with_graceful_shutdown; since 0.7.3 it is a method on axum::serve's Serve. None of
the books implements a readiness flag or drains WebSockets and SSE streams.