《TypeScript编程实战》5.3 优雅关闭与健康检查

本节讲解服务进程如何正确地「活着」与「死去」:从 SIGTERM 信号处理、连接排空与资源释放的关闭流程,到 liveness、readiness、startup 三类健康检查的语义差异与实现方式。你将学会为数据库、缓存等依赖编写带超时的探活,并把探针与容器、编排系统配置对齐,避免发布期间丢请求或误判重启。

本节目标:理解进程收到终止信号后应该做什么、按什么顺序做;掌握连接排空、资源释放与超时兜底的关闭流程;区分 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 模块、提供者与依赖注入 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes