一次正常发布后,我们看到短暂 502,同时有两条异步任务被重复执行。容器收到 SIGTERM 后直接退出,负载均衡还在把请求送过来,队列里的任务租约也没来得及归还。代码没有抛异常,问题出在进程结束的顺序。

优雅退出不是一个 process.on('SIGTERM') 日志,而是入口、在途工作和资源连接之间的协议。

Node.js 服务从停止接流量、耗尽在途工作到释放连接的退出架构

图 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 内存泄漏排查里记录的实例生命周期问题,和这里的关闭顺序是同一个原则的两面。