058-Node.js服务器怎么部署网站?PM2进程管理与反向代理配置

Node.js 项目部署有一个和 Java、Python 都不同的地方:它的运行时和依赖管理绑在一起。你装在服务器上的 Node 版本、node_modules 里编译出的原生模块,都和生产环境深度耦合。这也是为什么很多人会遇到"本地好好的,服务器上跑不起来"——多半是 Node 版本差了半个大版本,或者某个依赖在服务器上重新编译失败。

这篇文章按部署的实际顺序讲:先解决 Node 装哪个版本、怎么装(nvm 和发行版仓库两套),再讲依赖安装的坑;然后重点讲 PM2 进程管理,包括它最被低估的 cluster 模式怎么榨出多核性能;接着是环境变量的正确管理方式、Nginx 反向代理的关键配置;最后是零停机重载和日常更新流程。

命令给 RHEL/CentOS/Rocky 与 Debian/Ubuntu 两套。涉及 pm2 delete、覆盖目录等操作会标风险提示,因为这类命令写错会直接让站点挂掉。

一、Node 部署的特殊之处

先说清楚 Node 部署和传统后端部署的几个差异,理解了这些,后面的选择就都有依据。

第一,Node 是单线程处理 JavaScript 的(虽然底层有线程池处理 IO),一个进程只能用一个 CPU 核心。所以多核服务器上如果只起一个 Node 进程,剩下的核心完全是浪费的。解决方式是起多个进程或用 cluster,这正是 PM2 cluster 模式存在的理由。

第二,Node 进程的内存上限和历史包袱有关。32 位版本有 4G 地址空间限制,64 位版本没有硬限制但默认堆上限不低,实际中 Node 应用常见的内存瓶颈是 GC 停顿和内存泄漏,而不是"堆太小"。可以用 --max-old-space-size 调整。

第三,依赖目录 node_modules 是部署产物的一部分。它体积大(几十到几百兆很常见)、文件数多(几万个小文件都正常),所以既不要提交到 Git,也不要在生产机上频繁整目录重装。稳定的做法是在部署时用 npm ci 配合锁文件安装,保证装出来的依赖和开发时完全一致。

第四,Node 的进程退出行为比较"诚实":未捕获的异常默认会让进程直接退出。这意味着任何一处没处理好的异常都可能让线上服务中断,所以必须有进程守护来自动拉起。这也是 PM2 这类工具在 Node 生态里几乎是标配的原因。

二、安装 Node:两条路线

路线一:nvm 管理多版本(开发机和需要多版本时推荐)

nvm 是 Node 版本管理器,它把 Node 装在用户目录下,可以在不同版本之间自由切换。适合需要同时维护多个项目、每个项目要求不同 Node 版本的场景。

# 安装 nvm(脚本会写到 ~/.nvm 并修改 shell 配置)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

# 重新加载 shell 配置
source ~/.nvm/nvm.sh

# 装指定 LTS 版本
nvm install 20
nvm install 18

# 切换并设为默认
nvm use 20
nvm alias default 20

# 确认
node -v && npm -v

有一个必须知道的坑:nvm 是按用户生效的,装完之后 node 只在你这个用户的 shell 里可用。如果应用用 systemd 以另一个用户(比如 www)运行,或者用 root 跑 PM2,就会出现"我这儿有 node,服务里找不到"。处理办法是在部署脚本和服务文件里显式加载 nvm,或者干脆用路线二。

路线二:发行版仓库或官方源(生产服务器推荐)

# RHEL / Rocky / AlmaLinux:系统自带仓库的 Node 版本通常太老
sudo dnf module list nodejs            # 看看有哪些版本流
sudo dnf module enable nodejs:20 -y    # 启用 Node 20 流
sudo dnf install -y nodejs

# 或者用 NodeSource 源,版本更全
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
sudo dnf install -y nodejs

# Debian / Ubuntu
sudo apt update
sudo apt install -y nodejs npm
# 版本太老时用 NodeSource
# curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
# sudo apt install -y nodejs

