process 事件、Signal 与优雅退出

本章解决的问题

工作原理

Signal 是操作系统通知进程控制事件的一种机制。服务最常处理:

SIGINT   终端 Ctrl+C 常见
SIGTERM  systemd、PM2、Docker、Kubernetes 请求优雅终止时常见
SIGKILL  立即终止,应用无法捕获、阻止或清理

“收到终止信号”不等于“立即调用 process.exit()”。优雅停机是有上限、可重复调用的状态转换:

RUNNING
  → 收到 SIGTERM/SIGINT
SHUTTING_DOWN
  → 停接新请求
  → 等待在途请求和子进程
  → 关闭数据库、Redis、日志
EXITING

安装 SIGINT/SIGTERM 监听器后,不能再依赖原来的默认退出行为;处理器必须让资源最终关闭,使 Event Loop 清空,或在兜底条件下明确退出。

高频事件与准确边界

beforeExit

process.on("beforeExit", (code: number) => {
  console.log({ code });
});

Event Loop 即将没有工作时触发。监听器安排新的异步工作可使进程继续运行,之后还可能再次触发。显式 process.exit() 或某些致命终止路径不会触发它,因此不能作为通用关机钩子。

exit

process.on("exit", (code: number) => {
  // 此处只能同步收尾。
  console.error(`进程退出:${code}`);
});

此时 Event Loop 已不能继续运行。定时器、Promise、网络请求和异步关闭都不会被等待:

process.on("exit", () => {
  setTimeout(() => {
    // 不会执行。
  }, 0);
});

warning

process.on("warning", (warning: Error) => {
  process.stderr.write(`${warning.name}: ${warning.message}\n`);
});

可记录 Node.js 警告,如监听器过多。记录不等于解决根因,也不要因普通 warning 自动终止生产服务。

uncaughtExceptionunhandledRejection

它们是最后诊断边界,不是普通业务错误处理机制:

process.on("uncaughtExceptionMonitor", (error: Error, origin: string) => {
  process.stderr.write(`致命异常(${origin}):${error.stack ?? error.message}\n`);
});

uncaughtExceptionMonitor 可同步诊断且不改变默认崩溃行为。未捕获异常后,应用状态可能已不可靠,不应记录后继续提供流量。Node.js 22 默认设置下,未处理 Promise 拒绝通常会升级为未捕获异常;启动参数可改变行为,所以业务 Promise 仍应在边界显式 .catch()

若注册 uncaughtException,也只应做最小同步清理并尽快由进程管理器重启,不能把它当成“防崩溃”。

具体代码:HTTP 服务优雅停机

以下用 node:http 展示机制;Express app.listen() 返回的也是 http.Server,关闭思路相同。

import {
  createServer,
  type IncomingMessage,
  type Server,
  type ServerResponse,
} from "node:http";

const port: number = 8080;
const shutdownGraceMs: number = 10_000;

let shuttingDown: boolean = false;
let activeRequests: number = 0;

const server: Server = createServer(
  (request: IncomingMessage, response: ServerResponse) => {
    if (shuttingDown) {
      response.setHeader("Connection", "close");
      response.writeHead(503, { "Content-Type": "application/json" });
      response.end('{"error":"server_shutting_down"}');
      return;
    }

    activeRequests += 1;
    let counted: boolean = true;

    const completeRequest = (): void => {
      if (!counted) {
        return;
      }

      counted = false;
      activeRequests -= 1;
    };

    // finish 表示响应正常交给底层;close 也覆盖客户端中断。
    response.once("finish", completeRequest);
    response.once("close", completeRequest);

    response.writeHead(200, { "Content-Type": "application/json" });
    response.end(JSON.stringify({ pid: process.pid }));
  },
);

server.on("error", (error: NodeJS.ErrnoException) => {
  process.stderr.write(`Server 错误:${error.message}\n`);
  process.exitCode = 1;
});

server.listen(port, () => {
  process.stdout.write(`Server pid=${process.pid} listening=${port}\n`);
});

async function shutdown(signal: NodeJS.Signals): Promise<void> {
  if (shuttingDown) {
    process.stderr.write(`忽略重复的 ${signal}\n`);
    return;
  }

  shuttingDown = true;
  process.stderr.write(
    `收到 ${signal},开始停机,activeRequests=${activeRequests}\n`,
  );

  // 强制退出会截断异步输出,只作为最后手段。
  const forceExitTimer: NodeJS.Timeout = setTimeout(() => {
    process.stderr.write("优雅停机超时,强制退出\n");
    process.exit(1);
  }, shutdownGraceMs);
  forceExitTimer.unref();

  try {
    await new Promise<void>((resolve, reject) => {
      server.close((error?: Error) => {
        if (error) {
          reject(error);
          return;
        }

        resolve();
      });
    });

    // 此处继续 await:停领任务、等待或取消子进程、关闭 DB/Redis。
    process.exitCode = 0;
    process.stderr.write("资源已关闭,等待自然退出\n");
  } catch (error: unknown) {
    const message: string =
      error instanceof Error ? error.message : "未知关闭错误";
    process.stderr.write(`关闭失败:${message}\n`);
    process.exitCode = 1;
  } finally {
    clearTimeout(forceExitTimer);
  }
}

function requestShutdown(signal: NodeJS.Signals): void {
  void shutdown(signal).catch((error: unknown) => {
    const message: string =
      error instanceof Error ? error.message : "未知停机错误";
    process.stderr.write(`停机流程异常:${message}\n`);
    process.exitCode = 1;
  });
}

process.once("SIGINT", () => requestShutdown("SIGINT"));
process.once("SIGTERM", () => requestShutdown("SIGTERM"));

server.close() 停止接受新连接并等待连接收尾,但任意长请求仍可能卡住,因此需要总期限。Node.js 22 中还可按策略使用 closeIdleConnections(),或在宽限期最后使用 closeAllConnections();后者会丢请求,只能最后使用。

推荐关闭顺序

1. readiness 标记失败,让负载均衡器摘流
2. server.close() 停止新连接
3. 停止领取后台任务
4. 等待或取消请求与 child_process
5. 关闭数据库、Redis、队列、日志传输
6. 设置 exitCode,等待自然退出
7. 超过总期限才强制退出

正常时间线

SIGTERM → shutdown 首次进入 → shuttingDown=true
→ server.close → 在途请求完成 → 其他资源关闭
→ exitCode=0 → Event Loop 清空 → exit 0

异常时间线

关闭失败:

SIGTERM → 数据库 close 拒绝 → 记录 stderr → exitCode=1
→ 继续必要收尾 → Event Loop 清空 → exit 1

关闭卡死:

SIGTERM → 长请求一直不结束 → 达到 grace period
→ 记录诊断 → process.exit(1) → 剩余异步工作可能被截断

重复信号:

第一次 SIGTERM 启动关闭 → 第二次通知到来
→ 幂等保护忽略或只记录 → 不重复关闭资源

示例用 once 使同一种信号只触发一次,但不同信号仍可能依次触发,因此 shuttingDown 幂等保护仍必要。

Cluster 下的职责

每个 Cluster Worker 都是独立进程,各自关闭自己的 HTTP Server、连接和子进程。Primary 负责停止派生 Worker、通知 Worker 退出、等待并设置整体期限。

Primary:协调 Worker 生命周期,停机期间不再重启退出的 Worker
Worker:停接自身请求,清理自身连接与任务

每个 Worker 各注册一次监听器,并不是同一 EventEmitter 上重复监听;真正风险是外部副作用发生多次。同一进程的初始化函数被多次调用,才可能形成重复监听和 MaxListenersExceededWarning。生命周期模块应只初始化一次。

Windows 与 Linux

常见错误

  1. exit 事件中异步关闭数据库。
  2. 收到 SIGTERM 后立刻 process.exit(0),丢弃在途请求。
  3. 注册 Signal 监听器后不关闭 Server,进程永远不退出。
  4. 多个模块分别监听 SIGTERM,重复关闭同一资源。
  5. uncaughtException 当恢复点,捕获后继续服务。
  6. 没有总期限,部署永远卡住;或期限短于正常最长请求。
  7. Primary 停机时仍自动重启退出的 Cluster Worker。

最佳实践

练习题

  1. 启动示例后按 Ctrl+C,记录事件时间线和退出码。
  2. 添加一个 5 秒请求,请求执行中发送 SIGTERM,验证它是否完成。
  3. 模拟数据库关闭失败,确认退出码为 1。
  4. 让 shutdown 被调用两次,验证资源只关闭一次。
  5. 比较 process.exit(1) 与只设置 exitCode=1 对异步日志的影响。

验收点