侧边栏壁纸
博主头像
术业有道之编程博主等级

亦是三月纷飞雨,亦是人间惊鸿客。亦是秋霜去叶多,亦是风华正当时。

  • 累计撰写 100 篇文章
  • 累计创建 53 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

nextjs使用jenkins+pm2部署

Administrator
2025-11-07 / 0 评论 / 0 点赞 / 18 阅读 / 19255 字

写在前面

近2年多来,几乎所有的研发技术栈重心都转向了 nextjs+typescript+react,之前一直是丢到Vercel、cf的works这一类的平台上跑,后面自己部署都是使用docker化来完成。服务常年跑在不足1G内存的服务器上,在有限的硬件资源下,请求压力一大docker化模式明显吃不消,于是花了30分钟研究了一下pm2,将基于jenkisci/cd做了大面积更改,内存使用稳稳的在120M以内。

一、介绍pm2

pm2是一个nodejs的进程管理,功能非常丰富。

1、为什么要用pm2

如果直接使用node index.js这种方式,当退出控制台,node进程会被终止。
如果想把这个项目开机自启动,pm2也能很好的支持。

2、pm2常用功能

  • 使用pm2 启动项目
# 端口 3000 、环境变量 production、启动文件 server.js 、服务名称 prod-driver-website 、日志时间格式化
PORT=3000 NODE_ENV=production pm2 start server.js  --name "prod-driver-website" --log-date-format "YYYY-MM-DD HH:mm:ss"
  • 查看程序列表:pm2 list
    pm2 list 程序列表
  • 查看日志:pm2 logs prod-driver-website
    pm2 logs查看程序日志
  • 删除程序:pm2 delete prod-driver-website ,删了之后再执行pm2 list就是空列表

3、pm2设置程序开机自启动

  • 生成自启动配置 pm2 startup
    • 这个我没截图,本质就是给你自动创建了一个/etc/systemd/system/pm2-root.service,再设置systemctl enable pm2-root.service
  • 保存当前 pm2 list中的进程列表:pm2 save(注意:这一步一定要执行,等于是告诉pm2自启动哪些程序)
    • 这个我也没截图,就是把当前的pm2 list程序列表保存到了/root/.pm2/dump.json(我是root用户)
  • 卸载自启动 pm2 unstartup systemd

好了,这里是3分钟的插曲,接下来进入正题

二、编写shell编译部署脚本

我直接重新编写了在jenkins中使用的shell脚本

实现思路:

  • 假设已经拉取了代码
  • 配置jenkins中编译代码的nodejs环境变量
  • 开始编译源代码
  • 检查编译产物,开始剥离生产模式需要的文件,按标准nodejs理解的文件位置存放,压缩成一个文件包
  • 使用rsync将压缩包丢到运行服务器上
    • 注意:如果需要同时多分发,这里可以使用多线程rsync、scp,或者将其上传到oss、s3上,远端触发拉取更高效)
  • 使用反弹shell进行远程脚本执行。我这里有2个脚本
    • 检查远程运行服务器上是否存在指定版本的nodejspm2环境,没有就触发自动安装(remote_setup_nodejs.sh)。
    • 远程运行服务器上对压缩包解压,并且使用pm2启动运行(deploy.sh)

三、shell脚本

三个shell脚本需要协同工作,不是伪代码,可以直接拿来使用

  • shell脚本(jenkins-build.sh)
