Next.js 零停机部署记录:从 PM2 Cluster 到 Next Standalone 模式

2025年3月28日Elecmonkey

蓝绿部署

蓝绿部署是一种零停机部署策略,其核心思想是维护两套完全相同的环境:一套运行当前版本(所谓蓝色环境),另一套部署新版本(所谓绿色环境)。当新版本部署完成并验证无误后,将用户请求从蓝色环境平滑切换到绿色环境,从而实现零停机部署。

这个平滑切换就很玄学了。对于 Node.js 支持的应用,常见 PM2 工具部署。PM2 的 Cluster 模式 允许创建 Node.js 应用的多个进程,分布在多个 CPU 内核执行(如果你的服务器是多核的话(不过单核也没事儿,单核2实例依然能时间分片并行)),实现一定程度上的并发和对 CPU 的负载均衡。

pm2 reload 会在不中断服务的情况下,逐个重启 Cluster 模式下的进程。当 PM2 开始重启时,它会先启动一个新的进程,等待新进程完全启动并正常运行后,才会优雅地关闭旧进程。这样,在重启过程中,始终有进程在提供服务,从而实现零停机更新。

那怎么以最快速度更改掉生产环境的代码呢?软链接是个好东西。PM2 部署时,给 PM2 的应用指向当前运行版本的软链接(如 current -> releases/oldVersion),而不是直接用固定的目录。当需要部署新版本时,我们先将新版本部署到一个新目录(如 releases/newVersion),然后通过更新软链接指向,实现瞬间切换。这种方式的优势在于切换过程是原子操作,几乎没有停机时间,且如果新版本出现问题,可以立即将软链接指回旧版本,回滚也很容易。不过还有一个小坑,Node.js 会自作聪明把软链接解析了去,添加 --preserve-symlinks 参数可以避免这一问题。翻 Node.js preserve-symlinks相关文档 才翻出来原因。

目录名用部署时的时间戳,可以省去判断新旧环境的劳神费力。按当下时间创文件夹怎么都不会跟前面的冲突。

对于生产环境还会产生文件的应用(用户上传内容之类的),可以在应用根目录下创建一个文件夹用来共享。每次部署新版本时,共享目录的数据不跟着版本控制一块切换。

dir
1/APP_ROOT 2├── shared # 共享数据 3├── current -> releases/release-20250320-140000/ # 软连接 4└── releases/ 5 ├── release-20250320-140000/ # 旧版本 6 └── release-20250325-153030/ # 新版本
bash
1# 原子操作切换符号链接 2ln -sfn "$RELEASE_DIR" "$CURRENT_LINK.new" 3mv -T "$CURRENT_LINK.new" "$CURRENT_LINK"

更新后再pm2 reload似乎我们的零停机部署就这样设计好了。似乎?理论上这样没问题对吧()

PM2 Cluster 模式下的 Next.js 部署

只要在 ecosystem.config.js 里设置 instances: 2 就能让 Next.js 应用在双核服务器上跑两个实例?是我天真了。不过如上部署之后 GitHub Actions 显示跑通,页面也能正常打开,似乎真的一切顺利——还好我看了一眼 PM2 的日志,好家伙,永远只有一个实例在正常工作,另一个实例高频异常重启。

一开始我还以为是 PM2 的两个实例不能监听同一个端口,需要我自己在反代的 Caddy 或者 Nginx 配置负载均衡。后来各种查资料才发现错怪 PM2 了——多实例监听同端口自动负载均衡是 PM2 的核心功能之一(批评 PM2 的官方文档。简略的像新手指引)。问题出在 Next.js 。

Next.js 的 pnpm start 命令多实例部署会冲突。那那那,那怎么办。一通百度搜索,使用 standalone 模式编译可以解决这个问题:

json
1{ 2 "scripts": { 3 "build": "next build --standalone" 4 } 5}

standalone 模式下编译,Next.js 会提供一个包含独立服务器(可以用 server.js 启动)的产物。这样构建出来的产物可以直接用 node .next/standalone/server.js 启动服务,PM2 就可以正常启动多份了。

shell
1# 如果应用不存在,则首次启动 2if ! pm2 list | grep -q "app"; then 3 echo "首次启动应用..." 4 NODE_OPTIONS="--preserve-symlinks" pm2 start server.js \ 5 --name app \ 6 -i 2 \ 7 --time \ 8 --max-memory-restart 768M \ 9 --kill-timeout 5000 \ 10 --no-autorestart \ 11 --no-watch \ 12 --env production \ 13 --update-env \ 14 --cwd "$CURRENT_LINK" 15else 16 echo "平滑重载应用..." 17 NODE_OPTIONS="--preserve-symlinks" pm2 reload app \ 18 --update-env \ 19 --max-memory-restart 768M \ 20 --kill-timeout 5000 \ 21 --restart-delay=5000 22fi

静态资源丢失与 Next.js 构建方式

本以为问题解决了,结果网站前端出现了 JS、CSS 无法正常显示的问题。各种排查终于发现问题的根源:自动构建结果 .next/standalone 目录其实是个独立的项目,它并不依赖父层级中 .next 文件夹中的其它产物,事实上,它自己内部有一个独立的 .next 文件夹(是的,./.next/standalone/.next,不知道谁设计的)。所以需要需要把外面的 static 文件夹拷贝到 standalone 里。默认构建结果 .next/static.next/standalone 是平级的,层级关系错了所以一直有问题。正确的目录结构应该是:

dir
1.next/ 2├── standalone/ 3│ ├── .next 4│ ├── src # .md原始文件,SSR渲染用 5│ ├── server.js 6│ ├── package.json 7│ └── static/ # 需要把 static 文件夹拷贝到这里 8└── static/ # 原始静态资源

既然 standalone/ 可以当一个独立项目用,外面的就不需要了。不过这一层没有 pnpm-lock.yaml 文件,从最外层拷贝一份进来。这样子,部署脚本需要好好的重新考虑哪些文件是必要的了。

至于发现问题所在之后,如何知道正确的配置方式——表扬 Next.js 的文档。主要参考了Next.js 的构建产物文档

shell
1mkdir -p deploy 2 3cp -r .next/standalone/.next deploy/ 4cp -r .next/standalone/src deploy/ 5cp -r .next/standalone/package.json deploy/ 6cp -r .next/static deploy/.next/ 7 8cp .next/standalone/server.js deploy/ 9cp pnpm-lock.yaml deploy/ 10cp ecosystem.config.js deploy/ 11 12echo "部署目录总大小:" 13du -sh deploy/

这样修改后,Next.js 应用就能正常加载静态资源了。

加载图表,需要启用 JavaScript ...