nestjs pm2最佳实践

生产环境下的单机集群伸缩、进程管理与优雅停机方案

Posted by chanweiyan on May 2, 2026

Node.js 默认是单线程运行的。这意味着如果你把编译好的 NestJS 应用丢到服务器上直接 node dist/main.js,它只能利用可怜的一个 CPU 核心;而且如果进程抛出了一个未捕获的异常,整个服务会直接崩溃宕机。

为了解决这些问题,我们需要引入生产级的进程管理器——PM2

1. 为什么需要 PM2?

  1. 崩溃自动重启 (Auto Restart):服务意外挂掉后能够瞬间帮你拉起来。
  2. 集群模式 (Cluster Mode):无需修改任何业务代码,直接根据主机的 CPU 核心数进行多进程实例分发,彻底榨干计算性能。
  3. 平滑重载 (Zero Downtime Reload):更新业务代码时,能够逐个替换旧进程,做到终端用户无感知的无中断发布。

2. 配置文件:ecosystem.config.js

不要在部署脚本里苦哈哈地敲一大串带有各个运行参数的命令(如 pm2 start dist/main.js --name "api" -i max)。 符合工业规范的做法是:使用一份 ecosystem.config.js 生态配置文件来固化各项启动参数。

在项目根目录配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = {
  apps: [
    {
      name: "nest-api", // 进程名称
      script: "dist/main.js", // 编译后的启动文件(注意是指向 dist)
      instances: "max", // 集群模式下的实例数('max' 代表利用所有 CPU 核心)
      exec_mode: "cluster", // 开启集群模式,多实例负载均衡
      watch: false, // 生产环境务必关闭 watch,将更新权交由 CI/CD 控制
      max_memory_restart: "1G", // 内存泄漏最后防线:单个进程超过 1G 自动重启
      env: {
        NODE_ENV: "development",
      },
      env_production: {
        NODE_ENV: "production",
        PORT: 3000,
      },
    },
  ],
};

此后只需一次启动:pm2 start ecosystem.config.js --env production

3. 必须开启的事:NestJS 的优雅停机 (Graceful Shutdown)

当我们更新代码并执行 pm2 reload ecosystem.config.js 时,PM2 会依次给旧进程发送 SIGINT 结束信号。 默认情况下 Node.js 不会理会太多,有些处理到一半的 HTTP 请求、数据库事务可能直接被拦腰斩断(连接重置),极其危险。

NestJS 提供了生命周期钩子,我们必须要主动开启所谓的 优雅停机

main.ts 中开启:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 关键代码:开启优雅停机!
  // 接收到系统的 SIGINT / SIGTERM 等信号时,会先拒绝新请求接纳,并给当前活动请求缓冲完毕的时间,才会彻底结束进程。
  app.enableShutdownHooks();

  await app.listen(3000);
}
bootstrap();

有了 enableShutdownHooks() 时,PM2 发起重起指令不仅无缝平滑,应用内的 OnModuleDestroyBeforeApplicationShutdown 安全释放数据库连接的代码也能被完美地触发。

4. Docker + PM2 :最佳拍档还是多此一举?

由于 Docker 自身也是一个拥有守护能力和隔离能力的统筹工具(例如崩溃可以用 restart: always,集群负载有 K8s 的 replicas),所以业界有一种普遍的质疑声音:“跑了 Docker 就不要再套 PM2 了”

在容器编排极为完善(指具有成熟 k8s 分发能力)的公司里确实如此,直接采用原生的 CMD ["node", "dist/main.js"]

但是在中小厂或者单机 Docker 虚拟环境架构中,混合使用依然有巨大的价值: 利用 Docker 控制统一运行环境,利用 PM2 在一个实例里管理调度多核并发和快速守护。

🚨 绝对避雷:使用 pm2-runtime 而非 pm2 start

在容器哲学里,容器的前台必须有活动阻塞的进程才能保持运行不退出(前台即服、后台即死)。 如果你在 Dockerfile 里的写 CMD ["pm2", "start", "ecosystem.config.js"],PM2 会让 Node 放入系统级后台作为守护进程运行(Daemon)。然后,Dockerfile 前台没了任务阻塞——Docker 会认为执行完毕迅速挂壁 (Exited 0)

官方对于 Docker 专门给出了适配工具:pm2-runtime。 它是一个前台阻塞形的输出挂载命令。

需要修改你的 NestJS Dockerfile 最后:

1
2
3
4
5
6
7
# 镜像前面构建和提取 dist 阶段与常规方案无异...

# 全局安装 pm2 依赖
RUN npm install -g pm2

# 必须使用 pm2-runtime 启动生态配置,充当前台卡死进程的主轴
CMD ["pm2-runtime", "start", "ecosystem.config.js", "--env", "production"]

QA

QA: 生产环境可以用 PM2 去执行 TypeScript 源码启动吗?

💬点击展开/收起

虽然理论上能够通过 ts-node 等集成手段跳过编译直接交给 PM2,但这是极其违背最佳实践的

  1. 惊人的内存损耗:TS 的即时编译对于服务器内存负担极高,你的 ts-node 会吞噬大量的资源用于类型推理判定。
  2. 缓慢的启停时长:重载时长严重加剧。

无论是什么框架(Nest 等),只有使用 nest buildtsc 提取的纯纯 *.js 生成物才能配进入线上运行时 PM2 里。

QA: 既然开了 cluster 会启动出 N 个进程实例,定时任务 (CronJob) 会被反复执行 N 次吗?

💬点击展开/收起

会的!这是非常经典的一大坑。 比如你开个含有 @Cron('0 0 * * *') 并执行发送报表功能的 NestJS 代码。当以 pm2 cluster -i 4 启动时,四个子进程全都拿到了这块定时逻辑。到了时间点,邮件会被猛发 4 次。

解决方案通常有:

  1. 单独建立一台只含有 i: 1 实例的从机器进程用于专司跑 Task 批处理。
  2. 借由 pm2 原生的 NODE_APP_INSTANCE 环境变量判定,代码逻辑中只有这把互斥锁等于 0 时,定时处理器才执行发邮件功能。
  3. 弃用纯应用内存型定时器,借助包含锁机制的中心化队列库(如 Redis BullMQ)。