Node.js 服务可靠性三板斧:优雅关闭、健康检查与全局错误处理

深入讲解 Node.js 生产环境可靠性工程,涵盖优雅关闭(Graceful Shutdown)、健康检查(Health Check)设计、全局错误处理与 Kubernetes 探针集成,附完整可运行代码与避坑指南。

DevOps 与部署 2026-06-03 15 分钟

据 Datadog 2025 年度报告显示,超过 60% 的 Node.js 服务宕机事件源于部署重启期间的请求丢失和连接中断。更令人震惊的是,其中 80% 的问题可以通过正确实现优雅关闭(Graceful Shutdown)和健康检查(Health Check)来避免。这两个看似简单的概念,却是大多数 Node.js 服务在生产环境中最薄弱的环节。

很多开发者觉得「能跑就行」,直到某天凌晨三点收到告警:滚动更新时几十个请求返回 502,用户投诉数据丢失。这篇文章将从原理到实现,手把手教你构建生产级的 Node.js 服务可靠性体系。

🔧 一、优雅关闭:让每一次重启都不丢请求

1.1 为什么需要优雅关闭

当你执行 kill 命令或 Kubernetes 进行滚动更新时,操作系统会向 Node.js 进程发送 SIGTERM 信号。如果你没有捕获这个信号,Node.js 会立即终止——正在处理的请求被切断,数据库事务未提交,WebSocket 连接突然断开。

⚠️ 警告: SIGTERM 之后如果进程在规定时间内(通常 30 秒)没有退出,Kubernetes 会发送 SIGKILL 强制杀死进程。SIGKILL 无法被捕获,进程会立即死亡,没有任何清理机会。

优雅关闭的核心思路可以用四个步骤概括:

  1. 停止接受新请求 — 告诉负载均衡器「我不再接收流量了」
  2. 等待已有请求完成 — 给正在处理的请求一个合理的完成时间
  3. 清理资源 — 关闭数据库连接池、Redis 连接、文件句柄
  4. 退出进程 — 以状态码 0 正常退出,或 1 表示异常

这四个步骤的顺序至关重要。如果你先关闭数据库再等待请求完成,那些请求会因为数据库连接断开而失败。

1.2 Express 优雅关闭实现

错误写法: 直接 process.exit() — 正在处理的请求会被截断

// ❌ 千万不要这样写
process.on('SIGTERM', () => {
  process.exit(0)
})

正确写法: 完整的优雅关闭流程

// server.js — Express 优雅关闭完整实现
const express = require('express')
const app = express()

app.get('/api/data', async (req, res) => {
  // 模拟耗时操作
  await new Promise(resolve => setTimeout(resolve, 1000))
  res.json({ message: 'ok' })
})

const server = app.listen(3000, () => {
  console.log('Server running on port 3000')
})

// 标记服务是否正在关闭
let isShuttingDown = false

// 捕获终止信号
function gracefulShutdown(signal) {
  console.log(`\n收到 ${signal} 信号,开始优雅关闭...`)
  isShuttingDown = true

  // 第一步:停止接受新连接
  server.close(() => {
    console.log('HTTP 服务器已关闭,不再接受新连接')
  })

  // 第二步:通知负载均衡器(可选,取决于部署方式)
  // 在 Kubernetes 中,移除 Endpoints 后流量会自动停止

  // 第三步:等待已有请求完成(带超时)
  const forceExitTimeout = setTimeout(() => {
    console.error('⚠️ 超时未完成,强制退出')
    process.exit(1)
  }, 30000) // 30 秒超时

  // 允许进程在超时前退出
  forceExitTimeout.unref()
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))

💡 提示: server.close() 会停止接受新的连接,但不会关闭已有的连接。已建立的 HTTP Keep-Alive 连接会继续存在,直到客户端关闭或超时。这就是为什么需要设置强制退出超时——防止进程无限期挂起。

1.3 Fastify 优雅关闭实现

Fastify 内置了 close 钩子,实现更加优雅:

// fastify-server.js — Fastify 优雅关闭
import Fastify from 'fastify'

const fastify = Fastify({ logger: true })

// 注册关闭钩子 — 在 server.close() 时自动调用
fastify.addHook('onClose', async (instance) => {
  fastify.log.info('正在清理资源...')
  // 关闭数据库连接池
  // 关闭 Redis 连接
  // 刷新日志缓冲区
})

