写在前面
近2年多来,几乎所有的研发技术栈重心都转向了 nextjs+typescript+react,之前一直是丢到Vercel、cf的works这一类的平台上跑,后面自己部署都是使用docker化来完成。服务常年跑在不足1G内存的服务器上,在有限的硬件资源下,请求压力一大docker化模式明显吃不消,于是花了30分钟研究了一下pm2,将基于jenkis的ci/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 logs prod-driver-website

- 删除程序:
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个脚本- 检查远程运行服务器上是否存在指定版本的
nodejs和pm2环境,没有就触发自动安装(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}
到这就全部完成了

评论区