首页 / Node.js 教程 / 部署:PM2 + Docker + Nginx

Node.js 教程

部署:PM2 + Docker + Nginx

本教程共 76 篇 · 第 69 篇 · 更新于 2026-07-25 · 约 11 分钟阅读

Node.js部署PM2DockerNginx

69. 部署:PM2 + Docker + Nginx

本节目标:- 用 PM2 做进程守护,并利用多核跑起集群模式 - 用多阶段 Dockerfile 打出小体积、安全的镜像 - 用 docker-compose 把应用和依赖服务(如数据库)一起拉起 - 用 Nginx 做反向代理和负载均衡,把请求分发给多个实例 先说一个观念:这三样东西不是「三选一」,而是分层的。PM2 解决的是「单台机器上进程别死」;Docker 解决的是「环境一致、随时能搬」;Nginx 解决的是「门外怎么接客、怎么分流」。小项目可能只用其一,上了规模就是三层叠着用。

你写完代码、本地跑得好好的,离「线上可用」还差一截。真正的生产部署要考虑三件事:进程挂了能自动拉起来、环境一致不依赖「我机器上能跑」、再叠一层把请求稳稳接住的反向代理。这一章把 PM2、Docker、Nginx 这三件套讲清楚,给你一套能直接抄的配置文件。

为什么不用 node app.js 直接跑

最朴素的启动方式是 node app.js,但它有几个致命问题:

  • 进程崩了就真崩了,没人重启。
  • 单进程只能吃满一个 CPU 核,多核机器浪费。
  • 你退出终端,进程跟着没。

所以生产环境几乎一定会套一层进程管理器。Node.js 圈子里 PM2 是老牌选手,配置少、上手快。

Note

Node.js 自带的 cluster 模块也能做多进程(第 34 章讲过)。PM2 的集群模式本质上就是帮你把 cluster 包了一层,省去手写 fork 逻辑。

PM2 进程守护

先装(建议全局,或在项目里作为 devDependency,配合 npx 调用):

npm install -g pm2

假设你有个 Express 应用入口是 app.js(ESM 下可能是 app.mjs,下文以 app.js 配合 "type": "module" 为例)。用 PM2 启动:

pm2 start app.js --name my-api

常用命令记这几个就够:

pm2 list              # 看所有进程状态
pm2 logs my-api       # 看日志
pm2 restart my-api    # 重启
pm2 stop my-api       # 停止
pm2 delete my-api     # 删除
pm2 save              # 保存当前进程列表
pm2 startup           # 开机自启(按提示把生成的命令跑一遍)

pm2 save 配合 pm2 startup,机器重启后进程会自动复活——这是「进程守护」最实在的价值。

用 ecosystem.config.js 管理配置

手敲命令行参数容易忘,正经做法是写个配置文件 ecosystem.config.js

// ecosystem.config.js (ESM 项目用 .cjs 或 package 设 type 时注意)
module.exports = {
  apps: [
    {
      name: 'my-api',
      script: 'app.js',
      instances: 'max',       // 启动和 CPU 核数相同的进程
      exec_mode: 'cluster',   // 集群模式,多核并行
      env: {
        NODE_ENV: 'development',
      },
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
      max_memory_restart: '512M', // 内存超限自动重启,防泄漏拖垮
      watch: false,               // 生产别开 watch,否则改文件就重启
    },
  ],
};

启动生产环境:

pm2 start ecosystem.config.js --env production

instances: 'max' + exec_mode: 'cluster' 就是集群模式。假如你的机器是 4 核,PM2 会起 4 个进程、各自绑定同一个端口(靠底层负载分发),吞吐量直接翻几倍。

Warning

集群模式下,每个进程是独立的内存空间。不要在内存里存「全局状态」(比如登录 session 放变量里),进程间不共享。这种状态请放 Redis 或数据库。

Tip

max_memory_restart 是个保命设置。遇到内存慢慢泄漏的服务,设个上限让它自动重启,比半夜被告警叫醒强。

Docker 镜像构建

Docker 解决的是「环境一致性」。你交付的不再是一个压缩包,而是一个带运行时的完整镜像——开发、测试、生产跑的是同一个东西。

基础镜像:用 LTS 版本

以 v24 LTS 为准,用官方的 node:24-alpinealpine 体积最小,注意它用的是 musl 库,个别原生模块可能需要额外编译。

# 单阶段(适合最基础场景,仅作对比,不推荐生产用)
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]

单阶段镜像会带上 npm ci 的构建痕迹和 devDependencies,体积大、攻击面也大。生产请用多阶段。

多阶段构建

核心思路:用一个阶段装依赖、构建,再用一个干净的运行阶段只拷贝产物。

# ---- 构建阶段 ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci

# ---- 运行阶段 ----
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# 只拷贝构建阶段装好的 node_modules,跳过 devDependencies
COPY --from=build /app/node_modules ./node_modules
COPY . .
# 用非 root 用户,降低被攻破后的影响面
USER node
EXPOSE 3000
CMD ["node", "app.js"]

这里 npm ci 依赖 package-lock.json,要求依赖版本锁定,比 npm install 更适合 CI 环境。如果你的应用需要编译原生模块(比如 bcryptsharp),构建阶段要换成带编译工具的基础镜像:

FROM node:24 AS build
WORKDIR /app
COPY package*.json ./
RUN apt-get update && apt-get install -y python3 make g++ \
  && npm ci \
  && apt-get clean && rm -rf /var/lib/apt/lists/*
Warning

别忘了 .dockerignore。否则本地 node_modules.git、日志全打进构建上下文,既慢又可能覆盖镜像里的正确依赖。最小配置:

node_modules
npm-debug.log
.git
.env
*.log

构建与运行

docker build -t my-api:1.0.0 .
docker run -d -p 3000:3000 --name api my-api:1.0.0

docker-compose 编排多服务

真实项目往往不止一个 Node 进程,还得配数据库。手写一堆 docker run 参数太累,docker-compose.yml 一次描述清楚:

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=db
      - DB_PORT=5432
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppass
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  pgdata:

启动:

docker compose up -d      # 后台拉起全部服务
docker compose logs -f app # 跟踪应用日志
docker compose down       # 全部停掉

应用里连接数据库时,主机名直接写 db(compose 内部 DNS 会把服务名解析成容器 IP),而不是 localhost。这是新手最容易踩的坑。

Tip

restart: unless-stopped 让容器异常退出后自动重启。它和 PM2 的作用类似,但一个管「容器」一个管「进程」。Docker 场景下通常交给 Docker 的 restart 策略,不一定非要在容器里再套 PM2。

Nginx 反向代理与负载均衡

Docker 解决环境,Nginx 解决「门口怎么接」。它坐在你的 Node 进程前面,对外只暴露 80/443,把请求转发给后面的应用实例。

最简单的反向代理

# /etc/nginx/conf.d/my-api.conf
server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        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_set_header 那几行很关键:Node 拿到的 req.ipreq.headers.host 如果不传,会看到的全是 Nginx 的地址。加了 X-Real-IPX-Forwarded-For 之后,你在应用里才能正确读到真实客户端 IP。

Note

如果你的应用需要知道原始协议(http 还是 https),X-Forwarded-Proto 必传,否则生成绝对 URL(如邮件里的链接)会错写成 http。

负载均衡到多个实例

前面 PM2 集群是把多个进程跑在同一台机器上;Nginx 的 upstream 能把请求分到多台机器、或多个容器。

upstream api_backend {
    # 默认轮询(round-robin)。可加 weight 调整权重
    server 127.0.0.1:3000 weight=3;
    server 127.0.0.1:3001;
    server 10.0.0.2:3000;

    # 同一客户端 IP 总落到同一台,方便保持会话
    # ip_hash;
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        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;
    }
}

Nginx 默认用轮询把请求平摊到每个 server。如果你的服务有状态(比如 session 在内存),记得在 upstream 里加 ip_hash,让同一用户稳定落到同一台机器;更彻底的做法是无状态化,把 session 丢到 Redis,那样就能随便轮询。

Warning

负载均衡下做零停机发布要小心:你先停掉一台更新,Nginx 可能还在往已停的机器转发,返回 502。生产发布通常用「先把它从 upstream 摘掉(nginx reload),更新完再加回来」的方式,或使用蓝绿/金丝雀策略。

加一层 HTTPS

Nginx 也常负责 TLS 终止。拿到证书后:

server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    location / {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        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;
    }
}

# 80 强制跳 443
server {
    listen 80;
    server_name api.example.com;
    return 301 https://$host$request_uri;
}

优雅退出与线上运维

部署不只是「让它跑起来」,还包括「怎么安全地停下来」。进程收到 SIGTERM 时,应该把手上的请求处理完、释放数据库连接,再退出——这就是优雅退出(Graceful Shutdown)。Express 下的最小实现:

async function shutdown(signal) {
  console.log(`收到 ${signal},开始优雅退出`);
  server.close(() => {
    console.log('HTTP 连接已关闭');
    // 这里关闭数据库连接池等
    process.exit(0);
  });
  // 兜底:10 秒内还没退出就强杀
  setTimeout(() => process.exit(1), 10000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Docker 和 K8s 停止容器时发的就是 SIGTERM。没有优雅退出,正在处理的请求会被直接掐断,用户看到 502。PM2 重启(pm2 reload)也会先发信号再拉新进程,配合优雅退出就能做到零停机发布。

Warning

server.close() 只阻止新连接进来,已经连上的请求会继续处理完。如果你没等它回调就 process.exit(),那些请求照样被丢。真正的零停机要「先摘流量(Nginx 不再转发),再关进程」。

线上看进程状态,PM2 自带几个趁手的命令:

pm2 monit        # 实时看每个进程的 CPU、内存
pm2 dashboard    # 网页版面板(需装 pm2-web)
pm2 reload my-api # 零停机重启(轮流替换进程)

上线前检查清单

把前面零散的点收一收,正式部署前对着过一遍:

  • NODE_ENV=production,关掉调试和 watch
  • 用非 root 用户跑容器(Dockerfile 里 USER node)。
  • .dockerignore 配好,别把本地 node_modules.env 打进去。
  • 进程有优雅退出,且 pm2 reload / 容器重启不会丢请求。
  • Nginx 把 X-Real-IPX-Forwarded-Proto 传下去,应用能拿到真实客户端信息。
  • 健康检查路由(/health/ready)已就绪,编排系统能据此摘流量。
  • 监控、日志接上(第 65、71 章),出问题看得到。

这份清单不长,但少一项都可能变成半夜的事故。我习惯在发版前把它贴在工位上,逐条打勾。

一套组合拳怎么串

把三层叠起来看:用户 → Nginx(接请求、HTTPS、分流)→ 多个 Node 实例(PM2 集群 / 多个容器)→ 数据库(compose 里一起起)。

  • 单机小流量:PM2 集群 + Nginx 反代就够了,不一定上 Docker。
  • 要环境一致、好迁移:Docker 多阶段镜像 + docker-compose。
  • 多机、要弹性扩容:Nginx upstream 指向多机,或上 Kubernetes。

选型不靠拍脑袋,看流量规模、团队有没有运维 Kubernetes 的能力。小项目别为了「看起来专业」硬上全套,反倒把自己拖死。