什么是优雅退出?
想象一下:你正在处理一个用户的支付请求,突然执行了 kill -9 强制关闭服务,会发生什么?
对用户的影响:
- 正在处理的请求直接中断,用户看到 500 错误或连接重置;
- 新请求打过来时,服务还没重启,直接返回
connection refused; - 用户体验极差,甚至会投诉、流失
对应用服务的影响:
- 数据库事务可能只执行了一半,留下脏数据;
- 缓存、文件句柄等资源没来得及释放,造成内存泄漏;
- 负载均衡器无法平滑切换流量,导致短暂服务不可用;
怎么理解"缓存、文件句柄等资源没来得及释放,造成内存泄漏"呢?
Node.js 进程运行时,操作系统分配的 "可使用对象",比如文件句柄、数据库连接、缓存连接(Redis)、网络端口等;
这些资源被进程占用后,本应在使用完毕进程退出时释放,但因为代码逻辑问题(比如没主动关闭),操作系统无法回收这些资源。
比如:文件句柄未释放。
文件句柄是操作系统给 "打开的文件 / 网络连接" 分配的唯一标识,每个进程能打开的 FD 数量是有限的,比如 Linux 默认 1024。
js
// 错误示例:只打开文件,从不关闭
const fs = require('fs');
// 每次请求都打开文件,但不关闭 FD
app.get('/read-log', (req, res) => {
// 打开文件 → 占用 1 个 FD
const file = fs.createReadStream('./app.log');
file.pipe(res);
// 问题:即使响应结束,FD 可能没被及时回收(比如 pipe 异常)
});
如果此时用 kill -9 粗暴退出服务:
- 进程直接被杀死,没机会执行
file.close(); - 这些未关闭的 FD 会被操作系统标记为 "僵尸 FD",暂时无法复用;
- 若频繁粗暴重启,FD 会被快速耗尽,后续服务启动时会报错
EMFILE: too many open files(打开的文件太多),连日志文件都读不了。
js
// 监听退出信号,主动关闭所有打开的文件句柄
let openFiles = []; // 维护所有打开的文件句柄
app.get('/read-log', (req, res) => {
const file = fs.createReadStream('./app.log');
openFiles.push(file); // 记录句柄
file.pipe(res);
// 响应结束后移除句柄
res.on('finish', () => {
openFiles = openFiles.filter(f => f !== file);
file.destroy(); // 主动关闭
});
});
// 优雅退出时清理所有 FD
process.on('SIGTERM', () => {
openFiles.forEach(file => file.destroy()); // 关闭所有打开的文件
server.close(); // 停止接收新请求
process.exit(0);
});
按道理来说,当进程被杀死后,操作系统最终会回收文件句柄,但这个资源回收机制不是即时的:
- 第一步:操作系统会标记该进程的所有资源为 "僵尸资源",暂时禁止其他进程使用;
- 第二步:等待内核的资源回收调度周期(通常几秒到几十秒),才会彻底清理这些资源;
因此,后端服务也要像前端页面不能在用户提交表单时突然刷新一样,也需要 "体面地退场"。
优雅退出,就是让服务在收到退出信号时:
- 先拒绝新请求:不再接收新的 HTTP 连接;
- 再处理完旧请求:等正在执行的业务逻辑全部结束;
- 最后清理资源:关闭数据库连接、释放文件句柄等;
- 再安全退出进程
核心目标是:
- 用户体验:避免用户看到莫名其妙的错误,给用户友好提示;
- 数据安全:防止业务逻辑中断导致脏数据或状态不一致;
- 发布平滑:配合负载均衡,实现无感知的版本更新或服务重启;
Node.js 服务是怎么 "被通知退出" 的?
在类 Unix 系统(比如 Linux、macOS)里,进程是通过 信号(Signal) 来接收退出通知的。你可以把信号理解成系统发给进程的 "短信",告诉它 "该下班了"。

