feat(server): replace legacy health endpoints with K8s-style /livez and /readyz probes

This commit is contained in:
RainbowBird
2026-05-18 23:32:20 +08:00
parent af17e9cbc2
commit 5d256e4951
9 changed files with 56 additions and 57 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ services:
tempo:
condition: service_healthy
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3000/api/health']
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3000/livez']
interval: 10s
timeout: 5s
retries: 5
+1 -1
View File
@@ -48,7 +48,7 @@ services:
ports:
- '6112:3000'
healthcheck:
test: ['CMD-SHELL', 'curl -f http://localhost:3000/health || exit 1']
test: ['CMD-SHELL', 'curl -f http://localhost:3000/livez || exit 1']
interval: 10s
timeout: 5s
retries: 5
@@ -30,7 +30,7 @@ OTel SDK 在导出到 Prometheus 时做两件事:
>
> **STABLE-only**[instrumentation.ts](../../instrumentation.ts) 把 `OTEL_SEMCONV_STABILITY_OPT_IN=http` 提前注入。OLD 系列(`http.server.duration` in ms)不再发射。详见 [`observability-conventions.md` 的 SemconvStability 章节](./observability-conventions.md#semconvstability-迁移说明)。
>
> `/health` 路径在 [app.ts](../../src/app.ts) 的 @hono/otel 包装层被显式 skipRailway 健康检查不进 metric。
> `/livez` 和 `/readyz` 在 [app.ts](../../src/app.ts) 的 @hono/otel 包装层被显式 skipK8s 风格探针不进 metric。
## Auth & Users
@@ -4,7 +4,8 @@
应用在 `src/app.ts` 中挂载以下路由:
- `GET /health`
- `GET /livez` — K8s 风格 liveness 探针,纯静态 200,不碰任何外部依赖
- `GET /readyz` — K8s 风格 readiness 探针,并发 ping Postgres + Redis;任一失败回 503。**不**检查上游 LLM key 健康(R14
- `GET /` — 服务标识 JSON,避免邮件链接拼错落到框架默认 404
- `/api/auth/*`
- `/api/v1/characters`
@@ -45,25 +45,31 @@ Verification artifacts for the in-process router shipped across U1-U9 of
## Liveness probe
- **Scenario**: `GET /healthz/live` returns 200 with `{status: "live"}` even
when external dependencies are degraded.
- **Command**: `curl http://localhost:3000/healthz/live`
- **Scenario**: `GET /livez` returns 200 with `{status: "live"}` even
when external dependencies are degraded. K8s-style flat naming; legacy
`/health` and nested `/healthz/live` removed in this revision.
- **Command**: `curl -i http://localhost:3000/livez`
- **Expected output**: HTTP 200, body `{"status":"live"}`.
- **Actual output** (2026-05-15):
- **Actual output** (commit `cfad87757` + uncommitted route rename, 2026-05-15):
```
HTTP 200
{"status":"live"}
```
Cross-check: `curl http://localhost:3000/health` → HTTP 404 (legacy
endpoint removed); `curl http://localhost:3000/healthz/live` → HTTP 404
(nested form removed).
- **Last verified**: 2026-05-15.
## Readiness probe
- **Scenario**: `GET /healthz/ready` returns 200 when Postgres + Redis both
- **Scenario**: `GET /readyz` returns 200 when Postgres + Redis both
respond; 503 otherwise. Gateway-internal key health does NOT block
readiness (R14).
- **Command**: `curl http://localhost:3000/healthz/ready`
- **Command**: `curl -i http://localhost:3000/readyz`
- **Expected output**: HTTP 200, body `{"status":"ready","checks":{"db":"ok","redis":"ok"}}`.
- **Actual output** (2026-05-15):
- **Actual output** (commit `cfad87757` + uncommitted route rename, 2026-05-15):
```
HTTP 200
{"status":"ready","checks":{"db":"ok","redis":"ok"}}
```
- **Last verified**: 2026-05-15.
@@ -105,7 +105,7 @@
重要实现细节:
- `sdk.start()` 必须发生在 `metrics.getMeter()` 之前
- `/health` 会被 HTTP instrumentation 忽略
- `/livez``/readyz` 会被 HTTP instrumentation 忽略
## 运行时修改建议
@@ -10,7 +10,7 @@ deepened:
## Summary
`apps/server` 内新建一个 in-process 路由模块替换 knoway sidecarLLM `/v1/chat/completions` 走 SSE passthrough + 请求内多 key fallback + 跨 upstream fallbackTTS `/v1/audio/speech` 走 adapter interfacev1 三家:Azure / DashScope cosyvoice / Volcengine,非流式 REST 实现);`/v1/audio/voices` 由仓库内静态 JSON 提供;configKV 增 `LLM_ROUTER_CONFIG` composite 条目承载整棵路由器配置;新 envelope encryption 工具加密存储 provider keyOTel 用 `airi.gen_ai.gateway.*` 自定义属性,新增 fallback / key 健康相关 metrics;新增 `/healthz/live` + `/healthz/ready`;一次性切流 + 数据驱动决定何时删 knoway compose。
`apps/server` 内新建一个 in-process 路由模块替换 knoway sidecarLLM `/v1/chat/completions` 走 SSE passthrough + 请求内多 key fallback + 跨 upstream fallbackTTS `/v1/audio/speech` 走 adapter interfacev1 三家:Azure / DashScope cosyvoice / Volcengine,非流式 REST 实现);`/v1/audio/voices` 由仓库内静态 JSON 提供;configKV 增 `LLM_ROUTER_CONFIG` composite 条目承载整棵路由器配置;新 envelope encryption 工具加密存储 provider keyOTel 用 `airi.gen_ai.gateway.*` 自定义属性,新增 fallback / key 健康相关 metrics;新增 `/livez` + `/readyz`;一次性切流 + 数据驱动决定何时删 knoway compose。
---
@@ -61,7 +61,7 @@ R-IDs 沿用 origin 文档(详见 origin 中 R1-R19 描述):
| KTD-9 | Voice catalog 用 **静态 JSON 提交仓库**`apps/server/src/services/tts-adapters/voices/*.json`),不在运行时跨服务聚合 | origin D12 |
| KTD-10 | LLM/TTS 路由 logic **全部下沉到 `src/services/llm-router/`**,路由层只做 param validation + auth guard + 调 service + 处理响应;现有 `routes/openai/v1/index.ts` 的 TODO `:97-98` 同期解决 | apps/server/CLAUDE.md "Routes: thin — no business logic" |
| KTD-11 | 路由器**不持久化** key 死活状态(origin D33 risk-accepted),但 OTel 上报支持 SLO 触发器(fallback.depth > 0.5 / 24h, 单 key > 80% 错误 / 30min)以便后续手动促 v2 | origin Success Criteria + D29 |
| KTD-12 | `/healthz/live``/healthz/ready` 是**新路径**不替换现有 `/health` —— 后者保留兼容),按现有 `httpInstrumentationMiddleware` `/health` 排除规则同样跳过 | apps/server/src/app.ts:126-128 模式 |
| KTD-12 | `/livez``/readyz` 是**新路径**K8s 风格,post-implementation 决定不保留 legacy `/health`),按现有 `httpInstrumentationMiddleware`探针排除规则同样跳过 | apps/server/src/app.ts:126-128 模式 |
| KTD-13 | 跨 upstream fallback 在**同一请求内**触发:upstream A 全 key 失败后切 upstream B 全 key 试,全 upstream 都失败才返 5xx | origin R5 |
---
@@ -220,7 +220,7 @@ apps/server/
│ │ └── index.ts # MODIFY: prime new gateway metrics; new GatewayMetrics bundle
│ ├── services/
│ │ └── config-kv.ts # MODIFY: add LLM_ROUTER_CONFIG to ConfigEntrySchemas
│ ├── app.ts # MODIFY: DI wiring, /healthz routes, remove GATEWAY_BASE_URL
│ ├── app.ts # MODIFY: DI wiring, /livez + /readyz routes, remove GATEWAY_BASE_URL
│ ├── libs/env.ts # MODIFY: add LLM_ROUTER_MASTER_KEY env var; remove GATEWAY_BASE_URL
│ └── libs/env.test.ts # MODIFY
├── otel/
@@ -232,7 +232,7 @@ apps/server/
│ └── ai-context/
│ ├── observability-metrics.md # MODIFY: register new metrics
│ ├── observability-conventions.md # MODIFY: gen_ai.system values + airi.gen_ai.gateway.* namespace
│ ├── transport-and-routes.md # MODIFY: new /healthz routes; route → service mapping
│ ├── transport-and-routes.md # MODIFY: new /livez + /readyz routes; route → service mapping
│ ├── redis-boundaries-and-pubsub.md # MODIFY: configkv:invalidate channel contract
│ └── verifications/
│ └── llm-router.md # NEW: verification doc per AGENTS.md template
@@ -559,9 +559,9 @@ apps/server/
---
### U7. Pub/Sub config invalidation + `/healthz/live` + `/healthz/ready`
### U7. Pub/Sub config invalidation + `/livez` + `/readyz`
**Goal**: Wire Pub/Sub-driven invalidation of the LLM router config in-memory cache (KTD-4). Add per-instance `config.reload` OTel counter. Add `/healthz/live` (always 200) and `/healthz/ready` (Postgres + Redis ping) endpoints. **Gateway key health does NOT affect readiness** (R14).
**Goal**: Wire Pub/Sub-driven invalidation of the LLM router config in-memory cache (KTD-4). Add per-instance `config.reload` OTel counter. Add `/livez` (always 200) and `/readyz` (Postgres + Redis ping) endpoints. **Gateway key health does NOT affect readiness** (R14).
**Requirements**: R13, R14, R16, R16a (acknowledged as Outstanding Question — not actively delivered, see "Resolve before merging" below), KTD-4, KTD-12.
@@ -570,7 +570,7 @@ apps/server/
**Files**:
- `apps/server/src/services/llm-router/config-loader.ts` (MODIFY — add `subscribeToInvalidations(redis)` wiring)
- `apps/server/src/utils/redis-keys.ts` (MODIFY — add `configKvInvalidateChannel()` helper)
- `apps/server/src/app.ts` (MODIFY — register `/healthz/live`, `/healthz/ready`, exclude both from `httpInstrumentationMiddleware`; wire config-loader to redis subscriber; admin endpoint for `set LLM_ROUTER_CONFIG` publishes invalidation)
- `apps/server/src/app.ts` (MODIFY — register `/livez`, `/readyz`, exclude both from `httpInstrumentationMiddleware`; wire config-loader to redis subscriber; admin endpoint for `set LLM_ROUTER_CONFIG` publishes invalidation)
- `apps/server/src/routes/admin/...` (MODIFY — if admin set endpoint exists for configKV; publish on write)
- `apps/server/src/app.test.ts` (NEW — health endpoint tests)
- `apps/server/docs/ai-context/redis-boundaries-and-pubsub.md` (MODIFY — declare `configkv:invalidate` channel)
@@ -580,9 +580,9 @@ apps/server/
- On `configKV.set('LLM_ROUTER_CONFIG', value)`: publish to channel.
- In `createLlmRouterService` init: subscribe via separate `ioredis` instance (Redis Pub/Sub requires dedicated subscriber connection per ioredis docs). On message matching `key === 'LLM_ROUTER_CONFIG'`: call `config-loader.invalidate()` and increment `gateway.configReload.add(1, {service_instance_id, source: 'pubsub'})`.
- TTL fallback: in-memory cache has TTL = 5s. On TTL expiry next request reloads from configKV (Postgres+Redis source-of-truth chain) and increments counter with `source: 'ttl'`.
- `/healthz/live`: route returns `200 {status: 'live'}` always. No DB / Redis touch. Excluded from `httpInstrumentationMiddleware` (`apps/server/src/app.ts:126-128` pattern).
- `/healthz/ready`: route pings Postgres (`SELECT 1`) + Redis (`PING`). Returns 200 if both ok; 503 otherwise. **Does not check gateway key health** (R14 — single key flap can't take instance out of pool).
- Existing `/health` endpoint at `apps/server/src/app.ts:187` stays (keep external monitors stable); declared deprecated in docs in U8.
- `/livez`: route returns `200 {status: 'live'}` always. No DB / Redis touch. Excluded from `httpInstrumentationMiddleware` (`apps/server/src/app.ts:126-128` pattern).
- `/readyz`: route pings Postgres (`SELECT 1`) + Redis (`PING`). Returns 200 if both ok; 503 otherwise. **Does not check gateway key health** (R14 — single key flap can't take instance out of pool).
- Legacy `/health` endpoint removed post-implementation in favor of K8s-style `/livez` + `/readyz` (single source of truth, no overlap).
**Patterns to follow**:
- ioredis Pub/Sub: dedicated subscriber connection (search for existing pubsub usage in `apps/server``redis-boundaries-and-pubsub.md` references this)
@@ -593,17 +593,17 @@ apps/server/
- (1) Config-loader subscribes on init; on Pub/Sub message for `LLM_ROUTER_CONFIG`: cache cleared, next read fetches fresh.
- (2) Pub/Sub message for unrelated key: no invalidation, no counter increment.
- (3) TTL expiry path: cache populated → 5s elapse (mock clock or vitest fake timers) → next read fetches fresh + counter incremented with `source: 'ttl'`.
- (4) `GET /healthz/live` returns 200 + `{status: 'live'}` even when Redis is down (Redis client error mocked).
- (5) `GET /healthz/ready` returns 200 when both Postgres + Redis ping ok.
- (6) `GET /healthz/ready` returns 503 when Postgres down (mock pool query throws).
- (7) `GET /healthz/ready` returns 503 when Redis down (mock ping throws).
- (8) `GET /healthz/ready` returns 200 even with `LLM_ROUTER_CONFIG` missing (gateway state does not block readiness per R14).
- (9) `httpInstrumentationMiddleware` does NOT instrument `/healthz/*` requests (assert OTel http span count after probe = 0).
- (4) `GET /livez` returns 200 + `{status: 'live'}` even when Redis is down (Redis client error mocked).
- (5) `GET /readyz` returns 200 when both Postgres + Redis ping ok.
- (6) `GET /readyz` returns 503 when Postgres down (mock pool query throws).
- (7) `GET /readyz` returns 503 when Redis down (mock ping throws).
- (8) `GET /readyz` returns 200 even with `LLM_ROUTER_CONFIG` missing (gateway state does not block readiness per R14).
- (9) `httpInstrumentationMiddleware` does NOT instrument `/livez` or `/readyz` requests (assert OTel http span count after probe = 0).
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/app.test.ts` green
- Manual: `curl /healthz/live` → 200; `curl /healthz/ready` → 200 with Postgres + Redis up
- Manual: `curl /livez` → 200; `curl /readyz` → 200 with Postgres + Redis up
**Resolve before merging**:
- **R16a admin permission model** is an explicit Outstanding Question in origin; if it's not resolved before this unit ships, the admin set-config endpoint stays behind existing flat-admin-role auth. Note as known limitation in PR description: "Admin endpoint for `set LLM_ROUTER_CONFIG` uses existing flat admin role; role-scoping is follow-up work".
@@ -675,7 +675,7 @@ apps/server/
- `apps/server/src/libs/env.test.ts` (MODIFY)
- `apps/server/scripts/verify-router-config.ts` (NEW — operator script referenced in U1 Migration step; decrypts current `LLM_ROUTER_CONFIG` against `LLM_ROUTER_MASTER_KEY` to validate boot-time correctness)
- `apps/server/src/scripts/otel/llm-router-smoke.ts` (NEW — new smoke fixture that produces traces tagged with `airi.gen_ai.gateway.*` attrs; **note**: the previously-referenced `apps/server/src/scripts/otel/smoke.ts` does not exist — the actual existing file is `ws-smoke.ts` for WebSocket smoke; gateway-specific smoke is new work)
- `apps/server/docs/ai-context/transport-and-routes.md` (MODIFY — route → service mapping update; `/healthz/live` + `/healthz/ready` documented; `/health` marked deprecated)
- `apps/server/docs/ai-context/transport-and-routes.md` (MODIFY — route → service mapping update; `/livez` + `/readyz` documented; legacy `/health` removed)
- `apps/server/docs/ai-context/observability-conventions.md` (MODIFY)
- `apps/server/docs/ai-context/verifications/llm-router.md` (FINALIZE — full verification with real evidence)
- (Possibly) `apps/server/scripts/...` (NEW — knoway compose retention policy doc / data-driven trigger criteria)
@@ -684,7 +684,7 @@ apps/server/
- Grafana dashboard JSON: add 3 panels (key exhausted count time series, fallback depth distribution, upstream errors by status code) + 3 alert rules (P0 key.exhausted > 0 in 5min, P1 fallback ratio > 30% in 15min, P2 single key > 80% errors in 30min). Use existing `build.ts` to assemble. Thresholds are placeholders — refine post-launch.
- DI wiring: extend `AppDeps` interface (`apps/server/src/app.ts:70-88`) with `llmRouter` field. Register via `injeca.provide('services:llmRouter', { dependsOn: ['services:configKV', 'libs:redis', 'otel'], build: ... })`. Thread into `createV1CompletionsRoutes` factory.
- `GATEWAY_BASE_URL` removal: delete from env schema; verify no remaining consumers via grep — current consumers per existing brainstorm context: `apps/server/src/libs/env.ts`, `apps/server/src/libs/env.test.ts`, `apps/server/src/routes/openai/v1/index.ts`, `apps/server/src/routes/openai/v1/route.test.ts`, `apps/server/src/scripts/otel/smoke.ts`. Update each.
- Verification doc (`apps/server/docs/ai-context/verifications/llm-router.md`): follow AGENTS.md template — for each user path (chat completions happy / chat completions fallback / TTS speech happy / voices listing / healthz live / healthz ready), include scenario / command / expected output / actual output (curl response snippets) / environment (commit SHA + deploy env) / last verified date.
- Verification doc (`apps/server/docs/ai-context/verifications/llm-router.md`): follow AGENTS.md template — for each user path (chat completions happy / chat completions fallback / TTS speech happy / voices listing / livez / readyz), include scenario / command / expected output / actual output (curl response snippets) / environment (commit SHA + deploy env) / last verified date.
- knoway compose: do not delete yet. Document retention criteria in transport-and-routes.md and PR description: "knoway compose stays until: 14 days post-deploy without P1+ incidents OR 1 peak-traffic event without P1+ incidents. Reset on any P1."
**Patterns to follow**:
@@ -772,8 +772,8 @@ Verification doc lives at `apps/server/docs/ai-context/verifications/llm-router.
4. **TTS speech (cosyvoice)**: same against cosyvoice model.
5. **TTS speech (Volcengine)**: same against Volcengine model.
6. **Voices listing**: `curl GET /api/v1/openai/audio/voices?model=azure-tts` returns voice catalog JSON.
7. **Liveness**: `curl /healthz/live` returns 200 + `{status: 'live'}`.
8. **Readiness**: `curl /healthz/ready` returns 200 with Postgres+Redis up; 503 otherwise.
7. **Liveness**: `curl /livez` returns 200 + `{status: 'live'}`.
8. **Readiness**: `curl /readyz` returns 200 with Postgres+Redis up; 503 otherwise.
9. **Pre-upstream validation**: `curl POST /api/v1/openai/chat/completions model=unknown` returns 400 with `unknown_model` error code.
10. **All-keys exhaustion**: with all keys invalid, returns 502 (per KTD-1 final-cause mapping).
+1 -1
View File
@@ -454,7 +454,7 @@ elements['panel-15'] = statPanel(
elements['panel-3'] = statPanel(
3,
'Req/s (5m)',
'5-minute average inbound HTTP request rate. /health (Railway probe) is excluded at the @hono/otel middleware level so this reflects real user traffic. **Fixed 5m window — intentionally does not follow the dashboard time picker** (see row-level note). For trends, see panel-14 (HTTP Request Rate by Route).',
'5-minute average inbound HTTP request rate. /livez and /readyz (K8s probes) are excluded at the @hono/otel middleware level so this reflects real user traffic.',
[query(`sum(rate(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS"}[5m]))`, 'req/s')],
{ unit: 'reqps', steps: [{ color: 'green', value: 0 }, { color: 'yellow', value: 100 }, { color: 'red', value: 500 }], decimals: 2 },
)
+15 -23
View File
@@ -124,19 +124,16 @@ export async function buildApp(deps: AppDeps) {
// @hono/otel records `http.server.request.duration` and
// `http.server.active_requests` with the matched Hono route pattern
// (auto-instrumentation can't see Hono's router, so it would emit empty
// `http.route` and concrete URLs the previous Latency-by-Route bug).
// `http.route` and concrete URLs, the previous Latency-by-Route bug).
//
// /health is Railway's healthcheck pinger — high frequency, zero signal,
// skip outright.
// K8s-style probes are high-frequency and zero-signal for product
// metrics; skip outright so they don't pollute http.* dashboards.
const otelMw = httpInstrumentationMiddleware({
serviceName: deps.env.OTEL_SERVICE_NAME,
serviceVersion: process.env.npm_package_version || '0.0.0',
})
app.use('*', async (c, next) => {
// Skip /health (legacy Railway probe) and /healthz/* (new liveness +
// readiness routes added in U7) so high-frequency probes don't pollute
// http.* metrics or the Latency-by-Route panel.
if (c.req.path === '/health' || c.req.path.startsWith('/healthz/'))
if (c.req.path === '/livez' || c.req.path === '/readyz')
return next()
return otelMw(c, next)
})
@@ -224,25 +221,20 @@ export async function buildApp(deps: AppDeps) {
})
/**
* Health check route (legacy kept for backward compatibility with the
* existing Railway probe configuration). New deployments should target
* /healthz/live (liveness) and /healthz/ready (readiness) separately.
* Liveness probe (K8s convention). Returns 200 as long as the Node
* process is alive. Must not touch Postgres, Redis, or any external
* dependency: a single upstream blip should NOT cause Railway to
* recycle the pod (R13/R14).
*/
.on('GET', '/health', c => c.json({ status: 'ok' }))
.on('GET', '/livez', c => c.json({ status: 'live' }))
/**
* Liveness probe returns 200 as long as the Node process is alive.
* Must not touch Postgres, Redis, or any external dependency: a single
* upstream blip should NOT cause Railway to recycle the pod (R13/R14).
* Readiness probe (K8s convention). Verifies the instance can serve
* traffic by pinging Postgres + Redis (the only two infra dependencies
* that, if down, mean we genuinely can't serve). Gateway-internal key
* health is intentionally NOT checked (R14): one bad upstream key
* must not pull the whole instance out of the load balancer pool.
*/
.on('GET', '/healthz/live', c => c.json({ status: 'live' }))
/**
* Readiness probe verifies the instance can serve traffic. Pings
* Postgres + Redis (the only two infra dependencies that, if down, mean
* we genuinely can't serve). Gateway-internal key health is intentionally
* NOT checked (R14): one bad upstream key must not pull the whole instance
* out of the load balancer pool.
*/
.on('GET', '/healthz/ready', async (c) => {
.on('GET', '/readyz', async (c) => {
// Run both checks in parallel and let either fail independently.
const [dbResult, redisResult] = await Promise.allSettled([
deps.db.execute('SELECT 1'),