跳到主要内容

零停机部署与优雅退出

1. 概念

零停机 = 部署期间不出 5xx、不丢正在处理的请求。Node 应用做到这点要:

  1. 应用层正确响应 SIGTERM
  2. 编排层(K8s / PM2)配合
  3. 流量层先摘除 Pod 再停应用
  4. 健康检查准确反映状态

2. 优雅退出代码

const server = app.listen(3000)

let shuttingDown = false

// 健康检查响应"我要走了"
app.get('/health/ready', (req, res) => {
if (shuttingDown) return res.status(503).send('shutting down')
res.send('ok')
})

async function shutdown(signal) {
console.log(`收到 ${signal},开始优雅退出`)
shuttingDown = true

// 1. 不接新连接
server.close(async () => {
console.log('HTTP server 关闭')

try {
// 2. 关闭依赖(按依赖顺序逆序)
await db.disconnect()
await redis.quit()
await kafka.disconnect()
console.log('依赖关闭完成')
process.exit(0)
} catch (err) {
console.error('关闭依赖失败', err)
process.exit(1)
}
})

// 兜底:30 秒还没退出强杀
setTimeout(() => {
console.error('优雅退出超时')
process.exit(1)
}, 30_000).unref()
}

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

3. K8s 配合

spec:
terminationGracePeriodSeconds: 60 # 给优雅退出的总时间

containers:
- name: web
lifecycle:
preStop:
exec:
# 关键:先 sleep 让 Service / Ingress 把流量摘除
command: ["sh", "-c", "sleep 10"]

readinessProbe:
httpGet:
path: /health/ready
port: 3000
periodSeconds: 5
failureThreshold: 1

完整时序:

1. K8s 决定停 Pod
2. 标 Pod Terminating
3. 触发 preStop(sleep 10s)
→ 这期间 Service endpoint controller 把 Pod 从 endpoints 摘除
→ 新流量不再来这个 Pod
4. preStop 完成 → SIGTERM 发给容器
5. 应用收到 SIGTERM → server.close + 处理在飞请求
6. 应用 process.exit(0) 或 grace period 超时被 SIGKILL

preStop 的 sleep 不可省略:endpoints 同步是最终一致,几秒延迟,应用立即停会让正在路由的请求失败。

4. 滚动更新关键配置

strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
  • maxSurge: 1:可多起一个新 Pod
  • maxUnavailable: 0:保证旧 Pod 在新 Pod ready 前不停

新 Pod readiness 探针通过 → 老 Pod 才开始 terminate。

5. 长连接(WebSocket / SSE)

长连接退出更复杂:

  • WebSocket:服务端主动 close,客户端检测到自动重连到新 Pod
  • SSE:同样,客户端 EventSource 自动重连
function gracefulShutdown() {
// 通知所有客户端
for (const ws of wsServer.clients) {
ws.close(1012, 'server restart')
}
// 等几秒
setTimeout(() => server.close(), 3000)
}

6. 任务队列消费者

async function shutdown() {
shuttingDown = true
// 1. 停止 pull 新消息
await consumer.pause()
// 2. 等当前正在处理的任务完成
await waitForActiveTasks()
// 3. 关连接
await consumer.disconnect()
}

7. PM2 / Cluster 模式

PM2 reload 时给老 worker 发 SIGINT(非 SIGTERM)。代码里:

process.on('SIGINT', () => shutdown('SIGINT'))

wait_ready: true + process.send('ready') 让新 worker ready 才切换。

8. 数据库迁移注意

部署期间:

  • 旧版本运行
  • 新版本 Pod 起来
  • 此时数据库结构必须两版本都兼容

迁移分两步:

  1. 加列 / 加索引(兼容老)
  2. 部署新版本
  3. 删老列(再下次发版)

强行 schema 不兼容 → 滚动更新期间一半流量跑老版本一半新版本,必出问题。

9. 验证

测试零停机:

# 一边持续发请求
while true; do
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/api/health
done | sort | uniq -c

# 同时部署
kubectl rollout restart deployment/myapp

# 看输出全部 200 才算零停机

或用 hey / wrk 持续压测同时部署。

10. 故障排查

10.1 部署期间出现 502

  • preStop sleep 时间不够(endpoints 同步比想象慢)
  • terminationGracePeriodSeconds 太短被 SIGKILL
  • readiness 不准(旧 Pod 标 ready 但实际已停)

10.2 请求被截断

  • 应用没等 server.close 回调就 exit
  • 长请求超过 grace period 被 SIGKILL

10.3 数据库连接泄漏

  • 应用 exit 前没关连接池
  • 多 Pod 并行 terminate,DB 连接数瞬时翻倍

11. 常见反模式

  • 没监听 SIGTERM:K8s 等 30s 强杀,正在处理的请求丢
  • process.exit(0) 立即调用:在飞请求被截断
  • 没 preStop sleep:摘流量前停应用 → 502
  • terminationGracePeriodSeconds 太短:长请求被截
  • shuttingDown flag 不影响 readiness:K8s 不知道我要走了
  • 数据库迁移和应用部署一起:滚动期间不兼容
  • PM2 reload 没 wait_ready:流量切到没 listen 的 worker

12. 延伸阅读