Menu

健康检查端点

提示

/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
databaseselect 1数据库未启用时
redisGET 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: 40s

Kubernetes:

livenessProbe:
  httpGet:
    path: /api/health
    port: 3000
  initialDelaySeconds: 40
  periodSeconds: 30
readinessProbe:
  httpGet:
    path: /api/health
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 10

timeout 建议给到 5 秒以上:单个依赖的探测预算就是 3 秒,再加上并行调度和网络开销,卡得太紧会误杀。

Coolify / Dokploy

两者的应用设置里都有健康检查配置项,填 /api/health,期望状态码 200 即可。

负载均衡 / 反向代理

Nginx upstream 健康检查、云厂商的 ALB/NLB 目标组健康检查,同样指向 /api/health,判定依据是 HTTP 200。

添加一个依赖

app/api/health/route.tsCHECKS 数组里加一项就行,其他地方不需要任何分支:

app/api/health/route.ts
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,
  },
]

三条约定:

  1. probe 解析即健康,抛错即不健康,返回值被忽略
  2. probenull 表示「本次部署没配这个依赖」,报 skipped 而不是失败
  3. 探测必须是只读的。健康检查会被高频调用,任何写入都是隐患

常见问题

Q: 为什么数据库挂了返回 503 而不是 500?

503 Service Unavailable 的语义是「服务暂时不可用」,这正是负载均衡和编排器期望看到的信号 —— 它们会把这个实例摘出流量,等它恢复。500 表达的是「这次请求出错了」,语义不对。

Q: 需要把这个端点挡在鉴权后面吗?

不建议。它不返回任何敏感信息,而加上鉴权会让绝大多数监控工具的接入变复杂。如果你的合规要求必须挡,可以在反向代理层按来源 IP 限制。

Q: 能用它来判断部署是否成功吗?

可以。它同时是 liveness(进程还活着)和 readiness(依赖都通了)探针。部署脚本可以轮询它直到返回 200 再切流量。

Q: 和 /api/cron/credits 的 GET 有什么区别?

那个 GET 只是定时任务路由自己的健康检查,不探测任何依赖,用途完全不同。定时任务的真正入口是 POST,详见定时任务