本地运行 npm run dev,一切完美。推到服务器,瞬间崩溃。这就是 Node.js 开发者的日常------开发时真香,部署时真想砸电脑。
周五下午四点,产品说:"这个版本今天必须上线,用户在等。"我看了一眼本地运行得好好的项目,心想:不就是部署吗?半小时搞定。两个小时后,我盯着服务器上那一片红色的报错,手指悬在键盘上,不知道该从哪里开始。外卖凉了,咖啡也凉了,只有我的心,比它们更凉。
那些让人崩溃的部署瞬间
SSH 连上服务器,我深吸一口气,开始了这场"战斗"。首先是拉取代码,这个简单,git pull 一下就好了。然后是安装依赖,我输入了那个熟悉的命令:npm install。看着终端里滚动的日志,我以为一切都会很顺利,但是十分钟后,终端停在了一个错误上:
bash
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! node-sass@4.14.1 postinstall: `node scripts/build.js`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the node-sass@4.14.1 postinstall script.
我愣住了。本地明明运行得好好的,为什么到了服务器就不行了?我开始搜索这个错误,Stack Overflow 上有一堆答案,有人说是 Node 版本不对,有人说是 Python 环境问题,还有人说要重新编译 node-sass。我一个个试过去,删除 node_modules,清除缓存,重新安装,换源,升级 Node 版本......折腾了一个小时,终于装上了。但是当我运行 npm run build:h5 的时候,又出现了新的错误:
bash
Error: Cannot find module '@dcloudio/uni-cli-shared'
Require stack:
- /root/project/node_modules/@dcloudio/vue-cli-plugin-uni/lib/env.js
我看着这个错误,心里一阵无奈。明明 package.json 里写着这个依赖,为什么找不到?我打开 node_modules 目录,发现这个包确实存在,但是 Node 就是找不到。我开始怀疑是不是依赖树的问题,于是我删除了 package-lock.json,重新安装,还是不行。我又试着用 npm ci 代替 npm install,还是不行。最后我发现,原来是服务器上的 npm 版本太旧了,不支持某些新特性。我升级了 npm,重新安装依赖,这次终于成功了。
但是,当我运行构建命令的时候,又遇到了新的问题。这个项目是一个 uni-app 项目,需要构建成 H5、微信小程序、支付宝小程序等多个平台。我在本地开发时,用的是 Windows 系统,而服务器是 Linux 系统。构建过程中,有些路径分隔符不一样,有些文件权限不一样,导致构建失败。我不得不修改构建脚本,把所有的反斜杠改成斜杠,把所有的绝对路径改成相对路径。改完之后,重新构建,这次终于成功了。看着 dist 目录里生成的文件,我长舒了一口气,以为终于可以上线了。
环境变量的噩梦
构建完成后,我开始配置 Nginx,把静态文件指向 dist 目录。配置完成后,我在浏览器里打开网址,页面倒是显示出来了,但是所有的 API 请求都失败了。我打开浏览器的开发者工具,看到所有的请求都指向了 http://localhost:3000,而不是生产环境的 API 地址。我这才想起来,环境变量没有配置!
在本地开发时,我用的是 .env.development 文件来配置开发环境的 API 地址,而生产环境应该用 .env.production 文件。但是在构建时,我忘记指定环境变量了。我重新运行构建命令,这次加上了环境变量:
bash
NODE_ENV=production npm run build:h5
构建完成后,我刷新浏览器,API 请求还是指向了 localhost。我开始怀疑是不是环境变量没有生效,于是我打开构建后的 JS 文件,搜索 API 地址,发现确实还是 localhost。我回到代码里检查,发现原来是 vue.config.js 里的配置有问题,环境变量没有正确注入到代码里。我修改了配置,重新构建,这次终于成功了。
但是,当我测试支付功能的时候,又出现了新的问题。支付需要调用微信支付的 API,而微信支付需要配置商户号、密钥等敏感信息。这些信息不能写在代码里,也不能提交到 Git 仓库,只能通过环境变量来配置。我在服务器上创建了 .env 文件,把所有的敏感信息都写进去,然后重启服务。但是,Node 进程读取不到这些环境变量。我查了一下文档,发现原来 Node 进程需要用 dotenv 包来加载 .env 文件。我在代码里加上了 require('dotenv').config(),重新部署,这次终于成功了。
npm 包版本冲突的折磨
以为一切都搞定了,我开始测试各个功能。首页正常,课程列表正常,订单确认正常,支付......又出问题了。支付页面一直转圈,控制台报错:
bash
Uncaught TypeError: Cannot read property 'xxx' of undefined
我打开代码一看,发现是 crypto-js 这个包的问题。这个包用来加密支付参数,在本地运行得好好的,但是在生产环境就报错了。我检查了一下版本,本地是 4.1.1,服务器上也是 4.1.1,版本一样啊,为什么会报错?我开始怀疑是不是依赖的依赖有问题,于是我用 npm ls crypto-js 查看依赖树,发现这个包被多个其他包依赖,而且版本不一样。有的是 4.1.1,有的是 3.3.0,导致冲突了。
我尝试用 npm dedupe 来去重依赖,但是没有效果。我又尝试在 package.json 里加上 resolutions 字段,强制所有的 crypto-js 都用同一个版本,但是 npm 不支持这个字段,只有 yarn 支持。我不得不把整个项目从 npm 迁移到 yarn,重新安装依赖,重新构建,重新部署。这次终于成功了,支付功能正常了。
但是,当我测试国际化功能的时候,又发现了新的问题。这个项目用了 vue-i18n 来实现中英文切换,在本地运行得好好的,但是在生产环境,切换语言后,有些文案没有翻译,还是显示的 key。我检查了一下代码,发现是语言包没有正确加载。原来是构建时,语言包被打包到了不同的 chunk 里,而 Nginx 的缓存策略导致旧的 chunk 没有更新。我修改了 Nginx 配置,禁用了 JS 文件的缓存,重新部署,这次终于成功了。
Docker 部署的坑
折腾了一整天,我决定用 Docker 来部署,这样可以避免环境差异的问题。我写了一个 Dockerfile:
dockerfile
FROM node:14-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build:h5
EXPOSE 8080
CMD ["npm", "run", "serve"]
看起来很简单对吧?我运行 docker build -t my-app .,构建镜像。构建过程很顺利,但是当我运行 docker run -p 8080:8080 my-app 的时候,容器启动失败了。我查看日志,发现是 node-sass 又出问题了。原来 Docker 镜像用的是 Alpine Linux,而 node-sass 需要编译,Alpine 缺少一些编译工具。我不得不在 Dockerfile 里加上安装编译工具的命令:
dockerfile
RUN apk add --no-cache python3 make g++
重新构建镜像,这次成功了。但是当我访问网页的时候,发现静态资源加载不出来。我检查了一下,发现是路径问题。Docker 容器里的路径和宿主机不一样,导致 Nginx 找不到文件。我修改了 Nginx 配置,把路径改成容器里的路径,重新部署,这次终于成功了。
但是,当我重启容器的时候,发现所有的数据都丢失了。原来 Docker 容器是无状态的,重启后所有的数据都会清空。我不得不用 Docker Volume 来持久化数据,修改 docker-compose.yml:
yaml
version: '3'
services:
app:
build: .
ports:
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- API_URL=https://api.example.com
重新部署,这次终于成功了。但是,当我查看容器日志的时候,发现日志文件越来越大,占用了大量的磁盘空间。我不得不配置日志轮转,限制日志文件的大小。折腾了一整天,终于把 Docker 部署搞定了。
pm2 进程管理的痛苦
Docker 虽然解决了环境差异的问题,但是对于一些简单的项目,用 Docker 有点大材小用。我决定用 pm2 来管理 Node 进程,这样可以实现自动重启、负载均衡、日志管理等功能。我在服务器上安装了 pm2:
bash
npm install -g pm2
然后创建了一个 ecosystem.config.js 配置文件:
javascript
module.exports = {
apps: [{
name: 'my-app',
script: 'npm',
args: 'run serve',
instances: 2,
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 8080
}
}]
}
运行 pm2 start ecosystem.config.js,进程启动成功了。我用 pm2 list 查看进程状态,一切正常。但是,当我访问网页的时候,发现有时候能访问,有时候不能访问。我查看日志,发现有一个进程一直在重启,报错信息是端口被占用。原来是我配置了 2 个实例,但是它们都监听同一个端口,导致冲突了。我修改了配置,让 pm2 自动分配端口,重新启动,这次终于成功了。
但是,当我更新代码的时候,发现 pm2 没有自动重启。我查了一下文档,发现需要用 pm2 reload 命令来实现零停机更新。我运行 pm2 reload my-app,进程重启了,但是有几秒钟的时间,网站无法访问。我又查了一下文档,发现需要配置 wait_ready 和 listen_timeout 参数,让 pm2 等待应用完全启动后再切换流量。我修改了配置,重新部署,这次终于实现了零停机更新。
那些实用的部署技巧
经过无数次的踩坑,我总结了一些实用的部署技巧,希望能帮到同样在部署中挣扎的你:
依赖管理方面 ,首先要锁定依赖版本,使用 package-lock.json 或 yarn.lock 来确保开发环境和生产环境的依赖版本一致。其次,在 package.json 里使用精确版本号,而不是 ^ 或 ~,避免自动升级导致的问题。如果遇到依赖冲突,可以使用 npm ls 查看依赖树,找出冲突的包,然后用 resolutions 字段(yarn)或 overrides 字段(npm 8+)来强制指定版本。对于 node-sass 这种需要编译的包,建议换成 sass(dart-sass),它是纯 JavaScript 实现的,不需要编译,兼容性更好。
环境变量管理方面 ,不要把敏感信息写在代码里,也不要提交到 Git 仓库。使用 .env 文件来管理环境变量,并在 .gitignore 里忽略这个文件。在代码里使用 process.env.XXX 来读取环境变量,并在构建时通过 webpack.DefinePlugin 或 vue.config.js 的 define 选项来注入。对于不同的环境(开发、测试、生产),使用不同的 .env 文件(.env.development、.env.test、.env.production),并在构建时通过 NODE_ENV 来区分。
构建优化方面 ,使用 npm ci 代替 npm install,它会根据 package-lock.json 来安装依赖,速度更快,也更可靠。在 CI/CD 流程中,缓存 node_modules 目录,避免每次都重新安装依赖。使用多阶段构建(Docker)来减小镜像体积,只把必要的文件打包到镜像里。启用 Gzip 压缩和 Brotli 压缩,减小静态资源的体积。配置 CDN 来加速静态资源的加载。
日志管理方面 ,使用 pm2 或 systemd 来管理进程,它们都支持日志轮转和日志查看。配置日志级别,在生产环境只输出 error 和 warn 级别的日志,避免日志文件过大。使用日志聚合工具(如 ELK、Loki)来集中管理日志,方便查询和分析。在代码里加上请求 ID,方便追踪一个请求的完整生命周期。
监控和告警方面 ,使用 pm2 plus 或 New Relic 等工具来监控应用的性能和错误。配置健康检查接口,让负载均衡器定期检查应用是否正常。配置告警规则,当应用出现异常时,及时通知开发人员。使用 sentry 等工具来收集前端错误,方便排查问题。
常用的部署命令,我整理了一份清单:
bash
# 拉取代码
git pull origin main
# 安装依赖(推荐使用 ci)
npm ci
# 构建项目
npm run build:h5
# 使用 pm2 启动
pm2 start ecosystem.config.js
# 查看进程状态
pm2 list
# 查看日志
pm2 logs my-app
# 重启进程(零停机)
pm2 reload my-app
# 停止进程
pm2 stop my-app
# 删除进程
pm2 delete my-app
# 保存 pm2 配置(开机自启)
pm2 save
pm2 startup
# Docker 相关
docker build -t my-app .
docker run -d -p 8080:8080 --name my-app my-app
docker logs -f my-app
docker restart my-app
docker stop my-app
docker rm my-app
# 使用 docker-compose
docker-compose up -d
docker-compose logs -f
docker-compose restart
docker-compose down
开发环境 vs 生产环境的巨大鸿沟
每次部署,我都会感叹:为什么开发环境和生产环境的差异这么大?在本地,我用的是 Windows 系统,Node 14,npm 6,一切都运行得很好。但是到了服务器,Linux 系统,Node 16,npm 8,各种问题就出现了。路径分隔符不一样,文件权限不一样,环境变量不一样,依赖版本不一样,甚至连时区都不一样。
有一次,我遇到了一个非常诡异的问题:在本地,日期格式化的结果是 2024-01-01,但是在服务器上,结果是 2023-12-31。我排查了很久,才发现是时区的问题。本地是东八区,服务器是零时区,导致日期计算出现了偏差。我不得不在代码里显式指定时区,才解决了这个问题。
还有一次,我遇到了一个更诡异的问题:在本地,图片上传功能正常,但是在服务器上,上传的图片无法访问。我检查了文件路径,检查了文件权限,都没有问题。最后我发现,原来是 Nginx 配置的问题。Nginx 默认不允许访问隐藏文件(以 . 开头的文件),而我上传的图片文件名恰好以 . 开头,导致无法访问。我修改了 Nginx 配置,才解决了这个问题。
这些问题,在开发环境是不会出现的,只有在生产环境才会暴露出来。每次部署,都像是在拆盲盒,你永远不知道会遇到什么问题。有时候是依赖问题,有时候是环境变量问题,有时候是权限问题,有时候是配置问题。每一个问题,都需要花费大量的时间去排查和解决。
写在最后
Node.js 真的很香,开发效率高,生态丰富,前后端统一语言,让全栈开发变得更加容易。但是,部署真的很痛苦。每次部署,都像是在经历一场战斗,你需要和各种环境问题、依赖问题、配置问题作斗争。看着服务器报错,我的心也跟着崩了。有时候我在想,为什么部署不能像开发一样简单?为什么不能一键部署,自动处理所有的问题?
但是我知道,这是不可能的。软件开发本身就是一个复杂的过程,部署只是其中的一个环节。我们能做的,就是尽可能地规范化、自动化,减少人为错误,提高部署效率。使用 Docker 来统一环境,使用 CI/CD 来自动化部署,使用监控工具来及时发现问题,使用日志工具来快速定位问题。
虽然部署很痛苦,但是当你看到网站成功上线,用户能够正常使用,所有的付出都是值得的。这就是我们的工作,也是我们的价值。所以,加油吧,Node.js 开发者。虽然每次部署都想砸电脑,但是我们还是会继续部署,继续优化,继续成长。因为,这就是我们的热爱。
服务器终于部署成功了,但是我的周末又没了。明天还要继续优化性能,修复 Bug,处理用户反馈。Node.js 真香,但部署真的想砸电脑。晚安,服务器。晚安,Bug。晚安,我的周末。