Shutdown Check

搜索文档

查找页面或章节

停止接收新流量,完成进行中的请求,关闭资源,并在截止时间前退出。

Node.js 的优雅关闭(graceful shutdown)从进程收到 SIGTERM 开始。服务不再接收新工作,让进行中的请求处理完,关闭共享资源,然后在平台的截止时间之前以退出码 0 退出。

没有 SIGTERM 处理函数会怎样?

在 macOS 和 Linux 上,Node.js 会按信号的默认行为直接终止。未完成的请求可能因此断开连接,代理可能把这种断开转成 502 或其他上游错误。

shutdown-check 会把被中断的请求报告为 SC201:

FAIL SC201: In-flight request was interrupted: aborted

一旦注册了信号监听器,进程就不会再按默认行为立即退出。从这时起,就要由你的代码完成关闭流程,并让进程能够退出。

如何在 Node.js 中处理 SIGTERM?

可靠的信号处理函数按以下顺序执行:

  1. 把实例标记为未就绪。
  2. 拒绝仍然到达的新工作。
  3. 停止接受新连接。
  4. 等待进行中的 HTTP 请求完成。
  5. 关闭数据库、队列、worker 和定时器。
  6. 只有清理失败时才设置表示失败的退出码。
  7. 等所有句柄(handle)都释放后,让 Node.js 自行退出。

第 3–5 步的具体顺序取决于你的应用。只要还有进行中的请求处理函数要用数据库连接池,就不要关闭它。

node:http 示例

server.js
const http = require("node:http");
 
const port = Number(process.env.PORT ?? 3000);
let draining = false;
 
const server = http.createServer((request, response) => {
  if (request.url === "/health") {
    response.writeHead(draining ? 503 : 200, {
      "content-type": "text/plain",
    });
    response.end(draining ? "draining" : "ok");
    return;
  }
 
  if (draining) {
    response.writeHead(503, {
      connection: "close",
      "retry-after": "1",
    });
    response.end("shutting down");
    return;
  }
 
  if (request.url === "/slow") {
    response.writeHead(200, { "content-type": "text/plain" });
    response.flushHeaders();
    setTimeout(() => response.end("work complete\n"), 2000);
    return;
  }
 
  response.writeHead(404).end();
});
 
server.listen(port, "127.0.0.1");
 
process.on("SIGTERM", () => {
  if (draining) return;
  draining = true;
 
  server.close((error) => {
    if (error) {
      console.error("HTTP shutdown failed", error);
      process.exitCode = 1;
    }
  });
});

有了 draining 这个判断,重复收到 SIGTERM 也不会出问题。标志改变之前进入的请求,会在原来的处理函数里继续执行。在监听器关闭之前,新请求都会收到 503。

server.close() 会停止接受新连接,并等待进行中的请求完成。服务器关闭后才会执行它的回调,但这时其他资源仍可能让事件循环保持运行。

Express 优雅关闭

Express 底层用的是 Node.js 的 HTTP 服务器。保存 app.listen() 返回的对象,然后在信号处理函数里关闭它。

server.js
const express = require("express");
 
const app = express();
const port = Number(process.env.PORT ?? 3000);
let draining = false;
 
app.get("/health", (_request, response) => {
  response.status(draining ? 503 : 200).send(draining ? "draining" : "ok");
});
 
app.use((_request, response, next) => {
  if (!draining) return next();
  response.set("connection", "close");
  response.set("retry-after", "1");
  response.status(503).send("shutting down");
});
 
app.get("/slow", async (_request, response) => {
  response.status(200);
  response.flushHeaders();
  await new Promise((resolve) => setTimeout(resolve, 2000));
  response.end("work complete\n");
});
 
const server = app.listen(port, "127.0.0.1");
 
process.on("SIGTERM", () => {
  if (draining) return;
  draining = true;
 
  server.close((error) => {
    if (error) {
      console.error(error);
      process.exitCode = 1;
    }
  });
});

排空用的中间件要放在需要拒绝新工作的业务路由之前,但要放在就绪检查(readiness)路由之后,这样就绪检查路由才能返回明确的就绪状态。

