家里那台 N1 挂着一块 2560x1440 的屏,跑一个 kiosk dashboard,左边一栏叫 DEPLOYS,本来是拿来看后端各服务活没活的。今天打开一看,整栏红的。
不是服务挂了,是 dashboard 自己还活在上一个纪元。
背景:发现逻辑指向一个已经不存在的域名
这块 dashboard 是个 Node/Express 后端 + WebSocket 推给前端的结构,所有数据源在 config.json 里配置。DEPLOYS 这一栏对应 deploys 源,它的老逻辑是从 API 平台自动发现服务列表:
"deploys": { "interval": 60000, "apiPlatform": { "baseUrl": "https://api.lishuyu.top", "servicesEndpoint": "/services" }, ...}问题就在 api.lishuyu.top。这个 .top 域名早就整个删掉了——注册、zone、邮件路由全没了。平台第三次重写(代号 Project Hail Mary)之后,一切搬到了 .app,服务目录也换成了独立的 registry,公开入口是 registry.lishuyu.app。
老代码去 GET https://api.lishuyu.top/services,DNS 都解析不到,fetch 直接抛异常,catch 里返回一条 API Platform DOWN。所以不是”某个服务挂了”,是发现这一步整个就断了。
对应的 server/sources/deploys.js 里,解析逻辑也是老平台的形状:
const svc = entry.service || entry;const runtime = entry.runtime || {};const isRunning = runtime.status === 'running';它期待的是 { service, runtime: { status: 'running' } } 这种嵌套结构。新 registry 根本不长这样。
新平台的目录是鉴权的
先摸清新 registry 的接口。公开的只有一个聚合健康检查:
curl -s https://registry.lishuyu.app/health# {"status":"ok","services_count":19}只告诉你有 19 个服务,不给明细。想拿到带状态的列表,得走 /api/services,而这个是鉴权的——匿名请求直接 401。它的 /llm.txt 写得很清楚:
Every discovery surface is auth-gated (anonymous requests get HTTP 401).
带上管理员 PAT 确实能读到完整列表,新格式是扁平的,status 直接挂在每条上:
curl -s -H "Authorization: Bearer $PAT" https://registry.lishuyu.app/api/services# [{"id":"kvservice","endpoint":"https://api.lishuyu.app/kv","status":"healthy",...}, ...]到这儿有个岔路口:要让 dashboard 用上这个自动发现,就得给 kiosk 配一个 token。
为什么不把 PAT 放上 kiosk
我给自己定过一条规矩:设备凭证绝不用 PAT。手机、固件、这种摆在那儿长期运行的终端,要用就用专用的、最小权限的 token(write-only 的 ingest、read-only 的 view 之类),管理员 PAT 绝不上设备。
kiosk 就是典型的”设备”。它挂在墙上、开机自启、物理可及。往它的环境变量里塞一个能读全平台服务目录(甚至是 admin scope)的 PAT,一旦这台机器被人碰到或者镜像被拷走,等于把钥匙串一起给了。这是那种”当下省事、出事致命”的便利。
那不放 token,还能不能拿到服务状态?
能。换个角度想:registry 的目录是鉴权的,但每个服务自己的 /health 是公开的——平台的 UptimeFlare 状态页就是靠逐个轮询各服务的 /health 来判断上下线的。既然边缘的状态页都这么做,我这块内网 kiosk 更没理由绕道去要 token。
于是方案定了:不做鉴权的目录发现,改成直接探每个服务的公开 /health。 好处有四个:
- kiosk 上不需要任何凭证;
- 测的是从这块屏的视角出发的真实端到端延迟,比 registry 内部的心跳更贴合”状态墙”的语义;
- 不依赖 registry 的鉴权或可用性;
- 和
config.json里本来就有的sites(前端探活)模式一致。
代价是服务列表要手动维护——但服务增删本来就不频繁,改一行 config 的事。
实现:<500 就算可达
先把 19 个服务的 /health 挨个 curl 一遍,确认都通。绝大多数返回 200,但有两个特殊的:
curl -s -o /dev/null -w "%{http_code}" https://auth.lishuyu.app/api/users/me# 401curl -s -o /dev/null -w "%{http_code}" http://100.97.163.18:8000/health# 404auth 是 Layer-0 组件,压根没有公开 /health 路由,只有 /api/users/me——匿名打过去是 401。hotwatch 的健康检查在 /healthz 不在 /health,打错路径给 404。
这两个都在响应,只是不是 200。所以判活的标准不能是”必须 2xx”,得放宽成:只要应用返回了任何非 5xx 的 HTTP 响应,就算可达。 5xx(比如 502,Caddy 活着但后端死了)或者网络超时,才算 DOWN。
async function checkServices(services) { const results = await Promise.allSettled( (services || []).map(async (svc) => { const start = Date.now(); const probe = svc.health || svc.url; try { const res = await fetch(probe, { method: 'GET', signal: AbortSignal.timeout(6000), redirect: 'follow', headers: { 'User-Agent': 'WitchcraftDashboard/1.0' }, }); const latency = Date.now() - start; const reachable = res.status < 500; let status = 'DOWN'; if (reachable) status = latency > 2000 ? 'SLOW' : 'LIVE'; return { name: svc.name, url: svc.url || probe, status, latency, code: res.status }; } catch (err) { const latency = Date.now() - start; return { name: svc.name, url: svc.url || probe, status: 'DOWN', latency, error: err.message }; } }) ); return results.map((r, i) => r.status === 'fulfilled' ? r.value : { name: services[i].name, url: services[i].url, status: 'DOWN', latency: null } );}res.status < 500 这一行是整个方案的核心。它同时优雅地吞掉了 auth 的 401 和 hotwatch 的 404,不用为每个特例写 if。而 502/503 这种”网关活着但应用崩了”的情况,>= 500 会正确判成 DOWN——这正是你想要的。
config.json 里对应地把 apiPlatform 换成一份 services 列表,每条带 name / url(人看的入口)/ health(探针地址):
"services": [ { "name": "registry", "url": "https://registry.lishuyu.app", "health": "https://registry.lishuyu.app/health" }, { "name": "auth", "url": "https://auth.lishuyu.app", "health": "https://auth.lishuyu.app/api/users/me" }, { "name": "kv", "url": "https://api.lishuyu.app/kv/", "health": "https://api.lishuyu.app/kv/health" }, { "name": "hotwatch", "url": "http://xxx.xxx.xxx.xxx:8000", "health": "http://xxx.xxx.xxx.xxx:8000/healthz" }, ...]输出结构 { name, status, latency } 和老代码完全一致,所以前端 DeploysCard 和那套 24 小时 SLA 历史机制原样复用,一行都不用动。fetchDeploys 里只把调用点换掉:
const [services, sites, pages] = await Promise.all([ checkServices(config.services || []), checkSites(config.sites || []), checkStatusPages(config.statusPages || []),]);顺手把 netping.js 里残留的 api.lishuyu.top 也改成 .app,再加了 Registry 作为探测目标。
验证
不重启整个 dashboard,直接把 fetchDeploys 拉出来跑一遍真实探测:
LIVE 141ms 200 registryLIVE 329ms 401 authLIVE 197ms 200 kv...LIVE 123ms 200 hotwatchLIVE 141ms 200 tts...total rows: 33 | DOWN: 033 行全 LIVE,0 个 DOWN。auth 那行的 401 被正确判成 LIVE,延迟 120–520ms 都是真实测出来的。
顺带一个有意思的观察:tts 这个服务在 registry 里的心跳状态是 stale(心跳过期了),但我直接探它的 /health 返回 200——说明进程活得好好的,只是心跳线程没按时上报。直接探活反而比信 registry 的心跳更准。 这也反过来印证了当初”测端到端可达性”这个选择。
总结
这次改动本质不是修 bug,是一次发现机制的搬迁,但真正的决策点只有一个:状态墙要读服务状态,是去要一把能开目录的钥匙,还是走每个服务本来就敞开的那扇 /health 小门。
前者看着”高级”——自动发现、新服务自动冒出来、不用维护列表。但它要求 kiosk 持有凭证,而这台机器不配持有那种凭证。后者朴素,要手动维护一份列表,可它零凭证、测真实延迟、还不受 registry 鉴权链路的牵连。
安全边界和便利性冲突的时候,先问”这台机器凭什么能拿到这个权限”。kiosk 凭什么能读全平台目录?凭不了。那就别给它这个能力,绕过去。res.status < 500 这条朴素的可达性判据,比一个挂在墙上的 PAT 稳得多。