Next.js 使用 PM2 实现不停机更新部署

2025年10月5日

在我们实际 Web 开发中,Vercel 无疑是 Next.js 应用部署的首选平台,它提供了简单、无缝的部署体验。然而,并非所有项目都能够或希望依赖于 Vercel 这样的平台,特别是对于有自定义需求或高可控性的团队,选择自行托管可能是一个更合适的方案。

本篇文章是为了记录我的一次部署实践,将为你提供一套基于 PM2Next.js Standalone 模式的自部署方案,帮助个人开发者或团队在自有服务器上高效、可靠地部署 Next.js 应用,并实现零停机部署。

零停机部署

在很多生产环境中,零停机部署是提升服务可用性和用户体验的关键。而蓝绿部署作为最经典的零停机策略,通过维护两套环境来实现平滑切换:一套用于当前版本,另一套用于新版本。当新版本验证通过后,流量将切换至新的环境。

我们通过在服务器上运行多个 Node.js 进程(例如使用 PM2 Cluster 模式)来支撑这种平滑切换。在 Next.js 的自部署方案中,蓝绿部署的核心思路依然适用,但我们着重介绍的是如何利用 PM2Next.js Standalone 模式来实现部署和进程管理。

PM2 Cluster 模式

Next.js 应用需要在生产环境中处理大量并发请求时,使用 PM2 Cluster 模式是一个常见的做法。PM2Cluster 模式能够在多核服务器上启动多个进程,从而提升应用的吞吐量和稳定性。

PM2 的管理下,每个进程监听相同的端口并且自动进行负载均衡,这对于高并发的生产环境非常重要。然而,简单地在 ecosystem.config.js 中设置 instances: 2 并不足以让 Next.js 在多核机器上顺利运行。原因在于 **Next.js **默认的启动方式与多实例部署并不兼容。

为了解决这一问题,我们需要启用 Next.jsStandalone 模式,这不仅能够避免多个实例之间的端口冲突,还能实现更加灵活和高效的多进程部署。

Next.js Standalone 模式:解决多进程冲突

Next.js 在默认的模式下,会在应用根目录生成 .next 目录,包含了构建后的静态资源和页面。但是,当启用 PM2 Cluster 模式时,pnpm start 命令会导致多个进程之间的冲突,因为它们共享相同的文件结构。

为了解决这个问题,Next.js 提供了 Standalone 模式,该模式将构建输出和服务器打包成一个独立的运行环境,使得每个实例都可以单独运行,而不会产生进程间的冲突。

我们可以打开项目跟目录下的 next.config.ts 文件,给配置项中添加 output: 'standalone'

typescript
1import type { NextConfig } from "next"; 2 3const nextConfig: NextConfig = { 4 output: "standalone", 5}; 6 7export default nextConfig;

解决 Next.js Standalone 模式中的静态资源问题

在使用 Next.js Standalone 模式时,可能会遇到一个静态资源丢失的问题。原因在于 Standalone 模式会在 .next/standalone 目录下创建自己的 .next 文件夹,而原本存放静态资源的 .next/static 并不会被自动包含在其中。

为了让静态资源能够正确加载,需要将外部的 static 文件夹手动复制到 Standalone 模式的目录结构下,后面的部署脚本也会标注这一点。

编写脚本文件

以下是一份全面的 Bash 脚本部署方案,我将逐步解读该脚本的每个步骤,让你能够轻松理解并应用到自己的项目中。

1. 环境准备与基础设置

bash
1APP_ROOT="/data/frontend/project-name" 2REPO_DIR="$APP_ROOT/project-name" 3BRANCH="main" 4TIMESTAMP=$(date +%Y%m%d-%H%M%S) 5RELEASE_DIR="$APP_ROOT/releases/release-$TIMESTAMP" 6CURRENT_LINK="$APP_ROOT/current"

