☰
宝塔面板+PM2+Nginx:Node.js线上部署的完整实战指南
2026/10/20 15:49:22 网站建设 项目流程

第一次在服务器上部署 Node 项目,很多人想的是“装好 Node,把代码传上去,npm install,然后 node app.js 就完事了”。结果呢?SSH 一断开,服务就没了;半夜进程崩溃没人知道,第二天一上班才发现用户已经在评论区骂了好几条街。后来我调整了方案:用宝塔面板管理服务器底层,用 PM2 专职管理 Node 进程,再让 Nginx 在入口做反向代理。这套组合,成了我处理 Node.js 线上部署的默认方案。

这篇内容就是围绕这套协作链路来写的。适合刚接触 Linux 服务器、想把 Node 服务正经跑上线的开发者,也适合那些已经在用宝塔但只会在面板里点“Node 项目”按钮、出了问题完全不知道往哪儿查的人。我会把环境准备、PM2 的配置思路、Nginx 反代的联动细节、还有几个真实踩过的坑串起来讲,尽量做到看过之后能直接照着操作,而不是看完一堆概念还是不知道下一步点哪里。

1. 为什么我最终选了宝塔面板来做 Node.js 部署:一次部署方案的取舍复盘

1.1 裸跑、守护进程、systemd 的各自问题

先说说我没选哪几条路,以及为什么没选。

最原始的方式当然是直接把node app.js丢在终端里跑。这种方式的毛病不用多讲:SSH 窗口一关,进程收到 SIGHUP 就退了。就算你用nohup挂后台,进程崩溃之后依然没人替你拉起来。对本地开发来说无所谓,但对线上服务来说,等于把服务的可用性寄托在“它千万别崩”的侥幸上。

后来很多人会用forever这类工具做进程守护。它的思路很简单:主进程挂了自动重启。但用一段时间就会发现,它在日志管理、多实例、优雅退出这些方面都很单薄。你依然要自己处理很多边缘情况,而且这类工具已经明显处于维护放缓的阶段,新项目不太建议再引进。

也有人直接用 systemd 写 service 文件。这是 Linux 正统做法,足够稳定,也没有多余依赖。问题在于配置心智负担:ExecStart、WorkingDirectory、EnvironmentFile、Restart=on-failure、日志交给 journald,这一套写下来对不熟悉 systemd 的开发者并不友好。每次改端口、加环境变量、配启动参数,都要重新daemon-reload。如果你只是一个人维护两三台服务器,这种“正统”会变成时间黑洞。

1.2 宝塔、PM2、Nginx 三者职责的划分逻辑

后来我定下来的组合是“宝塔管底层 + PM2 管进程 + Nginx 管入口”。宝塔面板在这里解决的是服务器基础运维的琐事:Nginx 的安装、MySQL 或 Redis 的启动、防火墙端口放行、站点证书申请、定时任务、文件权限修改,这些操作在命令行里当然也能做,但面板点一点确实省时间。要注意的是,宝塔不直接去“运行”你的 Node 程序,它只负责把运行环境准备好。

PM2 则专心做 Node 进程的一生管理——启动、守护、重启、日志、集群模式、开机自启。它不关心你的业务代码里有什么接口,只保证进程按你定义的规则活着。

Nginx 在这一层干的活也纯粹:把 80/443 端口的流量按域名转发到 PM2 管理的 Node 监听端口上,顺便承担静态文件处理、Gzip 压缩、WebSocket 升级这些边角任务。Nginx 本身也是宝塔面板管理着的,所以你平时操作入口在面板的“网站”里,配置文件生成后也能直接改。

这套分工的核心思路是:每个工具只干自己最擅长的事,出问题时也方便按层排查。端口占用、进程崩溃、配置错误、网络不通,这些问题会明确对应到某一层,而不是挤在一团浆糊里。

2. 环境准备:Node 版本和编译依赖是第一个隐形门槛

2.1 用宝塔的 Node 版本管理器,还是自己编译

