我在自己机器上跑一个本地推理服务,重启之后客户端连着报了一串错。第一反应是模型没加载好,去看日志,日志里一行错误都没有;再看进程,ps 里它在,端口 netstat 里也 LISTENING,curl 一下 /health 还返回 200。

那到底是谁在骗我?是健康检查。它回答的是"进程还活着",我问的是"现在能不能接活",这两个问题根本不是一回事。

下面这些数字全是我本地真跑出来的。先说清楚一件事:我手上这台机器没有 NVIDIA 卡(nvidia-smi 直接 command not found),所以我没有真去跑 CUDA。我用 Node 22.22.2 写了个模拟服务——它会"加载"一段时间、能被人为卡死、能控制退出方式——然后拿真实客户端去打它。测的是服务治理,不是显存账,显存那部分我另说。

第一道:活着不等于能干活

我最早的写法只有一个 /healthz,返回 200 就代表一切正常。模型加载要 3 秒,我拿客户端每 100ms 打一次,一旦看到 200 就开始放流量:

/healthz 首次 200 的时间:启动后 28 ms
放行后 4 秒内 /infer:成功 8 次,失败 28 次,首次成功在放行后 3080 ms

进程起来 28 毫秒就报健康,但真正的请求在 3080 毫秒才第一次成功。中间那 3 秒里我灌进去 28 个请求,全挂。探针通过之后到服务可用之前,这 3 秒是没有任何东西在挡着的。

改成两个端点之后:

/readyz 首次 200 的时间:启动后 3013 ms
ready 之后 1.5 秒内 /infer:成功 13 次,失败 0 次

/healthz 只回答"进程活着",挂了就重启;/readyz 才回答"能接活",不过就不给它流量。两个探针的处置动作是不一样的:liveness 失败是重启,readiness 失败是摘流量。混成一个端点,就等于逼你在"重启一个正在加载的模型"和"给一个还没好的服务发请求"之间二选一。

第二道:探针自己也会被堵死

这一层是我踩得最疼的。推理卡死的时候——比如一个同步的死循环、一个没设超时的下游调用——整个事件循环是停的,这时候 /healthz 是什么表现?

我让服务同步阻塞 4 秒,同时每 150ms 探一次:

主线程 /healthz:探测 32 次,HTTP 200 32 次,延迟 min/中位/p99/max = 2/1706/4020/4020 ms
独立线程 /healthz:探测 32 次,HTTP 200 32 次,延迟 min/中位/p99/max = 2/3/17/17 ms
主线程探针超过 300ms 的有 25 个,最早一个发起于阻塞开始后 1 ms
独立线程探针超过 300ms 的有 0 个

32 次探测,32 次 HTTP 200,一次失败都没有。 而服务已经死了 4 秒。因为探针请求排在被堵死的那个线程后面,等循环让开,它才被处理,然后老老实实回一个 200——附带一个 4020ms 的延迟。如果你只看状态码,这是全世界最健康的服务。

所以第一条:不设超时的健康检查等于没有健康检查。 判据不是"返回什么",是"多久返回"。

第二条更麻烦:主线程探针在卡死期间 p99 是 4020ms,独立线程跑同一个逻辑是 17ms。差了 200 多倍。我把 liveness 挪到 worker 线程上单独监听一个端口,主线程堵死时它照样应答。但这也带来新问题——它只能证明"进程没死",证明不了"推理还通",因为 worker 线程压根不碰模型。所以我现在是三层:worker 的 liveness(进程死没死)、主线程的 readiness(加载完没完)、外加一次真的推理自检(端到端通不通)。

第三道:退出的时候别把在途请求扔了

重启是常态,但退出方式我以前没管过。20 个在途请求、每个要跑 1500ms,三种退法:

硬退出 process.exit(0)
  在途 20 个:完整返回 0 个,被中断 20 个,原因 ECONNRESET
  退出期间新请求:ECONNREFUSED;进程消失耗时 174 ms

假优雅 close() + closeAllConnections()
  在途 20 个:完整返回 0 个,被中断 20 个,原因 ECONNRESET
  退出期间新请求:ECONNREFUSED;进程消失耗时 176 ms