// 中间件:拒绝正在关闭时的新请求
fastify.addHook('onRequest', async (request, reply) => {
  if (isShuttingDown) {
    reply.code(503).send({ error: 'Service Unavailable' })
  }
})

fastify.get('/api/data', async () => {
  return { message: 'ok' }
})

fastify.listen({ port: 3000 })

// 优雅关闭函数
async function gracefulShutdown(signal) {
  console.log(`收到 ${signal},开始关闭...`)
  isShuttingDown = true

  try {
    // fastify.close() 会调用所有 onClose 钩子
    await fastify.close()
    console.log('服务已优雅关闭')
    process.exit(0)
  } catch (err) {
    console.error('关闭过程中出错:', err)
    process.exit(1)
  }
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))

Express 和 Fastify 在优雅关闭方面有显著差异:

特性 Express Fastify
内置关闭钩子 ❌ 无 onClose 钩子
连接排空 需手动实现 server.close() 内置
关闭超时控制 需手动实现 需手动实现
插件清理顺序 无保证 反注册顺序(LIFO)
适用场景 简单服务 生产级服务

关键结论: 如果你在 2026 年新建 Node.js 服务,优先选择 Fastify。它的生命周期管理更加完善,关闭钩子让资源清理变得可靠。

1.4 连接排空与负载均衡器协调

优雅关闭不仅仅是 Node.js 进程内部的事情。在生产环境中,你还需要和负载均衡器(Nginx、ALB、Kubernetes Service)协调:

  1. Pod 收到 SIGTERM — 开始优雅关闭流程
  2. Kubernetes 从 Endpoints 移除 Pod — 但这个过程有延迟(默认最多 30 秒)
  3. 在 Endpoints 更新之前,新请求仍然会打到这个 Pod — 所以需要用中间件拦截
  4. Endpoints 更新完成后 — 不再有新请求,等待已有请求完成

📌 记住: 从收到 SIGTERM 到 Endpoints 更新完成之间有一个时间窗口。在这个窗口内,新请求仍然会到达你的服务。这就是为什么必须在中间件中检查 isShuttingDown 标志,对新请求返回 503。

🏥 二、健康检查:告诉世界你还活着

2.1 存活探针 vs 就绪探针

健康检查不是简单地返回 200 OK。在生产环境中,你需要区分三种探针:

  • 存活探针(Liveness Probe): 进程是否还活着?如果失败,Kubernetes 会重启容器。这个探针应该非常轻量,只检查进程本身是否响应。
  • 就绪探针(Readiness Probe): 服务是否准备好接受流量?如果失败,Kubernetes 会从 Endpoints 中移除该 Pod,停止向其发送新请求。
  • ⚠️ 启动探针(Startup Probe): 应用是否已完成启动?用于启动慢的应用(比如需要预加载大量数据),在启动探针成功之前,存活探针和就绪探针不会生效。

⚠️ 警告: 存活探针失败会导致重启。如果你的存活探针检查了数据库连接,数据库抖动会导致所有 Pod 同时重启——这就是「级联故障」。存活探针应该只检查进程本身是否健康,比如事件循环是否卡死,不要检查外部依赖。

2.2 完整的健康检查实现

// healthcheck.js — 生产级健康检查实现
import express from 'express'
import { Pool } from 'pg'
import Redis from 'ioredis'

const app = express()

// 外部依赖客户端
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL })
const redis = new Redis(process.env.REDIS_URL)

// 启动时间
const startTime = Date.now()

// === 存活探针:只检查进程本身 ===
app.get('/healthz', (req, res) => {
  // 检查事件循环是否卡住(简单版)
  const uptime = Date.now() - startTime
  if (uptime < 1000) {
    // 刚启动,给一个宽限期
    return res.status(200).json({ status: 'starting' })
  }
  res.status(200).json({ status: 'alive', uptime })
})

// === 就绪探针:检查所有依赖 ===
app.get('/readyz', async (req, res) => {
  const checks = {}
  let isReady = true

  // 检查 PostgreSQL
  try {
    const start = Date.now()
    await pgPool.query('SELECT 1')
    checks.postgres = { status: 'ok', latency: Date.now() - start }
  } catch (err) {
    checks.postgres = { status: 'error', message: err.message }
    isReady = false
  }

  // 检查 Redis
  try {
    const start = Date.now()
    await redis.ping()
    checks.redis = { status: 'ok', latency: Date.now() - start }
  } catch (err) {
    checks.redis = { status: 'error', message: err.message }
    isReady = false
  }

  const statusCode = isReady ? 200 : 503
  res.status(statusCode).json({
    status: isReady ? 'ready' : 'not_ready',
    checks,
    timestamp: new Date().toISOString()
  })
})

