健康检查端点
提示
/api/health是 v4.0.0 新增的。不需要任何配置,部署后即可用。
契约
GET /api/health -> 200 { status: 'ok', at, uptimeSec, checks }
503 { status: 'error', at, uptimeSec, checks }响应示例:
{
"status": "ok",
"at": "2026-09-12T08:30:00.000Z",
"uptimeSec": 86400,
"checks": {
"database": { "status": "ok", "latencyMs": 12 },
"redis": { "status": "skipped", "latencyMs": 0 }
}
}checks 里每一项的状态有三种:
| 状态 | 含义 | 是否影响整体 |
|---|---|---|
ok | 探测往返成功 | 否 |
down | 探测失败或超时 | 是,整体变 503 |
skipped | 该依赖在本次部署中未配置 | 否 |
只有 down 会把整体探测判红。 这意味着监控只需要看 HTTP 状态码,永远不必解析响应体。
skipped 是个关键设计:模板默认不带 Redis,一个没配 Redis 的部署是完全健康的,不该因此报警。
当前探测哪些依赖
| 名称 | 探测方式 | 何时算 skipped |
|---|---|---|
database | select 1 | 数据库未启用时 |
redis | GET health:probe(只读,永不写入) | Redis 客户端未配置时 |
Redis 探测用的是一个只读的固定键。这个键存不存在无所谓 —— 测的是网络往返,不是值。
两个客户端本身就是「未配置即为 null」,所以它们的存在与否就是配置事实,代码里不需要再重复一遍环境变量清单。
两条设计约定
故意公开,故意不说话
这个端点不需要任何密钥,任何探测方都能访问。因为要让负载均衡、uptime 监控、容器编排共用同一个契约,任何一方要配密钥都会变成麻烦。
作为代价,它什么细节都不返回:没有错误信息,没有版本号,没有主机名。失败原因只进日志(用 warn 级别,不是 error —— 监控每几秒探一次,真出故障时用 error 会让 Sentry 被同一个异常刷屏,而 503 本身已经拉响警报了)。
每个探测都有超时
const CHECK_TIMEOUT_MS = 3_000一次探测是网络往返,不是计算。3 秒是每个依赖的全部预算 —— 超时就判 down,而不是让一个挂死的 TCP 连接把请求一直吊着,直到平台把它杀掉。
所有探测并行执行,所以总耗时是最慢的那个依赖,不是它们之和。
接入监控
Uptime 监控(UptimeRobot / BetterStack / Pingdom 等)
- URL:
https://你的域名/api/health - 方法:
GET - 期望状态码:
200 - 建议间隔:1–5 分钟
不需要配置关键字匹配或 JSON 解析,看状态码就够了。
容器编排(Docker / Kubernetes)
docker-compose.yml:
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40sKubernetes:
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 40
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10timeout 建议给到 5 秒以上:单个依赖的探测预算就是 3 秒,再加上并行调度和网络开销,卡得太紧会误杀。
Coolify / Dokploy
两者的应用设置里都有健康检查配置项,填 /api/health,期望状态码 200 即可。
负载均衡 / 反向代理
Nginx upstream 健康检查、云厂商的 ALB/NLB 目标组健康检查,同样指向 /api/health,判定依据是 HTTP 200。
添加一个依赖
在 app/api/health/route.ts 的 CHECKS 数组里加一项就行,其他地方不需要任何分支:
const CHECKS: HealthCheck[] = [
{
name: 'database',
probe: isDatabaseEnabled ? () => db.execute(sql`select 1`) : null,
},
{
name: 'redis',
probe: redisClient ? () => redisClient.get(REDIS_PROBE_KEY) : null,
},
// 新增:probe 返回 Promise,成功即健康,抛错即 down
// probe 为 null 表示本次部署未配置该依赖,记为 skipped
{
name: 'r2',
probe: r2Client ? () => r2Client.headBucket({ Bucket: bucketName }) : null,
},
]三条约定:
probe解析即健康,抛错即不健康,返回值被忽略probe为null表示「本次部署没配这个依赖」,报skipped而不是失败- 探测必须是只读的。健康检查会被高频调用,任何写入都是隐患
常见问题
Q: 为什么数据库挂了返回 503 而不是 500?
503 Service Unavailable 的语义是「服务暂时不可用」,这正是负载均衡和编排器期望看到的信号 —— 它们会把这个实例摘出流量,等它恢复。500 表达的是「这次请求出错了」,语义不对。
Q: 需要把这个端点挡在鉴权后面吗?
不建议。它不返回任何敏感信息,而加上鉴权会让绝大多数监控工具的接入变复杂。如果你的合规要求必须挡,可以在反向代理层按来源 IP 限制。
Q: 能用它来判断部署是否成功吗?
可以。它同时是 liveness(进程还活着)和 readiness(依赖都通了)探针。部署脚本可以轮询它直到返回 200 再切流量。
Q: 和 /api/cron/credits 的 GET 有什么区别?
那个 GET 只是定时任务路由自己的健康检查,不探测任何依赖,用途完全不同。定时任务的真正入口是 POST,详见定时任务。