优雅关闭:进程退出前那几秒,决定了发版会不会掉请求
优雅关闭:进程退出前那几秒,决定了发版会不会掉请求
滚动发布时偶发 502、客户端收到 connection reset、队列里出现重复消费——这几类问题的根因常常是同一个:进程收到终止信号后,走得太快。
容器编排默认给的宽限期是 30 秒,但真正决定成败的不是这个数字,而是进程在收到信号到真正退出之间做了什么。很多服务的实现只有一行:收到信号就 exit(0)。于是正在处理的请求被硬生生切断,连接池里的空闲连接没有正常关闭,消息队列里已经取出但还没确认的消息在重连后被重复投递。
一、标准顺序:先停止接客,再把手上活干完
无论用什么语言,顺序都应该是一样的:
- 停止接收新请求:关闭监听端口,或把健康检查标记为不就绪;
- 等待在途请求完成:跟踪活跃请求数,归零再继续;
- 释放下游资源:数据库连接池、Redis 连接、消息消费通道;
- 强制兜底:设一个硬超时,超时后无条件退出,避免进程永远卡住。
用 Node 写出来大概是这样:
let shuttingDown = false;
let inflight = 0;
server.on('request', (req, res) => {
if (shuttingDown) {
res.writeHead(503, { Connection: 'close' });
return res.end();
}
inflight++;
res.on('finish', () => inflight--);
});
process.on('SIGTERM', async () => {
shuttingDown = true;
server.close(); // 不再 accept 新连接
const deadline = Date.now() + 20000; // 给自己留 20s
while (inflight > 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
}
await db.end(); // 关连接池
process.exit(inflight > 0 ? 1 : 0);
});
有几个细节值得注意:server.close() 只是不再接受新连接,已有的 keep-alive 连接仍然可以继续发请求,所以还要配合「 shuttingDown 时直接回 503 」这道闸;res.on('finish') 才是请求真正写完的时刻,用 'close' 统计会偏早,容易在响应还没发完时就退出。
二、编排环境里更难的是时序竞态
在 Kubernetes 里,SIGTERM 和「从 Service 端点摘除」是两条并行且不保证先后的通道。常见翻车场景是:端点还没摘干净,流量仍被转发过来,而进程已经停止监听——这一小段时间里进来的请求全部失败。
工程上的处理办法是在 preStop 里先睡几秒,给负载均衡和 iptables 规则一点时间收敛:
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 30
preStop 的时间要算进宽限期里:睡 5 秒、等待在途请求 20 秒,加起来不能超过 30 秒,否则会被 SIGKILL 打断,前面做的都白费。这个预算建议写进配置注释,否则半年后没人记得为什么是 30。
三、消费端比服务端更容易被忽略
HTTP 服务至少还有「请求完成」这个明确信号,消息消费者没有。消费者收到终止信号时,手里可能正拿着一条已经取出、尚未确认的消息。如果此时直接退出,broker 会在超时后重新投递,于是业务侧看到重复执行。
两个要点:
- 先停止拉取,再处理完手头的。批量拉取的场景下,别用「收到信号就 abort」的实现,要等当前批次确认完;
- 业务本身要幂等。优雅关闭只能把重复概率降到很低,降到零要靠幂等键或去重表——这是最后的保险,不能省。
四、怎么验证它真的生效了
写完代码不代表生效,至少做两件事:
第一,发版时压着流量看错误率。用固定并发持续打请求,同时触发一次滚动发布;错误率应该为 0,或者只出现可以解释的 503。
第二,看退出码和日志时序。正常退出应该是 0;如果是 1 或 137,说明是被超时逼退或直接 SIGKILL,前面那套逻辑根本没跑完。日志里「开始关闭」到「进程退出」的时间差,就是这套机制的真实预算。
优雅关闭不是性能优化,而是正确性的一部分。它平时完全看不出来,只在发版、扩缩容、节点回收这些时刻兑现价值——而这几件事在任何生产环境里都会发生。
- 点赞
- 收藏
- 关注作者
评论(0)