这种方式的优点是把 Node 装到系统路径下,所有用户和服务都能用,也不需要每个部署用户各自装一份。缺点是切换版本不如 nvm 灵活。

维度 nvm 发行版仓库 / NodeSource
安装位置 用户家目录 系统路径
多版本切换 很方便 需要换源升级
服务可用性 需要额外处理 天然可用
升级方式 nvm install 新版本 包管理器
适合 开发机、多项目 生产服务器

选择建议很明确:生产服务器上用发行版仓库或 NodeSource,把版本固定住,不要在生产环境用 nvm 来回切版本。开发机用 nvm 无妨。

三、依赖安装与 npm / pnpm

生产环境安装依赖有一条铁律:必须用锁文件,且必须用 ci 而不是 install。

# 进到项目目录
cd /opt/app/current

# npm:ci 严格按照 package-lock.json 安装,不会改动锁文件
npm ci --omit=dev

# 注意:老版本 npm 用 --production,npm 7+ 两者都支持
# npm ci --only=production

# pnpm:先装 pnpm(可以用 corepack 管理版本)
corepack enable
corepack prepare pnpm@latest --activate
pnpm install --frozen-lockfile --prod

# yarn
# yarn install --frozen-lockfile --production

npm ci 和 npm install 的区别值得说清楚:install 会根据 package.json 计算依赖树,可能更新锁文件,装出来的东西和你测试的未必一致;ci 直接按锁文件装,先删掉已有的 node_modules 再装,结果完全可复现。生产部署用 ci 是基本原则。

国内网络下 npm 官方源可能很慢,配一下镜像能显著提速:

# 临时用
npm ci --omit=dev --registry=https://registry.npmmirror.com

# 永久配置(对当前用户生效)
npm config set registry https://registry.npmmirror.com
# pnpm
pnpm config set registry https://registry.npmmirror.com

注意:装原生模块(需要编译 C++ 的,比如某些数据库驱动、图像处理库)时会用到 node-gyp,它需要 Python 和编译工具链。如果服务器上没有,安装会失败:

# RHEL 系
sudo dnf groupinstall -y "Development Tools"
sudo dnf install -y python3

# Debian / Ubuntu
sudo apt install -y build-essential python3

另一个实用的做法是把构建产物分两段:在 CI 机器上跑 npm ci 和 npm run build(前端项目尤其需要),把整个 node_modules 和构建结果一起打包传到服务器。这样生产机只需要装一个精简的 Node 运行时,不需要编译工具链。

四、PM2 进程管理

PM2 是 Node 生态里最成熟的进程管理器,功能覆盖守护、集群、日志、监控、开机自启。

# 全局安装
npm install -g pm2
# 国内加速
npm install -g pm2 --registry=https://registry.npmmirror.com

pm2 --version

cluster 模式:把多核用起来

这是 PM2 最有价值的功能。Node 单进程只能用一颗核心,cluster 模式会基于 Node 内置的 cluster 模块起多个进程,共享同一个端口,由主进程分发连接。

# -i max 表示按 CPU 核心数起进程
pm2 start app.js --name myapp -i max

# 也可以指定具体数量,比如 4 核机器起 3 个进程,留一个给系统
pm2 start app.js --name myapp -i 3

关于进程数给个经验值:CPU 密集型应用(比如做图片处理、大量 JSON 序列化)可以按核心数起,-i max;IO 密集型应用(大部分 CRUD 接口)因为大部分时间在等数据库,起核心数的 1 到 2 倍也常见,但边际收益递减,一般不超过 2 × 核心数。进程数不是越多越好,因为每个进程都有独立的内存开销,而且数据库连接池会成倍增长。

cluster 模式有一个必须注意的前提:应用必须是无状态的。因为请求可能被分到任意一个进程,不能把用户会话存在进程内存里。会话要放到 Redis、数据库,或者用粘性会话(sticky session)。如果代码里有全局变量存用户状态,上 cluster 后会出现"刷新一下就要重新登录"这类问题。

