企业级实现
之前文中展示的例子是一条非常标准、具备"准企业级"水准的 Jenkins Pipeline,且架构设计非常清晰。
它不仅涵盖了 CI/CD 的核心流程,还通过引入 "第三个仓库(Ansible 剧本库)" 实现了代码与运维逻辑的解耦,这是很多中大型企业在 DevOps 成熟度提升阶段会采用的架构。以下是从企业级标准角度对 Pipeline 进行的深度评估和架构分析:
当前设计
目前的架构属于 "三位一体" 的分布式构建部署模型:
- 代码源(Source):前端、后端两个独立的 Git 仓库。
- 制品源(Artifact):Nexus 私服(同时存放 Maven Jar 和 Raw Zip)。
- 流程源(Orchestration) :Ansible Playbooks 仓库(第三个仓库) ------ 这是你架构中最亮眼的设计。
这种架构完美遵循了 "关注点分离" 原则:Jenkins 只负责编译和上传,不负责具体的部署逻辑;Ansible 负责环境差异和部署细节。 但是问题就是将前后端耦合在同一个 Jenkinsfile 中的典型痛点就是:如果我只想发布前端,这个时候前后端代码都会拉去打包一遍造成:资源浪费和风险增加。
所以将流水线拆分为"前端专用"和"后端专用"两个 Job,同时让它们指向同一个 Ansible 剧本仓库,正是很多中大型企业推崇的架构。这不仅是企业级的做法,更是目前主流云原生架构(如 GitLab CI/CD、Jenkins Shared Libraries)的标准实践。
在企业级 DevOps 实践中,核心原则是 "逻辑复用" 和 "关注点分离"。
将"部署逻辑"(Ansible 剧本)抽离出来作为一套公共资产,供不同的流水线调用,是解决重复代码和维护噩梦的最佳方案。
构建后端
1.ansible的主机清单稍微调整了一下,由于我的机器不够所以使用前端服务器作为灰度发布的主机组,然后提交到git的ansible仓库
ini
[root@jenkins-slave staging]# pwd
/root/ansible/inventory/staging
[root@jenkins-slave staging]# cat hosts.ini
[backend-test]
172.20.10.6 ansible_port=22 ansible_user=deploy
172.20.10.5 ansible_port=22 ansible_user=deploy
2.测试(灰度)后端变量:inventory/staging/group_vars/backend-test.yml
ini
---
# 测试环境变量覆盖
server_name: "jenkins-demo-backend-1.0.0.jar"
heap_size: "1024m"
dest_path: "/data/application"
server_port: 8080
upload_path: "/data/upload"
backup_path: "/data/backup"
#定义Nexus仓库的信息
nexus_url: "http://172.20.10.6:8081"
nexus_repo: "app-backend"
repo_path: "com/example/jenkins-demo-backend/1.0.0"
nexus_user: "admin"
nexus_password: "admin123"
3.修改剧本加入了灰度发布,通过jenkins变量来传入参数,识别是全量还是灰度
ini
[root@jenkins-slave ops-ansible-playbooks]# cat deploy.yml
---
# ==========================================
# Play 1: 后端服务通用更新 (合并了全量和灰度)带回滚
# ==========================================
- name: 滚动更新后端服务
hosts: "{{ target_group | default('backend-servers') }}" # 关键点:这里使用变量 target_group,如果没有传入,默认为 backend-servers
remote_user: deploy
become: yes
become_method: sudo #使用 sudo 方式提权
gather_facts: yes
# --- 滚动更新策略 ---
serial: 1 # 生产环境两台机器,这里设为1表示每次只更1台,实现零停机
max_fail_percentage: 0 # 只要有1台失败,立即终止
vars:
# 动态生成备份文件名,例如:app.jar.bak_20260509_1630,使用ansible中的 date, hour, minute, second 拼接
backup_suffix: "bak_{{ ansible_date_time.date }}_{{ ansible_date_time.hour }}{{ ansible_date_time.minute }}{{ ansible_date_time.second }}"
backup_file: "{{ backup_path }}/{{ server_name }}.{{ backup_suffix }}"
# 拼接完整的 Nexus 下载链接
nexus_download_url: "{{ nexus_url }}/repository/{{ nexus_repo }}/{{ repo_path }}/{{ server_name }}"
tasks:
- name: "确保部署目录和临时目录以及备份存在"
file:
path: "{{ item }}"
state: directory
mode: '0755'
loop:
- "{{ dest_path }}"
- "{{ upload_path }}"
- "{{ backup_path }}"
- name: "[后端] 停止应用服务"
# 这里使用 shell 执行停止命令,具体取决于你的启动方式
# 假设你是用 nohup 启动的,这里通过 ps/grep/kill 停止
shell: |
ps -ef | grep {{ server_name }} | grep -v grep | awk '{print $2}' | xargs kill -15
ignore_errors: yes # 如果进程不存在,忽略错误继续
- name: "[后端] 检查旧版本 Jar 包是否存在"
stat:
path: "{{ dest_path }}/{{ server_name }}"
register: old_app
- name: "[后端] 备份旧版本应用 (重命名)"
command: mv "{{ dest_path }}/{{ server_name }}" "{{ backup_file }}"
when: old_app.stat.exists
- name: "[后端] 推送新版本应用包"
get_url:
url: "{{ nexus_download_url }}"
dest: "{{ upload_path }}/{{ server_name }}"
username: "{{ nexus_user }}"
password: "{{ nexus_password }}"
mode: '0755'
timeout: 60
force: yes # 强制覆盖
- name: "将后端代码上传到工作目录"
command: mv "{{ upload_path }}/{{ server_name }}" "{{ dest_path }}/{{ server_name }}"
- name: "[后端] 启动新版本服务"
shell: |
source /etc/profile && nohup java -Xms{{ heap_size }} -Xmx{{ heap_size }} -jar "{{ dest_path }}/{{ server_name }}" --server.port={{ server_port }} > "{{ dest_path }}/app.log" 2>&1 &
async: 45 # 异步运行,最长等待45秒
poll: 0 # 不阻塞,立即返回
- name: "[后端] 等待服务启动完成"
wait_for:
port: "{{ server_port }}"
host: "{{ ansible_default_ipv4.address }}" #获取主机ip,通过ip测试
delay: 5
timeout: 60
state: started
register: health_check
ignore_errors: yes # 允许失败,以便进入回滚逻辑
- name: "[后端] 启动失败回滚"
block:
- name: "恢复旧版本包"
shell: mv "{{ backup_file }}" "{{ dest_path }}/{{ server_name }}"
when: old_app.stat.exists
- name: "重启旧版本服务"
shell: |
nohup java -Xms{{ heap_size }} -Xmx{{ heap_size }} -jar "{{ dest_path }}/{{ server_name }}" --server.port={{ server_port }} > "{{ dest_path }}/app.log" 2>&1 &
- name: "报错终止"
fail:
msg: "新版本启动失败,已回滚到旧版本!"
when: health_check is failed
# ==========================================
# Play 2: 前端服务并发更新
# ==========================================
- name: 更新前端静态资源
hosts: frontend-servers
remote_user: deploy
become: yes
become_method: sudo #使用 sudo 方式提权
gather_facts: yes
vars:
# 拼接前端 Nexus 链接
frontend_nexus_url: "{{ nexus_url }}/repository/{{ nexus_repo }}/{{ repo_path }}/{{ dist_package }}"
tasks:
- name: "确保部署目录和临时目录以及备份存在"
file:
path: "{{ item }}"
state: directory
mode: '0755'
loop:
- "{{ dist_dest_path }}"
- "{{ upload_path }}"
- "{{ backup_path }}"
- name: "备份现有前端文件 (如果目录非空)"
# 打包整个目录作为备份,文件名带时间戳
archive:
path: "{{ dist_dest_path }}/*"
dest: "{{ backup_path }}/frontend_bak_{{ ansible_date_time.date }}_{{ ansible_date_time.hour }}{{ ansible_date_time.minute }}{{ ansible_date_time.second }}.tar.gz"
format: gz
ignore_errors: yes # 如果目录为空或不存在,忽略错误继续
- name: "清理旧的前端文件"
file:
path: "{{ dist_dest_path }}/*"
state: absent
- name: "[前端] 推送前端压缩包"
get_url:
url: "{{ frontend_nexus_url }}"
dest: "{{ upload_path }}/{{ dist_package }}"
username: "{{ nexus_user }}"
password: "{{ nexus_password }}"
mode: '0644'
force: yes
- name: "[前端] 解压覆盖静态文件"
unarchive:
src: "{{ upload_path }}/{{ dist_package }}"
dest: "{{ upload_path }}/" # 先解压到上传目录,此时会生成 {{ upload_path }}/dist
remote_src: yes # 在远程服务器上解压
- name: "[前端] 移动内容并移除 dist 目录"
shell: |
# 将 dist 目录下的所有内容(包括隐藏文件)移动到目标目录
mv {{ upload_path }}/dist/* {{ dist_dest_path }}/ && mv {{ upload_path }}/dist/.* {{ dist_dest_path }}/ 2>/dev/null || true
# 删除空的 dist 目录
rm -rf {{ upload_path }}/dist
- name: "[前端] 清理临时压缩包"
file:
path: "{{ upload_path }}/{{ dist_package }}"
state: absent
- name: "[前端] 重载 Nginx"
shell: "{{ nginx_reload_cmd }}"
#ignore_errors: yes
3.编写pipeline流水线
ini
pipeline {
// 使用 Jenkins Slave 节点
agent {
node {
label 'jenkins-slave'
customWorkspace '/home/jenkins/backend-pipeline'
}
}
// --- 参数化构建定义 ---
parameters {
choice(
name: 'DEPLOY_ENV',
choices: ['production', 'staging'],
description: '选择部署环境:production为生产环境,staging为灰度环境'
)
// 新增:部署策略选择
choice(
name: 'DEPLOY_STRATEGY',
choices: ['灰度发布', '全量发布'],
description: '选择部署策略:灰度仅部署到 backend-test 组,全量部署到 backend-servers 组'
)
booleanParam(
name: 'SKIP_TESTS',
defaultValue: true,
description: '是否跳过 Maven 测试?'
)
}
// --- 全局选项 (Optimize)---
options {
// 1. ⏱️ 超时控制:防止任务挂起
timeout(time: 1, unit: 'HOURS')
// 2. 🧹 清理策略:保留最近 20 次构建,防止磁盘爆满
buildDiscarder(logRotator(numToKeepStr: '20'))
// 3. 🚫 禁止并发构建
disableConcurrentBuilds()
// 4. 📜 控制台输出时间戳
timestamps()
}
// 环境变量
environment {
// --- Nexus 配置 ---
NEXUS_CREDENTIALS_ID = 'nexus-deploy-creds'
NEXUS_TOOL_URL = '172.20.10.6:8081'
NEXUS_PROTOCOL = 'http'
NEXUS_MAVEN_REPO = 'app-backend'
// --- Git 配置 ---
BACKEND_GIT_URL = 'git@gitee.com:testpm/backend-docker.git'
DEPLOY_GIT_URL = 'git@gitee.com:testpm/ops-ansible-playbooks.git'
GIT_CREDENTIALS_ID = 'jenkins-slave-git'
// --- 版本与镜像 ---
BUILD_VERSION = "${BUILD_NUMBER}"
MAVEN_IMAGE = 'maven:3.9.9-eclipse-temurin-21'
BACKEND_JAR_GLOB = 'backend/target/*.jar'
}
stages {
// ================== 阶段 1: 拉取后端代码 ==================
stage('📥 拉取后端代码') {
steps {
script {
echo "🚀 开始拉取后端代码..."
dir('backend') {
deleteDir()
git branch: 'master',
credentialsId: "${GIT_CREDENTIALS_ID}",
url: "${BACKEND_GIT_URL}"
}
}
}
}
// ================== 阶段 2: 后端构建 (Maven) ==================
stage('🔨 后端构建') {
steps {
script {
retry(3) {
docker.image("${MAVEN_IMAGE}").inside("-u root:root -w ${WORKSPACE} -v /data/maven-cache:/root/.m2 --rm --entrypoint=''") {
dir('backend') {
def testOpt = params.SKIP_TESTS ? '-DskipTests' : ''
sh """
echo "🚀 开始 Maven 编译..."
mvn clean package ${testOpt} -B -U
"""
}
}
}
// 验证产物
def jars = findFiles(glob: "${BACKEND_JAR_GLOB}")
if (!jars) error "❌ 构建失败:未找到 Jar 包"
}
}
}
// ================== 阶段 3: 上传到 Nexus ==================
stage('☁️ 上传后端制品') {
steps {
script {
echo "🚀 开始上传 Jar 到 Nexus..."
def pom = readMavenPom file: 'backend/pom.xml'
def jarFile = findFiles(glob: "${BACKEND_JAR_GLOB}")[0]
nexusArtifactUploader(
nexusVersion: 'nexus3',
protocol: "${NEXUS_PROTOCOL}",
nexusUrl: "${NEXUS_TOOL_URL}",
groupId: "${pom.groupId}",
version: "${pom.version}",
repository: "${NEXUS_MAVEN_REPO}",
credentialsId: "${NEXUS_CREDENTIALS_ID}",
artifacts: [[
artifactId: "${pom.artifactId}",
classifier: '',
file: "${jarFile.path}",
type: 'jar'
]]
)
}
}
}
// ================== 阶段 4: 获取 Ansible 剧本 ==================
stage('📂 获取 Ansible 剧本') {
steps {
script {
echo "🚀 拉取公共运维剧本..."
dir('ansible-playbooks') {
deleteDir()
git branch: 'master',
credentialsId: "${GIT_CREDENTIALS_ID}",
url: "${DEPLOY_GIT_URL}"
}
}
}
}
// ================== 阶段 5: 执行部署 ==================
stage('🚀 部署后端') {
steps {
script {
echo "🚀 开始执行后端部署..."
// 👇 这里增加了人工确认和超时控制
timeout(time: 15, unit: 'MINUTES') {
input message: "确认要【${params.DEPLOY_STRATEGY}】到 ${params.DEPLOY_ENV} 环境吗?", ok: '确认部署'
}
dir('ansible-playbooks') {
// 👇 核心逻辑:根据参数DEPLOY_STRATEGY动态决定是"灰度发布"还是"全量发布",并传递给 Ansible `--extra-vars`
def target_group = ""
if (params.DEPLOY_STRATEGY == '灰度发布') {
target_group = "backend-test"
echo "⚠️ 当前模式:灰度发布,目标组 -> ${target_group}"
} else {
target_group = "backend-servers"
echo "⚠️ 当前模式:全量发布,目标组 -> ${target_group}"
}
// 传递参数给 Ansible,告诉剧本只做后端
def ansibleCmd = """
ansible-playbook \
-i inventory/${params.DEPLOY_ENV}/hosts.ini \
deploy.yml \
--extra-vars "target_group=${target_group}" \
-vvv
"""
sshagent (credentials: ['deploy-ssh-key']) {
sh "${ansibleCmd}"
}
}
}
}
}
}
// --- 统一清理 ---
post {
always {
// 无论成功失败,都清理工作空间
cleanWs()
}
}
}
这份 Jenkins Pipeline 属于典型的企业级 CI/CD 流水线雏形。它不仅涵盖了从代码拉取到部署的全流程,还融入了很多生产环境必需的"防错"和"优化"机制。