// === 启动探针(可选,用于启动慢的应用)===
let isBooted = false
async function boot() {
  // 执行预热:加载缓存、预编译模板等
  await pgPool.query('SELECT 1')  // 确保数据库可连接
  isBooted = true
  console.log('应用启动完成')
}

app.get('/startupz', (req, res) => {
  if (isBooted) {
    res.status(200).json({ status: 'booted' })
  } else {
    res.status(503).json({ status: 'booting' })
  }
})

boot()
app.listen(3000)

2.3 Kubernetes 探针配置

# k8s-deployment.yaml — Kubernetes 探针配置
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: app
          image: my-app:latest
          ports:
            - containerPort: 3000
          # 启动探针:给应用 60 秒启动时间
          startupProbe:
            httpGet:
              path: /startupz
              port: 3000
            failureThreshold: 30    # 30 次 × 2 秒间隔 = 60 秒
            periodSeconds: 2
          # 存活探针:进程是否活着
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3000
            periodSeconds: 10       # 每 10 秒检查一次
            failureThreshold: 3     # 连续 3 次失败才重启
            timeoutSeconds: 5       # 5 秒超时
          # 就绪探针:是否准备好接受流量
          readinessProbe:
            httpGet:
              path: /readyz
              port: 3000
            periodSeconds: 5        # 每 5 秒检查一次
            failureThreshold: 2     # 连续 2 次失败就摘流量
            timeoutSeconds: 3       # 3 秒超时

三种探针的配置策略对比:

探针类型 检查频率 失败阈值 超时时间 失败后果
启动探针 2 秒 30 次 Pod 不会被标记为就绪
存活探针 10 秒 3 次 5 秒 容器被重启
就绪探针 5 秒 2 次 3 秒 Pod 从 Endpoints 移除

⚠️ 警告: 就绪探针的 failureThreshold 不要设太大。如果数据库故障,你希望尽快摘掉流量(2-3 次),而不是继续把请求打到一个无法处理请求的 Pod 上。存活探针可以适当放宽,避免因暂时性问题频繁重启。

🛡️ 三、全局错误处理:最后一道防线

3.1 未捕获异常与未处理 Promise 拒绝

Node.js 中有两种全局错误事件,它们是应用的最后一道安全网:

  • uncaughtException:同步代码中未被 try/catch 捕获的异常
  • unhandledRejection:Promise 被 reject 后没有 .catch()try/await 处理

从 Node.js 15 开始,未处理的 Promise 拒绝默认会终止进程(之前只是打印警告)。这是一个重要的行为变更——如果你的代码中有遗留的未处理 reject,升级后会直接崩溃。

错误写法: 在全局错误处理中继续运行 — 进程可能处于不一致状态

// ❌ 千万不要这样写
process.on('uncaughtException', (err) => {
  console.error('未捕获异常:', err)
  // 继续运行?进程状态可能已经损坏!
})

正确写法: 记录错误后优雅退出

// error-handler.js — 生产级全局错误处理
import pino from 'pino'

const logger = pino({
  level: 'info',
  // 生产环境使用 JSON 格式,方便日志系统解析
  transport: process.env.NODE_ENV !== 'production'
    ? { target: 'pino-pretty' }
    : undefined
})

// 全局错误处理函数
function handleFatalError(err, origin) {
  // 1. 记录详细错误信息
  logger.fatal({
    err,
    origin,
    stack: err.stack,
    // 附加上下文信息
    pid: process.pid,
    memory: process.memoryUsage(),
    uptime: process.uptime()
  }, `致命错误: ${origin}`)

  // 2. 给日志系统一点时间刷新
  setTimeout(() => {
    process.exit(1)
  }, 1000)
}

// 捕获未处理的同步异常
process.on('uncaughtException', (err) => {
  handleFatalError(err, 'uncaughtException')
})

// 捕获未处理的 Promise 拒绝
process.on('unhandledRejection', (reason, promise) => {
  // 包装成 Error 对象以便记录完整堆栈
  const err = reason instanceof Error
    ? reason
    : new Error(`Unhandled Rejection: ${reason}`)
  handleFatalError(err, 'unhandledRejection')
})