宝塔面板的软件商店里提供了 Node.js 版本管理器,我个人的建议是:能用面板装就用面板装。它不是最新版本的,但胜在稳定和方便切换。如果你在本地开发时用的 Node 版本比较新,面板自带版本可能偏旧,这时候你需要确认项目兼容性,而不是无脑追求高版本。

有些老项目会依赖比较旧的 Node 特性,有些新项目又要求 Node 18 以上。宝塔的多版本管理允许你在不同项目里选择不同版本,这点比自己在服务器上装 nvm 再配 PATH 要省心。但要注意,面板的版本管理器不会自动帮你改全局 PATH,你在 SSH 命令行里执行node -v可能看到的是另一个版本,这属于正常现象,并不会影响 PM2 启动项目时使用的版本。

2.2 node-gyp 编译失败是新手最常见的拦路虎

如果你的项目里有node-sass、bcrypt、sharp这类带原生模块的依赖,npm install大概率会在编译阶段报错,常见提示是node-gyp rebuild失败,或者找不到python、make、gcc。

这个问题的本质是:这些库需要在你服务器上现场把 C++ 源码编译成.node二进制文件,而编译依赖的工具链没装全。宝塔面板一般会预装部分基础工具,但未必完整。

我的处理办法是在 CentOS 系服务器上先安装编译工具组:

yum groupinstall "Development Tools" -y yum install python3 -y

Debian/Ubuntu 系则是:

apt update apt install build-essential python3 -y

装完之后再重新npm install,大部分编译问题都能解决。还有一类情况是 module 版本和 Node 版本不匹配,比如老的node-sass不支持 Node 16 以上,这种要么升级库,要么降 Node 版本,在宝塔版本管理器里一键切换试到不报错为止。

2.3 npm 源的配置经验

国内服务器直接连 npm 官方源常常慢到怀疑人生,配置镜像源是我必做的一步:

npm config set registry https://registry.npmmirror.com

注意一个惯用提醒:镜像源适合下载依赖,但如果你要发布 npm 包,记得换回官方源,不然会发布到镜像上,虽然现代镜像一般会同步转发,但这习惯还是留着比较好。另外,npm ci在 CI/CD 场景里比npm install更严格,部署脚本里我推荐用npm ci来保证依赖版本完全按照 lock 文件安装,避免开发环境与线上环境不一致。

3. PM2 的核心价值:从“启动进程”到“管理服务”

3.1 最简单的启动方式和命令解释

第一次用 PM2 时,很多人只记住了一条命令:

pm2 start app.js

这确实能把进程拉起来,但要是你就这么上线,后面会吃不少亏。PM2 的启动策略应该从项目的长期维护角度来设计,我习惯直接用配置文件来定义整个进程行为,这样项目换台服务器或者同事接手时,执行一条命令就能恢复完全一致的运行状态。

先看一个常用的ecosystem.config.js示例:

module.exports = { apps: [ { name: 'blog-api', script: './src/app.js', instances: 2, exec_mode: 'cluster', max_memory_restart: '300M', env: { NODE_ENV: 'production', PORT: 3000, DB_HOST: '127.0.0.1' }, error_file: './logs/err.log', out_file: './logs/out.log', merge_logs: true, time: true } ] };

启动时执行:

pm2 start ecosystem.config.js

3.2 集群模式:要不要开多实例

配置文件里最关键的是instances和exec_mode。exec_mode设为cluster时,PM2 会以集群模式启动多个实例,由它在内部做负载均衡。对于 Node.js 这种单线程模型,多实例能利用多核 CPU,这是提升吞吐量的最直接方式。

是否要开多实例取决于你的应用状态存储方式。如果你的应用把 session、内存缓存直接存在进程里,那么多实例会导致用户请求落到不同进程时状态不一致,这时需要引入 Redis 之类的共享存储。一般来说,很多无状态 API 服务直接开instances: 2或instances: max都问题不大,但如果有状态,就得先改造,再开多实例。

我自己实践下来的建议是:先单实例跑通业务,再考虑集群化。不要一开始就把问题复杂度拉高,因为多实例带来的日志分散、定时任务重复执行、内存占用翻倍,都是需要额外处理的。