##!/usr/bin/env bash
## !/bin/sh
# chmod -R 777
# 编译模式 prod 表示生产 test 表示测试
mode=$1
# 项目名
project_name=$2
# 项目启动端口
project_launch_port=$3
# 本次编译标签 -- jenkins内置环境变量 ${GIT_COMMIT}
tag=$4
# 远程服务器 需要将jenkins的公钥添加到远程服务器上 lc@192.168.50.142
run_server_domain=$5
# 编译源代码的环境 nodejs-v16.20.2、nodejs-v20.17.0
code_build_tools=$6
# 启用网络代理 true、false
network_proxy=$7
# 手动构建
manual=$8
shell_deploy_path="deploy.sh"
remote_setup_path="remote_setup_nodejs.sh"
home_nodejs16_20_2="/opt/tools/nodejs/node-v16.20.2-linux-x64"
home_nodejs20_17_0="/opt/tools/nodejs/node-v20.17.0-linux-x64"
main(){
    echo "================================main======================================="
    out
    if [ "$manual" = "webhook" ]; then
       echo "当前触发模式为 webhook"
       git_commit
    else
      echo "当前触发模式为手动"
      mode
      network_proxy
      code_build
      push
      deploy
    fi
}
git_commit(){
    echo "================================git commit======================================="
    commit_msg=`git log -1 --pretty=format:"%s" | grep "ci"`
    if [ -z "$commit_msg" ]; then
       echo "本次提交不需要执行构建,退出当前任务"
       exit
    fi
    echo "last commit_msg:$commit_msg"
    if [[ $commit_msg = ci* ]]; then
        out
        mode
        network_proxy
        code_build
        push
        deploy
    else
        echo "本次提交不需要执行构建,退出当前任务"
        exit
    fi
}
out(){
    echo "================================out==================================="
    echo "当前脚本执行位置:`pwd`"
    echo "参数输出 start:"
    echo "mode:$mode"
    echo "project_name:$project_name"
    echo "project_launch_port:$project_launch_port"
    echo "tag:$tag"
    echo "run_server_domain:$run_server_domain"
    echo "code_build_tools:$code_build_tools"
    echo "network_proxy:$network_proxy"
    echo "manual:$manual"
    echo "参数输出 end"

    echo "源代码编译环境输出 start:"
    echo "home_nodejs16_20_2:$home_nodejs16_20_2"
    echo "home_nodejs20_17_0:$home_nodejs20_17_0"
    echo "源代码编译环境输出 end"
}
mode(){
  echo "================================mode==================================="
  project_name="$project_name"
  if [ "$mode" = "prod" ]; then
      echo "本次为:生产模式 $project_name"
  else
      echo "本次为:测试模式 $project_name"
  fi
}
network_proxy(){
    echo "================================network_proxy==========================="
    if [ "$network_proxy" = "true" ]; then
        export network_host="192.168.50.27"
        export no_proxy="localhost,127.0.0.1,localaddress,.localdomain.com"
        export no_proxy="localhost,127.0.0.1,localaddress,.localdomain.com"
        export http_proxy="http://${network_host}:7890"
        export https_proxy="http://${network_host}:7890"
        export all_proxy="socks5://${network_host}:7891"
        echo "已开启代理 ${network_host}"
    else
       unset http_proxy
       unset https_proxy
       unset all_proxy
       echo "已关闭代理"
    fi
}
code_build() {
    echo "================================code_build_tools================================"
    if [ "$code_build_tools" = "nodejs-v16.20.2" ]; then
        echo "初始化 nodejs 环境 ${home_nodejs16_20_2}"
        export NODE_HOME=$home_nodejs16_20_2
        nodejs_build
        if [ $? -ne 0 ]; then
            echo "❌ NodeJS(v16) 构建失败,脚本退出。"
            exit 1
        fi
        echo "✅ NodeJS(v16) 构建成功。"

    elif [ "$code_build_tools" = "nodejs-v20.17.0" ]; then
        echo "初始化 nodejs 环境 ${home_nodejs20_17_0}"
        export NODE_HOME=$home_nodejs20_17_0
        nodejs_build
        if [ $? -ne 0 ]; then
            echo "❌ NodeJS(v20) 构建失败,脚本退出。"
            exit 1
        fi
        echo "✅ NodeJS(v20) 构建成功。"

    else
        echo "⚠️ 没有匹配的源代码编译模式:$code_build_tools"
    fi
}

