发布时服务"瞬断"、扩缩容时请求被强杀、重启后流量黑洞……这些问题都源于进程没有体面地退出。优雅停机让进程收到信号后先停止接客、排空在途请求再退出;健康检查则让调度系统知道"什么时候该放流量进来"。本文覆盖信号处理、连接排空、探针设计与 K8s/PM2 配合。
1. 为什么需要优雅停机
1.1 强杀请求的代价
发布/重启时 K8s 或 PM2 发 SIGTERM
进程没处理 → 立即退出
在途请求被掐断 → 用户看到 502/重置
数据库写入半途而废 → 数据不一致
1.2 优雅停机的目标
1. 停止接收新请求
2. 排空已接收的在途请求
3. 释放外部资源(数据库连接、队列)
4. 兜底超时后强制退出
一句话:优雅停机 = 先停收新客、再排空在途、最后释放资源;没有它,每次发布都是一次小事故。
2. 信号处理
2.1 常见信号语义
| 信号 | 来源 | 默认行为 |
|---|---|---|
| SIGTERM | K8s/PM2 停机 | 终止进程 |
| SIGINT | Ctrl+C | 终止进程 |
| SIGHUP | 终端挂断 | 终止进程 |
Linux 停机信号首选 SIGTERM,进程应在此处理优雅退出;SIGKILL 无法被捕获,是兜底手段。
2.2 注册处理函数
function shutdown(reason) {
console.log(`收到 ${reason},开始优雅停机`);
// 此处安排:停新请求 → 排空 → 释放 → 退出
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
2.3 信号处理原则
只注册一次 → 避免多次触发重复清理
幂等处理 → 第二个信号直接 process.exit(1)
带原因日志 → 便于排查谁发的信号
一句话:SIGTERM/SIGINT 是优雅停机的入口信号;处理函数要幂等,收到重复信号直接退出,避免清理跑两遍。
3. HTTP 服务器优雅关闭
3.1 server.close 的语义
const server = http.createServer(handler);
process.on('SIGTERM', () => {
server.close(() => {
console.log('所有连接已关闭,退出');
process.exit(0);
});
});
server.listen(3000);
server.close() 停止接受新连接,等待已建立的连接自然结束后回调退出。这是优雅停机的主干。
3.2 keep-alive 连接拖死关闭
默认 keep-alive 的空闲连接会长期挂起,close() 永远等不到回调。要主动掐掉空闲连接:
const server = http.createServer(handler);
server.keepAliveTimeout = 5000; // 空闲 5 秒断开
server.headersTimeout = 8000;
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
// 强关空闲 keep-alive:遍历连接调用 end/destroy
for (const socket of server.connections ?? []) {
if (socket.idleTimeout === undefined) socket.end();
}
});
3.3 两种退出路径
正常路径 → server.close() 等排空 → exit(0)
兜底路径 → 超时强制 destroy 连接 → exit(0)
一句话:
server.close()停收新请求并等连接自然结束;keep-alive 空闲连接必须主动 end/destroy,否则 close 回调永远不来。
4. 在途请求排空
4.1 连接状态追踪
只靠 server.close() 不够——活跃请求仍可能超时。用请求计数器精确排空:
let inflight = 0;
const server = http.createServer(async (req, res) => {
inflight++;
try {
await handle(req, res);
} finally {
inflight--;
if (isShuttingDown && inflight === 0) process.exit(0);
}
});
4.2 停收新请求后响应
停机后新进来的请求应直接拒绝并给 503:
let isShuttingDown = false;
const server = http.createServer((req, res) => {
if (isShuttingDown) {
res.writeHead(503, { 'Retry-After': '5' });
res.end('正在停机,请稍后重试');
return;
}
handle(req, res);
});
process.on('SIGTERM', () => {
isShuttingDown = true;
const timer = setTimeout(() => {
console.error('排空超时,强制退出');
process.exit(1);
}, 30 * 1000);
timer.unref(); // 定时器不阻塞进程退出
server.close(() => process.exit(0));
});
4.3 资源释放清单
数据库连接池 → 关闭空闲连接
消息队列消费者 → 停止消费
定时任务 → clearInterval
WebSocket 连接 → 通知后关闭
一句话:排空 = 请求计数 + 停机后新请求回 503 + 超时强制退出;数据库、队列、定时器这些外部资源在退出前统一释放。
5. 健康检查端点
5.1 liveness 与 readiness
liveness(存活) → 进程活着就行,挂了重启
readiness(就绪) → 依赖就绪、能接流量,没就绪不发流量
5.2 实现两个端点
// 只检查进程存活
app.get('/healthz', (req, res) => {
res.json({ status: 'ok', pid: process.pid });
});
// 检查关键依赖(DB、Redis、队列)
app.get('/readyz', async (req, res) => {
try {
await db.ping();
await redis.ping();
res.json({ status: 'ready' });
} catch (err) {
res.status(503).json({ status: 'not-ready', reason: err.message });
}
});
5.3 就绪态配合优雅停机
// 停机时 readiness 立即返回 503,调度系统先摘流量
let isShuttingDown = false;
app.get('/readyz', (req, res) => {
if (isShuttingDown) return res.status(503).json({ status: 'stopping' });
res.json({ status: 'ready' });
});
一句话:liveness 看存活、readiness 看就绪;停机瞬间 readiness 先转 503,让调度系统把流量摘走,再执行排空。
6. 与 Kubernetes 配合
6.1 探针配置
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
livenessProbe:
httpGet: { path: /healthz, port: 3000 }
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet: { path: /readyz, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 5
terminationGracePeriodSeconds: 30
terminationGracePeriodSeconds 要大于业务排空时间,否则 K8s 等不及直接 SIGKILL。
6.2 停止流程全貌
1. K8s 更新 Pod 状态为 Terminating
2. readiness 探针失败 → 从 Service 摘流量
3. K8s 发 SIGTERM
4. 进程优雅排空
5. 超过 terminationGracePeriodSeconds → SIGKILL
6.3 preStop 钩子
需要"先做点事再停"时(如通知注册中心下线),加 preStop:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
一句话:K8s 配合 = liveness/readiness 探针 +
terminationGracePeriodSeconds大于排空时间;停机流程是「探针失败摘流量 → SIGTERM 排空 → 超时 SIGKILL」。
7. 与 PM2 配合
7.1 kill_timeout 配置
PM2 默认等进程退出也有时间窗,用 kill_timeout 放大排空时间:
module.exports = {
apps: [{
name: 'web-api',
script: './dist/index.js',
instances: 2,
kill_timeout: 15000, // 最多等 15 秒优雅退出
listen_timeout: 3000, // 等待新进程监听就绪
}],
};
7.2 wait_ready 模式
进程启动后依赖就绪再发 ready,避免流量黑洞:
// 依赖初始化完成后
process.send('ready'); // 告诉 PM2:我准备好了
7.3 PM2 停机触发链
pm2 reload → 对旧实例发 SIGINT/SIGTERM
进程监听到信号 → 排空 → 退出
新实例启动 → 依赖就绪 → 发 ready → 接流量
一句话:PM2 配合 =
kill_timeout给足排空时间 +wait_ready就绪再放流量;配合业务侧 SIGINT 监听实现零停机 reload。
8. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 不监听信号 | 请求被强杀 | 注册 SIGTERM/SIGINT |
| keep-alive 拖着 | close 回调不来 | 主动 destroy 空闲连接 |
| 停机后还收新请求 | 刚排空又进新流量 | 停机标识 + 回 503 |
| 排空无超时 | 永远卡死等退出 | 超时强制 exit(1) |
| termination 太短 | K8s 提前 SIGKILL | 调大 terminationGracePeriodSeconds |
| readiness 不摘流量 | 停机瞬间仍被打 | 停机时 readyz 转 503 |
| 依赖未就绪就监听 | 流量黑洞 | wait_ready + listen_timeout |
| 资源不释放 | 连接泄漏 | 数据库/队列/定时器统一清理 |
9. 总结
| 环节 | 要点 |
|---|---|
| 目标 | 停新请求 → 排空 → 释放 → 退出 |
| 信号 | SIGTERM/SIGINT 入口,幂等处理 |
| 关闭 | server.close + keep-alive 主动掐断 |
| 排空 | 请求计数 + 503 拒新 + 超时兜底 |
| 健康检查 | liveness 存活 / readiness 就绪 |
| K8s | 探针 + terminationGracePeriodSeconds |
| PM2 | kill_timeout + wait_ready |
| 兜底 | SIGKILL 是最后手段,能排空尽量排空 |
一句话记住:优雅停机是"让进程体面退场"的工程——信号来了先摘流量、排空在途、释放资源,超时兜底强退;配合 liveness/readiness 探针,K8s 与 PM2 就知道何时放流量、何时给时间。做好这两件事,发布从"事故"变成"无感"。
延伸阅读
- Node.js Docker 与 Kubernetes — 容器化部署的完整流程
- Node.js 微服务架构 — 服务生命周期与编排
- Node.js 可观测性 — 停机时的指标与日志
- Node.js 错误处理与日志 — 停机告警与异常记录
- Node.js 性能调优 — 排空耗时与吞吐分析
- Node.js 安全与部署 — 生产部署基线
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。