一次正常发布后,我们看到短暂 502,同时有两条异步任务被重复执行。容器收到 SIGTERM 后直接退出,负载均衡还在把请求送过来,队列里的任务租约也没来得及归还。代码没有抛异常,问题出在进程结束的顺序。
优雅退出不是一个 process.on('SIGTERM') 日志,而是入口、在途工作和资源连接之间的协议。
图 1:先让外部世界知道“不要再给我新工作”,再处理已经承诺的工作,最后关闭依赖。
信号到达后先进入 draining
let draining = false;
app.get("/health/ready", (_req, res) => {
if (draining) return res.status(503).json({ ready: false, reason: "draining" });
res.json({ ready: true });
});收到 SIGTERM 立即把 readiness 变为失败,让 Kubernetes 从 Service Endpoint 移除实例。这个传播需要时间,因此还要停止 HTTP server 接受新连接,并等待已有连接完成。
const server = app.listen(port);
function stopAcceptingRequests() {
return new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
}Keep-Alive 连接可能延长等待。运行时需要跟踪 socket,在软超时后通知关闭空闲连接,硬超时才强制销毁。
队列消费者先暂停领取
Worker 收到信号后停止领取新任务,当前任务根据语义选择完成或归还租约。不能先关闭 Redis,再尝试更新任务状态。
async function drainWorkers() {
await worker.pause(true);
await waitForActiveJobs({ timeoutMs: 20_000 });
}支付、发布这类有副作用任务必须有幂等和租约;即使硬超时杀进程,下一实例也能安全接管。优雅退出降低中断概率,不是替代任务可靠性。
资源关闭按依赖方向逆序
启动顺序通常是数据库/Redis → 仓储 → 服务 → HTTP/Worker,关闭反过来:入口 → 业务工作 → 日志/遥测 → Redis/数据库。业务还在运行时先断数据库,只会制造新的失败。
async function shutdown(signal: string) {
if (draining) return;
draining = true;
const hardStop = setTimeout(() => process.exit(1), 30_000).unref();
try {
await stopAcceptingRequests();
await drainWorkers();
await telemetry.flush();
await redis.quit();
await database.close();
clearTimeout(hardStop);
process.exit(0);
} catch (error) {
logger.error("shutdown_failed", { signal, error });
process.exit(1);
}
}时间预算必须小于平台终止窗口
| 阶段 | 预算示例 | 超时动作 |
|---|---|---|
| Endpoint 摘除传播 | 3 秒 | HTTP 拒绝新业务请求 |
| HTTP 在途请求 | 10 秒 | 关闭剩余 socket |
| 当前队列任务 | 15 秒 | 归还租约/允许重投 |
| 遥测刷新 | 2 秒 | 丢弃非关键批次 |
| 连接关闭 | 2 秒 | 强制退出 |
总预算不能超过 Kubernetes terminationGracePeriodSeconds。平台在 30 秒杀进程,应用内部设计 60 秒等待没有意义。
图 2:预算不是平均分配,而是先给“让外部世界停止投递”留足时间,业务收尾才真正开始。
每个阶段还有各自的降级动作,不是排队等待超时才一起放弃:Endpoint 摘除超时,HTTP 层直接拒绝新请求;HTTP 超时,剩余 socket 强制关闭;队列任务超时,归还租约允许其他 Worker 重投。逐段降级让最坏情况也只损失最远的一段工作,而不是全部等待被一个卡住的阶段耗尽。
用真实信号做集成测试
我们在测试环境持续发送请求和队列任务,向进程发送 SIGTERM,断言:readiness 先失败;新请求不再进入;已接收请求有明确结果;任务不丢且不会产生不可接受重复;退出码和耗时被记录。
上线后观察发布窗口的 502、任务 lease 过期、强制退出数和 shutdown 各阶段耗时。只有应用、负载均衡和编排平台的时间线能对齐,优雅退出才不是一段看起来完整的 finally。队列连接关闭时如果还持有任务状态,那就是另一类泄漏——Node.js 内存泄漏排查里记录的实例生命周期问题,和这里的关闭顺序是同一个原则的两面。