本节目标:理解进程收到终止信号后应该做什么、按什么顺序做;掌握连接排空、资源释放与超时兜底的关闭流程;区分 liveness / readiness / startup 三类探针的语义并正确实现;学会为数据库、缓存等依赖编写带超时的探活,并把探针参数与容器编排配置对齐。
5.3 优雅关闭与健康检查
前两节我们让服务能正确地处理请求。但一个服务在生命周期里还有两个关键时刻同样重要:被部署系统替换掉的时刻(关闭)和被负载均衡器询问「你还好吗」的时刻(探活)。
这两件事经常被当作「运维的事」而跳过,直到某次发布出现 502、某次滚动更新导致一批请求超时,才回头补课。本节把它们当成应用代码的一部分来处理。
5.3.1 为什么需要优雅关闭
先看一个反面例子。假设服务里有这样一个定时任务:
setInterval(async () => {
await db.query('UPDATE counters SET value = value + 1 WHERE id = 1')
}, 1000)
如果进程在两次 tick 之间被 SIGKILL 杀掉,正在执行的 SQL 会被中断,连接池里的连接被粗暴切断,数据库侧可能留下未提交事务。同理,一个正在写入响应流的请求会被截断,客户端收到不完整的 body。
容器编排系统的做法是:先发 SIGTERM 表示「请你体面退出」,等待一段时间(宽限期)后若进程还活着,再发 SIGKILL 强制结束。优雅关闭就是在这段时间窗口内完成该做的事。
需要完成的事通常有四类:
| 类别 | 具体动作 | 不做的后果 |
|---|---|---|
| 停止接收 | 从服务发现摘除、停止接受新连接 | 新请求打到正在关闭的实例 |
| 排空在途 | 等待正在处理的请求完成 | 客户端收到 502 / 连接重置 |
| 释放资源 | 关闭数据库连接池、Redis、MQ 消费者 | 连接泄漏、消息重复消费 |
| 落盘与上报 | 刷缓冲日志、上报最终指标 | 丢失最后一段可观测数据 |
还有一条容易被忽略的性质:关闭流程必须是幂等的。容器编排可能重复发送 SIGTERM,运维也可能手抖连按两次 Ctrl+C,而 app.close() 被调用两次会抛出 FST_ERR_REOPENED_CLOSE_SERVER 之类的错误。用「首次进入即置标志位、后续直接返回」的模式包一层,比在每个清理函数里做判断更省心。
let closing: Promise<void> | undefined
export function shutdownOnce(signal: string) {
// 后续信号复用同一个 Promise,天然幂等
closing ??= doShutdown(signal)
return closing
}
另外要区分「优雅关闭」与「崩溃」:前者是主动、有序、可预期的;后者是未捕获异常导致的非正常终止。unhandledRejection 与 uncaughtException 应当走同一条关闭路径,而不是让进程直接消失,相关边界处理见 3.2 全局错误边界与未捕获异常
。
5.3.2 关闭流程:从信号到连接排空
最小可用的实现是手动监听信号并调用框架的 close():
import Fastify from 'fastify'
const app = Fastify({ logger: true })
const shutdownHooks: Array<() => Promise<void>> = []
async function shutdown(signal: string) {
app.log.info({ signal }, '开始优雅关闭')
// 1. 停止健康检查返回 200(让 LB 先把流量摘走)
setUnhealthy()
// 2. 关闭 HTTP 服务器:等待在途请求结束,拒绝新连接
await app.close()
// 3. 逆序释放资源:先停消费者,再关连接池
for (const hook of shutdownHooks.reverse()) {
await hook()
}
app.log.info('优雅关闭完成')
process.exit(0)
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
顺序很关键:先让探针失败、再关闭 HTTP、最后关资源。如果反过来先关连接池,正在处理的请求会因为拿不到数据库连接而失败,虽然「优雅」地退出却让用户看到了错误。
生产环境更推荐用 close-with-grace,它替你处理了超时兜底、重复信号与未捕获异常:
import closeWithGrace from 'close-with-grace'
closeWithGrace({ delay: 10_000 }, async ({ signal, err }) => {
if (err) app.log.error({ err }, '未捕获异常触发关闭')
app.log.info({ signal }, '收到关闭信号')
await app.close()
await redis.quit()
await db.destroy() // Prisma / Knex 的连接池销毁
})
delay: 10_000 是硬上限:10 秒内没关完就强制退出。这个值必须小于编排系统的 terminationGracePeriodSeconds,否则 K8s 会先发 SIGKILL,你的超时逻辑根本来不及执行。经验值是探针宽限期的 80%。
数据库连接池的关闭细节(尤其是长事务与迁移场景)见 7.3 迁移、事务与连接池 。
5.3.3 健康检查三件套
很多项目只写一个 /health,这是问题的起点。三类探针的语义完全不同:
| 探针 | 回答的问题 | 失败时编排系统的动作 | 应检查什么 |
|---|---|---|---|
| liveness | 进程是否还活着、是否需要重启 | 重启容器 | 只检查进程自身(事件循环是否阻塞) |
| readiness | 现在能否接流量 | 从 Service 摘除,不重启 | 依赖可用性、是否正在关闭 |
| startup | 是否已完成启动 | 暂缓其他探针 | 初始化是否完成 |
最常见的错误是把依赖检查塞进 liveness。当数据库抖动时,所有实例的 liveness 同时失败,编排系统会把它们全部重启——一次数据库抖动被放大成一次全量雪崩。正确的做法是:liveness 只做最轻的自检,依赖状态全部放进 readiness。
import type { FastifyInstance } from 'fastify'
let shuttingDown = false
let started = false
export function setUnhealthy() {
shuttingDown = true
}
export function markStarted() {
started = true
}
export function registerHealth(app: FastifyInstance) {
// liveness:只证明进程没死,绝不查依赖
app.get('/healthz', async () => ({ status: 'ok' }))
// startup:启动探针,完成后编排系统才启用另外两个
app.get('/startupz', async (_req, reply) => {
if (!started) return reply.code(503).send({ status: 'starting' })
return { status: 'started' }
})
// readiness:能否接流量
app.get('/readyz', async (_req, reply) => {
if (shuttingDown) {
return reply.code(503).send({ status: 'shutting-down' })
}
const checks = await Promise.all([
checkDb(),
checkRedis(),
])
const ok = checks.every((c) => c.ok)
return reply.code(ok ? 200 : 503).send({
status: ok ? 'ready' : 'degraded',
checks,
})
})
}
/readyz 返回结构化结果而不只是状态码,是为了排障时能一眼看出是哪个依赖挂了。注意这些端点不应挂在业务鉴权中间件下,否则探针会因为 401 而被判失败——注册时要显式跳过鉴权钩子。
5.3.4 依赖探活与超时
探活最容易被忽略的是超时。一个没有超时的 db.query('SELECT 1') 在数据库挂掉时会一直挂着,探针请求堆积,最终把 Node 的事件循环拖垮。
type CheckResult = { name: string; ok: boolean; latencyMs: number; error?: string }
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`探活超时 ${ms}ms`)), ms)
})
try {
return await Promise.race([p, timeout])
} finally {
clearTimeout(timer!)
}
}
async function checkDb(): Promise<CheckResult> {
const start = performance.now()
try {
await withTimeout(db.query('SELECT 1'), 800)
return { name: 'postgres', ok: true, latencyMs: performance.now() - start }
} catch (e) {
return {
name: 'postgres',
ok: false,
latencyMs: performance.now() - start,
error: (e as Error).message,
}
}
}
三个要点:超时值要小于探针自身的 timeout(K8s 默认 1 秒,所以 800ms 是合理值);探活必须用轻量查询,SELECT 1 或 Redis PING,绝不能是 COUNT(*);结果要带耗时,latencyMs 突然升高往往比直接失败更早暴露问题。
还需要一个工程约定:依赖探活要有熔断或缓存。若每 5 秒有 20 个 Pod 各探一次,数据库压力会持续累积;常见做法是缓存探活结果 2 到 3 秒,或在连续失败后短路返回。可观测性侧的接线见 17.2 指标与告警 。
5.3.5 容器与编排配置对齐
探针写好了,配置没对齐等于白写。下面是一份最小可用的组合:
# Dockerfile 中的 HEALTHCHECK(单机 / Compose 场景)
HEALTHCHECK --interval=10s --timeout=1s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# Kubernetes Deployment 片段
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
ports:
- containerPort: 3000
startupProbe:
httpGet: { path: /startupz, port: 3000 }
failureThreshold: 30
periodSeconds: 2
livenessProbe:
httpGet: { path: /healthz, port: 3000 }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /readyz, port: 3000 }
periodSeconds: 5
failureThreshold: 2
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
三处参数值得单独解释:
第一,preStop 里的 sleep 5 看似多余,实际是必需的。K8s 在发 SIGTERM 的同时会异步从 Service 端点列表摘除该 Pod,两者之间有几秒的竞态窗口;sleep 让进程在收到信号前保持正常服务,避免「已经被摘除但 LB 还在转发」造成的 502。
第二,failureThreshold × periodSeconds 决定探针容忍时间。readiness 用 5×2=10 秒是为了快速摘流量;liveness 用 10×3=30 秒是为了抗住短暂抖动不重启。
第三,startupProbe 的 failureThreshold: 30 配合 periodSeconds: 2 给了 60 秒启动窗口。有 startupProbe 时,liveness 在启动阶段不会执行,因此可以把 liveness 配得更激进而不担心冷启动被误杀。
发布流程里这些参数如何与灰度、回滚配合,见 18.1 Docker 与 CI/CD 流水线 ;反向代理侧的探活与连接保持可延伸阅读 Nginx upstream 健康检查 。
5.3.6 常见坑
第一个坑是信号被吞掉。在容器里以 sh -c "node server.js" 启动时,sh 会接管 SIGTERM 而不会转发给 node 进程,表现为「每次都等满宽限期被 SIGKILL」。解决方式是使用 exec 形式(CMD ["node", "server.js"])或加 exec 前缀。
第二个坑是关闭时仍在写日志。app.close() 之后 logger 可能已被销毁,后续的 app.log.info 会抛错,掩盖真正的关闭异常。关闭阶段的日志建议直接用 process.stdout.write。
第三个坑是探针端点被限流或鉴权拦截。健康检查来自编排系统而非用户,若走了全局限流,在高负载时探针会因 429 被判失败,反而触发重启。探针路由必须显式排除在限流与鉴权之外。
第四个坑是在途长连接。SSE 与 WebSocket 连接会让 server.close() 一直等待,必须在关闭流程里主动向这些连接发送关闭帧并设置自己的超时,相关模式见 10.3 心跳、重连与广播
。
至此,第五章的服务端基础部分就完整了:路由把请求接进来,中间件完成横切处理,优雅关闭与健康检查保证进程能体面地进退。下一章我们进入应用架构层,讨论模块划分、依赖注入与生命周期管理。延伸阅读可参考 Node.js 优雅关闭与健康检查 与 Docker 健康检查与自愈 。
小结
本节的核心是:服务的「进退」与「健忘」同样属于应用代码的职责。
- 优雅关闭的顺序是「先摘流量、再关 HTTP、最后关资源」,任何一步反了都会让用户看到错误;
close-with-grace提供了超时兜底与重复信号处理,其delay必须小于编排系统的terminationGracePeriodSeconds;- liveness 只自检进程,依赖检查全部放进 readiness,否则一次数据库抖动会被放大成全量重启;
- 探活必须带超时(且小于探针 timeout)、用轻量查询、返回耗时与结构化结果;
preStop的 sleep 用于覆盖「信号已发但端点尚未摘除」的竞态窗口;- 容器里务必用
exec形式启动,否则SIGTERM会被 shell 吞掉。
下一章我们进入应用架构层:模块怎么划分、依赖怎么注入、生命周期怎么管理,把这一章的单体服务升级为可组合的应用骨架。
阅读导航:上一节:5.2 中间件与请求上下文 · 下一节:6.1 模块、提供者与依赖注入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。