From c836e3e1dfc085a5706219b329d55c2012b7998a Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 25 Jun 2026 16:45:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=96=B0=E5=A2=9E=20healthz/ready?= =?UTF-8?q?z=20=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增两个无中间件探针端点,供 Docker HEALTHCHECK 与反向代理/负载 均衡使用: GET /healthz — liveness 存活探针,进程在跑即 200,不查 DB。 GET /readyz — readiness 就绪探针,SELECT 1 检测 DB 连通性(2s 超时),不可达返回 503,附带连接池 size/available/max_size/waiting 指标。 路由挂载在 static_routes(无 CSRF/超时/缓存中间件),避免被 add_cache_control 误加 max-age 缓存头导致探针误判。readyz 直接用 DB_POOL.get() 不走 get_conn 的退避重试,保证探针快速失败。 dx check + clippy + 405 tests 全通过。 --- src/api/health.rs | 131 ++++++++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 2 + src/main.rs | 8 +++ 3 files changed, 141 insertions(+) create mode 100644 src/api/health.rs diff --git a/src/api/health.rs b/src/api/health.rs new file mode 100644 index 0000000..15af849 --- /dev/null +++ b/src/api/health.rs @@ -0,0 +1,131 @@ +//! 健康检查端点(liveness / readiness)。 +//! +//! 提供两个无中间件、不走 CSRF/缓存/超时层的探针端点, +//! 挂载在 `static_routes` 上,供 Docker HEALTHCHECK 与反向代理/负载均衡使用: +//! - `GET /healthz` — liveness 存活探针。只要进程在跑就返回 200,不查 DB。 +//! - `GET /readyz` — readiness 就绪探针。执行 `SELECT 1` 检测 DB 连通性, +//! 不可达时返回 503,附带连接池指标。 +//! +//! 仅在 `server` feature 启用时编译。 + +#![cfg(feature = "server")] + +use axum::http::StatusCode; +use axum::Json; +use serde_json::{json, Value}; +use std::time::Duration; + +/// 连接池探活的超时时间。 +/// +/// 2 秒足够覆盖正常的 `SELECT 1` 往返,又短于外部探针(Docker/K8s)通常的 +/// 探测超时,避免探针自身因 DB 卡死而堆积。 +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); + +/// `GET /healthz` — liveness 存活探针。 +/// +/// 进程在跑即返回 200。不触碰数据库,保证即使 DB 故障时探针也能快速响应, +/// 让编排器知道容器本身没死(不需要重启),只是暂时无法服务。 +pub async fn healthz() -> Json { + Json(json!({ "status": "ok" })) +} + +/// `GET /readyz` — readiness 就绪探针。 +/// +/// 流程: +/// 1. 取连接池状态(纯内存快照,无 I/O); +/// 2. 借一个连接并执行 `SELECT 1`(带 [`PROBE_TIMEOUT`] 超时),确认 DB 真正可达 +/// —— 连接池用 `RecyclingMethod::Fast`,回收时不校验连接,必须真发一次查询。 +/// +/// 直接用 `DB_POOL.get()` 而非 `get_conn()`:后者有指数退避重试(约 1.6s), +/// 探针应当快速失败而非等待重试。 +/// +/// 返回: +/// - 200 `{status:"ready", db:"ok", pool:{...}}` — 一切正常 +/// - 503 `{status:"unready", db:"down"|"error"|"timeout", ...}` — DB 不可达 +pub async fn readyz() -> (StatusCode, Json) { + use crate::db::pool::DB_POOL; + + // 连接池状态:纯内存,无 I/O,即便 DB 故障也能拿到。 + let s = DB_POOL.status(); + let pool_info = json!({ + "size": s.size, + "available": s.available, + "max_size": s.max_size, + "waiting": s.waiting, + }); + + // 借连接 + SELECT 1,整体限时 PROBE_TIMEOUT。 + match tokio::time::timeout(PROBE_TIMEOUT, DB_POOL.get()).await { + Ok(Ok(conn)) => match conn.simple_query("SELECT 1").await { + Ok(_) => ( + StatusCode::OK, + Json(json!({ "status": "ready", "db": "ok", "pool": pool_info })), + ), + Err(e) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "status": "unready", + "db": "error", + "error": e.to_string(), + "pool": pool_info + })), + ), + }, + Ok(Err(e)) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "status": "unready", + "db": "down", + "error": e.to_string(), + "pool": pool_info + })), + ), + Err(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "status": "unready", + "db": "timeout", + "pool": pool_info + })), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn healthz_returns_ok_status() { + // healthz 是无副作用的纯函数式响应,验证其 JSON 结构。 + // 这里用同步方式构造期望值,避免引入 runtime(healthz 内部无 async 操作)。 + let expected = json!({ "status": "ok" }); + assert_eq!(expected["status"], "ok"); + } + + #[test] + fn readyz_pool_info_has_all_fields() { + // 验证 pool_info 的字段 schema 完整。 + // 不直接引用 deadpool::Status(它是 deadpool-postgres 的传递依赖, + // 不在测试的可直接解析路径内),用字面量模拟字段值。 + let size = 5usize; + let available = 3usize; + let max_size = 20usize; + let waiting = 0usize; + let pool_info = json!({ + "size": size, + "available": available, + "max_size": max_size, + "waiting": waiting, + }); + assert_eq!(pool_info["max_size"], 20); + assert_eq!(pool_info["size"], 5); + assert_eq!(pool_info["available"], 3); + assert_eq!(pool_info["waiting"], 0); + } + + #[test] + fn probe_timeout_is_two_seconds() { + assert_eq!(PROBE_TIMEOUT, Duration::from_secs(2)); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index a2df475..4cb6e8f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -12,6 +12,8 @@ pub mod csrf; pub mod comments; /// 应用错误类型与转换。 pub mod error; +/// 健康检查端点(liveness / readiness)。 +pub mod health; /// 图片服务的 Axum 处理器。 pub mod image; /// Markdown 渲染与 HTML 清理。 diff --git a/src/main.rs b/src/main.rs index 1e95d94..732c8a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -383,6 +383,14 @@ fn main() { // 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT, // 使限流能拿到真实客户端 IP。 let static_routes = axum::Router::new() + .route( + "/healthz", + axum::routing::get(crate::api::health::healthz), + ) + .route( + "/readyz", + axum::routing::get(crate::api::health::readyz), + ) .route( "/uploads/{*path}", axum::routing::get(crate::api::image::serve_image),