模式 CPU 利用 内存开销 适用条件
fork(单进程) 只用 1 核 最低 无状态要求高、流量小
cluster -i max 用满所有核 每进程独立 无状态应用、CPU 密集
cluster -i 2 用 2 核 中等 需要控制连接数或内存

常用命令

pm2 start app.js --name myapp          # 启动并命名
pm2 list                                # 查看所有进程状态
pm2 show myapp                          # 查看某个进程详情
pm2 logs myapp                          # 实时日志(所有进程)
pm2 logs myapp --lines 200              # 看最近 200 行
pm2 monit                               # 实时资源监控面板
pm2 restart myapp                       # 重启(有短暂中断)
pm2 reload myapp                        # 零停机重载(cluster 模式)
pm2 stop myapp                          # 停止但保留在列表中
pm2 delete myapp                        # 从列表移除(谨慎)
pm2 scale myapp 4                       # 动态调整进程数
pm2 save                                # 保存当前进程列表
pm2 resurrect                           # 恢复保存的列表

风险提示:pm2 delete 会从进程列表里彻底移除应用,之后 pm2 save 也不会再保留它,pm2 resurrect 恢复不出来。如果你只是想临时停一下,用 pm2 stop;确实要删除时,先确认 pm2 list 里没有别的在用。

开机自启

PM2 的开机自启靠一个系统级的启动脚本加一份保存的进程列表实现:

# 生成开机启动脚本(按发行版自动选择 systemd)
pm2 startup systemd -u deployuser --hp /home/deployuser

# 上面的命令会输出一条需要 root 执行的命令,照抄执行

# 之后把当前进程列表存下来
pm2 save

两个要点。第一,pm2 startup 是以某个用户为粒度的,如果你用 root 跑了 PM2,那就要按 root 配置;实际生产里更推荐用普通用户跑 PM2,startup 时用 -u 指定。第二,每次新增或删除进程后都要重新 pm2 save,否则重启后恢复的是旧列表。很多人"重启后应用没起来",就是忘了这一步。

五、环境变量与配置文件

环境变量在 Node 里有两个常见来源:一是 shell 环境,二是 .env 文件(配合 dotenv 这类库)。生产环境的做法要更严格一些。

.env 文件的问题在于:它容易被误提交到仓库、权限不容易控制、和 systemd 的环境隔离。合理的折中是:.env 只用于开发,生产用 systemd 的 EnvironmentFile 或 PM2 的配置文件来注入。

用 PM2 的 ecosystem.config.js 是最集中的方式:

// /opt/app/current/ecosystem.config.js
module.exports = {
  apps: [{
    name: 'myapp',
    script: 'server.js',
    cwd: '/opt/app/current',
    instances: 'max',              // 或数字,如 4
    exec_mode: 'cluster',          // cluster 模式必须显式声明
    watch: false,                  // 生产环境绝不要开 watch
    max_memory_restart: '800M',    // 超过阈值自动重启,防内存泄漏
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    // 从外部文件读取敏感变量,文件权限设 600
    env_file: '/etc/app/app.env',
    error_file: '/var/log/app/error.log',
    out_file: '/var/log/app/out.log',
    merge_logs: true,              // 多进程日志合并到一个文件
    log_date_format: 'YYYY-MM-DD HH:mm:ss',
    kill_timeout: 5000,            // 优雅退出等待时间
    listen_timeout: 10000          // 等待进程 ready 的时间
  }]
};
# 用配置文件启动
pm2 start ecosystem.config.js

# 从外部文件加载环境变量的另一种方式(systemd 风格)
# 文件内容形如 KEY=value,每行一个

max_memory_restart 是个很实用的兜底:Node 应用内存泄漏不容易发现,配上这个参数,进程内存超过阈值就自动重启,能把"服务慢慢变卡最后挂掉"变成"服务偶尔重启一下"。阈值要参考应用正常的内存占用,设成正常值的 1.5 到 2 倍。

