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:
parent
5ceef74f3b
commit
3101789d32
@ -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"]
|
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user