Fastify 优雅关闭

Fastify 提供了 fastify.close(),它会停止接受请求,并执行已注册的 onClose 钩子。

server.js
const Fastify = require("fastify");
 
const fastify = Fastify();
const port = Number(process.env.PORT ?? 3000);
let draining = false;
 
fastify.get("/health", async (_request, reply) => {
  reply.code(draining ? 503 : 200);
  return draining ? "draining" : "ok";
});
 
fastify.get("/slow", async (_request, reply) => {
  reply.raw.writeHead(200, { "content-type": "text/plain" });
  reply.raw.flushHeaders();
  await new Promise((resolve) => setTimeout(resolve, 2000));
  reply.raw.end("work complete\n");
});
 
await fastify.listen({ port, host: "127.0.0.1" });
 
process.on("SIGTERM", async () => {
  if (draining) return;
  draining = true;
 
  try {
    await fastify.close();
  } catch (error) {
    fastify.log.error(error);
    process.exitCode = 1;
  }
});

数据库和队列的清理,可以注册到 Fastify 钩子里,也可以等 HTTP 工作排空之后再 await 执行。

关闭应用资源

HTTP 服务器关闭了,Node.js 进程仍可能继续运行。等进行中的请求不再需要以下资源后,关闭它们:

  • 数据库和缓存连接池
  • 消息的消费者和生产者
  • worker 和子进程
  • 定时执行的 interval
  • 文件监听器
  • 遥测数据导出器
  • 长期存活的客户端

依赖其他系统的清理操作要设置超时。如果没有超时,服务可能一直运行到平台发送 SIGKILL,shutdown-check 会把这种情况报告为 SC300。

处理 keep-alive 连接

server.close() 会停止接受新连接,并等待进行中的请求完成。较新的 Node.js 版本还会关闭空闲的 keep-alive 连接。如果你的技术栈自己跟踪 socket,不要在收到 SIGTERM 时销毁所有 socket:活跃的 socket 上承载的,正是你想保住的请求。

如果你要主动关闭空闲连接,应在监听器开始关闭之后再做,并且不要动活跃的连接。

谨慎添加强制退出超时

生产环境的服务通常需要一个最终超时,防止某个出错的清理步骤一直运行下去。这个超时必须长于正常的排空时间,同时短于平台强制终止进程的截止时间。

const forceExit = setTimeout(() => {
  console.error("shutdown deadline exceeded");
  process.exit(1);
}, 25_000);
 
forceExit.unref();

调用过 unref 的定时器,不会让一个本已完成的进程继续存活。但强制退出仍可能打断正在进行的工作,所以只能把它当作最后的手段,并监控它何时触发。

常见错误

错误用户看到的现象shutdown-check 结果
没有信号处理函数部署期间请求被重置SC201 或 SC301
在处理函数里调用 process.exit()进行中的工作被打断SC201
请求还没完成,数据库就关闭了处理函数报错或返回错误的响应SC202 或 SC203
服务器已关闭,但还有其他句柄没有释放进程一直运行到平台的截止时间SC300
包装进程不转发信号父进程退出,子进程里的服务仍存活SC302
就绪检查一直返回 200新流量继续进来SC310
仍然接受新工作晚到的请求可能被中断SC311
收到第二个信号时强制退出已有的请求被中断SC114 或 SC201

验证信号处理函数

使用一份检查完整关闭行为的配置:

shutdown-check.json
{
  "command": ["node", "server.js"],
  "baseUrl": "http://127.0.0.1:3000",
  "readiness": { "path": "/health" },
  "workload": {
    "path": "/slow",
    "bodyIncludes": "work complete",
    "started": { "type": "response-headers" }
  },
  "shutdown": {
    "deadlineMs": 10000,
    "readinessWithdrawal": true,
    "newRequests": { "path": "/slow", "rejectStatuses": [503] },
    "repeatSignalAfterMs": 500
  }
}

运行:

npx shutdown-check test

通过的时间线应当依次显示:发送信号前已有进行中的工作;发送信号后撤回就绪状态、拒绝新请求;请求完成;进程干净退出。