据 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无法被捕获,进程会立即死亡,没有任何清理机会。
优雅关闭的核心思路可以用四个步骤概括:
- 停止接受新请求 — 告诉负载均衡器「我不再接收流量了」
- 等待已有请求完成 — 给正在处理的请求一个合理的完成时间
- 清理资源 — 关闭数据库连接池、Redis 连接、文件句柄
- 退出进程 — 以状态码 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)协调:
- Pod 收到 SIGTERM — 开始优雅关闭流程
- Kubernetes 从 Endpoints 移除 Pod — 但这个过程有延迟(默认最多 30 秒)
- 在 Endpoints 更新之前,新请求仍然会打到这个 Pod — 所以需要用中间件拦截
- 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 不可用 | 记录 + 降级 | ❌ 否 |
| 业务逻辑错误 | 参数校验失败、权限不足 | 返回错误码 | ❌ 否 |
| 网络错误 | ECONNRESET、ETIMEDOUT |
重试 + 降级 | ❌ 否 |
📌 记住: 未捕获异常(
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 拒绝会终止进程 - ❌ 不要在关闭时关闭负载均衡器的健康检查 — 先摘流量,再关闭服务
⚠️ 常见坑点
- 数据库连接池未关闭 — 优雅关闭时必须关闭所有数据库连接,否则进程会挂起。
pg.Pool的end()方法会等待所有活跃连接释放,但如果有事务未提交,可能需要设置超时强制关闭。 - 定时器未清理 —
setInterval会阻止进程退出,必须在关闭时clearInterval。建议维护一个全局的定时器注册表,在关闭时统一清理。 - WebSocket 连接未断开 — 需要主动关闭所有 WebSocket 连接。
ws库的server.close()不会自动断开已建立的连接,需要遍历server.clients逐个关闭。 - 日志缓冲区未刷新 — 异步日志库(如 Pino)可能有缓冲区,需要调用
flush()确保日志不丢失。 - 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 文档 — 探针配置官方指南