pipeline基本语法参考以前文章:Jenkins 配置gitlab的 pipeline流水线任务
本篇文章基于以前文章优化与扩展,看不懂的可以参考前面文章熟悉语法与配置。
目录
后端:
微服务架构一个仓库下,多个服务。需要每个服务单独构建发布。如:
project-root/
├── common/
├── service-gateway/
│ └── pom.xml
├── service-api/
│ └── pom.xml
├── pom.xml
和以前的构建脚本一样,只是拉取完整个库后,单独构建发布服务,构建命令不同而已。
#构建打包整个项目的所有服务
mvn clean package -DskipTests
#只构建打包其中一个服务,如gateway
mvn clean package -pl service-gateway -am -DskipTests
#-pl (--projects):只构建某个模块(如 gateway)
#-am (--also-make):同时构建这个模块依赖的模块(如 common)
其他服务可以单独创建流水线,便于管理,如果用一个流水线也可以用参数选择构建,但服务太多的话,一个流水线不好管理。
Active Choices Reactive Parameter
根据BRANCH_NAME环境不同,显示对应的分支文件夹。这里同样以xxl-job-admin作为示例。
如:
dev环境保存包: /opt/jar/dev/xxl-job-admin/
test环境保存包: /opt/jar/test/xxl-job-admin/
// 判断是否为“以往版本发布”
if (RELEASE_TYPE != "以往版本发布") {
return []
}
// 读取存储目录下的所有 jar 文
def branch = BRANCH_NAME
if (!branch) return []
def dir = new File("/opt/jar/${branch}/xxl-job-admin/")
if (!dir.exists()) return ["目录不存在"]
def files = dir.listFiles().findAll { it.name.endsWith(".jar") }
if (files.isEmpty()) return ["无可用版本"]
return files*.name.sort().reverse()
Referenced Parameters: RELEASE_TYPE,BRANCH_NAME
pipeline script
def REMOTE_IP_LIST = ''
pipeline {
agent {
node {
//需在系统管理 >> 节点和云管理 >> 选择对应的节点(如:master),标签添加 any。
label 'any'
//自定义工作空间,防止不同环境覆盖冲突。以“工作名+分支”重命名空间(xxl-job-admin_dev)。
customWorkspace "${JENKINS_HOME}/workspace/${JOB_NAME}_${params.BRANCH_NAME}"
}
}
parameters {
choice(name: 'BRANCH_NAME', choices: ['==选择环境分支==','dev', 'test', 'main'], description: '请选择构建分支')
choice(name: 'RELEASE_TYPE', choices: ['全新构建发布', '以往版本发布'], description: '发布方式')
}
environment {
APP_NAME = 'xxl-job-admin'
PORT = '8080'
JAR_PATTERN = "${JENKINS_HOME}/workspace/${JOB_NAME}_${params.BRANCH_NAME}/${APP_NAME}/target/"
JAR_PATH = "/opt/jar/${params.BRANCH_NAME}/${APP_NAME}"
REMOTE_DIR = "/app/${APP_NAME}"
DEV_IPS = '198.19.249.107'
TEST_IPS = '198.19.249.107'
MAIN_IPS = '198.19.249.103 198.19.249.104' //空格作为分割
}
tools {
maven "maven-3.9.6"
jdk 'jdk17'
}
stages {
stage('部署环境') {
steps {
script {
switch (params.BRANCH_NAME) {
case 'dev': REMOTE_IP_LIST = env.DEV_IPS; break
case 'test': REMOTE_IP_LIST = env.TEST_IPS; break
case 'main': REMOTE_IP_LIST = env.MAIN_IPS; break
default: error "未知分支:${params.BRANCH_NAME}"
}
echo "部署环境分支:${params.BRANCH_NAME}"
echo "部署方式:${params.RELEASE_TYPE}"
if (params.RELEASE_TYPE != '全新构建发布') {
echo "部署版本包: ${params.APP_VERSION}"
}
echo "部署服务器:${REMOTE_IP_LIST}"
//每次构建清理空间,防止旧文件影响,插件:Workspace Cleanup Plugin
echo "清理workspace: ${JOB_NAME}_${params.BRANCH_NAME}"
cleanWs()
}
}
}
stage('Git 拉取代码') {
when {
expression { return !params.APP_VERSION } // 如果是空,表示全新构建
}
steps {
git branch: "${params.BRANCH_NAME}",
credentialsId: '25238080-093d-4e3c-884c-f70a027eb1c9',
url: '[email protected]:project-1/xxl-job.git'
echo "拉取 ${params.BRANCH_NAME} 分支成功"
}
}
stage('Maven 构建') {
when {
expression { return !params.APP_VERSION } // 只在构建新版本时执行
}
steps {
sh "mvn -v"
sh "java --version"
sh "mvn clean package -Dmaven.test.skip=true"
echo "构建完成"
}
}
stage('存储备份 JAR 包') {
when {
expression { return !params.APP_VERSION } // 只在构建新版本时执行
}
steps {
script {
def regex = "^${env.APP_NAME}(-[0-9.]+)?\\.jar\$"
def jarFile = sh(
script: "ls -t ${env.JAR_PATTERN} | grep -E '${regex}' | head -n 1",
returnStdout: true
).trim()
if (!jarFile) {
error("未找到构建生成的 jar 包")
}
// 获取当前时间戳
def timestamp = sh(script: "date +%Y%m%d%H%M", returnStdout: true).trim()
// 生成带时间的备份文件名
def newFileName = jarFile.replace(".jar", "-${timestamp}.jar")
sh """
mkdir -p ${env.JAR_PATH}
cp ${env.JAR_PATTERN}${jarFile} ${env.JAR_PATH}/${newFileName}
ls -tp ${env.JAR_PATH}/*.jar | grep -v '/\$' | tail -n +11 | xargs -r rm --
"""
echo "备份完成,保留最新 10 个版本:${jarFile}"
}
}
}
stage('拷贝 JAR 到远程服务器') {
steps {
script {
def jarFileName
def jarFilePath
if (params.APP_VERSION?.trim()) {
// 指定了版本:用选定的版本文件名
jarFileName = params.APP_VERSION.trim()
jarFilePath = "${env.JAR_PATH}/${jarFileName}"
} else {
// 未指定版本:自动找最新的
jarFileName = sh(script: "ls -t ${env.JAR_PATH}/*.jar | head -n 1", returnStdout: true).trim().tokenize("/").last()
jarFilePath = "${env.JAR_PATH}/${jarFileName}"
}
if (!jarFileName || !fileExists(jarFilePath)) {
error("未找到 JAR 包用于部署: ${jarFilePath}")
}
echo "部署使用 JAR 包:${jarFilePath}"
for (ip in REMOTE_IP_LIST.split()) {
echo "拷贝到远程 IP: ${ip}"
sh """
ssh -o StrictHostKeyChecking=no root@${ip} 'mkdir -p ${REMOTE_DIR} /app/logs /app/shell'
rsync -az ${jarFilePath} root@${ip}:${REMOTE_DIR}/${APP_NAME}.jar
"""
}
}
}
}
stage('远程启动服务') {
steps {
script {
for (ip in REMOTE_IP_LIST.split()) {
echo "部署到远程 IP: ${ip}"
sh """
ssh -o StrictHostKeyChecking=no root@${ip} '/bin/bash /app/shell/start.sh ${APP_NAME} ${PORT}'
"""
}
}
echo "${env.APP_NAME}服务部署完成"
}
}
}
}
部署后端服务器的start.sh部署脚本
脚本使用传参运行,即pipeline脚本传的两个参数:
1: 名称(用于匹配jar包名称运行)
2: 端口 (用于健康检查)
启动为添加到systemd系统服务启动。
#!/bin/bash
#set -x
APP_NAME=$1
PORT=$2
#环境参数也可以传参,不想手动可以改脚本为第三个参数 $3
ENV=test
# 匹配检查规则
[ -z "$APP_NAME" ] || [ -z "$PORT" ] && {
echo "Usage: sh run-service.sh [app_name] [port]"
exit 1
}
# 目录定义
SCRIPT_DIR=$(cd $(dirname $0); pwd)
APP_HOME="/app/${APP_NAME}"
LOG_HOME="/app/logs"
GC_LOG_DIR="${LOG_HOME}/gc/${APP_NAME}"
DUMP_DIR="${LOG_HOME}/heapdump/${APP_NAME}"
STDOUT_LOG="${LOG_HOME}/stdout/${APP_NAME}.log"
SERVICE_FILE="/etc/systemd/system/${APP_NAME}.service"
GC_FILE_NAME="gc-$(date +%Y%m%d%H%M%S).log"
DUMP_FILE_NAME="errorDump-$(date +%Y%m%d%H%M%S).hprof"
#健康检查链接,这里xxl-job-admin为例,不同服务链接不同
CHECK_URL="http://127.0.0.1:${PORT}/xxl-job-admin/actuator/health"
# 创建目录
mkdir -p "$GC_LOG_DIR" "$DUMP_DIR" "$(dirname $STDOUT_LOG)" "$APP_HOME"
# 创建 systemd 服务(如不存在)
echo "[INFO] 创建更新 systemd 服务文件:$SERVICE_FILE"
cat <<EOF > "$SERVICE_FILE"
[Unit]
Description=${APP_NAME} service
After=network.target
[Service]
Type=simple
ExecStart=/opt/jdk-17.0.11/bin/java -Xmx2048m -Xms2048m -Xmn1024m \\
-XX:MetaspaceSize=128M -XX:MaxMetaspaceSize=256M \\
-XX:MaxDirectMemorySize=512m \\
-Dfile.encoding=UTF-8 \\
-Duser.timezone=Asia/Shanghai \\
-Xlog:gc*:file=${GC_LOG_DIR}/${GC_FILE_NAME}:tags,uptime,time,level \\
-XX:+HeapDumpOnOutOfMemoryError \\
-XX:HeapDumpPath=${DUMP_DIR}/${DUMP_FILE_NAME} \\
-Dspring.profiles.active=${ENV} \\
-jar ${APP_HOME}/${APP_NAME}.jar \\
--spring.cloud.nacos.config.namespace=${ENV} \\
--spring.cloud.nacos.discovery.namespace=${ENV}
WorkingDirectory=${APP_HOME}
Restart=on-failure
StandardOutput=append:${STDOUT_LOG}
StandardError=append:${STDOUT_LOG}
User=root
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reexec
systemctl daemon-reload
systemctl enable ${APP_NAME}
# 用 awk 提取 [Service] 段落的内容(包括[Service]这一行)
service_section=$(awk '
/^\[Service\]/{flag=1; next} # 遇到[Service]时开启标记,不打印该行
/^\[/{flag=0} # 遇到新段落时关闭标记
flag
' "${SERVICE_FILE}")
echo "[INFO] 服务启动参数:"
echo "$service_section"
# 优雅停止服务
echo "[INFO] 尝试停止旧服务..."
if systemctl is-active --quiet ${APP_NAME}.service; then
systemctl stop ${APP_NAME}
sleep 5
fi
# 检查是否仍有残留进程
PIDS=$(pgrep -f "${APP_NAME}.jar" || true)
if [ -n "$PIDS" ]; then
echo "[WARN] 服务未完全停止,强制 kill:$PIDS"
kill -9 $PIDS
fi
# 启动服务
echo "[INFO] 启动 ${APP_NAME} 服务..."
systemctl start ${APP_NAME}
# 健康检查函数
check_health() {
local response
response=$(curl -s --max-time 2 "$CHECK_URL" || echo "")
echo "[HEALTH] 第$((DURATION+1))次检查: ${response:-NULL}"
# 宽松判断,只要包含 "UP" 就视为健康
#[[ "$response" == *UP* ]]
#简单判断,不为空,则启动
[ -n "$response" ]
}
# 健康检查等待
TIMEOUT=75
DURATION=0
echo "[INFO] 健康检查中... curl -s ${CHECK_URL}"
while [ $DURATION -lt $TIMEOUT ]; do
sleep 1
if check_health; then
echo "[SUCCESS] ${APP_NAME} 启动成功,用时 ${DURATION} 秒"
break
fi
DURATION=$((DURATION + 1))
done
# 不管成功或失败,都输出最近日志
echo "[INFO] 检查日志输出:$STDOUT_LOG"
echo "[INFO] 最后50条启动日志:"
tail -n 50 "$STDOUT_LOG" || echo "[WARN] 日志文件不存在"
# 成功或失败的退出判断
if [ $DURATION -lt $TIMEOUT ]; then
exit 0
else
echo "[ERROR] 启动超时(${TIMEOUT}s),服务未能成功启动"
exit 1
fi
构建所有服务并存储备份
这个只需要一个构建参数即可,选择环境分支:dev, test
此pipeline只构建,不部署,用于测试代码构建功能。
配置需要保存的服务在APP_LIST 里面
def REMOTE_IP_LIST = ''
pipeline {
agent {
node {
label 'any'
customWorkspace "${JENKINS_HOME}/workspace/${JOB_NAME}_${params.BRANCH_NAME}"
}
}
parameters {
choice(name: 'BRANCH_NAME', choices: ['==选择环境分支==','dev', 'test'], description: '请选择构建分支')
}
environment {
BASE_PATH = "/opt/jar"
WORKSPACE = "${JENKINS_HOME}/workspace/${JOB_NAME}_${params.BRANCH_NAME}"
APP_LIST = 'service-gateway service-api' // 用空格分隔的字符串,需要保存的服务可以一直添加
}
tools {
maven "maven-3.9.6"
jdk 'jdk17'
}
stages {
stage('部署环境') {
steps {
script {
if (!params.BRANCH_NAME || params.BRANCH_NAME == '==选择环境分支==') {
error("构建失败:请选择一个有效的分支参数")
}
echo "部署环境分支:${params.BRANCH_NAME}"
sh "mvn -v"
sh "java --version"
echo "清理workspace 空间"
cleanWs()
}
}
}
stage('Git 拉取代码') {
when {
expression { return !params.APP_VERSION }
}
steps {
git branch: "${params.BRANCH_NAME}",
credentialsId: '8e0973eb-d725-4153-89bd-503f23ef2683',
url: '[email protected]:project-1/project-root.git'
echo "拉取 ${params.BRANCH_NAME} 分支成功"
}
}
stage('Maven 构建所有模块') {
when {
expression { return !params.APP_VERSION }
}
steps {
sh "mvn clean package -U -Dmaven.test.skip=true -P ${params.BRANCH_NAME}"
echo "构建完成"
}
}
stage('存储构建的JAR包') {
steps {
script {
def apps = env.APP_LIST.split(' ')
def branch = params.BRANCH_NAME
for (app in apps) {
echo "正在查找模块:${app}"
def jarPath = sh(
script: "find ${env.WORKSPACE} -name '${app}*.jar' -type f | head -n 1",
returnStdout: true
).trim()
if (!jarPath) {
echo "⚠️ 未找到 ${app} 模块的 jar 包"
continue
}
// 生成时间戳命名
def timestamp = sh(script: "date +%Y%m%d%H%M", returnStdout: true).trim()
def newFileName = jarPath.tokenize('/').last().replace('.jar', "-${timestamp}.jar")
def targetDir = "${env.BASE_PATH}/${branch}/${app}"
// 备份 jar
sh """
mkdir -p ${targetDir}
cp ${jarPath} ${targetDir}/${newFileName}
echo "✅ 已备份 ${app} 到 ${targetDir}/${newFileName}"
# 保留最新 10 个版本
ls -tp ${targetDir}/*.jar | grep -v '/\$' | tail -n +11 | xargs -r rm --
"""
}
}
}
}
}
}
前端
Active Choices Reactive Parameter
Groovy Script 脚本,和后端差不多
// 判断是否为“以往版本发布”
if (RELEASE_TYPE != "以往版本发布") {
return []
}
// 读取存储目录下的所有 tar.gz 文件
def branch = BRANCH_NAME
if (!branch) return []
def dir = new File("/opt/frontend/${branch}/sports-admin-web/")
if (!dir.exists()) return ["目录不存在"]
def files = dir.listFiles().findAll { it.name.endsWith(".tar.gz") }
if (files.isEmpty()) return ["无可用版本"]
return files*.name.sort().reverse()
pipeline script
流水线也差不多,逻辑是将整个构建的文件夹tar打包,保存在相应目录,并传送至远程服务器。
def REMOTE_IP_LIST = ''
pipeline {
agent {
node {
label 'any'
customWorkspace "${JENKINS_HOME}/workspace/${JOB_NAME}_${params.BRANCH_NAME}"
}
}
parameters {
choice(name: 'BRANCH_NAME', choices: ['==选择环境分支==','dev', 'test'], description: '请选择构建分支')
choice(name: 'RELEASE_TYPE', choices: ['全新构建发布', '以往版本发布'], description: '发布方式')
}
environment {
APP_NAME = 'admin-web'
DIST_DIR = "${JENKINS_HOME}/workspace/"
BUILD_CMD = 'pnpm install'
STATIC_PATH = "/opt/frontend/${params.BRANCH_NAME}/${APP_NAME}"
REMOTE_DIR = "/app/package/"
DEV_IPS = '192.168.47.29'
TEST_IPS = '192.168.47.49'
}
tools {
nodejs 'node-18'
}
stages {
stage('部署环境') {
steps {
script {
switch (params.BRANCH_NAME) {
case 'dev': REMOTE_IP_LIST = env.DEV_IPS; break
case 'test': REMOTE_IP_LIST = env.TEST_IPS; break
default: error "未知分支:${params.BRANCH_NAME}"
}
echo "部署环境分支:${params.BRANCH_NAME}"
echo "部署方式:${params.RELEASE_TYPE}"
if (params.RELEASE_TYPE != '全新构建发布') {
echo "部署版本包: ${params.APP_VERSION}"
}
echo "部署服务器:${REMOTE_IP_LIST}"
echo "清理旧workspace: ${JOB_NAME}_${params.BRANCH_NAME}"
cleanWs()
}
}
}
stage('Git 拉取代码') {
when { expression { return !params.APP_VERSION } }
steps {
git branch: "${params.BRANCH_NAME}",
credentialsId: '25238080-093d-4e3c-884c-f70a027eb1c9',
url: '[email protected]:project-1/admin-web.git'
echo "拉取 ${params.BRANCH_NAME} 分支成功"
}
}
stage('Node 构建') {
when { expression { return !params.APP_VERSION } }
steps {
sh 'node -v'
//sh 'npm install pnpm -g'
//sh 'pnpm install'
sh "${env.BUILD_CMD}"
echo "构建完成"
}
}
stage('备份构建包') {
when { expression { return !params.APP_VERSION } }
steps {
script {
def timestamp = sh(script: "date +%Y%m%d%H%M", returnStdout: true).trim()
def archiveName = "${env.APP_NAME}-${timestamp}.tar.gz"
sh """
mkdir -p ${env.STATIC_PATH}
cd ${env.DIST_DIR}
tar -czf ${archiveName} -C ${env.DIST_DIR} ${JOB_NAME}_${params.BRANCH_NAME}
mv ${archiveName} ${env.STATIC_PATH}/
ls -tp ${env.STATIC_PATH}/*.tar.gz | grep -v '/\$' | tail -n +11 | xargs -r rm --
"""
echo "构建产物备份完成:${archiveName}"
}
}
}
stage('拷贝至远程服务器') {
steps {
script {
def deployFile
if (params.APP_VERSION?.trim()) {
deployFile = "${env.STATIC_PATH}/${params.APP_VERSION.trim()}"
} else {
deployFile = sh(script: "ls -t ${env.STATIC_PATH}/*.tar.gz | head -n 1", returnStdout: true).trim()
}
if (!fileExists(deployFile)) {
error "未找到构建产物:${deployFile}"
}
for (ip in REMOTE_IP_LIST.split()) {
echo "拷贝到远程 IP: ${ip}"
def remoteFile = "${REMOTE_DIR}${APP_NAME}.tar.gz"
sh """
ssh -o StrictHostKeyChecking=no root@${ip} 'mkdir -p ${REMOTE_DIR}'
rsync -az ${deployFile} root@${ip}:${remoteFile}
"""
}
}
}
}
stage('远程部署服务') {
steps {
script {
for (ip in REMOTE_IP_LIST.split()) {
sh """
ssh -o StrictHostKeyChecking=no root@${ip} '/bin/bash /app/shell/deploy.sh ${APP_NAME} ${JOB_NAME}_${params.BRANCH_NAME}'
"""
echo "✅ 前端服务 ${APP_NAME} 已部署到 ${ip}"
}
}
}
}
}
}
deploy.sh 脚本
同样采取传参运行。因为打包名称,和部署名称不一样。
如果对名称不敏感,也不需要传参数运行,直接解压到指定文件夹就行了。
此脚本会移除原来文件夹所有文件,如果有图片也在原来的文件夹,可以根据需求更改脚本逻辑。
#!/bin/bash
#set -x
# 参数校验
if [ $# -ne 2 ]; then
echo "用法: $0 <APP名称> <tar包内目录名>"
echo "示例: $0 admin-web frontend-admin-dev"
exit 1
fi
APP_NAME=$1 # 目标目录名(新服务目录)
ORIG_NAME=$2 # 包中原始目录名
PACKAGE_DIR="/app/package"
DEPLOY_DIR="/app"
BACKUP_DIR="${DEPLOY_DIR}/backup"
TAR_FILE=$(ls ${PACKAGE_DIR}/${APP_NAME}.tar.gz 2>/dev/null | head -n 1)
STDOUT_LOG="/app/logs/stdout"
if [ -z "$TAR_FILE" ]; then
echo " 未找到 tar 包:${PACKAGE_DIR}/${APP_NAME}.tar.gz"
exit 2
fi
echo "开始部署 ${APP_NAME},解压包:$TAR_FILE"
# 创建备份目录
mkdir -p $BACKUP_DIR $STDOUT_LOG
# 如果已有旧版本,移动到备份
if [ -d "${DEPLOY_DIR}/${APP_NAME}" ]; then
TIMESTAMP=$(date +'%Y%m%d%H%M%S')
mv "${DEPLOY_DIR}/${APP_NAME}" "${BACKUP_DIR}/${APP_NAME}-${TIMESTAMP}"
echo "已备份旧目录为 ${BACKUP_DIR}/${APP_NAME}-${TIMESTAMP}"
# 保留最新 10 个备份,其余删除
echo " 清理旧备份,仅保留最近 10 个:"
ls -dt ${BACKUP_DIR}/${APP_NAME}-* 2>/dev/null | tail -n +11 | xargs -r rm -rf
fi
# 解压 tar 包
tar -xzf "$TAR_FILE" -C "$DEPLOY_DIR"
# 检查是否解压出了预期目录
if [ ! -d "${DEPLOY_DIR}/${ORIG_NAME}" ]; then
echo " 解压失败:未找到目录 ${DEPLOY_DIR}/${ORIG_NAME}"
exit 3
fi
#检查目录是否移走
if [ -d "${DEPLOY_DIR}/${APP_NAME}" ]; then
echo "目录${DEPLOY_DIR}/${APP_NAME} 已存在,准备删除"
rm -rf ${DEPLOY_DIR}/${APP_NAME}
fi
# 重命名解压目录为目标目录名
mv "${DEPLOY_DIR}/${ORIG_NAME}" "${DEPLOY_DIR}/${APP_NAME}"
# 查找已运行的服务并终止(pnpm)
EXIST_PIDS=$(pgrep -f "pnpm")
if [ -n "$EXIST_PIDS" ]; then
echo "检测到以下 pnpm 相关进程将被终止:"
echo "$EXIST_PIDS"
kill -9 $EXIST_PIDS
pkill -f vite.js
echo "所有 pnpm 相关进程已终止"
sleep 5
fi
cd ${DEPLOY_DIR}/${APP_NAME}
echo "启动调试命令:nohup pnpm test > ${STDOUT_LOG}/${APP_NAME}.log 2>&1 &"
nohup pnpm test > ${STDOUT_LOG}/${APP_NAME}.log 2>&1 &
sleep 5
echo "启动日志预览:tail -n 30"
tail -n 30 "${STDOUT_LOG}/${APP_NAME}.log"
echo "✅ 部署完成:${DEPLOY_DIR}/${APP_NAME}"