nodejs学习6:nodejs应用的优雅退出

什么是优雅退出?

想象一下:你正在处理一个用户的支付请求,突然执行了 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);
});

按道理来说,当进程被杀死后,操作系统最终会回收文件句柄,但这个资源回收机制不是即时的:

  • 第一步:操作系统会标记该进程的所有资源为 "僵尸资源",暂时禁止其他进程使用;
  • 第二步:等待内核的资源回收调度周期(通常几秒到几十秒),才会彻底清理这些资源;

因此,后端服务也要像前端页面不能在用户提交表单时突然刷新一样,也需要 "体面地退场"。

优雅退出,就是让服务在收到退出信号时:

  1. 先拒绝新请求:不再接收新的 HTTP 连接;
  2. 再处理完旧请求:等正在执行的业务逻辑全部结束;
  3. 最后清理资源:关闭数据库连接、释放文件句柄等;
  4. 再安全退出进程

核心目标是:

  • 用户体验:避免用户看到莫名其妙的错误,给用户友好提示;
  • 数据安全:防止业务逻辑中断导致脏数据或状态不一致;
  • 发布平滑:配合负载均衡,实现无感知的版本更新或服务重启;

Node.js 服务是怎么 "被通知退出" 的?

在类 Unix 系统(比如 Linux、macOS)里,进程是通过 信号(Signal) 来接收退出通知的。你可以把信号理解成系统发给进程的 "短信",告诉它 "该下班了"。

重点:只有 SIGTERMSIGINT 这类信号才能被我们捕获,实现优雅退出;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 服务基于它)的设计逻辑:

  1. server.close() 调用后,服务会进入关闭阶段,但会维护一个 connections 计数器(记录已建立的客户端连接数);

  2. 只有当 connections 计数器变为 0 时,才会触发 close 回调;

  3. 如果你 kill 进程时,HTTP/1.1 默认开启 Keep-Alive(长连接),即使请求响应完成,连接也会保持几秒,这也是 connections 计数器不为 0 的常见原因;

  4. 当你 "再次访问" 时,会建立使用上面的那个连接(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 会收到信号,此时它需要:

  1. 停止 fork 新 Worker
  2. 给所有正在运行的 Worker 发送 "关闭指令"
  3. 等待所有 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 信号后,要执行和单进程一样的优雅退出逻辑:

  1. 停止接收新请求(server.close()
  2. 处理完现有请求
  3. 释放资源(数据库 / 缓存连接)
  4. 通知 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 进程,并结合容器的生命周期配置,实现「流量切走 → 服务优雅关闭 → 容器退出」的完整流程。

容器环境下,优雅退出的最大问题是:

  1. 信号传递丢失:Docker 容器内的 PID 1 进程如果不是 Node.js,退出信号(SIGTERM)会无法传递到 Node.js 进程;
  2. 容器销毁超时:K8s/Docker 会给容器设置 "销毁超时时间",超时后直接发送 SIGKILL 强制杀死;
  3. 流量未切走:容器退出前,负载均衡(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 模式。

相关推荐
军哥全栈AI3 小时前
Windows11 彻底卸载Node.js(无残留,适配所有版本)
npm·node.js
困惑阿三3 小时前
全栈部署排雷手册:从 405 报错到飞书推送成功
服务器·前端·后端·nginx·阿里云·node.js·飞书
Andytoms4 小时前
Node.js 版本和 pnpm 版本的对应关系
node.js
头发多多程序媛1 天前
解决依赖下载报错,npm ERR! code EPERM
前端·npm·node.js
fanjinzhi1 天前
Node.js通用计算15--TypeScript介绍
javascript·typescript·node.js
light blue bird1 天前
MES/ERP的Web多页签报表系统
数据库·node.js·ai大数据·mes/erp·web报表
Doris8931 天前
【Node.js 】Node.js 与 Webpack 模块化工程化入门指南
前端·webpack·node.js
alanesnape1 天前
在 Surface Pro X (ARM64) 上成功部署 Claude Code 的完整复盘
git·node.js·claude code部署·msys2clangarm64·美区apple id·礼品卡支付·surface pro x