nodejs_build(){
    export PATH=${NODE_HOME}/bin:${PATH}
    echo "npm版本:`npm -v`"
    echo "开始编译源代码"
    rm -rf package-lock.json
    npm config set strict-ssl false
    npm --registry=https://registry.npm.taobao.org install --force
    npm run build  # Build complete
}
push(){
  echo "================================push====================================="

  local standalone_dir="./.next/standalone"
  local public_dir="./public"
  local build_dir="./.next"
  local source_archive="${project_name}-${tag}.tar.gz"
  local remote_temp_dir="/tmp/${project_name}"

  # 创建临时传输目录
  local transfer_tmp_dir="/tmp/${project_name}_transfer"
  rm -rf "$transfer_tmp_dir"
  mkdir -p "$transfer_tmp_dir"

  # --- 1. 检查 Standalone 模式 ---
  if [ -d "$standalone_dir" ]; then
      echo "✅ 检测到 Standalone 模式,准备完整构建包..."

      # 复制 standalone(核心执行文件、精简 node_modules、server.js)
      cp -r "$standalone_dir"/* "$transfer_tmp_dir"/

      # 复制 public 静态资源
      cp -r "$public_dir" "$transfer_tmp_dir"/

      # 复制整个 .next 目录(保留构建元数据)
      mkdir -p "$transfer_tmp_dir"/.next
      cp -r "$build_dir"/* "$transfer_tmp_dir"/.next/

      # 但不要把 .next/standalone 自己再递归复制进去(会形成死循环)
      rm -rf "$transfer_tmp_dir"/.next/standalone

      # 复制环境文件(如果有)
      [ -f .env.production ] && cp .env.production "$transfer_tmp_dir"/
      [ -f .env ] && cp .env "$transfer_tmp_dir"/

      echo "Standalone 文件已收集完毕。"

  elif [ -d "$build_dir" ]; then
      echo "检测到非 Standalone 模式。打包全部部署文件。"

      # **重点修正:确保复制了整个 .next 目录!**
      cp -r "$build_dir" "$transfer_tmp_dir"/        # <-- 完整复制 .next 目录
      cp -r "$public_dir" "$transfer_tmp_dir"/
      cp package.json "$transfer_tmp_dir"/           # next start 需要 package.json

      # 复制生产依赖
      echo "复制 node_modules 目录..."
      # ⚠️ 确保 CI 脚本中只安装了生产依赖,否则包会很大。
      cp -r node_modules "$transfer_tmp_dir"/

      echo "非 Standalone 模式文件已收集完毕。"
  else
      echo "❌ 找不到构建产物目录 (.next),无法执行 Push。"
      export scp_success="false"
      exit 1
  fi

  # 显示所有文件(-a)
  tree -a -L 2 "$transfer_tmp_dir"
  # --- 2. 压缩和传输 ---
  echo "开始打包文件 ${source_archive}..."

  # 压缩临时目录中的内容
  tar -czf ${source_archive} -C "$transfer_tmp_dir"/ .

  # 获取文件大小,并去除文件名部分,只保留大小
  file_size=$(du -h "${source_archive}" | cut -f1)

  # 输出带文件大小的完成信息
  echo "✅ 打包文件 ${source_archive} 完成,大小为 ${file_size}。"

  # 确保远程临时目录存在
  echo "开始传输文件 ${source_archive} 到 ${run_server_domain}:${remote_temp_dir}"
  ssh ${run_server_domain} "mkdir -p ${remote_temp_dir}"

  # 传输文件
  rsync -avzP -e "ssh -o StrictHostKeyChecking=no" ${source_archive} ${run_server_domain}:"${remote_temp_dir}/"

  if [ $? -eq 0 ]; then
      echo "✅ 文件传输成功!"
      export scp_success="true"
  else
      echo "❌ 文件传输失败!"
      export scp_success="false"
      exit 1
  fi

  # --- 3. 清理 ---
  echo "清理本地临时文件和归档文件..."
  rm -rf "$transfer_tmp_dir"
  rm -f ${source_archive}
}

check_and_install_nodejs() {
    echo "================================check_nodejs_env============================="
    echo "正在远程检查 ${run_server_domain} 上的 Node.js 环境..."

    # 假设 remote_setup_path 变量已定义,指向本地的 'remote_setup_nodejs.sh' 文件
    local remote_setup_path="remote_setup_nodejs.sh"
    # Node.js 目标版本 (使用 v24.11.0)
    local target_node_version="v24.11.0"

    # 检查本地设置脚本是否存在
    if [ ! -f "${remote_setup_path}" ]; then
        echo "❌ 错误:未找到本地环境设置脚本 ${remote_setup_path}。"
        return 1
    fi

    # 1. 使用 SSH 管道方式执行远程设置脚本
    # 'bash -s' 告诉远程服务器的 bash 从标准输入读取命令。
    # '< ${remote_setup_path}' 将本地脚本文件内容重定向到标准输入。
    # -- "${target_node_version}" 是将 Node 版本作为参数传递给远程脚本。
    echo "通过管道将 ${remote_setup_path} 传递给 ${run_server_domain} 执行..."

    # 组合 SSH 命令
    ssh ${run_server_domain} -o StrictHostKeyChecking=no 'bash -s' -- "${target_node_version}" < "${remote_setup_path}"

    local ssh_exit_code=$?

    if [ $ssh_exit_code -ne 0 ]; then
        echo "❌ 远程环境检查或安装脚本执行失败 (退出码: ${ssh_exit_code})!"
        return 1
    fi

    echo "✅ 远程 Node.js/PM2 环境已就绪。"
    return 0
}
deploy(){
   echo "================================deploy====================================="
   # 检查 push 阶段是否成功
   if [ "$scp_success" = "true" ]; then
      echo "文件传输成功,开始执行远程部署脚本..."
        # 远程检查和安装 Node.js/PM2 ---
        check_and_install_nodejs
        if [ $? -ne 0 ]; then
            echo "❌ Node.js/PM2 环境检查或安装失败,部署退出。"
            exit 1
        fi
      # 远程执行 shell 脚本启动服务
      # 假设远程服务器上的 deploy.sh 负责:
      # 1. 停止旧服务 (pm2 stop ${project_name})
      # 2. 解压新的 tar 包到目标目录
      # 3. 启动新服务 (pm2 start ...)

      # -o StrictHostKeyChecking=no 用于避免在第一次连接时出现交互式确认
      # 'bash -s' < ${shell_deploy_path} 表示将本地的 ${shell_deploy_path} 脚本内容通过 stdin 传输到远程执行
      ssh  ${run_server_domain} -o StrictHostKeyChecking=no 'bash -s' < ${shell_deploy_path} "${tag}" "${project_name}" "${project_launch_port}"

      if [ $? -eq 0 ]; then
          echo "✅ 远程部署脚本执行成功!服务已启动或更新。"
      else
          echo "❌ 远程部署脚本执行失败!请检查远程服务器日志。"
          exit 1
      fi

   else
      echo "❌ scp 失败,跳过部署阶段。"
      exit 1
   fi
}
main

  • 检查并自动安装所需环境(remote_setup_nodejs.sh
#!/usr/bin/env bash
# remote_setup_nodejs.sh

# 接收目标 Node.js 版本作为参数
TARGET_NODE_VERSION=$1
if [ -z "$TARGET_NODE_VERSION" ]; then
    echo "错误:请提供目标 Node.js 版本作为第一个参数 (e.g., v24.11.0)。"
    exit 1
fi

echo "===================== 远程 Node.js/PM2 环境设置开始 ====================="
# 导入系统环境变量
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm

# --- 1. 检查 Node.js ---
if command -v node >/dev/null 2>&1; then
    echo "✅ Node.js 已经安装,版本为: $(node -v)"
else
    echo "未检测到 Node.js,开始安装 NVM..."

    # 1. 安装 NVM
    # 使用 'bash -s -- -' 来运行安装脚本
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

    # 2. 尝试加载 NVM
    export NVM_DIR="$HOME/.nvm"
    [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
    [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

    if command -v nvm >/dev/null 2>&1; then
        echo "✅ NVM 安装成功,正在安装目标版本 Node.js ${TARGET_NODE_VERSION}..."

        # 3. 安装并设置默认版本
        nvm install ${TARGET_NODE_VERSION}
        nvm alias default ${TARGET_NODE_VERSION}
    else
        echo "❌ NVM 安装失败,无法安装 Node.js。"
        exit 1
    fi
fi

# --- 2. 确保 PATH 包含当前 Node.js 版本 (非常重要) ---
# 必须手动设置 NVM 相关的环境变量,以便后续的 npm 命令生效
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

# 如果使用 NVM 安装,确保当前 shell 使用正确版本
if command -v nvm >/dev/null 2>&1; then
    nvm use --silent default
fi

# --- 3. 检查并安装 PM2 ---
if ! command -v pm2 >/dev/null 2>&1; then
    echo "未检测到 PM2,开始全局安装..."

    # 确保 npm 命令可用
    if ! command -v npm >/dev/null 2>&1; then
        echo "❌ NPM 不可用,无法安装 PM2。"
        exit 1
    fi

    npm install -g pm2

    if [ $? -ne 0 ]; then
        echo "❌ PM2 安装失败。请检查权限或网络。"
        exit 1
    fi
    echo "✅ PM2 安装成功。"
else
    echo "✅ PM2 已安装,版本为: $(pm2 -v | head -n 1)"
fi

echo "最终 Node 版本: $(node -v)"
echo "最终 NPM 版本: $(npm -v)"
echo "===================== 远程 Node.js/PM2 环境设置完成 ====================="
exit 0

  • 压缩包解压,并且使用pm2启动运行服务(deploy.sh
#!/usr/bin/env bash

# deploy.sh 脚本在远程服务器上执行
# 接收从本地 CI 脚本传递的参数
tag=$1                  # 本次编译标签 (例如 Git Commit ID)
project_name=$2         # 项目名称
project_launch_port=$3  # 项目启动端口

# --- 全局变量配置 ---
# 部署目标目录
DEPLOY_BASE_DIR="/var/www"
PROJECT_DIR="${DEPLOY_BASE_DIR}/${project_name}"
# 代码包的临时存放位置
REMOTE_TEMP_DIR="/tmp/${project_name}"
SOURCE_ARCHIVE="${project_name}-${tag}.tar.gz"
ARCHIVE_PATH="${REMOTE_TEMP_DIR}/${SOURCE_ARCHIVE}"

# 导入系统环境变量
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm

# 确保 PM2 已经被安装在 PATH 中
if ! command -v pm2 >/dev/null 2>&1; then
    echo "致命错误:PM2 仍未找到,请检查安装脚本是否成功执行。"
    exit 1
fi
# --- 核心函数定义 ---

# 停止当前正在运行的服务
stop_service() {
    echo "1. 尝试停止并删除现有的 PM2 进程:${project_name}"
    # 使用 || true 确保即使进程不存在,脚本也不会因错误退出
    pm2 stop "${project_name}" || true
    pm2 delete "${project_name}" || true
    echo "现有进程已停止/删除。"
}

# 准备部署目标目录
prepare_directory() {
    echo "2. 准备部署目录:${PROJECT_DIR}"
    mkdir -p "${PROJECT_DIR}"

    # 清空目标目录
    echo "正在清理目标目录..."
    rm -rf "${PROJECT_DIR}"/*
    rm -rf "${PROJECT_DIR}"/.* 2>/dev/null # 删除隐藏文件和目录,忽略不存在的错误
}

# 解压新代码包到目标目录
extract_code() {
    echo "3. 检查代码包是否存在并解压..."

    if [ ! -f "$ARCHIVE_PATH" ]; then
        echo "❌ 错误:未找到代码包 ${ARCHIVE_PATH}。部署失败。"
        exit 1
    fi

    echo "正在解压代码包到 ${PROJECT_DIR}..."
    tar -xzf "$ARCHIVE_PATH" -C "${PROJECT_DIR}"

    if [ $? -ne 0 ]; then
        echo "❌ 错误:代码包解压失败。部署失败。"
        exit 1
    fi

    echo "✅ 代码解压完成。"
}

# 启动新的服务进程
start_service() {
    echo "4. 确定启动命令并使用 PM2 启动服务..."

    # 进入部署目录
    cd "${PROJECT_DIR}"

    local start_script=""
    local node_modules_path="./node_modules"

    # 检查是否为 Standalone 模式 (Standalone 模式下,项目根目录有 server.js)
    if [ -f "./server.js" ]; then
        echo " -> 检测到 Standalone 模式,使用 'node server.js' 启动。"
        start_script="server.js"
    else
        echo " -> 检测到非 Standalone 模式,使用 'npm start' 启动。"
        # 非 Standalone 模式需要确保 node_modules 存在
        if [ ! -d "$node_modules_path" ]; then
            echo "⚠️ 警告:非 Standalone 模式但未检测到 node_modules,尝试 npm install..."
            npm install --production --registry=https://registry.npm.taobao.org
        fi
        start_script="npm start"
    fi

    echo " -> 使用 PORT=${project_launch_port} 启动服务..."

    # 组合最终的启动命令
    local final_pm2_command="PORT=${project_launch_port} NODE_ENV=production pm2 start ${start_script} --name \"${project_name}\" --log-date-format \"YYYY-MM-DD HH:mm:ss\""

    echo "DEBUG: 执行命令: ${final_pm2_command}"

    # 使用 'eval' 或 'bash -c' 来执行完整的字符串命令
    eval ${final_pm2_command}

    timeout 15s pm2 logs ${project_name} --lines 100 --raw

    if [ $? -ne 0 ]; then
        echo "❌ 错误:PM2 启动服务失败!"
        exit 1
    fi
    echo "✅ 服务 ${project_name} 已成功启动/重启,监听端口 ${project_launch_port}。"
}

# 清理临时文件并保存 PM2 状态
cleanup() {
    echo "5. 清理临时代码包并保存 PM2 状态..."

    rm -f "$ARCHIVE_PATH"
    echo "临时代码包已清理。"

    # 永久保存 PM2 进程列表,确保系统重启后能自动恢复
    pm2 save
    echo "PM2 状态已保存。"
}

# --- 主执行流程 ---
main() {
    if [ -z "$tag" ] || [ -z "$project_name" ] || [ -z "$project_launch_port" ]; then
        echo "❌ 部署失败:缺少必要的参数 (tag, project_name, project_launch_port)。"
        exit 1
    fi

    echo "================================ Deploy Script Start ==============================="
    echo "项目: ${project_name} | 端口: ${project_launch_port} | 标签: ${tag}"

    stop_service
    prepare_directory
    extract_code
    start_service
    cleanup

    echo "================================ Deploy Script Success ==============================="
    exit 0
}

main

最后,在jenkins中选择shell模式,只需要填入一行代码(代码说明请参考jenkins-build.sh文件头部参数说明)

bash jenkins-build.sh ${mode} ${JOB_BASE_NAME} ${project_launch_port} ${GIT_COMMIT} ${run_server_domain} ${code_build_tools} ${network_proxy} ${manual} 

到这就全部完成了
个人公众号

0

评论区