重点:只有
SIGTERM、SIGINT这类信号才能被我们捕获,实现优雅退出;SIGKILL是 "绝杀",任何程序都躲不过,所以永远不要用kill -9去关生产服务!
如何实现 Node.js 优雅退出?
js
const http = require('http')
const server = http.createServer((req, res) => {
if (isShuttingDown) {
// 如果正在关闭,返回 503 告诉客户端稍后重试
res.writeHead(503, { 'Content-Type': 'text/plain' })
res.end('Service is shutting down, please try again later')
return
}
setTimeout(() => {
res.end('Hello, World!')
}, 6000)
})
let isShuttingDown = false
function shutdown(server) {
if (isShuttingDown) return
isShuttingDown = true
server.close((err) => {
if (err) {
console.error('Error closing server:', err)
process.exit(1)
}
console.log(
'No more new requests, waiting for existing requests to finish...',
)
setTimeout(() => {
console.log('All requests finished, server exited gracefully')
process.exit(0)
}, 6000)
})
}
process.on('SIGTERM', () => shutdown(server))
process.on('SIGINT', () => shutdown(server))
server.listen(3001, () => {
console.log('process id is:', process.pid)
console.log('Server running on port 3001')
})
当你启动这个文件,访问localhost:3001后,立即执行kill process.pid就会杀死这个进程,就会触发SIGTERM,执行server.close()停止接受新的请求,但是并不触发里面的回调函数。但是,再次访问的时候,就会触发回调函数。
这是为什么呢?
这是 Node.js net.Server(HTTP 服务基于它)的设计逻辑:
-
server.close()调用后,服务会进入关闭阶段,但会维护一个connections计数器(记录已建立的客户端连接数); -
只有当
connections计数器变为 0 时,才会触发close回调; -
如果你 kill 进程时,HTTP/1.1 默认开启
Keep-Alive(长连接),即使请求响应完成,连接也会保持几秒,这也是connections计数器不为 0 的常见原因; -
当你 "再次访问" 时,会建立使用上面的那个连接(
connections=1),但因为服务已进入关闭阶段,这个连接会被立即拒绝并断开(connections从 1 变回 0),此时计数器变化触发了回调执行。
因此,最简单的解决方案是让这个连接是一个短连接就可以了。
js
setTimeout(() => {
res.writeHead(200, { 'Content-Type': 'text/plain', Connection: 'close' })
res.end('Hello, World!')
}, 6000)
这样,当kill进程时,就能执行server.close()里面的回调函数了。
但是,我们一般不会采用短连接的方式。
可以采用手动维护连接数,强制触发回调函数。
js
const http = require('http')
const process = require('process')
// 跟踪活跃的连接数
let activeConnections = 0
const server = http.createServer((req, res) => {
// 每建立一个连接,计数+1
activeConnections++
if (req.url === '/') {
setTimeout(() => {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('Hello, World!')
// 请求处理完成,计数-1
activeConnections--
// 检查是否可以关闭服务器
checkIfServerCanClose()
}, 5000) // 模拟处理请求的时间
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
// 请求处理完成,计数-1
activeConnections--
checkIfServerCanClose()
}
// 监听连接关闭事件(比如客户端主动断开)
res.on('close', () => {
activeConnections = Math.max(0, activeConnections - 1)
checkIfServerCanClose()
})
})
// 标记服务器是否正在关闭
let isShuttingDown = false
// 检查是否可以关闭服务器
function checkIfServerCanClose() {
if (isShuttingDown && activeConnections === 0) {
console.log('All connections are closed. Exiting process.')
process.exit(0)
}
}
const PORT = 3010
server.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`)
console.log('pid is:', process.pid)
})
// 统一的优雅退出处理函数
function gracefulShutdown(signal) {
console.log(`Received ${signal}. Shutting down gracefully...`)
isShuttingDown = true
// 停止接收新请求
server.close(() => {
console.log('Server stopped accepting new connections.')
})
// 立即检查是否有活跃连接,没有则直接退出
checkIfServerCanClose()
// 超时保护:如果10秒后还没退出,强制关闭
setTimeout(() => {
console.error('Timeout waiting for connections to close. Forcing exit.')
process.exit(1)
}, 10000)
}
// 监听常见的退出信号
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT')) // Ctrl+C 触发
process.on('SIGQUIT', () => gracefulShutdown('SIGQUIT'))
无论哪种方案,都要加 setTimeout 兜底,防止某个连接卡死导致服务永远退不出去;
js
// 超时保护:如果10秒后还没退出,强制关闭
setTimeout(() => {
console.error('Timeout waiting for connections to close. Forcing exit.')
process.exit(1)
}, 10000)
Node.js 集群模式下的优雅退出
面对多进程集群(Cluster)的场景,这就像从 "管理一家小店" 升级到 "管理连锁门店",需要协调好主进程(Master)和所有工作进程(Worker)的退场顺序,才能做到真正平滑、无感知。
Node.js 集群模式里有两个核心角色:
- Master 进程(主进程) :负责管理所有 Worker 进程,不处理业务请求,只做 "调度员";
- Worker 进程(工作进程) :真正处理 HTTP 请求的进程,数量通常等于 CPU 核心数;
集群模式下优雅退出的核心逻辑就是:Master 先收到退出信号 → 通知所有 Worker 优雅关闭 → Worker 处理完现有请求、释放资源 → 全部 Worker 退出后,Master 再安全退出。
集群优雅退出的 4 个核心步骤:
1. Worker 异常退出后自动 refork(高可用保障)
生产环境中,Worker 可能因为代码 Bug、OOM 等原因意外退出,我们需要让 Master 自动重启它,保证服务高可用。
js
const cluster = require('cluster')
const numCPUs = require('os').cpus().length
if (cluster.isPrimary) {
console.log(`Master 进程 PID: ${process.pid}`)
// 启动初始 Worker
for (let i = 0; i < numCPUs; i++) {
cluster.fork()
}
// 监听 Worker 退出事件,自动 refork
cluster.on('exit', (worker, code, signal) => {
console.log(
`Worker ${worker.process.pid} 退出,退出码: ${code},信号: ${signal}`,
)
// 清理旧 Worker 的事件监听
worker.removeAllListeners()
// 只有在非主动退出的情况下才重启(避免优雅退出时也被重启)
if (!worker.exitedAfterDisconnect) {
console.log('=== 自动重启新 Worker ===')
cluster.fork()
}
})
}
判断是主进程还是工作进程使用cluster.isPrimary。

2. Master 监听退出信号,批量通知 Worker
当我们执行 kill <master-pid> 或 Ctrl+C 时,Master 会收到信号,此时它需要:
- 停止 fork 新 Worker
- 给所有正在运行的 Worker 发送 "关闭指令"
- 等待所有 Worker 退出后,自己再退出
js
if (cluster.isPrimary) {
// 优雅关闭所有 Worker
async function shutdownWorkers(signal) {
console.log(`Master 收到信号 ${signal},开始关闭所有 Worker...`);
// 获取所有活跃 Worker
const workers = Object.values(cluster.workers);
if (workers.length === 0) {
console.log('没有活跃 Worker,Master 直接退出');
process.exit(0);
return;
}
// 批量给所有 Worker 发送终止信号
const killPromises = workers.map(worker => {
return new Promise((resolve) => {
// 监听 Worker 退出事件,确认关闭完成
worker.on('exit', resolve);
// 给 Worker 发送 SIGTERM 信号(触发 Worker 的优雅退出逻辑)
process.kill(worker.process.pid, 'SIGTERM');
});
});
// 等待所有 Worker 关闭完成
await Promise.all(killPromises);
console.log('所有 Worker 已关闭,Master 退出');
process.exit(0);
}
// 监听常见的退出信号
['SIGINT', 'SIGQUIT', 'SIGTERM'].forEach(signal => {
process.once(signal, () => shutdownWorkers(signal));
});
}
3. Worker 监听信号,优雅关闭 HTTP 服务
Worker 收到 Master 发来的 SIGTERM 信号后,要执行和单进程一样的优雅退出逻辑:
- 停止接收新请求(
server.close()) - 处理完现有请求
- 释放资源(数据库 / 缓存连接)
- 通知 Master 自己已退出
js
if (cluster.isWorker) {
const http = require('http');
const server = http.createServer((req, res) => {
// 模拟耗时业务(比如数据库查询)
setTimeout(() => {
res.end('Hello from Worker ' + process.pid);
}, 2000);
});
server.listen(3000, () => {
console.log(`Worker ${process.pid} 启动,监听端口 3000`);
});
// 优雅关闭函数
function gracefulShutdown() {
console.log(`Worker ${process.pid} 开始优雅关闭...`);
// 1. 停止接收新请求, 这里的回调不一定执行
server.close((err) => {
if (err) {
console.error(`Worker ${process.pid} 关闭服务失败:`, err);
process.exit(1);
}
process.exit(0);
});
console.log(`Worker ${process.pid} 已停止接收新请求,等待现有请求处理完成`);
// 2. 通知 Master 自己即将断开连接
const worker = cluster.worker;
if (worker) {
try {
worker.send({ message: 'disconnect' });
worker.disconnect(); // 断开与 Master 的 IPC 通道
} catch (e) {
console.error(`Worker ${process.pid} 断开 IPC 失败:`, e);
}
}
// 3. 超时兜底(防止请求卡死)
setTimeout(() => {
console.error(`Worker ${process.pid} 关闭超时,强制退出`);
process.exit(1);
}, 10000);
}
// 监听 Master 发来的 SIGTERM 信号
process.on('SIGTERM', gracefulShutdown);
}
这里区分下process.kill(worker.process.pid, 'SIGTERM') 和 process.exit()的使用。
process.kill() 的名字极具误导性 ------ 它不是直接杀死进程,而是给指定 PID 的进程发送一个信号(signal)。
- 第一个参数
worker.process.pid:目标进程的 PID(这里是 Worker 进程的 PID); - 第二个参数
'SIGTERM':要发送的信号(默认是SIGTERM,即 "优雅终止请求")。
process.exit() 是 "硬退出",调用后,当前进程会立即终止,无论是否有未完成的代码、未释放的资源。
核心参数 code:
code=0:正常退出(无错误);code≠0(如 1):异常退出(告诉操作系统 "进程因错误终止")。
容器(Docker/K8s)部署下 Node.js 服务的优雅退出
容器化部署(Docker/K8s)下的优雅退出,核心是让容器的退出信号能正确传递到 Node.js 进程,并结合容器的生命周期配置,实现「流量切走 → 服务优雅关闭 → 容器退出」的完整流程。
容器环境下,优雅退出的最大问题是:
- 信号传递丢失:Docker 容器内的 PID 1 进程如果不是 Node.js,退出信号(SIGTERM)会无法传递到 Node.js 进程;
- 容器销毁超时:K8s/Docker 会给容器设置 "销毁超时时间",超时后直接发送 SIGKILL 强制杀死;
- 流量未切走:容器退出前,负载均衡(Service/Ingress)还在转发流量,导致用户请求失败。
Docker 容器的 PID 1 进程是 "容器的主进程",只有 PID 1 进程能接收操作系统的退出信号。如果你的 Dockerfile 这样写:
js
CMD node app.js
此时执行 docker stop,SIGTERM 信号会发给 sh 进程,而 sh 不会把信号转发给 node 进程,导致 Node.js 收不到退出信号,容器超时后被 SIGKILL 强制杀死。
-
执行
docker stop node-app,Docker 给 Node.js(PID 1)发SIGTERM; -
Node.js 开始执行优雅退出逻辑(停止接收新请求、处理完现有请求、释放资源);
-
如果 Node.js 在 10 秒内完成并执行
process.exit(),容器正常销毁; -
如果 Node.js 卡在长连接处理,10 秒后还没退出,Docker 会强制发送
SIGKILL,Node.js 被秒杀,未处理的请求直接失败。
需要这么写:CMD ["node", "app.js"], JSON 数组格式。当 CMD/ENTRYPOINT 使用 JSON 数组格式 时,Docker 会直接执行数组中的命令,不通过 shell 解析,这种方式就是 exec 模式。