首先,脚本定义了一些重要的环境变量,包括应用根目录(APP_ROOT)、代码库目录(REPO_DIR)和目标分支(BRANCH)。TIMESTAMP 用于标记每次部署的时间,确保每次部署的版本目录不会冲突。RELEASE_DIR 用来存放新版本的文件,CURRENT_LINK 是指向当前活动版本的符号链接。 请你以你服务器的实际目录路径来填写,以下内容会 project-name 来代替项目名称,你需要按照实际来进行修改。

2. 更新代码:从 Git 拉取最新版本

bash
1cd "$REPO_DIR" 2git fetch origin 3git reset --hard origin/$BRANCH

这一部分负责更新代码。脚本进入项目目录,使用 git fetch 拉取远程仓库的最新代码,接着通过 git reset --hard 强制将本地代码切换到指定的分支,确保应用代码是最新的,避免因本地代码过期而导致的部署失败。

3. 使用 pnpm 安装依赖并构建项目

bash
1pnpm install --frozen-lockfile 2pnpm build

在此步骤中,脚本使用 pnpm 来安装依赖并构建项目。 --frozen-lockfile 确保安装时使用锁文件中的版本,避免因依赖版本变化带来的不确定性。pnpm build 会构建出生产环境所需的文件,准备好部署。

4. 打包所需文件

bash
1mkdir -p "$RELEASE_DIR" 2cp -r .next/standalone/.next "$RELEASE_DIR/" 3cp -r .next/standalone/package.json "$RELEASE_DIR/" 4cp -r .next/standalone/.env.production "$RELEASE_DIR/" 5cp -r .next/static "$RELEASE_DIR/.next/" 6cp -r public "$RELEASE_DIR/" 7cp .next/standalone/server.js "$RELEASE_DIR/" 8cp pnpm-lock.yaml "$RELEASE_DIR/"

这一步是将构建好的文件打包到一个新的目录中。具体来说,它会将 .nextpackage.json、.env.production(若有) 等必要的文件和配置复制到 RELEASE_DIR 目录中。通过将这些文件收集到一个独立的目录,确保新的部署不会受到旧版本的干扰。

5. 安装生产依赖

bash
1cd "$RELEASE_DIR" 2pnpm install --prod --frozen-lockfile

在新的 RELEASE_DIR 目录下,脚本再次使用 pnpm 安装生产环境依赖。使用 --prod 参数确保只安装生产环境所需的依赖,这样可以减少不必要的包和资源消耗。

6. 切换版本:更新软链接

bash
1ln -sfn "$RELEASE_DIR" "$CURRENT_LINK.new" 2mv -T "$CURRENT_LINK.new" "$CURRENT_LINK"

此步骤实现了版本的平滑切换。通过更新指向当前版本的符号链接,确保新版本能够即时投入生产环境。ln -sfn 是创建或更新符号链接的命令,它确保 CURRENT_LINK 始终指向最新的版本目录。

7. 启动或重载 PM2 管理的应用

bash
1PORT=3100 2HOSTNAME=0.0.0.0 3 4if ! pm2 list | grep -q "project-name"; then 5 echo "第一次启动 pm2 应用..." 6 PORT=$PORT HOSTNAME=$HOSTNAME NODE_OPTIONS="--preserve-symlinks" pm2 start server.js \ 7 --name project-name \ 8 -i 2 \ 9 --time \ 10 --max-memory-restart 768M \ 11 --kill-timeout 5000 \ 12 --env NODE_ENV=production \ 13 --cwd "$CURRENT_LINK" 14else 15 echo "平滑重载应用..." 16 PORT=$PORT HOSTNAME=$HOSTNAME NODE_OPTIONS="--preserve-symlinks" pm2 reload project-name \ 17 --update-env \ 18 --max-memory-restart 768M \ 19 --kill-timeout 5000 \ 20 --restart-delay=5000 21fi 22 23pm2 save

这一步脚本通过 PM2 来启动或重载应用。PM2 是一个非常流行的 Node.js 进程管理工具,它可以帮助你管理应用的生命周期、进行负载均衡并确保应用稳定运行。

如果应用尚未启动,脚本会通过 pm2 start 启动应用,并使用 -i 2 启动两个实例(适合多核服务器)。

如果应用已经在运行,则使用 pm2 reload 实现平滑重载,确保服务无缝更新。