// 捕获警告(可选,用于调试)
process.on('warning', (warn) => {
  logger.warn({ warn }, 'Node.js 警告')
})

export { logger }

3.2 Express/Fastify 错误处理中间件

全局错误事件是最后的防线,但在正常流程中,你应该用框架的错误处理中间件来捕获路由中的错误。这样可以返回友好的错误信息,而不是让进程崩溃。

// error-middleware.js — Express 错误处理中间件
import { logger } from './error-handler.js'

// 自定义错误类
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message)
    this.statusCode = statusCode
    this.isOperational = true  // 标记为可预期的业务错误
  }
}

// Express 错误处理中间件(必须有 4 个参数)
app.use((err, req, res, next) => {
  // 记录错误详情
  logger.error({
    err,
    method: req.method,
    url: req.url,
    query: req.query,
    headers: {
      'user-agent': req.headers['user-agent'],
      'x-request-id': req.headers['x-request-id']
    },
    requestId: req.id
  }, `请求处理失败: ${err.message}`)

  // 根据错误类型返回不同的状态码
  const statusCode = err.statusCode || err.status || 500
  const isServerError = statusCode >= 500

  res.status(statusCode).json({
    error: isServerError ? 'Internal Server Error' : err.message,
    // 生产环境不暴露服务器错误的详情
    message: isServerError ? '服务暂时不可用,请稍后重试' : err.message,
    requestId: req.id  // 方便用户反馈问题
  })
})

// 404 处理
app.use((req, res) => {
  res.status(404).json({
    error: 'Not Found',
    message: `路由 ${req.method} ${req.path} 不存在`
  })
})

export { AppError }

💡 提示: 使用自定义的 AppError 类区分「可预期的业务错误」和「不可预期的系统错误」。业务错误(如参数校验失败)返回具体信息;系统错误(如数据库连接断开)返回通用信息,避免泄露内部实现细节。

3.3 错误分类与恢复策略

不同类型的错误需要不同的处理策略。一刀切地「catch 所有错误然后继续运行」是生产环境的大忌:

错误类型 示例 处理策略 是否重启
编程错误 TypeError: Cannot read property of undefined 记录 + 重启 ✅ 是
资源耗尽 ENOMEM、连接池耗尽 记录 + 重启 ✅ 是
外部依赖故障 数据库超时、第三方 API 不可用 记录 + 降级 ❌ 否
业务逻辑错误 参数校验失败、权限不足 返回错误码 ❌ 否
网络错误 ECONNRESETETIMEDOUT 重试 + 降级 ❌ 否

📌 记住: 未捕获异常(uncaughtException)意味着进程状态可能已损坏,应该退出并让进程管理器(PM2、Kubernetes)重启。不要试图在 uncaughtException 处理器中「恢复」——你无法知道哪些变量处于不一致状态。

💡 四、最佳实践与避坑指南

在生产环境中,以上三个机制需要协同工作。以下是关键的最佳实践:

✅ 推荐做法

  • 使用 PM2 或 Kubernetes 管理进程 — 不要手动管理 Node.js 进程的生命周期
  • 为就绪探针设置合理的超时 — 外部依赖检查应在 3 秒内完成
  • 在优雅关闭时刷新所有缓冲区 — 日志、metrics、写缓冲都要 flush
  • 使用 server.close() 而非 process.exit() — 让已有请求自然完成
  • 记录关闭过程的每一步 — 方便排查部署期间的问题
  • 在中间件中检查 isShuttingDown 标志 — 拒绝新请求,返回 503
  • 使用请求 ID(x-request-id — 方便追踪全链路请求

❌ 避免做法

  • 不要在存活探针中检查数据库 — 数据库抖动会导致所有 Pod 重启
  • 不要在 uncaughtException 中继续运行 — 进程状态可能已损坏
  • 不要使用 process.exit(0) 退出 — 使用 process.exit(1) 表示异常退出
  • 不要忽略 unhandledRejection — 从 Node.js 15 开始,未处理的 Promise 拒绝会终止进程
  • 不要在关闭时关闭负载均衡器的健康检查 — 先摘流量,再关闭服务

⚠️ 常见坑点

  1. 数据库连接池未关闭 — 优雅关闭时必须关闭所有数据库连接,否则进程会挂起。pg.Poolend() 方法会等待所有活跃连接释放,但如果有事务未提交,可能需要设置超时强制关闭。
  2. 定时器未清理setInterval 会阻止进程退出,必须在关闭时 clearInterval。建议维护一个全局的定时器注册表,在关闭时统一清理。
  3. WebSocket 连接未断开 — 需要主动关闭所有 WebSocket 连接。ws 库的 server.close() 不会自动断开已建立的连接,需要遍历 server.clients 逐个关闭。
  4. 日志缓冲区未刷新 — 异步日志库(如 Pino)可能有缓冲区,需要调用 flush() 确保日志不丢失。
  5. Kubernetes terminationGracePeriodSeconds 太短 — 默认 30 秒,如果应用需要更长时间清理(比如等待长任务完成),需要在 Pod spec 中调整这个值。

📊 五、完整生产模板

将以上所有内容整合为一个可直接使用的生产模板。这个模板包含了优雅关闭、健康检查、错误处理和请求追踪:

// production-server.js — 生产级 Node.js 服务完整模板
import express from 'express'
import pino from 'pino'
import { Pool } from 'pg'

// === 1. 初始化日志 ===
const logger = pino({ level: process.env.LOG_LEVEL || 'info' })

// === 2. 初始化依赖 ===
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL })
const app = express()
const startTime = Date.now()
let isShuttingDown = false