真优雅 close() 等在途走完
  在途 20 个:完整返回 20 个,被中断 0 个
  退出期间新请求:ECONNREFUSED;进程消失耗时 1331 ms

中间那档是我自己写出来的 bug,而且我怀疑很多人写的就是这档:close() 和 closeAllConnections() 挨着写,看起来很"优雅",实际效果和直接 process.exit(0) 一模一样——在途 20 个全丢。close() 的语义是"不再接受新连接",不是"掐掉老连接",你再手动掐一次,前面那句就白写了。

代价是慢:1331ms vs 174ms。所以我给它配了个 8 秒的兜底超时,超过就强杀,否则一个挂死的请求能把服务拖着永远退不掉。

顺手量了个一直含糊的东西——旧进程优雅退出后,新进程多久能重新绑同一个端口:

旧进程优雅退出后,第 2 次尝试绑定成功,耗时 69 ms

不到 100 毫秒,但这 69 毫秒内新进程 bind 是失败的。所以自动重启脚本别写成"退了就起",得带重试。

我最后在用的判据

一个零依赖的脚本,把"多久返回"变成退出码:

// healthcheck.js <url> <预算毫秒>
// 退出码 0=健康  1=超时或慢  2=HTTP 不健康  3=连不上
const http = require('http');
const url = process.argv[2];
const budget = +(process.argv[3] || 1000);
const t0 = Date.now();
const req = http.get(url, { agent: false }, res => {
  let b = ''; res.on('data', d => b += d);
  res.on('end', () => {
    const lat = Date.now() - t0;
    if (lat > budget) { console.log(`SLOW  HTTP ${res.statusCode} ${lat}ms > ${budget}ms`); process.exit(1); }
    if (res.statusCode !== 200) { console.log(`DOWN  HTTP ${res.statusCode} ${lat}ms`); process.exit(2); }
    console.log(`OK    HTTP 200 ${lat}ms`); process.exit(0);
  });
});
req.on('error', e => { console.log(`ERR   ${e.code} ${Date.now() - t0}ms`); process.exit(3); });
req.setTimeout(budget, () => { req.destroy(); console.log(`TIMEOUT >${budget}ms`); process.exit(1); });

对着同一个服务跑的真实结果:

正常状态 主线程 /healthz 预算300ms : OK    HTTP 200 32ms   [exit 0]
正常状态 独立线程 /healthz 预算300ms: OK    HTTP 200 34ms   [exit 0]
卡死期间 主线程 /healthz 预算300ms : TIMEOUT >300ms        [exit 1]
卡死期间 独立线程 /healthz 预算300ms: OK    HTTP 200 32ms   [exit 0]
加载未完成 主线程 /readyz          : TIMEOUT >300ms        [exit 1]

最后一行值得单独说:加载没完成时打 /readyz,它是超时而不是 503。因为连请求都排不进去。超时和非 200 要分开记,前者是"卡",后者是"明确说不行",处置动作完全不同——卡要重启,明确说不行就再等等。

我的判断

这三道里,第一道(liveness/readiness 分离)几乎所有框架都给你做好了,成本最低,收益最确定,没有理由不做。第三道(优雅退出)改动量也小,二十行以内,而且效果是我这次唯一能直接量出"丢了几个请求"的。

真正难的是第二道。把探针挪到独立线程只是把"进程死没死"这件事测准了,模型那头该卡还是卡。端到端推理自检才有意义,但它有成本——每 5 秒真跑一次推理,哪怕只用最小输入,也是在跟真实请求抢资源。我现在的折中是:自检间隔拉到 30 秒,超时预算设成正常推理 p99 的 3 倍,连续 2 次失败才摘流量。这个数我没有理论依据,是拍的,后面得按真实流量调。

还有一个我没解决的:这套东西全建立在"卡死是同步阻塞"的假设上。如果推理是异步的、线程池里慢慢堆但不报错,探针每次都秒回,延迟一点不涨,我这三层一道都抓不到。那种情况得靠单请求的端到端耗时分布去发现,而不是靠健康检查。健康检查能兜住的,其实比我以为的少。

最后修改:2026 年 10 月 25 日
如果觉得我的文章对你有用,请随意赞赏