
先交代一下背景我有个用 NestJS 写的接口服务在本地开发环境跑得好好的代码逻辑、单元测试全部通过结果一部署到阿里云 ECS 上就各种出事。从 SSH 上传代码开始到 Node 进程起不来再到 Nginx 反代配置报错最后还撞上服务器内存不足被 OOM Killer 干掉进程前前后后折腾了两天。这篇文章就把这次完整过程记录下来重点是那些不踩一遍根本不知道的坑端口为什么不通、PM2 为什么进程会自动消失、Nginx 502 到底怎么排查、数据库连接为什么时好时坏。这篇内容适合两类人一是第一次把 NestJS 项目发布到云服务器对 Linux、Nginx、PM2 都不太熟的新手二是本地开发没问题、一到生产环境就状况频出想找一份完整排查思路的同学。我会尽量把每一步的操作命令、配置文件和判断逻辑都写清楚你照着做基本能少走我踩过的那些弯路。1. 部署前的技术选型与服务器准备1.1 服务器规格与内存陷阱2G 是最低门槛NestJS 本身并不吃 CPU真正吃的是内存。一个只提供 REST API 的最小 NestJS 服务基础占用大约在 150M 到 300M 之间听起来很低对吧但一旦你引入 TypeORM 或 Prisma、Redis 连接、队列任务、日志框架内存占用会直接翻倍。我这次部署的服务用了 TypeORM Redis 定时任务调度在 1核1G 的 ECS 上跑前 30 分钟一切正常之后系统负载越来越高最后 PM2 进程直接消失SSH 登录一看 dmesg里面躺着 OOM Killer 的击杀记录。所以我对服务器规格的建议非常明确预算允许就直接 2核2G 起步数据量稍大或者并发稍高就上 2核4G。1核1G 的实例不是不能跑但它要求你对每个依赖都非常克制日志轮转、Redis 内存上限、TypeORM 连接池全部得做精细调优新手这么搞很容易心态崩。我后来还做了一个补救措施给 ECS 增加了 2G 的 swap 分区好消息是 OOM Kill 的情况明显减少了坏消息是 swap 只能作为缓冲长期运行还是建议升配。系统镜像方面阿里云默认推荐 Alibaba Cloud Linux但我个人更建议你用 Ubuntu 22.04 LTS 或 Debian 12。原因是这两个系统的软件源更新快安装 Node.js、Nginx、Redis 的命令和网上教程基本一一对应出了问题 Google 一下就有一堆答案。Alibaba Cloud Linux 本身不错但遇到小众报错时中文资料往往少得可怜对新手不太友好。1.2 Node.js 版本与包管理器nvm 才是正确姿势NestJS 对 Node 版本有硬性要求我用的是 Node 20 LTS搭配 npm 10 或 pnpm。这里有个很容易犯的错误很多人直接用 apt install nodejs 装系统自带的版本以为是 LTS结果装出来是 Node 16 甚至更低之后执行 nest build 就会报各种语法错误比如 optional chaining 不支持、private class fields 不支持这种报错非常误导人让你以为是代码问题实际上是运行环境版本太旧。正确做法是装 nvm用 nvm 来管理 Node 版本。安装命令是curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash安装完之后千万记得执行 source ~/.bashrc让 nvm 命令立即生效否则会提示 command not found我第一次就卡在这里。然后安装 Node 20nvm install 20 nvm alias default 20包管理器上我建议团队内部统一如果本地用 pnpm服务器上也要用 pnpm 安装依赖。混用 npm 和 pnpm 会导致 node_modules 的结构不一致最常见的现象是本地构建成功、服务器构建报错查了半天发现是幽灵依赖导致的。我这次部署就是本地 npm、服务器 pnpm结果 dist 目录编译到一半报找不到某个包后来统一换成 npm 才正常。2. 代码构建上传与进程启动2.1 构建流程选择本地打包再上传省去服务器等待关于 NestJS 项目的 dist 目录是应该本地构建好再传上去还是把源码传到服务器再构建我两种方式都试过最终选择的是本地构建再上传。原因很简单ECS 服务器的 CPU 性能通常比本地开发机差不少一个几百个文件的项目在本地 10 秒就能构建完在 1核2G 的服务器上可能要等两三分钟如果涉及大型依赖的二次编译时间差距还会更明显。方案一的具体操作流程是本地执行 nest build生成 dist 目录但注意传上去之前不要带上 node_modules否则上传文件数量会爆炸。我一般用 rsync 配合 exclude 参数rsync -av --exclude node_modules --exclude .git --exclude .env ./dist userECS_IP:/var/www/myapp/dist rsync -av --exclude node_modules --exclude .git ./package.json ./package-lock.json userECS_IP:/var/www/myapp/到服务器之后进入项目目录执行 npm install --production只安装生产依赖。这里有个细节安装依赖的时候不要图省事直接 npm install因为开发依赖在服务器上完全用不到既占空间又可能触发 postinstall 脚本导致一些不需要的编译动作。方案二也不是不能用适合 team 还没有统一构建流程、或者你需要在服务器上动态改代码的场景。但要注意先在服务器上安装完整的构建依赖不然编译到一半报缺包你就得来回折腾。我个人的体会是能本地构建就本地构建服务器保持越干净越好出问题也好排查。2.2 PM2 配置细节进程名、实例数与环境变量注入直接 node dist/main.js 启动服务是一种非常脆弱的方式。SSH 一断开进程就可能被挂在终端的会话组里随着连接断开一起挂掉。生产环境的标准做法是用 PM2 做进程守护。把它理解成 Node 进程的“保姆”崩了自动拉起开机自动启动还能把 stdout 和 stderr 输出到指定日志文件里。PM2 的安装和基本命令npm install -g pm2 pm2 start dist/main.js --name my-nest-app pm2 savepm2 save 的目的是把当前进程列表保存下来之后配合 pm2 startup 生成开机自启脚本。但更推荐用 ecosystem.config.js 来管理配置这样可以统一管理环境变量、日志路径和实例数量以后迁移服务器直接 pm2 start ecosystem.config.js 就恢复服务了。module.exports { apps: [ { name: my-nest-app, script: dist/main.js, instances: 1, exec_mode: fork, env: { NODE_ENV: production, PORT: 3000, DB_HOST: rds-xxxx.mysql.rds.aliyuncs.com, }, out_file: /var/log/pm2/my-nest-app-out.log, error_file: /var/log/pm2/my-nest-app-error.log, merge_logs: true, max_memory_restart: 512M, }, ], };这里重点说两个参数。第一instances 不要随意设成 max。NestJS 的实例数不是越多越好Instances 翻倍意味着内存翻倍对 1核2G 的机器来说一个实例就够了多开反而会导致 CPU 争抢、响应变慢。需要用满多核的时候再考虑 cluster 模式并且要评估内存是否有余量。第二max_memory_restart 必须设置。我建议设置为服务器内存的 1/4 左右比如 2G 内存就设 512M。进程超过这个内存上限后 PM2 会自动重启虽然不能根治内存泄漏但至少能让服务保持可用状态。日志路径也要提前规划。默认情况下 PM2 会把日志放在 ~/.pm2/logs 目录下但生产环境我习惯统一放到 /var/log/pm2 下配合 logrotate 做日志轮转不然日志文件会无限膨胀最终把磁盘塞满。2.3 环境变量隔离别把数据库密码写进代码NestJS 项目里一般用 nestjs/config 管理环境变量默认读取根目录的 .env 文件。本地开发时 .env 里的数据库地址可能是 localhost到了服务器必须改成 RDS 的地址或者 ECS 自建数据库的内网地址。我强烈建议服务器上有独立的 .env 文件并且不要手动去改代码里的配置常量。具体做法是本地保留 .env.development生产环境单独维护 .env.production传到服务器后重命名成 .env。或者更彻底的把关键变量直接注入到 PM2 的 env 字段里这样 .env 文件都不需要放服务器上数据库密码、JWT Secret 这类敏感信息都收口到 PM2 配置里。两种方式各有利弊配置在代码库外的方案安全性更高但也要把 PM2 配置文件本身保护起来别把密钥提交到 Git 仓库。我在这一步踩了一个低级错误把 .env 文件传到服务器后权限是 644但 PM2 启动时始终提示 EACCES: permission denied。排查了半天才发现文件所有人和运行用户不一致直接用 chown 改掉问题就消失了。服务器上创建专用运行用户是另外一件重要的事别直接用 root 跑业务服务虽然方便但安全风险太高而且一旦被攻击整个服务器直接沦陷。我用的是一个叫 appuser 的普通用户目录权限按照最小化原则配置。3. Nginx 反向代理与 HTTPS 配置3.1 Nginx 为什么要挡在 NestJS 前面NestJS 默认监听 3000 端口直接 http://IP:3000 访问也能通但这只能算“能跑”算不上“可用”。裸露业务端口的第一问题是安全风险第二个问题是无法统一管理域名和证书第三个问题是没法做负载均衡、静态文件缓存。所以生产环境的经典架构是Nginx 对外监听 80HTTP和 443HTTPS端口把请求转发到 127.0.0.1:3000。用一个生活化的类比Nginx 是酒店前台所有客人进门先找前台前台确认身份、记录来访信息后才把客人带到对应的业务部门。Node 进程就是后台业务人员他不需要跟外面世界直接打交道客户也接触不到他的工位。这样做不仅安全而且 Nginx 处理高并发连接的能力比 Node 自身强不少。换个角度说Nginx 和 Node 之间是用内网回环地址 127.0.0.1 通信的客户端根本无法直接访问 3000 端口除非有人攻破了内网或者你同时把安全组 3000 端口也放行了那就等于自己把后台入口暴露了。这也是很多新手部署完之后外网仍然访问不了 3000 端口的原因之一——ECS 安全组没放行Node 进程只监听 127.0.0.1。这些都属于正常现象不要慌先把架构目标明确了对外只开 80 和 443。3.2 Nginx 配置怎么写反向代理、超时参数与前后端分离Nginx 配置文件的完整示例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; } }配置完毕用 nginx -t 检查语法然后 systemctl reload nginx 让配置生效。注意 reload 和 restart 的区别reload 不会中断现有连接适合在配置变更时使用。这里有几个关键点值得展开。第一proxy_set_header Host $host 必须配置否则 NestJS 里的 request.get(host) 会变成 127.0.0.1:3000导致某些依赖域名判断的功能失效比如 OAuth 回调地址。第二X-Forwarded-Proto 的作用是告诉后端“原始请求是 HTTP 还是 HTTPS”。当你在 HTTPS 环境里调用 http 接口或者生成带协议的跳转链接时后端才能正确拼出 https:// 开头。第三proxy_read_timeout 要根据实际业务调整。如果接口里有长轮询或者大文件下载默认 60 秒可能不够需要适当调大。如果项目是前后端分离架构前端静态文件放在 Nginx 的 /var/www/html 目录后端接口在 /api 路径那么可以这样配server { listen 80; server_name www.example.com; location / { root /var/www/html; index index.html; } location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有个细节location /api/ 后面 proxy_pass 加了末尾斜杠和没加斜杠的效果不一样。加上斜杠表示把 /api/ 替换为 /比如请求 /api/users 会转成 /users 传给后端不加斜杠则保留完整路径 /api/users 传给后端。两者取决于你 NestJS 里的路由前缀用错了接口全挂。我这次就吃了一次亏NestJS 端设置了 app.setGlobalPrefix(api)Nginx 里又配了 location /api/ 并要求去掉前缀结果所有接口都 404。3.3 阿里云免费 SSL 证书申请与自动续期HTTP 明文跑外网属于裸奔状态现在主流流量入口基本都强制 HTTPS。阿里云数字证书管理服务里可以申请免费 SSL 证书注意免费版一般只能单域名不支持通配符域名而且有效期通常只有三个月到一年到期前要记得续期。申请流程大致是登录阿里云控制台搜索“数字证书管理服务”进入免费证书申请填写域名然后做域名验证验证通过后签发证书。下载时选择 Nginx 格式会得到一个 pem 文件和一个 key 文件。把这两个文件上传到服务器的 /etc/nginx/cert 目录然后在 Nginx 配置里加上 443 端口server { listen 443 ssl http2; server_name api.example.com; ssl_certificate /etc/nginx/cert/api.example.com.pem; ssl_certificate_key /etc/nginx/cert/api.example.com.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } }同时保留 80 端口做强制跳转server { listen 80; server_name api.example.com; return 301 https://$host$request_uri; }证书续期这块免费证书到期前阿里云会发短信和邮件提醒控制台里可以直接一键续期下载新证书后替换服务器文件再 reload Nginx 就行。我自己吃过亏的是续期下载回来的证书如果文件名一样覆盖后一定要执行 nginx -t 并 reload否则 Nginx 还在用旧证书浏览器端会报证书过期。另外有很多人用 certbot 做 Lets Encrypt 自动续期这在普通 VPS 上很好用但阿里云免费证书是控制台管理的两套体系不一样别混淆。4. 常见问题排查实录4.1 端口与安全组排查ECS 常见第一坑部署完成后最常见的问题是公网 IP 访问不了服务。端口和安全组这两个层面的排查顺序不要反先确认端口在监听再确认安全组放行。注意 ECS 安全组和服务器内部防火墙是两套机制安全组在阿里云控制台里配置内部防火墙是你服务器上的 ufw 或者 firewalld。很多新手只在服务器内部放行端口却忘了安全组也放行或者反过来只在阿里云控制台放行但服务器内部防火墙拒绝了连接。判断端口是否在监听用 ss 命令ss -tlnp | grep 3000如果结果显示 127.0.0.1:3000说明进程在跑但只监听回环地址外部访问不了这时候要确认 Nginx 配置里的 proxy_pass 是否指向 3000而不是把 Node 直接改成监听 0.0.0.0。如果显示 0.0.0.0:3000说明监听所有网卡配合安全组放行公网入口后外网就能直接访问。安全组规则建议最小化开放80、443 对外开放22 端口只允许你的办公 IP 访问其他端口一律不开放。我踩过一个坑为了调试方便把 3000 端口放行到所有 IP结果服务开始收到大量来自外部的扫描请求日志里全是一堆乱七八糟的路径探测安全风险极高。生产环境千万别干这种事。如果你在内网还要访问 RDS 数据库需要在 RDS 的白名单里添加 ECS 的内网 IP 或者公网 IP。这里有个点ECS 和 RDS 在同一地域且同一 VPC 下用内网地址连接数据库速度更快、网络更安全访问白名单也只需要加私网 IP。如果你把数据库连接地址配成了公网地址不但慢而且外网流量还可能产生额外费用。4.2 数据库连接失败区分网络、账号、配置三类问题部署之后接口能起来但所有涉及数据库的接口都报 500这类问题大概率是数据库连接。NestJS 里 TypeORM 或 Prisma 的报错信息比较长里面通常会带上原因常见的有三种。第一种是 ECONNREFUSED连接被拒绝。这几乎可以确定是网络层面的问题——数据库地址写错、ECS 到数据库的路由不通、RDS 白名单没有加当前服务器 IP。一条条排查先 ping 数据库地址确认基本可达再用 telnet 测试端口通不通。如果数据库是 RDS确认白名单如果是 ECS 自建数据库确认数据库进程的 bind-address 配置有没有监听正确网卡。第二种是 Access denied for user账号或密码不对。这种报错最直接但最容易被忽略的是用户授权范围。MySQL 里 root 用户默认只允许 localhost 登录如果用远程连接会直接被拒绝。建议创建专用账号并授权允许远程访问账号名尽量不用 root。这一步走完再用 Navicat 或者命令行连一次确认无误后再把连接地址更新到 .env。第三种是超时 time out。通常是连接池配置太小或者数据库负载太高。TypeORM 默认的连接池大小可以调大一些但也不要无脑调连接太多同样会拖垮数据库。我一般设置 max 为 10 或者 20配合数据库实例规格来定。另外Prisma 的 connection_limit 参数同理调太大没意义反而会因为连接等待导致请求排队。4.3 502 Bad GatewayNginx 和 Node 进程的协同排查502 Bad Gateway 是 Nginx 反代场景里最经典的报错背后可能是任何一环出了问题。排查顺序我一般是从后往前先确认 Node 进程是否活着再确认 Nginx 能否访问到后端最后看 Nginx 错误日志。第一步看 PM2 状态pm2 status如果进程状态是 errored 或者 stopped说明后端没起来先去 PM2 的错误日志里找原因。常见的是 main.js 路径不对、环境变量缺失、端口被占用。把 ecocystem.config.js 里的 path 检查一遍别问我怎么知道要检查的我第二次部署时就写错了相对路径PM2 一直报 Cannot find module。如果 PM2 显示 online但 Nginx 仍然 502那就看 Nginx 错误日志tail -n 100 /var/log/nginx/error.log日志里通常写着 connect() failed (111: Connection refused) while connecting to upstream或者 connect() failed (110: Connection timed out) while connecting to upstream。前者说明 3000 端口上没有进程在监听后者说明请求发了但 Node 没有及时响应。如果是 Connection refused回到 PM2 status 那一步如果是 timeout大概率是请求处理时间超过了 Nginx 的 proxy_read_timeout需要调大超时时间或者检查后端某个接口是不是存在阻塞问题。另外还有一步容易被忽略Nginx 配置改完没有 reload。改完配置只执行 nginx -t 检查语法忘了 reload结果配置根本没生效我在第三遍排查时才反应过来。做完修改后养成习惯nginx -t systemctl reload nginx。4.4 内存溢出与 OOM Killer用 PM2 观察内存走势前面说过的 1核1G 的服务器跑 NestJS TypeORM Redis 时 OOM这个现象在生产中非常典型。Node 进程不会因为单个对象过大而直接崩掉更常见的问题是系统物理内存被多个进程吃满Linux 的 OOM Killer 会选择一个“最该死”的进程杀掉。判断方法很简单dmesg | grep -i oom-killer日志里如果能看到 Node 进程的 PID说明它就是那个“幸运儿”。解决方案有四个层面升级实例内存是根解法加 swap 分区是缓冲优化代码是长期方案PM2 设置 max_memory_restart 是自动恢复兜底。我现在 2G 内存加 2G swapmax_memory_restart 设为 512M配合 PM2 的自动重启稳定性比以前好多了。内存观察建议用 pm2 monit能实时看到每个进程的 CPU 和内存占用也能看出进程是不是存在内存泄漏趋势——如果内存曲线持续上升重启后又会降一点那基本可以断定代码里有泄漏点。NestJS 里常见的泄漏场景包括全局数组或 Map 只增不删、定时任务不断创建连接、事件监听器没有移除。这个问题一时半会不好根除但至少生产环境要有自动恢复机制兜底。写在最后踩过这些坑之后的几个习惯这次部署让我养成了几个习惯分享出来可能对你有帮助。第一生产环境配置一律收口到 PM2 的 ecosystem.config.js 或专门的配置管理服务不散落在代码里也不随手写在 .env 里到处传。第二每次改完 Nginx 配置先 nginx -t 再 reload绝不跳过检查。第三部署前先在服务器上把环境变量、数据库连通性、磁盘空间等基础项检查一遍这个项目级检查清单能省掉很多表面排查的时间。最实用的小技巧是上线前先配好 pm2 log 路径并打开 Nginx access log一旦出问题日志就是最直接的破案线索。服务器环境的坑很多是“一次踩完下次永远记得”希望这篇记录能帮你把这些坑提前填平。