13. Graceful Shutdown
📋 Jump to Takeaways🎁 When Kubernetes sends SIGTERM, your service has a window to finish in-flight requests. Without graceful shutdown, those requests get cut off mid-response. With it, you drain cleanly and exit with code 0.
The Problem
axum::serve runs forever. A CTRL+C or SIGTERM kills the process immediately — any requests in flight are dropped.
Graceful shutdown means:
- Stop accepting new connections
- Wait for in-flight requests to complete
- Close the database pool
- Exit cleanly
Signal Handling
Tokio provides tokio::signal for catching OS signals:
use tokio::signal;
async fn shutdown_signal() {
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 => {},
}
}tokio::select! waits for whichever signal arrives first. On non-Unix platforms (Windows), SIGTERM doesn't exist — pending() is a future that never resolves.
Wiring into axum::serve
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();.with_graceful_shutdown takes a future. When that future resolves, Axum stops accepting new connections and waits for in-flight requests to complete.
Cleanup After Shutdown
Run cleanup code after serve returns:
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let config = Config::from_env().unwrap_or_else(|e| {
eprintln!("configuration error: {}", e);
std::process::exit(1);
});
let pool = create_pool(&config.database_url).await;
run_migrations(&pool).await;
let state = AppState { pool: pool.clone(), config };
let app = build_router(state);
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
// serve() returned — shutdown signal was received and all requests drained
pool.close().await; // close database connections cleanly
tracing::info!("shutdown complete");
}pool.close() waits for all in-flight database queries to finish and closes all connections. Without it, the process exits while queries are still running — not catastrophic for SQLx, but unclean.
Shutdown Timeout
Production services add a maximum drain time — if a request takes too long, give up:
use std::time::Duration;
use tokio::time::timeout;
async fn main() {
// ...
let serve = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal());
if let Err(_) = timeout(Duration::from_secs(30), serve).await {
tracing::warn!("shutdown timed out after 30s — forcing exit");
}
pool.close().await;
}Kubernetes's default terminationGracePeriodSeconds is 30 seconds — match your timeout to it.
Key Takeaways
with_graceful_shutdown(future)stops new connections when the future resolves and drains in-flight requeststokio::signal::ctrl_c()andsignal::unix::signal(SIGTERM)catch OS signalstokio::select!waits for whichever signal arrives first- Always close the database pool after
servereturns to cleanly drain queries - Add a shutdown timeout matching Kubernetes's
terminationGracePeriodSecondsto avoid hanging forever
🎁 Your service is production-ready. But it's running on your laptop. Next up: packaging it in Docker and deploying it with environment-based configuration.