Health checks
Liveness and readiness probes for umbral. Mounts GET /healthz plus GET /ready (alias /readyz), with a built-in DB probe, pluggable dependency checks, and an opt-in migration gate for zero-downtime deploys.
Health checks
umbral-health mounts two probe endpoints so an orchestrator (Kubernetes, a load balancer, an uptime monitor) can tell whether your app is alive and whether it is ready to serve traffic:
GET /healthz- liveness. Always returns200as long as the process is answering. Use it to decide whether to restart the container. It never touches a dependency, so a slow database can't trigger a restart loop.GET /ready(aliasGET /readyz) - readiness. Probes the default DB pool (SELECT 1), runs every registered check, and — when opted in — checks the migration state. Returns200with a JSON body when everything passes, or503(with the failing check's reason in the body) when one fails. Use it to decide whether to route traffic to this instance./readyzis the same handler under the k8s-convention name (/livez//readyz); use whichever your tooling expects.
Liveness and readiness are split on purpose: a failed dependency (DB down) should pull the instance out of rotation (readiness 503), not kill the process (liveness stays 200) - restarting won't fix a downstream outage.
Install
cargo add umbral-healthWiring
use umbral::prelude::*;use umbral_health::HealthPlugin; App::builder() .plugin(HealthPlugin::default()) // GET /healthz + GET /ready .build()?;With no checks registered, /ready just confirms the app is up - the same signal as /healthz, but on the endpoint your orchestrator polls for routing decisions.
Readiness checks
A readiness check is anything that implements HealthCheck: a stable name() plus an async check() that returns Ok(()) when healthy or a HealthError when not. Register one per dependency you want /ready to verify on every call.
use umbral::prelude::*;use umbral_health::{HealthCheck, HealthError, HealthPlugin};use std::time::Duration; struct DatabaseCheck; impl HealthCheck for DatabaseCheck { fn name(&self) -> &'static str { "database" } async fn check(&self) -> Result<(), HealthError> { // Run a cheap query against the dependency; turn any error into a HealthError. // `SomeModel` is one of your own models. SomeModel::objects() .count() .await .map(|_| ()) .map_err(|e| HealthError::new(format!("database unreachable: {e}"))) }} App::builder() .plugin( HealthPlugin::default() .check(DatabaseCheck) .check_timeout(Duration::from_secs(3)), // per-check cap (default 5s) ) .build()?;Each check's name is surfaced in the /ready JSON body, so a 503 tells operators exactly which dependency is down. check_timeout bounds every check so a hung dependency can't make the probe itself hang.
Gate readiness on migrations
Turn on require_migrations() and /ready (and /readyz) additionally returns 503 while the database is behind the migrations this binary carries — i.e. while any migration on disk is unapplied — and flips to 200 the moment the schema catches up:
App::builder() .plugin(HealthPlugin::default().require_migrations()) .build()?;// GET /readyz while the one-shot `migrate` job is still running:{ "status": "fail", "checks": { "database": { "status": "ok" }, "migrations": { "status": "fail", "reason": "2 migrations pending" }} }This fixes the classic rolling-deploy race. Your compose/k8s stack runs postgres → migrate → web, but a new web container can come up and connect before migrate finishes — to a database still on the old schema. With a DB-only readiness check it reports ready and starts 500ing against columns that don't exist yet. Gating on migrations holds the pod out of the load balancer until migrate lands, then lets it in.
Point your container HEALTHCHECK (or k8s readinessProbe) at /readyz:
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s \ CMD curl -fsS http://127.0.0.1:8000/readyz || exit 1A rollback stays ready. Deploying an older binary against a newer schema shows the database's extra migrations as "applied but missing on disk" — the database is ahead, which is a valid backward-compatible state — so only genuinely-unapplied migrations block. You never get a rollback stuck out of rotation.
Draining on shutdown
/ready and /readyz also report 503 while the process is draining — the moment it receives a shutdown signal, before it stops accepting connections. That's what lets a load balancer pull the instance out of rotation during a rollout so no in-flight request is dropped. You don't configure anything on the plugin for this; it reads umbral::shutdown::is_draining(), which App::serve sets. Turn on the drain window with AppBuilder::shutdown_drain(...).
// GET /readyz after SIGTERM, during the drain window:{ "status": "draining", "checks": { "shutdown": { "status": "draining" } } }See Going to production → Zero-downtime rollout for how readiness, the drain, and graceful shutdown compose into a no-dropped-request deploy.
See also
- Going to production - the full deploy recipe these probes plug into.
- The Plugin trait - how a plugin contributes routes like these.
- Logging and tracing - the other half of operating an umbral app.