日志要配 merge_logs: true,否则 cluster 模式下每个进程一个日志文件,排查问题时要在几个文件之间来回翻。同时用 pm2 install pm2-logrotate 做日志切割,否则 PM2 的日志会无限增长:

pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 100M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

启动顺序上有个细节:env_file 里的变量对 ecosystem.config.js 里其他字段是不生效的(比如你不能用 env 里的变量去拼 cwd),它只注入到应用进程里。不要试图用它做复杂的模板。

六、Nginx 反向代理

Node 应用监听高位端口(比如 3000),由 Nginx 监听 443 对外。这是标准架构,理由和 Spring Boot 那篇一样:静态文件、TLS、限流交给 Nginx,Node 只管业务。

upstream node_backend {
    # 如果后端是 PM2 cluster 多进程,这里写一个地址即可,因为端口是共享的
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 443 ssl http2;
    server_name www.example.com;

    ssl_certificate     /etc/nginx/ssl/site.crt;
    ssl_certificate_key /etc/nginx/ssl/site.key;

    client_max_body_size 20m;
    gzip on;
    gzip_types text/plain text/css application/json application/javascript;

    # 静态资源(React/Vue 打包产物)直接由 Nginx 提供
    location /static/ {
        root /opt/app/current/public;
        expires 30d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }

    location / {
        proxy_pass http://node_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;    # WebSocket 必需
        proxy_set_header Connection "upgrade";        # WebSocket 必需
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 5s;
        proxy_read_timeout    60s;
        proxy_send_timeout    60s;

        # 后端重启期间的短暂失败,自动重试下一个
        proxy_next_upstream error timeout http_502 http_503;
    }
}

关键点有三个。第一,Host 和 X-Forwarded-* 头必须传,否则应用拿到的客户端 IP 是 127.0.0.1,生成的重定向链接也可能指向错误的主机。Express 里要设 app.set('trust proxy', true) 才会信任这些头。

第二,WebSocket 需要 Upgrade 和 Connection 两个头,这两行不写,Socket.io 之类的会一直连不上,而且报错信息不明显。如果你的应用用了 WebSocket,这两行是必须的。

第三,Connection 头的处理有冲突:keepalive 到上游需要 proxy_set_header Connection "",而 WebSocket 需要 Connection "upgrade"。如果两种流量都要走,要么分开 location,要么用 map 指令按请求头动态判断:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
# 然后在 location 里用
# proxy_set_header Connection $connection_upgrade;

Node 侧也要把监听地址限制在本地,避免绕过 Nginx 直接访问:

// 只监听本地回环
app.listen(3000, '127.0.0.1');
// 或者用环境变量控制
const host = process.env.HOST || '127.0.0.1';
app.listen(process.env.PORT || 3000, host);

七、零停机重载与更新流程

PM2 的 reload 是 cluster 模式下的零停机重载:它逐个重启进程,每个进程在收到 SIGINT 后先停止接收新连接、处理完存量请求再退出,同时其他进程继续服务。所以只要 instances 大于 1,就能做到用户无感知。

# 零停机重载(cluster 模式,多进程)
pm2 reload myapp

# fork 模式下 reload 等于 restart,会有中断

要让 reload 真正无损,应用需要正确处理信号。Node 里默认行为是收到 SIGINT/SIGTERM 直接退出,正在处理的请求会被截断。加上优雅退出:

const server = app.listen(3000, '127.0.0.1');

process.on('SIGINT', () => {
  console.log('收到 SIGINT,开始优雅退出');
  server.close(() => {
    console.log('存量请求已处理完,退出');
    process.exit(0);
  });
  // 兜底:超过 10 秒强制退出,避免卡住
  setTimeout(() => process.exit(1), 10000);
});

注意 PM2 默认发的信号可以通过 kill_timeout 和 shutdown_with_message 等参数调整。PM2 默认等 1600 毫秒,如果应用处理一个请求需要更久,要相应调大 kill_timeout。

一个完整的更新流程可以写成脚本:

