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?
可靠的信号处理函数按以下顺序执行:
- 把实例标记为未就绪。
- 拒绝仍然到达的新工作。
- 停止接受新连接。
- 等待进行中的 HTTP 请求完成。
- 关闭数据库、队列、worker 和定时器。
- 只有清理失败时才设置表示失败的退出码。
- 等所有句柄(handle)都释放后,让 Node.js 自行退出。
第 3–5 步的具体顺序取决于你的应用。只要还有进行中的请求处理函数要用数据库连接池,就不要关闭它。
node:http 示例
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() 返回的对象,然后在信号处理函数里关闭它。
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 钩子。
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 |
验证信号处理函数
使用一份检查完整关闭行为的配置:
{
"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通过的时间线应当依次显示:发送信号前已有进行中的工作;发送信号后撤回就绪状态、拒绝新请求;请求完成;进程干净退出。