前端知库

Node.js 服务如何实现优雅退出?

从容器滚动发布和进程信号出发,解释 Node.js 优雅退出、连接排空、请求超时、重复信号和强制终止的实现边界。

参考答案

简答

  • 收到 SIGTERM 后先停止接收新请求,再等待已有请求完成。
  • server.close() 停止接受新连接(Node.js 19 起也会关闭空闲连接),但不会中断正在处理的请求,所以每个请求都要有超时。
  • 设置一个最终截止时间,超时后强制退出。
  • 关闭开始时把 readiness 检查改为失败,让负载均衡器尽快摘除实例。

在容器平台滚动发布 Node.js 服务时,旧进程收到 SIGTERM 后如果立刻退出,正在上传文件、写入订单或等待数据库响应的请求会被中断。新的请求又可能在负载均衡器还没完成摘除前继续进入旧实例。优雅退出的目标是:停止接收新流量,给已有请求一个有限的完成时间,完成必要清理后再退出;超时或清理失败时仍要有强制终止边界。

process.exit() 为什么会截断请求

process.exit() 会立即结束进程,事件循环里尚未完成的异步操作、响应写入和日志刷新都可能来不及执行。设置 process.exitCode 则不同:它只指定进程在事件循环自然清空后的退出码。因此收到终止信号后,不能先调用 process.exit() 再期待 finally 或 HTTP 响应完成。

退出流程一般分为四步:

  1. 标记实例正在关闭,让健康检查返回失败或不再接收流量。
  2. 停止接收新连接,并等待已有请求结束。
  3. 关闭数据库连接、消息消费者和定时任务等资源。
  4. 达到截止时间仍未完成时,记录原因并强制退出。

HTTP 服务器需要“停止接新请求”和“排空旧请求”

server.close() 会停止接受新的连接,并在连接全部关闭后回调。它不会替你取消一个卡住的请求,也不会自动关闭其他资源。Node.js 版本和 HTTP 模块不同,空闲 keep-alive 连接还可能需要显式处理,因此不能只调用一个方法就认为服务已经排空。

JavaScript
import http from 'node:http';

let shuttingDown = false;
let shutdownPromise;

const server = http.createServer(async (request, response) => {
  try {
    await new Promise((resolve) => setTimeout(resolve, 20));
    if (shuttingDown) response.setHeader('Connection', 'close');
    response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
    response.end('ok');
  } catch (error) {
    if (!response.headersSent) response.writeHead(500);
    response.end('error');
  }
});

server.listen(3000);

function shutdown(signal) {
  if (shutdownPromise) return shutdownPromise;
  shuttingDown = true;

  shutdownPromise = new Promise((resolve) => {
    const forceTimer = setTimeout(() => {
      console.error('优雅退出超时,强制结束进程');
      // 最终截止阶段允许中断请求;exitCode 本身不能结束活跃连接。
      server.closeAllConnections();
      process.exit(1);
    }, 10_000);
    forceTimer.unref();

    server.close((error) => {
      clearTimeout(forceTimer);
      if (error) {
        console.error('关闭 HTTP 服务失败', error);
        process.exitCode = 1;
      }
      resolve();
    });
  });

  return shutdownPromise;
}

for (const signal of ['SIGTERM', 'SIGINT']) {
  process.on(signal, () => {
    void shutdown(signal).then(() => {
      process.exitCode ??= 0;
    });
  });
}

示例面向 Node.js 22 的普通 HTTP/1 服务,用 server.close() 作为排空入口,并用同一个 Promise 防止重复信号启动多套清理流程。正常结束设置退出码,让事件循环自然清空;超过最终截止时间才使用 process.exit(1),此时明确接受未完成响应和日志可能被截断。定时器 unref() 让它本身不会阻止正常退出,但活跃连接仍会让进程存活并触发截止处理。

Node.js 19 起,HTTP server.close() 会关闭空闲连接;closeAllConnections() 自 18.2 提供,且不关闭已经升级为 WebSocket 的连接。示例不实现 WebSocket、数据库或队列资源清理。加入这些资源后,应把全局截止计时器保留到所有清理结束,而不是在 HTTP 排空后就清除。Promise.allSettled 可以收集清理结果,但不能让永不结束的 Promise 自动超时。

健康检查和负载均衡器必须配合

进程收到 SIGTERM 后应立即把 readiness 检查改为失败,例如返回 503;否则负载均衡器可能继续把新请求发给正在关闭的实例。readiness 表示“现在是否适合接收流量”,liveness 表示“进程是否还活着”,两者不能共用一个永远返回 200 的接口。

健康检查失败到负载均衡器真正摘除之间存在传播延迟。优雅退出截止时间应大于这段延迟,并预留已有请求的完成时间。若平台支持 preStop 或 termination grace period,也要把它和 Node.js 内部的强制截止时间一起设计,避免平台先杀进程导致应用层没有机会清理。

请求超时是优雅退出的必要条件

如果一个请求没有超时,客户端断开或上游服务失联后,Node 进程可能永远等不到它结束。应根据业务设置请求头、请求体和空闲连接的超时,并在超时时取消数据库查询或下游 fetch:

JavaScript
async function loadData() {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 5_000);

  try {
    const response = await fetch('https://api.example.test/data', {
      signal: controller.signal,
    });
    if (!response.ok) throw new Error(`下游返回 HTTP ${response.status}`);
    return await response.json();
  } finally {
    clearTimeout(timer);
  }
}

超时不能只在 Node 进程层设置。下游请求、数据库连接池、消息确认和文件流都要有自己的取消或截止策略,否则最外层的优雅退出仍会被内部任务拖住。取消操作也应区分“客户端主动取消”和“服务端关闭”,避免把正常排空误报成业务错误。

keep-alive、WebSocket 和长连接要单独处理

普通 HTTP 请求完成后,keep-alive TCP 连接可能仍然空闲。关闭服务器时应按当前 Node.js 版本文档选择合适的连接关闭方法,并在截止时间到达时销毁仍未结束的连接。WebSocket、SSE 和长轮询不会自然快速结束,应用需要向客户端发送关闭或重连信号,或者把它们纳入更短的单独截止时间。

不能把 server.closeAllConnections() 当作默认方案:它会中断正在传输的请求,适合强制截止阶段,而不是正常排空阶段。使用前要确认 Node.js 版本支持,并记录被强制中断的连接数量。

消息消费者要先停止拉取,再处理已取出的消息

服务包含队列消费者时,退出顺序通常是先停止新的拉取,再等待已经取出的消息完成或回滚确认。直接杀进程可能导致消息重复投递;因此处理函数必须具备幂等性,确认消息的时机要放在业务写入成功之后。优雅退出也不能无限等待一条外部服务永远不返回的消息,应使用任务级超时和可重试的失败策略。

常见误区和选型

  • 只监听 SIGTERM:本地 Ctrl+C 通常是 SIGINT,两者都应按相同策略处理。
  • 关闭监听端口后立即退出:已有请求和数据库事务仍可能未完成。
  • 清理函数没有上限:任何网络依赖都可能卡住,必须有全局截止时间。
  • 多次信号启动多个清理流程:用 Promise 锁定第一次退出流程,后续信号可以记录并加速强制阶段。
  • 把优雅退出当成数据一致性保证:它只能减少中断,不能代替事务、幂等、重试和恢复机制。

短请求的无状态 HTTP 服务通常采用“拒绝新流量、等待请求完成、超时强制结束”的方案;含长连接、队列或复杂事务的服务应为各类资源分别设计关闭协议,并通过滚动发布演练验证实际时间,而不是只依赖本地单元测试。

参考资料