第一次在服务器上部署 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、Restarton-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 -yDebian/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.js3.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 savepm2 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 不支持 Upgradeproxy_read_timeout设长一些避免空闲连接被 Nginx 掐断。这里的经验是WebSocket 路径单独做一个 location不要和普通 API 混在一起这样超时和升级配置可以各自独立调不会互相干扰。5. 真实踩坑记录PM2 与 Nginx 协作的排查链路5.1 502 Bad Gateway不一定是代码问题我在一次部署中遇到过这种情况PM2 显示进程正常onlineNginx 配置看着也没问题但浏览器访问直接 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 转发到 NodeNode 监听 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 关键配置、常见故障排查步骤都记下来。时间久了你会发现所谓“运维经验”不过就是把这些琐碎的决策和坑位老老实实留成了文字下次遇到时不用重头再来。
阅读完成 · 觉得有帮助?