pm2 save 用来保存 PM2 的进程列表,以便在服务器重启后自动恢复。

8. 健康检查:确保服务正常运行

bash
1echo "⏳ 开始健康检查..." 2MAX_RETRIES=30 3RETRY_INTERVAL=2 4for i in $(seq 1 $MAX_RETRIES); do 5 response=$(curl -s http://127.0.0.1:3100/api/health || true) 6 if echo "$response" | grep -q '"status":"ok"'; then 7 echo "✅ 健康检查通过!" 8 break 9 fi 10 echo "重试 $i/$MAX_RETRIES..." 11 sleep $RETRY_INTERVAL 12done 13

健康检查是保障生产环境应用稳定性的重要步骤。在这段代码中,脚本通过 curl 访问应用的健康检查接口(/api/health),确保应用能够正常响应。如果在最大重试次数内无法通过健康检查,脚本会执行回滚操作。 您需要自己写一个健康检查 api 来实现,如果没有,可以参考以下代码简单实现一个即可:

# /app/api/health/route.js
export async function GET() {
  return new Response(JSON.stringify({ status: 'ok' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
}

9. 回滚到先前版本(如果健康检查失败)

bash
1if [ $i -eq $MAX_RETRIES ]; then 2 echo "❌ 健康检查失败,执行回滚..." 3 PREVIOUS=$(ls -1t $APP_ROOT/releases | grep -v "release-$TIMESTAMP" | head -1) 4 if [ -n "$PREVIOUS" ]; then 5 ln -sfn "$APP_ROOT/releases/$PREVIOUS" "$CURRENT_LINK.new" 6 mv -T "$CURRENT_LINK.new" "$CURRENT_LINK" 7 NODE_OPTIONS="--preserve-symlinks" pm2 reload project-name 8 echo "🔄 已回滚到 $PREVIOUS" 9 else 10 echo "⚠️ 没有可回滚的版本!" 11 fi 12 exit 1 13fi 14

如果健康检查失败,脚本会自动回滚到上一个版本。它会查找最新的有效版本,并通过更新符号链接和 PM2 重载操作恢复旧版本,确保服务不会长时间不可用。

10. 清理旧版本:释放磁盘空间

bash
1cd "$APP_ROOT/releases" 2TMP_DIR="/tmp/project-name" 3mkdir -p "$TMP_DIR" 4 5OLD_RELEASES=$(ls -1tr | head -n -5) 6 7for dir in $OLD_RELEASES; do 8 TARGET="$TMP_DIR/${dir}-$(date +%s)" 9 echo "移动旧版本 $dir -> $TARGET" 10 mv "$dir" "$TARGET" 11done 12 13echo "✅ 旧版本移动完成,保存在 $TMP_DIR" 14

这一步主要是清理旧版本的部署文件。它将 releases 目录中时间较旧的版本(保留最近的 5 个)移动到 /tmp 目录,/tmp 目录在 Linux 系统下会定时清除文件,以释放磁盘空间。

11. 完整代码

bash
1#!/bin/bash 2set -e 3 4APP_ROOT="/data/frontend/project-name" 5REPO_DIR="$APP_ROOT/project-name" 6BRANCH="main" 7TIMESTAMP=$(date +%Y%m%d-%H%M%S) 8RELEASE_DIR="$APP_ROOT/releases/release-$TIMESTAMP" 9CURRENT_LINK="$APP_ROOT/current" 10 11echo "🚀 开始部署 $BRANCH 分支,版本 $TIMESTAMP" 12 13# 1. 更新代码 14cd "$REPO_DIR" 15git fetch origin 16git reset --hard origin/$BRANCH 17 18# 2. 安装依赖 & 构建 19pnpm install --frozen-lockfile 20pnpm build 21 22# 3. 打包需要的文件 23mkdir -p "$RELEASE_DIR" 24cp -r .next/standalone/.next "$RELEASE_DIR/" 25cp -r .next/standalone/package.json "$RELEASE_DIR/" 26cp -r .next/standalone/.env.production "$RELEASE_DIR/" 27cp -r .next/static "$RELEASE_DIR/.next/" 28cp -r public "$RELEASE_DIR/" 29cp .next/standalone/server.js "$RELEASE_DIR/" 30cp pnpm-lock.yaml "$RELEASE_DIR/" 31 32cd "$RELEASE_DIR" 33echo "在非活跃目录安装生产依赖..." 34pnpm install --prod --frozen-lockfile 35 36# 4. 切换版本 37ln -sfn "$RELEASE_DIR" "$CURRENT_LINK.new" 38mv -T "$CURRENT_LINK.new" "$CURRENT_LINK" 39 40# 5. 启动/重载 PM2 41PORT=3100 42HOSTNAME=0.0.0.0 43 44if ! pm2 list | grep -q "project-name"; then 45 echo "第一次启动 pm2 应用..." 46 PORT=$PORT HOSTNAME=$HOSTNAME NODE_OPTIONS="--preserve-symlinks" pm2 start server.js \ 47 --name project-name \ 48 -i 2 \ 49 --time \ 50 --max-memory-restart 768M \ 51 --kill-timeout 5000 \ 52 --env NODE_ENV=production \ 53 --cwd "$CURRENT_LINK" 54else 55 echo "平滑重载应用..." 56 PORT=$PORT HOSTNAME=$HOSTNAME NODE_OPTIONS="--preserve-symlinks" pm2 reload project-name \ 57 --update-env \ 58 --max-memory-restart 768M \ 59 --kill-timeout 5000 \ 60 --restart-delay=5000 61fi 62 63pm2 save 64 65# 6. 健康检查 66echo "⏳ 开始健康检查..." 67MAX_RETRIES=30 68RETRY_INTERVAL=2 69for i in $(seq 1 $MAX_RETRIES); do 70 response=$(curl -s http://127.0.0.1:3100/api/health || true) 71 if echo "$response" | grep -q '"status":"ok"'; then 72 echo "✅ 健康检查通过!" 73 break 74 fi 75 echo "重试 $i/$MAX_RETRIES..." 76 sleep $RETRY_INTERVAL 77done 78 79if [ $i -eq $MAX_RETRIES ]; then 80 echo "❌ 健康检查失败,执行回滚..." 81 PREVIOUS=$(ls -1t $APP_ROOT/releases | grep -v "release-$TIMESTAMP" | head -1) 82 if [ -n "$PREVIOUS" ]; then 83 ln -sfn "$APP_ROOT/releases/$PREVIOUS" "$CURRENT_LINK.new" 84 mv -T "$CURRENT_LINK.new" "$CURRENT_LINK" 85 NODE_OPTIONS="--preserve-symlinks" pm2 reload project-name 86 echo "🔄 已回滚到 $PREVIOUS" 87 else 88 echo "⚠️ 没有可回滚的版本!" 89 fi 90 exit 1 91fi 92 93# 7. 清理旧版本(移动到 /tmp/project-name) 94cd "$APP_ROOT/releases" 95 96# 创建临时目录(如果不存在) 97TMP_DIR="/tmp/project-name" 98mkdir -p "$TMP_DIR" 99 100# 列出旧版本(按时间从旧到新,除最新5个) 101OLD_RELEASES=$(ls -1tr | head -n -5) 102 103# 移动旧版本到临时目录 104for dir in $OLD_RELEASES; do 105 TARGET="$TMP_DIR/${dir}-$(date +%s)" # 给每个目录加时间戳避免冲突 106 echo "移动旧版本 $dir -> $TARGET" 107 mv "$dir" "$TARGET" 108done 109 110echo "✅ 旧版本移动完成,保存在 $TMP_DIR" 111 112echo "🎉 部署完成!当前版本: $TIMESTAMP" 113pm2 status 114

总结

通过这份部署脚本,开发者可以快速实现 Next.js 应用的自动化部署,并结合 PM2、健康检查和回滚机制确保应用稳定运行。此脚本适用于自托管环境,可以灵活应用于不同的项目,只需要根据自己的实际需求稍作修改。