优雅关闭 Node 服务器
Node 的优雅关闭与 SIGTERM:切换 readiness,用 server.close 完成请求,按顺序关闭资源,避免发布时出现 502。
优雅的 Node 关闭流程会按固定顺序处理 SIGTERM:先让就绪检查(readiness check)失败,等待负载均衡器停止转发流量,用 server.close() 排空在途请求,按依赖顺序关闭资源,最后退出进程。
如果你已经做到了其中大部分,但每次发布仍然出现 502 和连接重置,那么问题往往并不在处理函数本身。通常是它周围的三件事造成的:信号根本没有传到 Node、排空过程永远无法完成,或者编排系统在排空完成前就杀掉了 Pod。本文将构建一个精简的处理函数,逐一解决这三个问题,并在最后给出一个可以直接运行的测试。
关键要点
- 没有 SIGTERM 处理函数时,Node 会立即退出,所有在途请求都会以连接重置或响应被截断收场。
- 在关闭数据库连接池之前先
await server.close();反过来先关闭连接池会让每个在途查询都变成错误。 - 从 Node 19.0.0 起,
server.close()会自行关闭空闲的 keep-alive 连接;过去那种“close 回调永不触发”的挂起问题已成为历史。 - Dockerfile 中使用 shell 形式的
CMD意味着接收 SIGTERM 的是 shell 而不是 Node,于是任何处理代码都不会被执行。 - 设置一个进程内的强制退出定时器,时间要小于
terminationGracePeriodSeconds,让进程自己决定如何退出,而不是被 SIGKILL 终结。
没有 SIGTERM 处理函数会发生什么?
根据信号事件文档,Node 对 SIGTERM 的默认响应是终止进程。那一刻所有在途的工作都会在响应中途被切断:客户端看到连接重置或不完整的响应体,负载均衡器则记录下一个 502。在会话回放(session replay)中,这类故障有一个很好辨认的特征:某个用户操作一直转圈然后报错,并且集中出现在一个很窄的时间窗口内——这个窗口对应的是一次发布,而不是用户做了什么。这种客户端视角通常正是这个 bug 最先被发现的地方。
正确的 Node 优雅关闭顺序
一个正确的处理函数会按顺序做五件事:翻转关闭标志位让就绪检查失败、短暂等待负载均衡器做出反应、await server.close() 以让在途请求完成、关闭其余资源、退出进程。其中对 close 的 await 才是关键。如果处理函数调用了 server.close() 却不等待其回调,就去清理资源并退出,那它恰好重现了自己本来要防止的那种崩溃。
- 翻转关闭标志位,让就绪探针返回 503。
- 短暂等待,让负载均衡器停止转发新流量。
await server.close(),让在途请求完成。- 按依赖顺序关闭其余资源。
- 退出进程。
// server.js (Express)
const { setTimeout: sleep } = require('node:timers/promises');
let shuttingDown = false;
app.get('/health/live', (req, res) => res.sendStatus(200));
app.get('/health/ready', (req, res) => res.sendStatus(shuttingDown ? 503 : 200));
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true; // readiness now returns 503
await sleep(LB_WAIT_MS); // matches your preStop sleep
await new Promise((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
await closeResources(); // next section
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
排空为什么会卡住?
在任何受支持的 Node 版本上,server.close() 都会停止接受新连接、关闭空闲的 keep-alive 套接字,并等待仍有在途请求的连接完成。那个被广泛传播的警告——空闲 keep-alive 套接字会导致 close 回调永远不触发——描述的是 Node 19.0.0 之前的行为,因此在受支持的版本上这种挂起已不会出现。如果你必须运行更老的运行时,请在发起 close 之后(而不是之前)立即调用 server.closeIdleConnections()(Node 18.2.0 引入),以避免与新建立的连接产生竞态。
如今仍会拖住排空的,是真正处于活跃状态的工作:慢处理函数、server-sent events,以及已升级到其他协议的套接字。这正是下文那个强制退出定时器存在的理由。
按依赖顺序关闭资源
先关闭 HTTP 服务器,然后是队列 worker 和后台任务,接着是 Redis,最后是数据库连接池。这个顺序遵循依赖链:请求处理函数和任务会使用 Redis 与连接池,因此在它们还在运行时就关闭连接池,会把每个在途查询都变成错误,这就白费了你刚刚 await 的那次排空。
async function closeResources() {
await worker.close(); // BullMQ or similar: finish the active job
await redis.quit();
await pool.end(); // pg pool last: nothing queries after this
}
运行时间超过宽限期的任务需要的是检查点(checkpointing)机制以便恢复,而不是排空。
哪些容器配置会破坏优雅关闭?
Node 必须是真正接收到信号的那个进程,否则上面所有内容都无从谈起。Dockerfile 参考文档明确指出,shell 形式会让你的命令在 /bin/sh -c 下运行,而它不会把信号传给子进程,所以使用 shell 形式的 CMD 意味着 docker stop 发出的 SIGTERM 永远到不了 Node。
# Broken: /bin/sh -c receives SIGTERM, node never does
CMD node server.js
# Correct: node is the signal target
CMD ["node", "server.js"]
如果你需要一个 init 进程来回收僵尸进程,tini 会把信号转发给子进程,因此处理函数依然会执行。注意在 Linux 上,docker stop 默认在 10 秒后升级为 SIGKILL,可通过 -t 配置。
在 Kubernetes 中,把 terminationGracePeriodSeconds(默认 30)设置为大于你的总排空时间,并添加一个 preStop sleep:端点移除是与 SIGTERM 并行进行的,并通过 EndpointSlices 异步传播,所以信号发出后的短暂时间内请求仍会继续到达。要让存活探针(liveness probe)在就绪探针失败期间保持通过;如果存活探针接到了同一个正在失败的端点,容器就会在排空中途被重启。
terminationGracePeriodSeconds: 30 # > preStop + LB wait + drain + cleanup
lifecycle:
preStop:
exec:
command: ["sleep", "5"] # LB_WAIT_MS should match
在 SIGKILL 之前自己决定如何退出
在处理函数最开头启动一个强制退出定时器,其时间设置为小于编排系统的宽限期,这样即使排空卡住,也会以你自己的日志行和退出码收尾,而不是被 SIGKILL 终结。作为最后手段,它会调用 server.closeAllConnections()(Node 18.2.0 引入),它会拆掉所有打开的连接,包括仍在处理请求的连接。已切换到其他协议的连接不受影响,因此 WebSocket 需要自行广播关闭帧(close frame)。
const forceExit = setTimeout(() => {
server.closeAllConnections();
process.exit(1);
}, GRACE_MS - 2_000); // grace period minus a buffer, never a fixed default
forceExit.unref();
如何证明关闭流程真的有效?
验收测试很机械化:启动服务器,向一个刻意设置得很慢的路由发起请求,在请求进行到一半时发送 SIGTERM,然后断言响应仍以 200 返回、进程以 0 退出。
// verify-shutdown.js
const { spawn } = require('node:child_process');
const assert = require('node:assert');
const child = spawn('node', ['server.js'], { stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.on('data', async (chunk) => {
if (!chunk.toString().includes('listening')) return;
const pending = fetch('http://localhost:3000/slow'); // route awaits ~2s
setTimeout(() => child.kill('SIGTERM'), 100);
const res = await pending;
assert.equal(res.status, 200);
const code = await new Promise((r) => child.on('exit', r));
assert.equal(code, 0);
console.log('graceful shutdown verified');
});
如果这个测试在本地通过,但发布时仍然丢请求,剩下的嫌疑就在容器层:shell 形式的 CMD,或者宽限期小于你的排空预算。
处理函数本身只有四十行;可靠性来自于顺序安排以及它周围的环境。把这个测试接入 CI,让下一次重构无法悄无声息地重新引入你刚修好的“发布即重置”问题。
常见问题
Node.js 进程能捕获或处理 SIGKILL 吗?
不能。Node 拒绝为 SIGKILL 注册监听器,而且无论你的代码怎么写,这个信号在所有平台上都会直接结束进程,因此不会执行任何清理。SIGSTOP 同样无法被监听。这正是进程内强制退出定时器重要的原因:它必须在编排系统的宽限期到期之前触发,让进程在收到 SIGTERM 时完成排空并退出,而不是在毫无反应机会的情况下被杀掉。
closeIdleConnections 和 closeAllConnections 有什么区别?
这两个方法都在 Node 18.2.0 中引入。closeIdleConnections 只会关闭在请求之间处于空闲状态的套接字,因此任何正在处理请求的连接都会被留到完成。closeAllConnections 则更粗暴:它会拆掉所有打开的连接,包括仍在处理请求的连接,不过已切换到其他协议(如 WebSocket)的连接不受影响。从 Node 19.0.0 起,server.close 会自行清理空闲连接,所以只有在你仍需支持更老的运行时时,才值得调用 closeIdleConnections。
process.exit 会等待在途请求或待处理的异步工作吗?
不会。process.exit 会立即关闭进程,并丢弃仍在排队的所有异步工作,甚至包括尚未写完到 stdout 或 stderr 的输出。这就是为什么关闭处理函数要在调用 exit 之前先 await server.close 和资源清理;提前调用 exit 就等于重现了这个处理函数本要防止的丢请求崩溃。在处理函数之外,更推荐设置 process.exitCode 并让进程自然退出。
SIGTERM 处理函数在 Windows 上有效吗?
并非以相同方式生效。Windows 没有 POSIX 信号,Node.js 文档将 SIGTERM 列为在该平台上不受支持,尽管你的代码仍然可以为它注册监听器。Ctrl+C 在所有平台上都会触发 SIGINT,这也是处理函数同时监听 SIGINT 以便本地开发的原因。请在 Linux 容器内测试 SIGTERM 排空路径,因为 docker stop 和 Kubernetes 只有在那里才会真正投递该信号。