From 3101789d32f34630da7a70684b468857842e882a Mon Sep 17 00:00:00 2001 From: Alan Kahn Date: Tue, 3 Mar 2026 09:57:19 -0500 Subject: [PATCH] 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 --- docker/standalone/Dockerfile | 34 +++++++++++++--------------- tidal-server/src/main.rs | 13 +++++++---- tidal-server/src/router.rs | 43 +++++++++++++++++++++++++++--------- tidal-server/src/state.rs | 15 ++++++++++++- 4 files changed, 70 insertions(+), 35 deletions(-) diff --git a/docker/standalone/Dockerfile b/docker/standalone/Dockerfile index 8c84fd3..44ca6fe 100644 --- a/docker/standalone/Dockerfile +++ b/docker/standalone/Dockerfile @@ -1,5 +1,6 @@ -FROM rust:1.91 AS builder -WORKDIR /app +FROM rust:1.91-slim AS builder +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 Cargo.toml Cargo.lock ./ @@ -16,20 +17,15 @@ COPY . . RUN cargo build -p tidal-server --release FROM debian:bookworm-slim -WORKDIR /srv -RUN useradd --system --home /srv tidal && \ - apt-get update && apt-get install -y ca-certificates curl && \ - rm -rf /var/lib/apt/lists/* - -COPY --from=builder /app/target/release/tidal-server /usr/local/bin/tidal-server -COPY tidal-server/config /etc/tidal-server - -USER tidal -EXPOSE 9400 9091 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - 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"] +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* +RUN useradd -m -u 10001 tidal +COPY --from=builder --chown=tidal:tidal /build/target/release/tidal-server /usr/local/bin/tidal-server +RUN mkdir -p /config && chown tidal:tidal /config +USER tidal:tidal +WORKDIR /data +EXPOSE 9500 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -sf http://localhost:9500/health || exit 1 +ENV TIDAL_SERVER_LOG=info +ENTRYPOINT ["/usr/local/bin/tidal-server"] +CMD ["standalone", "--listen", "0.0.0.0:9500", "--schema", "/config/eros-schema.yaml", "--data-dir", "/data"] diff --git a/tidal-server/src/main.rs b/tidal-server/src/main.rs index 627b1dc..f50be46 100644 --- a/tidal-server/src/main.rs +++ b/tidal-server/src/main.rs @@ -111,13 +111,16 @@ async fn serve(state: ServerState, addr: &str, api_key: Option>) -> Res let actual = listener.local_addr()?; tracing::info!("listening on http://{actual}"); - axum::serve(listener, build_router(Arc::new(state), api_key)) - .with_graceful_shutdown(shutdown_signal()) + let state = Arc::new(state); + let shutdown_state = state.clone(); + + axum::serve(listener, build_router(state, api_key)) + .with_graceful_shutdown(shutdown_signal(shutdown_state)) .await?; Ok(()) } -async fn shutdown_signal() { +async fn shutdown_signal(state: Arc) { // SIGTERM is Unix-only; on other platforms we fall back to ctrl-c alone. #[cfg(unix)] let sigterm = async { @@ -146,5 +149,7 @@ async fn shutdown_signal() { _ = 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"); } diff --git a/tidal-server/src/router.rs b/tidal-server/src/router.rs index 827663a..fee1eb8 100644 --- a/tidal-server/src/router.rs +++ b/tidal-server/src/router.rs @@ -75,6 +75,8 @@ pub fn build_router(state: Arc, api_key: Option>) -> Route // Public routes — exempt from auth so health probes always work. let public = Router::new() .route("/health", get(health)) + .route("/health/startup", get(health_startup)) + .route("/health/live", get(health_live)) .with_state(Arc::clone(&state)); // Protected routes — gated by Bearer token when a key is configured. @@ -387,24 +389,43 @@ async fn search( })) } -#[derive(Serialize)] -struct HealthResponse { - status: &'static str, - mode: &'static str, - items: u64, +/// Startup probe: always 200 (no migrations to check). +async fn health_startup() -> Json { + Json(serde_json::json!({ "ok": true, "service": "tidaldb" })) } +/// Liveness probe: always 200 (process is alive). +async fn health_live() -> Json { + Json(serde_json::json!({ "ok": true, "service": "tidaldb" })) +} + +/// Readiness probe: 200 when ready, 503 when shutting down. async fn health( State(state): State>, Query(query): Query>, -) -> Result, AppError> { +) -> std::result::Result<(StatusCode, Json), 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 items = state.item_count(region).map_err(AppError)?; - Ok(Json(HealthResponse { - status: "ok", - mode: "standalone", - items, - })) + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "mode": "standalone", + "items": items, + })), + )) } struct TidalErrorWrapper(tidaldb::TidalError); diff --git a/tidal-server/src/state.rs b/tidal-server/src/state.rs index b63bc66..5f6c534 100644 --- a/tidal-server/src/state.rs +++ b/tidal-server/src/state.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tidaldb::TidalDb; use tidaldb::query::retrieve::Retrieve; @@ -25,11 +26,23 @@ fn ensure_standalone(region_name: Option<&str>) -> Result<()> { #[derive(Clone)] pub struct ServerState { db: Arc, + shutting_down: Arc, } impl ServerState { 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(