#!/bin/bash
set -euo pipefail
APP_DIR=/opt/app/current
cd "$APP_DIR"

# 1) 拉代码
git fetch --all --prune
OLD_REV=$(git rev-parse HEAD)
git reset --hard origin/main

# 2) 装依赖(用锁文件,保证一致)
npm ci --omit=dev

# 3) 构建(前端项目)
npm run build

# 4) 语法检查,提前发现低级错误
node --check server.js

# 5) 零停机重载
if ! pm2 reload myapp; then
  echo "重载失败,回滚到 $OLD_REV"
  git reset --hard "$OLD_REV"
  npm ci --omit=dev
  pm2 reload myapp
  exit 1
fi

# 6) 健康检查
sleep 3
if ! curl -fsS --max-time 5 http://127.0.0.1:3000/health > /dev/null; then
  echo "健康检查失败,回滚"
  git reset --hard "$OLD_REV"
  npm ci --omit=dev
  pm2 reload myapp
  exit 1
fi

pm2 save
echo "部署完成"

风险提示:脚本里的 git reset --hard 会丢弃工作目录中的本地修改,npm ci 会删除并重建整个 node_modules。执行前确认目录里没有需要保留的本地文件,并保证部署窗口期内服务器能访问 npm 源,否则回滚会因为装不上依赖而失败。稳妥做法是保留最近一次成功的 node_modules 备份。

常见问题

Q:pm2 startup 之后重启服务器,应用还是没起来?

最常见的原因是配置完之后没有执行 pm2 save。开机自启恢复的是"保存的进程列表",不保存就恢复不出东西。其次检查 pm2 startup 输出的那条 root 命令有没有真正执行。

Q:cluster 模式下用户登录状态老是丢失?

因为请求被分到了不同进程,而会话存在进程内存里。解决方式是改用外部会话存储(Redis)或者把会话放到客户端(JWT),也可以配置粘性会话(Nginx 的 ip_hash 或 sticky 模块)。前者才是根本解法。

Q:部署后 node_modules 里少了很多包?

检查是不是用了 --omit=dev 但把构建工具(webpack、vite 等)放在了 devDependencies 里。这类工具在构建阶段需要,运行时不需要。正确做法是在 CI 阶段完成构建,把产物和运行时依赖一起带上服务器;或者构建时先装全量依赖、构建完再清理。

Q:Node 进程内存一直涨,是内存泄漏吗?

先用 pm2 monit 或 pm2 list 看内存趋势。稳定在某个值附近波动是正常的,持续单调上涨就是泄漏。排查手段有 --inspect 配合 Chrome DevTools 抓堆快照、node --heapsnapshot-signal 在需要时导出快照。短期兜底可以用 max_memory_restart 自动重启。

Q:静态资源 404 或者缓存不更新?

检查 Nginx 的 root 路径和后端应用的路径是否一致(React/Vue 打包后的 dist 目录名容易写错)。缓存不更新通常是文件名没带哈希(应该用 vite/webpack 的哈希文件名),或者是 Nginx 的 expires 设得太长而 index.html 也被缓存了——index.html 必须短缓存或不缓存,否则用户永远拿到旧页面。

总结

Node.js 部署的关键决定有四个。Node 版本用发行版仓库或 NodeSource 固定在生产机上,别用 nvm 在生产环境来回切。依赖安装必须用 npm ci 配合锁文件,保证装出来的东西和测试时一致。

进程管理用 PM2,多核机器上开 cluster 模式,但前提是应用无状态、会话存在外部。配好 max_memory_restart 作为内存泄漏的兜底,配好 pm2-logrotate 防止日志爆盘。开机自启别忘了 pm2 save,这是最常被漏的一步。

Nginx 反向代理要传全 Host 和 X-Forwarded-* 头,用 WebSocket 的话加上 Upgrade 两行,静态资源让 Nginx 直接提供,index.html 不缓存。

零停机重载靠 pm2 reload 加应用层的优雅退出。把更新流程写成一个带健康检查和自动回滚的脚本,比每次手工操作可靠得多。