3.3 守护和重启策略:PM2 是怎么“保活”的

PM2 的守护机制并不是什么黑魔法,它会一直有一个主进程在系统里挂着,监控各子进程的状态。当某个子进程异常退出时,PM2 主进程捕获到退出事件,再根据你配置的重启策略决定是否拉起重启。

max_memory_restart: '300M'的意思是当进程内存占用超过 300MB 时,PM2 会自动重启它。这是兜底方案,不能当作内存泄漏的借口——重启只是缓解症状,你还是要排查为什么内存一直涨。

restart_delay可以设置重启延迟,比如restart_delay: 3000表示崩溃后 3 秒再拉起,避免出现“进程起不来→PM2 疯狂重启→日志刷爆磁盘”的恶性循环。我见过一台服务器因为启动失败被 PM2 用默认策略反复拉起,半天时间磁盘就被错误日志塞满了。所以,给重启加个延迟,再配合日志轮转,是线上必须考虑的事项。

3.4 开机自启:这个步骤容易被忽略

服务器重启后,PM2 管理的进程不会自动跟着恢复,除非你配置了开机自启。操作很简单:

pm2 startup pm2 save

pm2 startup会生成一条系统服务配置,让 PM2 在开机时启动。pm2 save则把当前的进程列表保存为快照,开机后 PM2 会按快照拉起所有进程。

一个实际遇到的细节:如果你服务器上有 Node 版本切换了,PM2 的启动脚本可能还带着旧版本的路径,导致开机自启失败。这时重新执行一次pm2 startup重新生成脚本就行。

4. Nginx 反向代理:把 Node 服务从“端口”变成“网址”

4.1 最小可用配置:从 3000 端口到域名

PM2 把 Node 服务跑起来后,通常监听的是类似3000这样的端口。但用户不可能记端口访问,需要 Nginx 在 80/443 端口接收请求,再转发给内部端口。

以宝塔面板为例,在“网站”里添加站点后,进入配置文件修改,最终核心内容是这样:

server { listen 80; server_name api.example.com; location / { proxy_pass http://127.0.0.1:3000; 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 60s; proxy_read_timeout 60s; } }

proxy_pass是转发目标,127.0.0.1:3000意味着 Nginx 把请求转发给本机 3000 端口上运行的 Node 进程。这里有一个策略性问题:Node 监听地址应该写成127.0.0.1而不是0.0.0.0。如果写成0.0.0.0,意味着任何机器都能直接访问 3000 端口,等于绕过了 Nginx,也就绕过了你在 Nginx 层加的所有配置和防护。只监听本地回环地址,外网就摸不到业务端口,这是我在所有项目里的固定做法。

4.2 静态文件该让 Nginx 处理还是 Node 处理

如果你的 Node 应用(比如 Express 或 Koa)自带静态资源中间件,开发时很方便。但到了线上,如果访问量稍微上来,让 Node 处理静态文件就有点浪费了。Node 单线程处理业务逻辑时还要腾出精力读文件、做 MIME 判断,不如把这些活交给 Nginx。

此时 Nginx 配置会变成:

location /static/ { alias /home/www/your-project/public/; expires 7d; access_log off; } location / { proxy_pass http://127.0.0.1:3000; }

加了expires 7d之后,浏览器会缓存静态文件 7 天,用户二次访问时根本不会请求服务器,负载进一步降低。要注意alias和root的区别:alias是映射到具体目录,而root会拼接完整路径。用错的话,静态资源 404 是最常见的表现。

4.3 WebSocket 长连接:少了三个头就会掉线

如果你的 Node 项目包含 WebSocket 服务(比如在线聊天、实时通知、协作功能),Nginx 配置里不能只是普通转发。WebSocket 协议在握手阶段需要 HTTP Upgrade 机制,Nginx 默认不做这个,所以必须显式配置:

location /ws { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }

proxy_http_version 1.1是因为 HTTP/1.0 不支持 Upgrade;proxy_read_timeout设长一些,避免空闲连接被 Nginx 掐断。这里的经验是:WebSocket 路径单独做一个 location,不要和普通 API 混在一起,这样超时和升级配置可以各自独立调,不会互相干扰。

5. 真实踩坑记录:PM2 与 Nginx 协作的排查链路

5.1 502 Bad Gateway:不一定是代码问题

我在一次部署中遇到过这种情况:PM2 显示进程正常online,Nginx 配置看着也没问题,但浏览器访问直接 502。很多人这时候会怀疑是代码崩了,其实 502 本质是 Nginx 无法从上游拿到响应。

我的排查链路是这样的:

先确认 Node 进程真的在监听预期端口:

netstat -tlnp | grep 3000

如果输出为空,说明 Node 进程虽然没退出,但根本没监听端口。这种情况常见于:代码中设置了环境变量 PORT,但启动时没传进去,进程默认监听其他端口;或者业务代码启动过程中异步报错导致监听逻辑未执行。此时去查 PM2 日志:

pm2 logs

如果端口监听正常,那就curl一下本地服务:

curl http://127.0.0.1:3000/health

能返回内容,说明 Node 侧正常,问题在 Nginx 配置。比如宝塔生成站点时设置的root路径不对,或者开启了某些伪静态规则,把请求吞掉了。这时别死磕proxy_pass,先检查外层 server 块有没有多余的try_files或rewrite规则。

5.2 端口冲突:Node 和 Nginx 都在抢 80

另一个高频问题,是把 Node 服务直接监听 80 端口。宝塔自带的 Nginx 默认也监听 80,两个进程一冲突,Nginx 起不来,或者 Node 报ENOSPC/EADDRINUSE。

这类问题最干净的处理方式,就是坚持“Node 只监听内网高位端口,Nginx 独占 80/443”的分工。如果你在宝塔面板的 Node 项目管理里填了 80,然后面板自动生成 Nginx 配置又转发到 80,就会形成请求链路上的闭环:Nginx 转发到 Node,Node 监听 80 但已经被 Nginx 占用,结果就是进程起不来、站点 502 循环出现。解决办法是 Node 监听端口改成 3000 或 3001,再把 Nginx 的proxy_pass指向对应地址。

5.3 环境变量不一致:本地能跑线上挂

本地开发时.env文件里写了一堆配置,上传服务器后npm start跑起来,接口调不通。追查后发现 PM2 启动的进程里根本没有这些环境变量。

PM2 加载环境变量有几个层面:启动命令里的--env参数、ecosystem.config.js里的env字段、系统环境变量、以及.env文件。如果你的代码用了dotenv来读.env,那 PM2 只要在正确的工作目录启动,.env文件在项目根目录就能被加载。但如果代码是直接用process.env.DB_HOST,而 PM2 环境里没设过这个变量,那启动后自然是 undefined。

我建议把环境和敏感配置写进ecosystem.config.js的env字段,并配合宝塔的“环境变量”功能或者/www/.env这类全局文件统一管理。同时注意:不要把生产数据库密码直接写在代码仓库里,哪怕仓库是私有的,这习惯也要守住。

5.4 内存持续上涨:重启只是缓兵之计

有个项目运行了两周后,我上去看pm2 status,发现内存占用快 1GB,而业务高峰根本没那么多请求。直觉告诉我这是内存泄漏。用pm2 restart确实能把内存降下来,但这只是把炸弹指针往后拨了一点。

定位内存泄漏,我建议先用node --inspect配合 Chrome 的 DevTools 做堆快照分析,或者简单点,在代码里对可疑的高频操作加上日志,看对象是否被异常持有。最常见的原因是全局变量不断塞数据、事件监听器未移除、以及闭包引用了大对象。PM2 的max_memory_restart在这里是最后一道防线,但设计上,它只是防止进程拖垮整台服务器,不能替代真正的代码排查。

6. 上线之后的运维习惯:日志、监控与发布方式

6.1 日志管理:别让日志吃掉磁盘

PM2 默认会把console.log输出到日志文件,时间一长文件会很大。我的做法是给日志加上时间标识,并开启轮转:

pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 50M pm2 set pm2-logrotate:retain 7

这样单个日志文件超过 50MB 就会被切割,最多保留 7 份。另外,正式环境里我基本不写console.log,业务日志统一走日志库并带上结构化字段,这样排查问题时能按请求 ID 串联整条链路。PM2 本身不关心日志内容,它只负责把流接到文件,因此日志格式设计还是要靠自己在业务代码里保证。

6.2 健康检查与告警:最简单的保命措施

很多小项目没有专门的监控系统,但至少要保证“服务挂了能第一时间知道”。我的最低配置是:在 Node 里放一个/health接口,返回内存、进程存活时间、数据库连接状态;然后写一个简单的外部探活脚本,从外网访问一次,失败就推送到手机。

这类脚本可以放在宝塔的“计划任务”里,每两分钟执行一次。这个方案的优点是零成本、不引入额外组件,缺点是只能做到“挂了知道”,做不到自动处理。自动处理其实也不需要太复杂:配合 PM2 自身重启策略,加上健康检查失败时执行pm2 reload的脚本,基本能应付大多数小业务。

6.3 平滑发布:reload 和 restart 的差异

每次改代码后的发布,我会优先用reload而不是restart和stop/start。差异在于restart是直接终止进程再拉起,期间会有几十毫秒到几秒的请求失败;而reload会走优雅退出流程——先通知旧进程停止接收新连接,等当前请求处理完再退出,然后启动新进程接续。

对于集群模式,pm2 reload ecosystem.config.js会逐一小批重启,保证服务不中断。但要做到真正平滑,业务代码里最好配合处理SIGINT信号,关闭服务器前先停止接收新请求:

process.on('SIGINT', () => { server.close(() => { process.exit(0); }); });

这里还要注意,如果代码里有定时器、消息队列消费者,它们在进程退出时会中断任务,发布后再启动,可能会造成任务丢失。这种情况下,靠 PM2 的wait_ready和listen_timeout配置能缓解一部分,但更稳妥的方案还是引入任务队列本身的重试机制。

6.4 部署脚本:把重复工作固化成一条命令

几台服务器手动操作还能忍,一旦服务数量变多,我就写了简单的发布脚本:

# deploy.sh git pull origin main npm ci pm2 reload ecosystem.config.js

配合宝塔勾选的 WebHook,每次推送代码后自动执行。脚本要做的只有三件事:拉代码、装依赖、重载进程。别看简单,这三步的顺序不能乱——先拉代码再装依赖,是因为依赖锁文件可能变化;装完依赖立刻重载,是避免旧进程还在跑新代码。

另外,在脚本里把npm ci的失败判定加上,只要依赖安装失败就中断后面的重启操作,防止线上服务被半成品代码搞挂。这个细节我一个同事就踩过:脚本里少了失败退出,结果依赖拉取超时,脚本继续往下执行pm2 reload,把正在正常服务的旧版本也重启了,用户当场体验了一波所有接口 5xx。

结尾:最后分享一个让我省心的习惯

部署 Node 服务这件事,我最大的感受是:不要跟它较劲,要跟它配合。宝塔、PM2、Nginx,单独拿出来每个都不复杂,但它们拼在一起时,很多人会在“谁管哪一层”上栽跟头。我的原则一直很固定——Nginx 管入口,PM2 管进程,宝塔管环境,业务代码只干业务的事。每次遇到问题,我从不先怀疑代码,而是按“入口→进程→环境”的顺序一层层排查,多数问题半小时内能定位。

最后分享一个小技巧:每次完成环境配置后,我都会在项目根目录写一个DEPLOY.md,把端口、路径、PM2 启动命令、Nginx 关键配置、常见故障排查步骤都记下来。时间久了你会发现,所谓“运维经验”,不过就是把这些琐碎的决策和坑位老老实实留成了文字,下次遇到时不用重头再来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询