feat: k8s health lifecycle + standalone Dockerfile for internal deployment

Add /health/startup and /health/live probes, flip readiness to 503 on
SIGTERM so k8s stops routing before drain. Update standalone Dockerfile
for internal deployment: port 9500, schema mounted at runtime (not baked
in), persistent data dir, non-root user with fixed UID 10001.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alan Kahn 2026-03-03 09:57:19 -05:00
parent 5ceef74f3b
commit 3101789d32
4 changed files with 70 additions and 35 deletions

View File

@ -1,5 +1,6 @@
FROM rust:1.91 AS builder FROM rust:1.91-slim AS builder
WORKDIR /app WORKDIR /build
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
# Copy workspace manifests first for layer caching. # Copy workspace manifests first for layer caching.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
@ -16,20 +17,15 @@ COPY . .
RUN cargo build -p tidal-server --release RUN cargo build -p tidal-server --release
FROM debian:bookworm-slim FROM debian:bookworm-slim
WORKDIR /srv RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN useradd --system --home /srv tidal && \ RUN useradd -m -u 10001 tidal
apt-get update && apt-get install -y ca-certificates curl && \ COPY --from=builder --chown=tidal:tidal /build/target/release/tidal-server /usr/local/bin/tidal-server
rm -rf /var/lib/apt/lists/* RUN mkdir -p /config && chown tidal:tidal /config
USER tidal:tidal
COPY --from=builder /app/target/release/tidal-server /usr/local/bin/tidal-server WORKDIR /data
COPY tidal-server/config /etc/tidal-server EXPOSE 9500
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
USER tidal CMD curl -sf http://localhost:9500/health || exit 1
EXPOSE 9400 9091 ENV TIDAL_SERVER_LOG=info
ENTRYPOINT ["/usr/local/bin/tidal-server"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD ["standalone", "--listen", "0.0.0.0:9500", "--schema", "/config/eros-schema.yaml", "--data-dir", "/data"]
CMD curl -f -H "Authorization: Bearer ${TIDAL_API_KEY:-}" http://localhost:9400/health || exit 1
ENTRYPOINT ["tidal-server", "standalone", \
"--listen", "0.0.0.0:9400", \
"--metrics", "0.0.0.0:9091"]

View File

@ -111,13 +111,16 @@ async fn serve(state: ServerState, addr: &str, api_key: Option<Arc<str>>) -> Res
let actual = listener.local_addr()?; let actual = listener.local_addr()?;
tracing::info!("listening on http://{actual}"); tracing::info!("listening on http://{actual}");
axum::serve(listener, build_router(Arc::new(state), api_key)) let state = Arc::new(state);
.with_graceful_shutdown(shutdown_signal()) let shutdown_state = state.clone();
axum::serve(listener, build_router(state, api_key))
.with_graceful_shutdown(shutdown_signal(shutdown_state))
.await?; .await?;
Ok(()) Ok(())
} }
async fn shutdown_signal() { async fn shutdown_signal(state: Arc<ServerState>) {
// SIGTERM is Unix-only; on other platforms we fall back to ctrl-c alone. // SIGTERM is Unix-only; on other platforms we fall back to ctrl-c alone.
#[cfg(unix)] #[cfg(unix)]
let sigterm = async { let sigterm = async {
@ -146,5 +149,7 @@ async fn shutdown_signal() {
_ = sigterm => {} _ = sigterm => {}
} }
tracing::info!("shutdown signal received"); // Flip readiness BEFORE axum starts draining
state.set_shutting_down();
tracing::info!("readiness flipped to not-ready, draining in-flight requests");
} }

View File

@ -75,6 +75,8 @@ pub fn build_router(state: Arc<ServerState>, api_key: Option<Arc<str>>) -> Route
// Public routes — exempt from auth so health probes always work. // Public routes — exempt from auth so health probes always work.
let public = Router::new() let public = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.route("/health/startup", get(health_startup))
.route("/health/live", get(health_live))
.with_state(Arc::clone(&state)); .with_state(Arc::clone(&state));
// Protected routes — gated by Bearer token when a key is configured. // Protected routes — gated by Bearer token when a key is configured.
@ -387,24 +389,43 @@ async fn search(
})) }))
} }
#[derive(Serialize)] /// Startup probe: always 200 (no migrations to check).
struct HealthResponse { async fn health_startup() -> Json<serde_json::Value> {
status: &'static str, Json(serde_json::json!({ "ok": true, "service": "tidaldb" }))
mode: &'static str,
items: u64,
} }
/// Liveness probe: always 200 (process is alive).
async fn health_live() -> Json<serde_json::Value> {
Json(serde_json::json!({ "ok": true, "service": "tidaldb" }))
}
/// Readiness probe: 200 when ready, 503 when shutting down.
async fn health( async fn health(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Query(query): Query<HashMap<String, String>>, Query(query): Query<HashMap<String, String>>,
) -> Result<Json<HealthResponse>, AppError> { ) -> std::result::Result<(StatusCode, Json<serde_json::Value>), AppError> {
if state.is_shutting_down() {
return Ok((
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"ok": false,
"service": "tidaldb",
"cause": "shutting down"
})),
));
}
let region = query.get("region").map(|s| s.as_str()); let region = query.get("region").map(|s| s.as_str());
let items = state.item_count(region).map_err(AppError)?; let items = state.item_count(region).map_err(AppError)?;
Ok(Json(HealthResponse {
status: "ok", Ok((
mode: "standalone", StatusCode::OK,
items, Json(serde_json::json!({
})) "status": "ok",
"mode": "standalone",
"items": items,
})),
))
} }
struct TidalErrorWrapper(tidaldb::TidalError); struct TidalErrorWrapper(tidaldb::TidalError);

View File

@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tidaldb::TidalDb; use tidaldb::TidalDb;
use tidaldb::query::retrieve::Retrieve; use tidaldb::query::retrieve::Retrieve;
@ -25,11 +26,23 @@ fn ensure_standalone(region_name: Option<&str>) -> Result<()> {
#[derive(Clone)] #[derive(Clone)]
pub struct ServerState { pub struct ServerState {
db: Arc<TidalDb>, db: Arc<TidalDb>,
shutting_down: Arc<AtomicBool>,
} }
impl ServerState { impl ServerState {
pub fn new(db: TidalDb) -> Self { pub fn new(db: TidalDb) -> Self {
Self { db: Arc::new(db) } Self {
db: Arc::new(db),
shutting_down: Arc::new(AtomicBool::new(false)),
}
}
pub fn set_shutting_down(&self) {
self.shutting_down.store(true, Ordering::SeqCst);
}
pub fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::SeqCst)
} }
pub fn write_item( pub fn write_item(