部署:PM2 + Docker + Nginx
本教程共 76 篇 · 第 69 篇 · 更新于 2026-07-25 · 约 11 分钟阅读
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 是老牌选手,配置少、上手快。
NoteNode.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-alpine。alpine 体积最小,注意它用的是 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 环境。如果你的应用需要编译原生模块(比如 bcrypt、sharp),构建阶段要换成带编译工具的基础镜像:
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.ip、req.headers.host 如果不传,会看到的全是 Nginx 的地址。加了 X-Real-IP、X-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-IP、X-Forwarded-Proto传下去,应用能拿到真实客户端信息。 - 健康检查路由(
/health、/ready)已就绪,编排系统能据此摘流量。 - 监控、日志接上(第 65、71 章),出问题看得到。
这份清单不长,但少一项都可能变成半夜的事故。我习惯在发版前把它贴在工位上,逐条打勾。
一套组合拳怎么串
把三层叠起来看:用户 → Nginx(接请求、HTTPS、分流)→ 多个 Node 实例(PM2 集群 / 多个容器)→ 数据库(compose 里一起起)。
- 单机小流量:PM2 集群 + Nginx 反代就够了,不一定上 Docker。
- 要环境一致、好迁移:Docker 多阶段镜像 + docker-compose。
- 多机、要弹性扩容:Nginx
upstream指向多机,或上 Kubernetes。
选型不靠拍脑袋,看流量规模、团队有没有运维 Kubernetes 的能力。小项目别为了「看起来专业」硬上全套,反倒把自己拖死。