零停机部署与优雅退出
1. 概念
零停机 = 部署期间不出 5xx、不丢正在处理的请求。Node 应用做到这点要:
- 应用层正确响应 SIGTERM
- 编排层(K8s / PM2)配合
- 流量层先摘除 Pod 再停应用
- 健康检查准确反映状态
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:可多起一个新 PodmaxUnavailable: 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 起来
- 此时数据库结构必须两版本都兼容
迁移分两步:
- 加列 / 加索引(兼容老)
- 部署新版本
- 删老列(再下次发版)
强行 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