// === 3. 请求 ID 中间件 ===
app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || crypto.randomUUID()
  res.setHeader('x-request-id', req.id)
  next()
})

// === 4. 关闭守卫中间件 ===
app.use((req, res, next) => {
  if (isShuttingDown) {
    res.set('Connection', 'close')
    return res.status(503).json({ error: 'Service is shutting down' })
  }
  next()
})

// === 5. 业务路由 ===
app.get('/api/data', async (req, res) => {
  const result = await pgPool.query('SELECT NOW()')
  res.json({ time: result.rows[0].now })
})

// === 6. 健康检查 ===
app.get('/healthz', (_, res) => res.json({ status: 'alive' }))
app.get('/readyz', async (_, res) => {
  try {
    await pgPool.query('SELECT 1')
    res.json({ status: 'ready' })
  } catch {
    res.status(503).json({ status: 'not_ready' })
  }
})

// === 7. 错误处理中间件 ===
app.use((err, req, res, next) => {
  logger.error({ err, requestId: req.id }, '请求处理失败')
  res.status(500).json({ error: 'Internal Server Error', requestId: req.id })
})

// === 8. 启动服务 ===
const server = app.listen(3000, () => {
  logger.info('服务启动在端口 3000')
})

// === 9. 优雅关闭 ===
async function gracefulShutdown(signal) {
  logger.info({ signal }, '收到关闭信号')
  isShuttingDown = true

  server.close(async () => {
    logger.info('HTTP 服务器已关闭')
    try {
      await pgPool.end()
      logger.info('数据库连接池已关闭')
    } catch (err) {
      logger.error({ err }, '关闭数据库连接池失败')
    }
    process.exit(0)
  })

  // 30 秒后强制退出
  setTimeout(() => {
    logger.error('强制退出')
    process.exit(1)
  }, 30000).unref()
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
process.on('uncaughtException', (err) => {
  logger.fatal({ err }, '未捕获异常')
  process.exit(1)
})
process.on('unhandledRejection', (reason) => {
  logger.fatal({ err: reason }, '未处理的 Promise 拒绝')
  process.exit(1)
})

这个模板大约 70 行代码,但覆盖了生产环境最常见的可靠性问题。建议将它作为项目脚手架的一部分,确保每个新服务从第一天起就具备生产级可靠性。

总结

Node.js 生产环境可靠性不是一个「可选」的优化项,而是每一个上线服务的「必选」基础。优雅关闭确保每次部署不丢请求,健康检查让负载均衡器做出正确决策,全局错误处理则是最后的安全网。

这三个机制的实现并不复杂,但细节很多。最常见的错误是「能跑就行」的心态——直到某天凌晨三点收到告警,才意识到这些细节的重要性。

相关工具推荐:

  • 🔧 Fastify — 内置生命周期管理的 Node.js 框架
  • 🔧 Pino — 高性能 JSON 日志库
  • 🔧 Terminus — 优雅关闭的独立库(适用于 Express)
  • 🔧 Kubernetes 文档 — 探针配置官方指南

📚 相关文章