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
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"]

View File

@ -111,13 +111,16 @@ async fn serve(state: ServerState, addr: &str, api_key: Option<Arc<str>>) -> 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<ServerState>) {
// 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");
}

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.
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<serde_json::Value> {
Json(serde_json::json!({ "ok": true, "service": "tidaldb" }))
}
/// 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(
State(state): State<Arc<ServerState>>,
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 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);

View File

@ -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<TidalDb>,
shutting_down: Arc<AtomicBool>,
}
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(