04 · Production Services¶
A service that only handles happy-path requests isn't production-ready.
This module adds the three things an orchestrator (Kubernetes, a load
balancer, an on-call human) actually depends on: structured logging via
tracing, separate liveness/readiness health checks, and graceful shutdown
that stops accepting new work without dropping in-flight requests.
Setup¶
cargo add axum
cargo add tokio --features full
cargo add tracing tracing-subscriber --features "tracing-subscriber/env-filter"
cargo add serde --features derive
Structured logging with tracing¶
use tracing::{info, warn};
tracing_subscriber::fmt()
.with_target(false)
.init();
info!("listening on {}", addr);
warn!("readiness check failed: warming up");
tracing's macros look like println! but produce structured events with a
level, a timestamp, and (when you use its span APIs) request-scoped context
— the difference from println! matters once logs are aggregated
centrally: println! output is just text, tracing events can carry
key-value fields a log pipeline can filter and query on.
Liveness vs. readiness — two different questions¶
async fn liveness() -> &'static str {
"ok"
}
async fn readiness(State(state): State<AppState>) -> Result<Json<Health>, StatusCode> {
if !state.ready.load(Ordering::Relaxed) {
warn!("readiness check failed: warming up");
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
let count = state.request_count.fetch_add(1, Ordering::Relaxed);
Ok(Json(Health { status: "ready", requests_served: count }))
}
/healthz answers "is the process alive at all" — if this fails, the
orchestrator should kill and restart the container. /readyz answers "can
this instance take traffic right now" — if this fails, the instance should
be pulled from the load balancer's rotation but not restarted, because
it's likely still starting up (warming a cache, running a migration,
opening a connection pool) and killing it would just repeat the delay.
Collapsing both into one endpoint is a common mistake: it makes the
orchestrator restart instances that were merely still booting.
Simulated warm-up and the state it flips¶
let ready_flag = state.ready.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(200)).await;
ready_flag.store(true, Ordering::Relaxed);
info!("warm-up complete, marking ready");
});
AtomicBool here stands in for "some real startup cost" — loading a
config, running a migration, warming a cache. The server starts accepting
connections immediately (the socket is bound and listening), but
/readyz reports 503 until the flag flips, exactly the signal a load
balancer needs to hold off routing real traffic to this instance.
Running it¶
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3003/readyz
2026-08-15T07:50:15.496552Z WARN readiness check failed: warming up
503
$ curl -s localhost:3003/readyz
{"status":"ready","requests_served":0}
The WARN line is printed by the server process itself (to stderr) the
moment the early request comes in — that's the log line an on-call engineer
would see in an aggregator during a rollout, correlating "still 503s" with
"still warming up," instead of guessing.
Graceful shutdown¶
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
};
tokio::select! {
_ = ctrl_c => {
info!("shutdown signal received");
}
}
}
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
with_graceful_shutdown doesn't kill in-flight requests the instant the
signal arrives — it stops accepting new connections and waits for
existing ones to finish before the axum::serve(...).await call returns.
This is the mechanism that turns a rolling deploy or pod eviction from
"some requests get an abrupt connection reset" into "requests in flight
complete normally, new ones go to a different instance."
Rust-specific traps¶
AtomicBool/AtomicU64 orderings matter, even when they look
interchangeable. Ordering::Relaxed is used above because there's no
other memory being synchronized alongside the flag — but the moment a
readiness flag is meant to guarantee "and everything it depends on is also
visible" (e.g., a cache that was populated before the flag flipped), you
need Ordering::Release on the write and Ordering::Acquire on the read,
or a data race is possible even though both operations are individually
atomic.
tracing_subscriber::fmt().init() panics if called twice. It installs a
global default subscriber; calling .init() a second time (common in tests
that each set up their own logging) panics with "a global default trace
dispatcher has already been set." Test code typically uses
tracing_subscriber::fmt().try_init() or a once_cell/std::sync::Once
guard instead.
Health check handlers must not do expensive work. A /readyz that
actually re-runs a DB query on every check (rather than reading a
pre-computed flag, as above) adds load precisely when the system is already
stressed — orchestrators poll these endpoints every few seconds, so an
expensive check compounds under the exact conditions it's meant to protect
against.
Signal handling is platform-specific beyond ctrl_c. tokio::signal::unix::signal(SignalKind::terminate())
is needed to also catch SIGTERM (what Kubernetes actually sends on pod
eviction, not SIGINT) — signal::ctrl_c() alone only catches Ctrl+C /
SIGINT, which is fine for local dev but incomplete for a real deployment.
Cheat sheet¶
| Concern | Mechanism |
|---|---|
| Structured logs | tracing + tracing_subscriber::fmt().init() |
| "Is the process alive" | /healthz — cheap, always 200 unless truly wedged |
| "Can this instance serve traffic" | /readyz — reflects real startup/dependency state |
| Stop new work, finish in-flight | axum::serve(...).with_graceful_shutdown(fut) |
Catch SIGTERM (not just Ctrl+C) |
tokio::signal::unix::signal(SignalKind::terminate()) |
| Thread-safe counters without locks | std::sync::atomic::{AtomicU64, AtomicBool} |
How It Actually Works¶
AtomicU64/AtomicBool compile to real hardware atomic instructions
(lock cmpxchg, lock add on x86) rather than a lock-based emulation —
they're atomic because the CPU itself guarantees the read-modify-write
happens indivisibly, with no Mutex or OS involvement at all, which is why
they're dramatically cheaper for a single counter than wrapping a plain
u64 in a Mutex. Ordering is a separate concern from atomicity: it
controls how the compiler and CPU are allowed to reorder surrounding
non-atomic memory operations relative to the atomic one. Relaxed only
guarantees the operation itself is atomic, with no ordering guarantee about
anything else in memory around it — fine for an independent counter nobody
else's visibility depends on. Release/Acquire form a pairing: a
Release store guarantees every ordinary write that happened before it in
program order becomes visible to any thread that later does an Acquire
load of that same atomic and sees the new value — that's the actual
mechanism that makes "the cache was populated before the flag flipped"
provably true across threads, not just true by coincidence of timing.
Graceful shutdown works by threading a future through
with_graceful_shutdown that the server polls alongside every accepted
connection: when that future resolves (because your SIGTERM/ctrl_c
handler completed), the server stops calling accept() on its listening
socket but leaves already-spawned per-connection tasks running until each
one's future naturally completes — this is ordinary async task management
from Level 3, not special shutdown magic, just a select!-shaped race
between "keep accepting" and "shutdown signal fired" wired into axum's
serve loop.
Exercise¶
Add a SIGTERM handler alongside the existing ctrl_c branch in
shutdown_signal, using tokio::select! to race both, so the service
shuts down gracefully on either signal. Test it by starting the server,
running kill -TERM <pid> in another terminal, and confirming the same
"shutdown signal received" log line appears.