Node.js 服务搭建这件事,本地跑通一个 HTTP 接口三五行代码就够,但真到生产环境,你会发现问题一个接一个:进程崩了没人管、端口被莫名其妙占用、日志找不到、环境变量换个服务器就失效。这篇文章是我从零搭一套生产级 Node.js 服务的完整记录,从环境准备、技术选型、核心代码组织,到服务器部署、PM2 守护、Nginx 反代、HTTPS 配置,再到日常遇到的问题排查,全流程走一遍。适合刚入门的同学照着做,也适合本地能跑但一上服务器就各种出问题的同行对照自查。
1. 动手之前,先搞清楚“生产级”到底意味着什么
很多朋友上来就 npm init && npm install express && npm start,本地跑起来觉得自己已经会了。但生产级服务的要求完全不在一个维度,它不只是一个能响应的 HTTP 进程,而是要在长期无人值守的情况下稳定运行、出了问题能快速定位、遇到流量波动不至于直接打挂的一套综合治理体系。
1.1 本地能跑和生产能跑,差距在哪里
先列几个我实际踩过的本地没问题、上生产就翻车的场景,你们感受一下:
- 本地 Windows / Mac 上
node app.js跑得好好的,服务器是 Linux,路径分隔符、文件权限、shell 脚本逻辑全部不一样,启动直接报 EACCES。 - 代码里用了
console.log打日志,本地在终端肉眼可见,部署后日志没人看,进程挂了什么线索都没留下。 - 数据库连接串、第三方 API Key 直接写死在代码里,代码传到 Git 仓库等于密钥裸奔,换环境还得改代码重新发版。
- 端口号写死 3000,服务器上一个服务占用了 3000,你的服务启不来,排查半天发现是冲突。
- 服务进程因为未捕获的异常挂掉,没有任何自动恢复机制,第二天一看服务宕了一整夜。
这些问题单独看都不难解决,但如果不在一开始就按生产环境的标准来设计,后面堆出来的修复代码会越来越乱。我个人的经验是:本地开发只是编码环境,默认配置就是为“有人盯着终端屏幕”设计的;而生产环境是无人值守的,一切依赖外部保障的设计都必须显式补齐。
1.2 技术选型:框架、语言和进程管理怎么定
技术选型是第一步,也是最容易被低估的一步。很多人看着某个框架最新、下载量高就选哪个,但生产项目稳定优先,社区成熟度和团队熟悉度比“技术时髦”重要得多。
Node.js 服务端框架,目前主流选择是 Express、Fastify、Koa 三个:
- Express:老牌王者,生态最全,中间件资源丰富,遇到问题搜解决方案基本一搜一大把。缺点是从底层到高层都要自己搭,灵活性高,但约束少。
- Fastify:性能比 Express 好不少,内置了 Schema 校验、日志、生命周期管理,对 TypeScript 支持很友好。我做新项目时越来越倾向选它。
- Koa:Express 原班人马做的下一代,基于 async/await 的洋葱模型,中间件写起来很舒服,但生态比 Express 略弱。
如果你只是需要一个标准的 REST API 服务,我个人建议:团队没人用过 Fastify 就老实用 Express,理由不是谁更好,而是出问题时团队能最快定位。技术上够用永远比理论最优重要。
语言方面,JavaScript 还是 TypeScript?我的态度很明确:生产级服务首选 TypeScript。它不是锦上添花,而是在动了几年后你会感谢当初的选择——类型约束能挡住大量显而易见的低级错误,接口定义就是自带文档,重构时 IDE 联动改类型比人肉搜索靠谱太多。当然,如果你只是写个三五天就扔的脚本,纯 JavaScript 完全没问题,别过度设计。
进程管理这块,生产环境必须有一个守护进程的工具。选择有三个方向:
- 直接用系统自带的 systemd 托管,适合单实例部署,配置简单,但功能有限。
- 用 PM2,功能全面,自带负载均衡、日志管理、监控面板、优雅重启,是 Node.js 社区最主流的方案。
- 放 Docker 里跑,然后由 K8s 或 Docker Compose 管理,这是云原生标准玩法,但从零部署的学习成本会高不少。
这篇文章我重点讲 PM2 方案,它跟传统服务器部署场景贴合得最紧密,又不需要额外学习容器化知识,对大多数中小团队来说性价比最高。
1.3 目录结构与代码组织方式
很多初学者项目目录就一个 app.js,所有东西都往里面堆。这在自己的小项目里没问题,但一旦需求增加、多人协作,就变成灾难。生产级项目,目录结构在第一天就要规划好。
我常用的一个基础结构长这样:
code复制my-service/
├── src/
│ ├── app.js # 应用入口,负责装配中间件和路由
│ ├── server.js # 服务器启动入口,负责监听端口
│ ├── config/
│ │ ├── index.js # 统一配置出口
│ │ └── env.js # 环境变量解析与校验
│ ├── routes/ # 路由定义
│ ├── controllers/ # 业务逻辑层(控制器)
│ ├── services/ # 服务层,封装具体业务操作
│ ├── models/ # 数据模型 / 数据库访问
│ ├── middlewares/ # 自定义中间件
│ ├── utils/ # 工具函数
│ ├── logs/ # 日志目录(生产环境改为外部挂载)
│ └── tests/ # 测试用例
├── ecosystem.config.js # PM2 配置文件
├── .env.example # 环境变量样例
├── .gitignore
├── package.json
└── tsconfig.json # 如果用 TypeScript,这里是编译配置
这个分层的思想是:路由只做转发,控制器做参数校验和结果返回,服务层写业务逻辑,模型层管数据访问。各层之间单向依赖,避免你中有我我中有你。我见过太多项目把数据库查询写在路由回调里,一开始很爽,后面要加鉴权、缓存、埋点的时候,就会发现无处下手,只能大范围改代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:不装在自己电脑上,根本跑不到这一课
这里的“环境准备”不是去官网下载一个安装包双击完事,而是要从第一天就按生产环境的标准管理你的 Node.js 版本和依赖。版本混乱是新手团队最常见的内耗来源。
2.1 Node.js 版本选择:为什么我强烈建议用 LTS
Node.js 的版本发布节奏是:偶数版本(如 20、22、24)会进入 LTS(Long Term Support)长期维护版,奇数版本是当前版,只有 6 个月的支持周期。生产环境必须选 LTS,理由很简单:安全补丁覆盖时间长,生态兼容性经过验证。
很多同行跟我说生产环境想尝鲜用新版本,结果部署后某个依赖的原生模块编译不过,或者某个 API 行为变了导致线上故障,最后只能回滚。我自己的经验是:本地开发可以随意切版本测试,生产环境的 Node.js 版本一旦定下来,非必要不升级,升级前先在 staging 环境完整回归。
这里必须推荐使用 NVM(Node Version Manager)来管理版本。它可以让你在同一个系统里自由切换多个 Node.js 版本,解决不同项目需要不同 Node 版本的问题。安装方式很简单:
bash复制# 安装 nvm(macOS / Linux)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
# Windows 用户可以用 nvm-windows,从官方仓库下载 exe 安装包
# 安装最新 LTS 版本(以 22 为例,具体版本以官方为准)
nvm install 22
nvm use 22
node -v
装上 nvm 之后,新老项目切换版本就一行命令的事,再也不用担心“我本地是 18,服务器是 20,CI 上是 22,代码逻辑在某个环境跑挂了”。顺便说一句,.nvmrc 文件配合项目使用是个好习惯,在项目根目录写 22 然后执行 nvm use,团队成员切换时不会用错版本。
2.2 项目初始化与依赖管理
新建项目我很少用 npm init -y 一把梭,而是先把基础依赖按职责分好,再逐个安装。核心依赖就两类:运行时依赖(dependencies)和开发依赖(devDependencies),千万别混,否则 npm install --production 时把测试工具、打包工具也装上去了,浪费磁盘是小,给生产环境带来安全隐患才是大问题。
一个典型的生产级基础依赖大概长这样:
- 运行时依赖:express、dotenv、helmet、cors、pino(或 winston)、pino-pretty(开发用)
- 开发依赖:typescript、ts-node、@types/express、jest 或 vitest、supertest、eslint、prettier
安装命令:
bash复制npm install express dotenv helmet cors pino
npm install -D typescript ts-node @types/express @types/node jest supertest eslint prettier
关于包管理器,npm 依然是最稳的选择,但如果要在 CI/CD 里做依赖安装缓存,pnpm 的硬链接机制效率高很多,yarn 的经典版本则相对成熟。我建议新项目直接上 pnpm,它把磁盘占用和安装速度都优化得很好,还能天然规避依赖幽灵问题。
依赖版本最好用 package-lock.json(或 pnpm-lock.yaml / yarn.lock)锁死,提交到 Git 仓库。不锁版本的话,过半年部署,依赖里的一个小版本更新可能就带来一个破坏性变更,到时候排错排到怀疑人生。
2.3 TypeScript 的取舍:生产级项目我推荐加上
如果项目规模超过一个文件,我基本都会上 TypeScript。原因很简单:大型项目的维护成本中,代码阅读成本占了很高比例,类型就是最廉价、最可靠的内联文档。
一个最小可用的 tsconfig.json 配置:
json复制{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}
开发时用 ts-node-dev 或者 tsx 做热重载,生产构建时用 tsc 把 TypeScript 编译成 JavaScript 放到 dist/,然后 PM2 直接跑编译后的产物。这套流程我从 18 版本时代用到现在,没出过什么幺蛾子。
注意:把编译后的
dist/目录加入.gitignore是一个常见争议点。我建议忽略掉,因为 CI/CD 流程里每次部署都会重新构建,源码仓库不应当包含构建产物,否则容易造成仓库膨胀和构建不一致。
3. 核心服务实现:从能跑通到能扛事
这一章是重头戏。就算目录结构设计好了,代码写得不讲究,生产环境依然会各种坑。我来逐个拆解我用一个 Express + TypeScript 项目做示范,这些细节同样适用于 Fastify。
3.1 用 Express 搭建第一个真正能上线的基础骨架
App 入口 src/app.js 的设计非常关键,它决定了你的代码能不能被测试,以及能不能在多种环境下复用。我习惯把“应用装配”和“网络监听”拆成两个文件:app.js 只创建 app 对象并挂载中间件和路由,server.js 才真正启动监听。这样写的好处是写单元测试时可以直接 request(app),不需要真的去占一个端口。
一个基础但完整的 app.js 长这样:
javascript复制const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const { requestLogger, errorHandler, notFoundHandler } = require('./middlewares');
const routes = require('./routes');
const app = express();
// 基础安全中间件:设置各种 HTTP 安全头
app.use(helmet());
// 解析 JSON 请求体
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// 开发环境下允许跨域,生产环境按需配置白名单
app.use(cors());
// 结构化访问日志
app.use(requestLogger);
// 健康检查端点:负载均衡和监控系统都用它
app.get('/healthz', (req, res) => {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});
// 业务路由
app.use('/api', routes);
// 404 处理
app.use(notFoundHandler);
// 统一错误处理
app.use(errorHandler);
module.exports = app;
这里有几个点,我单独展开说说:
- helmet 几乎是必装的,它通过设置一堆 HTTP 安全头(比如 X-Content-Type-Options、X-Frame-Options)来缓解常见的 Web 攻击面,一行代码就装上,成本几乎为零。
- 请求体大小限制
limit: '1mb'看似随意,实际上能有效防止恶意请求把内存打爆。根据业务场景调整,但一定不能省。 - 健康检查端点
GET /healthz是云服务器、负载均衡器、容器编排工具判断服务存活状态的标准方式,没有这个端点,后面接自动重启和流量调度会非常别扭。
server.js 的监听部分要特别注意优雅退出。生产环境里进程收到 SIGTERM(比如服务器要重启了)时,应该先把正在处理的请求处理完,再关闭数据库连接、释放资源,最后才退出。直接 process.exit(0) 会丢掉正在进行的请求,这是生产事故的高发点。
一个比较标准的优雅退出实现:
javascript复制const http = require('http');
const app = require('./app');
const server = http.createServer(app);
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
// 优雅退出:收到退出信号时先停止接收新请求,再处理完存量请求
async function shutdown(signal) {
console.log(`${signal} received, shutting down gracefully...`);
server.close(async () => {
// 在这里关闭数据库连接、Redis 连接等
// await db.end();
process.exit(0);
});
// 超时兜底:如果 10 秒内没能优雅退出,强制终止
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
这里 server.close() 会停止接受新连接,并等待已存在的连接处理完毕。setTimeout(...).unref() 是为了让定时器不阻止进程自然退出,如果正常退出了定时器就失去作用,如果挂住了就强制退出,双保险。
3.2 环境变量与配置管理:12-Factor 是关键
配置管理是生产级和玩具级的分水岭。12-Factor App 规范里有一条:配置要存于环境变量,而不是写死在代码里。核心思想是同一个代码包,通过环境变量切换配置,就能在不同环境(开发、测试、生产)运行,无需重新构建。
实际操作层面,我推荐这种方式:
- 项目根目录放
.env.example,里面列出所有环境变量和示例值,作为配置文档,提交到 Git。 - 真实配置写在
.env文件,加入.gitignore,绝不提交。 - 代码里用 dotenv 加载
.env文件,然后在src/config/env.js中集中解析和校验。
src/config/env.js 示例:
javascript复制const dotenv = require('dotenv');
// 加载 .env 文件,但不覆盖已经存在的环境变量
dotenv.config();
function getEnv(key, defaultValue) {
const value = process.env[key] ?? defaultValue;
if (value === undefined) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
module.exports = {
env: getEnv('NODE_ENV', 'development'),
port: parseInt(getEnv('PORT', '3000'), 10),
databaseUrl: getEnv('DATABASE_URL'),
redisUrl: getEnv('REDIS_URL', null),
apiKey: getEnv('API_KEY'),
logLevel: getEnv('LOG_LEVEL', 'info'),
};
为什么要在启动时就校验环境变量缺失?因为错误暴露得越早,排查成本越低。如果启动时没报错,运行到一半发现数据库连接串是空的,那才是灾难。把配置集中到一个文件里,也方便后续做 key 的统一管理和加注释。
部署到服务器时,环境变量可以直接用 PM2 的生态文件注入,或者放到 systemd 的 EnvironmentFile 里,或者用 Docker 的 -e 参数注入。总之,代码包本身不携带任何环境相关的内容,这就是 12-Factor 的核心收益:同一个构建产物,可以在任何环境跑。
3.3 日志:你排查事故的第一现场
很多人觉得日志不就是 console.log 吗?等到线上出问题要排查的时候,发现连一个带时间戳的日志都没有,全是一堆 undefined is not a function 这种毫无上下文信息的报错,那种绝望我经历过太多次了。
生产级服务必选结构化日志。结构化的意思是每条日志是一个 JSON 对象,包含固定字段(如时间、级别、请求 ID、路由、耗时、错误堆栈)和自定义业务字段,方便后续投递到 ELK、Loki 等日志中心做检索和分析。
我推荐使用 pino,它性能极好,同时天然支持 JSON 输出。请求日志中间件可以和 pino 配合,每个请求自动生成一个 requestId,贯穿整个请求生命周期:
javascript复制const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
timestamp: pino.stdTimeFunctions.isoTime,
});
// 中间件:为每个请求生成 requestId 并注入日志
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || crypto.randomUUID();
req.log = logger.child({ requestId: req.id });
const start = Date.now();
res.on('finish', () => {
req.log.info({ method: req.method, url: req.url, status: res.statusCode, duration: Date.now() - start }, 'request completed');
});
next();
});
日志要覆盖哪些内容?访问日志必须有(方法、路径、状态码、耗时),错误日志必须有(堆栈、上下文、请求体但不包括敏感信息),业务关键动作也建议记录(比如用户注册、订单创建),但注意日志里绝不能打印密码、Token、完整银行卡号等敏感信息。
注意:生产环境千万别用
console.log打乱日志。一是无法结构化,二是会影响性能(console.log 在终端打印是同步操作),三是日志级别、输出格式都没法控制。写一个小习惯:代码评审时看到 console.log 一律打回。
3.4 错误处理:把所有异常都变得可预期
Node.js 的哲学是“错误优先”,但实际写起来,异常总会在意想不到的地方冒出来。生产级错误处理有三层防线:
第一层是同步代码的 try/catch。在 Express 4 里,async 路由的异常需要手动包一层 try/catch,否则异常会被吞掉。Express 5 已经支持自动捕获 async 错误,但为了保险,我还是习惯用一个小工具包一下:
javascript复制// asyncHandler:包装 async 路由处理器,把异常传给错误处理中间件
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/user/:id', asyncHandler(async (req, res) => {
const user = await getUserById(req.params.id);
res.json(user);
}));
第二层是统一错误处理中间件。Express 的中间件链条中,错误处理中间件必须接收四个参数(err, req, res, next),它会捕获前面所有中间件和路由抛出的异常:
javascript复制app.use((err, req, res, next) => {
const status = err.status || 500;
const message = status === 500 ? 'Internal Server Error' : err.message;
// 记录完整的错误信息,包括堆栈
req.log.error({ err, req: { method: req.method, url: req.url } }, 'unhandled error');
// 500 错误对外只返回通用消息,避免暴露内部细节
res.status(status).json({ error: message });
});
这里值得强调的是:500 错误信息绝对不能原样返回给客户端。很多框架默认会把堆栈直接抛出来,相当于把服务端代码结构双手奉上给攻击者。生产环境对外错误信息一律泛化,详细错误只进日志。
第三层是兜底处理未捕获的 Promise 异常:
javascript复制process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// 生产环境可以选择退出进程并由 PM2 自动重启,也可以只记录日志,视业务而定
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
});
这里有个业界争议:遇到未捕获异常应该直接退出进程让 PM2 重启,还是尝试继续运行?我的观点是:未捕获异常意味着程序已经进入不可预期的状态,继续运行可能输出错误结果,不如直接退出由 PM2 拉起来,让服务恢复到一个干净状态。但重启必然造成几秒的不可用,所以更核心的是通过测试和评审减少这类异常。
4. 部署到生产环境:服务器上的每一步都有讲究
服务写完了,本地测试也过了,接下来就是把它搬到服务器上。这一步的坑大多来自环境差异、权限问题和进程管理。
4.1 服务器准备:Node 环境的三种部署方式
根据团队的技术底座,Node 服务上服务器有三种主流姿势:
方式一:直接用主机 Node 运行 + PM2 守护
这是最传统、也最容易理解的方式。先登录服务器,用 nvm 装上对应版本的 Node.js,拉代码,装依赖,构建,然后用 PM2 常驻运行。优点是好排查(直接在服务器上看日志、改配置);缺点是环境一致性靠人肉保证,多台服务器要一台台重复配置。
方式二:Docker 容器化部署
用 Dockerfile 把应用和运行环境打包成镜像,服务器只需要 Docker 运行时,镜像里自带 Node 版本、系统依赖、代码和启动命令,彻底消灭“在我电脑上能跑”的问题。这是目前最推荐的方式,环境一致性有了质的飞跃。配合 Docker Compose 管理多容器(比如服务加 Redis 加 Nginx),日常运维很流畅。
一个最小可用的 Dockerfile 示例:
dockerfile复制# 多阶段构建:第一阶段用于编译 TypeScript,第二阶段只保留运行产物
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
镜像里用 USER node 是安全最佳实践,避免容器以 root 身份运行。镜像应该尽量小、精简,alpine 基础镜像比完整的 Debian 要小很多,但要注意个别 npm 原生模块可能需要在 alpine 里额外装编译工具。
方式三:云 PaaS 平台
直接推到 Railway、Render、Fly.io 这类平台,平台自动识别 Node 项目、安装依赖、跑起服务,然后给你一个域名。这种方式对小型项目和原型验证友好,省心,但绑定平台,长期成本弹性也要评估。
本文以方式一为主讲透细节,因为理解了 PM2 和反向代理的原理,Docker 化只是换个壳的事。
4.2 PM2 进程守护与负载均衡
PM2 的核心作用有三块:进程守护(崩溃自动重启)、负载均衡(多实例共享端口)、日志管理。
安装和启动:
bash复制npm install -g pm2
# 启动服务,指定进程名为 my-api
pm2 start dist/server.js --name my-api --env production
# 查看运行状态
pm2 status
# 查看日志
pm2 logs my-api
但生产环境我强烈推荐用 PM2 的生态配置文件 ecosystem.config.js,把应用配置固化在代码仓库里,而不是每次部署时用命令行参数拼:
js复制module.exports = {
apps: [
{
name: 'my-api',
script: 'dist/server.js',
instances: 'max', // 根据 CPU 核数开启多实例
exec_mode: 'cluster', // 集群模式,实现负载均衡
max_memory_restart: '512M', // 内存超 512MB 自动重启
env: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: '/var/log/node/my-api-error.log',
out_file: '/var/log/node/my-api-out.log',
merge_logs: true,
time: true, // 日志中加时间戳
},
],
};
配置完用 pm2 start ecosystem.config.js 启动。这里 instances: 'max' 会让 PM2 按 CPU 核数启动多个进程,cluster 模式下 PM2 内置了负载均衡,将请求分发到不同进程,单进程崩溃只影响部分流量,配合自动重启,可用性提升非常大。
max_memory_restart 是一个很实用的兜底:如果代码有内存泄漏,进程内存涨到阈值自动重启,能拖延到你有时间排查修复,而不是让服务慢慢拖死服务器。
另外两个必做的 PM2 运维操作:
bash复制# 保存当前进程列表,服务器重启后自动恢复
pm2 save
# 生成系统启动脚本,让 PM2 随系统开机自启
pm2 startup
如果你用 systemd 管 PM2,pm2 startup 会自动生成一个 systemd service,把 PM2 变成系统服务管理,服务器重启后 PM2 自动拉起所有应用。这一步不做,服务器一旦重启,服务就凉了,很多人栽在这里。
4.3 Nginx 反向代理与 HTTPS
Node.js 服务默认监听某个端口(如 3000),但生产环境几乎不会直接把端口暴露给用户,而是让 Nginx 作为反向代理监听 80/443,再把请求转发给 Node 进程。这样做的原因有几个:
- Nginx 处理静态文件、HTTPS 终结、HTTP/2、Gzip、限流等比 Node 高效得多。
- Node 只需要关心业务逻辑,不需要处理 TSL 证书、并发连接优化这些底层事情。
- 可以统一管理多个上游服务,按域名或路径转发到不同后端。
一个典型 Nginx 配置:
nginx复制server {
listen 80;
server_name api.example.com;
# HTTP 自动跳转到 HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/nginx/ssl/api.example.com.pem;
ssl_certificate_key /etc/nginx/ssl/api.example.com.key;
# 反向代理到 PM2 的 Node 进程
location / {
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_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_cache_bypass $http_upgrade;
}
}
这里 proxy_set_header X-Forwarded-For 和 X-Forwarded-Proto 很重要。Node 服务通过它们才能拿到用户的真实 IP 和请求协议。同时注意,拿到代理头之后,Node 侧最好用 app.set('trust proxy', true) 告诉 Express 信任位于代理之后的请求(Express 默认拒绝,是为了防止伪造 IP,只有明确知道前面是自己的反向代理时才开启)。
HTTPS 证书申请,推荐用 Let's Encrypt 的 certbot,免费且自动续期:
bash复制sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com
certbot 会自动修改 Nginx 配置、安装证书、配置自动续期 cron,整个 HTTPS 落地过程可以做到零维护。
4.4 数据库连接、缓存与外部服务配置
服务跑起来之后,如果还要连数据库、Redis 或者调用外部 API,需要在部署时一并把这些依赖配置好。这一步最常见的坑是:本地连的是 localhost,服务器上数据库凭证、内网地址、SSL 连接参数全都不一样。
我的建议是:把数据库连接、Redis、第三方 API 的配置全部放到环境变量(并通过 4.2 节提到的 PM2 env 配置注入),并且写一个启动自检逻辑:启动时先检查数据库和缓存是否可达,不可达就快速失败,而不是让服务带着残缺依赖跑起来,然后在第一个请求时报错。
同时数据库连接池要配置合理参数。拿 pg 连接池举例:
javascript复制const { Pool } = require('pg');
const pool = new Pool({
connectionString: config.databaseUrl,
max: 20, // 最大连接数
idleTimeoutMillis: 30000, // 空闲连接超时
connectionTimeoutMillis: 5000, // 获取连接超时
});
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
process.exit(-1);
});
max: 20 不是一个随便定的数字,它要根据服务器的数据库账号最大连接数和实例数来推算。比如 PostgreSQL 默认最大连接数是 100,如果你开了两台 Node 实例,每个实例的池子 max 就不要超过 40,否则高峰期连接数打满,数据库直接拒绝新连接。
5. 常见问题与排查技巧实录
这章是我的“踩坑簿”,挑几个我自己和身边同行经常被问到的典型问题,按照“症状-原因-解决”的方式整理。这些问题都不深奥,但每次遇到都能卡住半天,放在一起方便你对照排查。
5.1 端口占用问题
症状:pm2 start 报端口被占用,或者服务起来了但访问没反应。
排查:先确认端口是不是被别的进程占了:
bash复制# Linux / macOS
lsof -i :3000
# 也经常用
netstat -tunlp | grep 3000
常见场景:本地开发同时起了几个项目,都默认监听 3000;或者上一次进程没杀掉,端口还挂着。解决方法是把服务端口作为环境变量配置,每个项目用不同端口,避免互相打架。生产环境则可能是 Nginx 配置转发到 3000,但 Node 实际起的端口是 3001,查 PM2 进程的状态和 Nginx 的上游配置就能发现不匹配。
5.2 版本和依赖相关报错
症状一:本地新装 Node 版本,跑 dev 时报错 A later version of Node.js is required。这通常是某个依赖要求最低 Node 版本,你本地的版本低于它。解决:用 nvm 切到 LTS 版本,或升级对应依赖。
症状二:生产构建时报 node.js v24.x.x is not yet released or is not available。这个错误本质是 npm 源里还没有这个版本号,或者官方尚未发布该版本。遇到这种情况,先查官方 release 列表,确认版本是否存在,再检查你用的 nvm 源是不是同步不及时,执行 nvm ls-remote 看看远程可用版本。生产环境不要追新版本,用官方 LTS 就稳当。
症状三:Windows 上卸载 Node.js 报错 2053。这是 Windows 安装程序的常见问题,我见到最多的原因是安装目录权限异常或残留注册表项。解决:用官方安装包自带的修复模式,或者直接用 nvm-windows 重新安装,再手动清理 %APPDATA%\npm 和 %APPDATA%\npm-cache 目录。
依赖问题排查黄金法则:遇到依赖相关报错,第一步永远是把 node_modules 和 lock 文件删除重装,而不是盲目升级依赖。npm ci 会严格按照 lock 文件安装,比 npm install 可靠得多,CI/CD 和部署环境里永远用 npm ci。
5.3 服务进程老是被杀掉
症状:服务跑几个小时就挂,或者服务器重启后服务不自动恢复。
排查:先看 PM2 有没有把它拉起来,pm2 status 看 restart count 是否一直在涨。如果是,看日志 pm2 logs 定位崩溃原因。
- 内存超限被 PM2 杀掉:配置了
max_memory_restart后,进程内存超标 PM2 会主动重启,查看日志确认是不是内存泄漏,用node --inspect配合内存快照分析。 - 服务器 OOM(Out Of Memory)杀掉进程:用
dmesg | grep -i oom查看系统日志,确认有没有被内核 OOM Killer 干掉。如果是,要么优化代码内存占用,要么给服务器加内存,要么给 PM2 的 Node 进程设置NODE_OPTIONS=--max-old-space-size=1024限制堆大小。 - 服务器重启后服务没起来:说明没执行
pm2 startup和pm2 save,这是运维遗漏,重新执行一遍。
5.4 常见问题速查表
| 问题 | 可能原因 | 快速排查 | 解决方案 |
|---|---|---|---|
| 端口被占用 | 多实例端口冲突 | lsof -i :端口 | 端口改为环境变量配置,错开使用 |
| 服务启动即退出 | 环境变量缺失 | 看 PM2 日志 | 检查 env 配置,启动时校验配置完整性 |
| 504 Gateway Timeout | Nginx 代理超时 | Nginx error.log | 调整 proxy_read_timeout 或优化后端响应 |
| 502 Bad Gateway | Node 进程没起/崩溃 | pm2 status | 重启 Node 进程,检查监听端口 |
| 请求能看到 IP 全部是 127.0.0.1 | 反向代理未设置 X-Forwarded-For | Nginx 配置 | 添加 proxy_set_header X-Forwarded-For |
| 日志没有时间 | PM2 日志配置问题 | cat 日志文件 | 配置 time: true |
| 内存不停上涨 | 内存泄漏 | node --inspect 分析堆快照 | 排查全局变量、缓存、事件监听器 |
| 版本报错 not yet released | npm 源版本信息落后 | nvm ls-remote | 同步 nvm 源或指定已发布版本 |
5.5 几个从小代价换大收益的实践
最后分享几个我长期坚持的小实践,成本极低,但能在关键时刻救你命:
- 代码里所有出口(HTTP 响应、错误分支)都要有日志,但日志要素结构化,不要裸打字符串。哪怕有一天你上日志收集系统,都是直接在 config 层解决,不用回改业务代码。
- 不要让代码在服务器上裸奔。至少做一次安全加固:Nginx 配置限制请求体大小、禁掉不安全的 HTTP 方法、加上 rate limit;Node 侧装 helmet、不用官方提示已废弃的 API。
- 备份和恢复思路从第一天就建立。数据库定时备份、PM2 配置和 Nginx 配置文件备份、代码仓库远程备份,缺一不可。等到事故发生了再想备份已经晚了。
从零搭一个生产级 Node.js 服务,其实每个环节都不难,难的是把环境、代码、进程守护、反向代理、日志、错误处理这些拼图完整拼起来。按这套流程走过一遍之后,你后面再搭新服务就是纯熟练工种了。我这套方案不一定是最优解,但每一个细节都是在线上真实环境验证过的,照着做能少踩很多坑。如果你在实际部署中遇到文章里没覆盖到的问题,欢迎交流,我根据实际场景给你出排查思路。
