Node.js 服务如何实现优雅退出?
从容器滚动发布和进程信号出发,解释 Node.js 优雅退出、连接排空、请求超时、重复信号和强制终止的实现边界。
简答
- 收到 SIGTERM 后先停止接收新请求,再等待已有请求完成。
- server.close() 停止接受新连接(Node.js 19 起也会关闭空闲连接),但不会中断正在处理的请求,所以每个请求都要有超时。
- 设置一个最终截止时间,超时后强制退出。
- 关闭开始时把 readiness 检查改为失败,让负载均衡器尽快摘除实例。
在容器平台滚动发布 Node.js 服务时,旧进程收到 SIGTERM 后如果立刻退出,正在上传文件、写入订单或等待数据库响应的请求会被中断。新的请求又可能在负载均衡器还没完成摘除前继续进入旧实例。优雅退出的目标是:停止接收新流量,给已有请求一个有限的完成时间,完成必要清理后再退出;超时或清理失败时仍要有强制终止边界。
process.exit() 为什么会截断请求
process.exit() 会立即结束进程,事件循环里尚未完成的异步操作、响应写入和日志刷新都可能来不及执行。设置 process.exitCode 则不同:它只指定进程在事件循环自然清空后的退出码。因此收到终止信号后,不能先调用 process.exit() 再期待 finally 或 HTTP 响应完成。
退出流程一般分为四步:
- 标记实例正在关闭,让健康检查返回失败或不再接收流量。
- 停止接收新连接,并等待已有请求结束。
- 关闭数据库连接、消息消费者和定时任务等资源。
- 达到截止时间仍未完成时,记录原因并强制退出。
HTTP 服务器需要“停止接新请求”和“排空旧请求”
server.close() 会停止接受新的连接,并在连接全部关闭后回调。它不会替你取消一个卡住的请求,也不会自动关闭其他资源。Node.js 版本和 HTTP 模块不同,空闲 keep-alive 连接还可能需要显式处理,因此不能只调用一个方法就认为服务已经排空。
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:
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 服务通常采用“拒绝新流量、等待请求完成、超时强制结束”的方案;含长连接、队列或复杂事务的服务应为各类资源分别设计关闭协议,并通过滚动发布演练验证实际时间,而不是只依赖本地单元测试。