企业级 Jenkins Pipeline 实战Docker构建前端+Ansible发布

构建前端

记录错误

ini 复制代码
java.nio.file.AccessDeniedException: /home/jenkins/frontend-pipeline/frontend/frontend-dist-13.tar.gz

这个错误发生在 stash 操作时,Jenkins 尝试读取 tar.gz 文件但没有权限

根本原因在于你的 Docker 容器配置:

复制代码
docker.image("${NODE_IMAGE}").inside("-u root:root -w ${WORKSPACE} -v /data/npm-cache:/root/.npm --rm --entrypoint=''")

虽然你在容器内用了 root 用户(-u root:root),但问题在于:

  1. 文件所有权冲突 :容器内的 root 创建的文件(frontend-dist-13.tar.gz),在宿主机上可能属于 root 用户,而 Jenkins slave 进程是以 jenkins 用户运行的
  2. umask 问题 :即使容器内用 root,创建的文件权限可能是 600(仅 root 可读写),导致宿主机上的 Jenkins 进程无法读取

修改 Docker 容器的用户(推荐)

让容器使用与宿主机 Jenkins 进程相同的 UID/GID:

复制代码
stage('🎨 前端构建') {
    steps {
        script {
            // 获取当前用户的 UID 和 GID
            def uid = sh(script: 'id -u', returnStdout: true).trim()
            def gid = sh(script: 'id -g', returnStdout: true).trim()
            
            docker.image("${NODE_IMAGE}").inside("-u ${uid}:${gid} -w ${WORKSPACE} -v /data/npm-cache:/home/node/.npm --rm") {
    }
}

npm缓存目录权限问题

ini 复制代码
Your cache folder contains root-owned files, due to a bug in previous versions of npm
npm error To permanently fix this problem, please run:
npm error sudo chown -R 1000:1000 "/home/node/.npm"

你在容器内使用了 -u ${uid}:${gid}(应该是 1000:1000),但 volume 挂载的 /home/node/.npm 目录在宿主机上可能:

  1. 不存在
  2. 权限不是 1000:1000
  3. 或者之前被 root 用户创建过

导致容器内的 node 用户(UID 1000)无法写入这个目录。修复缓存目录权限(推荐)

在执行 Pipeline 前,先在 Jenkins slave 上修复目录权限:

复制代码
# 在 Jenkins slave 机器上执行
sudo mkdir -p /data/npm-cache
sudo chown -R 1000:1000 /data/npm-cache
前端不需要进行灰度

需要修改一下剧本

添加tags参数,用于区分前后端,这也是最终的完成通用剧本

ini 复制代码
[root@jenkins-slave ops-ansible-playbooks]# cat deploy.yml 
---
# ==========================================
# Play 1: 后端服务通用更新 (合并了全量和灰度)带回滚
# ==========================================
- name: 滚动更新后端服务
  tags: backend #添加tag 用于在pipeline指定区分前后端
  hosts: "{{ target_group | default('backend-servers') }}" # 关键点:这里使用变量 target_group,如果没有传入,默认为 backend-servers
  remote_user: deploy
  become: yes
  become_method: sudo       #使用 sudo 方式提权
  gather_facts: yes
  any_errors_fatal: true  # 关键修复1:任何主机失败立即停止整个playbook

  # --- 滚动更新策略 ---
  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
  tags: frontend #添加tag 用于在pipeline指定区分前后端
  remote_user: deploy
  become: yes
  become_method: sudo       #使用 sudo 方式提权
  gather_facts: yes
  any_errors_fatal: true  #任何主机失败立即停止整个playbook


  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

编写pipeline

通过--tags 指定要执行的paly的标记

ini 复制代码
 pipeline {
    agent { 
        node { 
            label 'jenkins-slave' 
            customWorkspace '/home/jenkins/frontend-pipeline' 
        } 
    }

    // --- 参数化构建定义 ---
    parameters {
        choice(
            name: 'DEPLOY_ENV', 
            choices: ['production'], 
            description: '选择部署环境'
        )
    }

    // --- 全局选项 ---
    options {
        // 1. ⏱️ 超时控制
        timeout(time: 30, unit: 'MINUTES')
        
        // 2. 🧹 清理策略
        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_RAW_REPO = 'app-frontend-raw'

        // --- Git 配置 ---
        FRONTEND_GIT_URL = 'git@gitee.com:testpm/frontend-docker.git'
        DEPLOY_GIT_URL = 'git@gitee.com:testpm/ops-ansible-playbooks.git'
        GIT_CREDENTIALS_ID = 'jenkins-slave-git'

        // --- 版本与镜像 ---
        BUILD_VERSION = "${BUILD_NUMBER}"
        NODE_IMAGE = 'node:20'
    }

    stages {
        // ================== 阶段 1: 拉取前端代码 ==================
        stage('📥 拉取前端代码') {
            steps {
                script {
                    echo "🚀 开始拉取前端代码..."
                    dir('frontend') {
                        deleteDir()
                        git branch: 'master', 
                             credentialsId: "${GIT_CREDENTIALS_ID}", 
                             url: "${FRONTEND_GIT_URL}"
                    }
                }
            }
        }

        // ================== 阶段 2: 前端构建 (Node.js) ==================
        stage('🎨 前端构建') {
            steps {
                script {
                    // 获取当前用户的 UID 和 GID
                    def uid = sh(script: 'id -u', returnStdout: true).trim()
                    def gid = sh(script: 'id -g', returnStdout: true).trim()
                    docker.image("${NODE_IMAGE}").inside("-u ${uid}:${gid} -w ${WORKSPACE} -v /data/npm-cache:/home/node/.npm --rm --entrypoint=''") {
                        dir('frontend') {
                            retry(3) {
                                sh '''
                                    echo "📦 安装依赖..."
                                    npm config set registry https://registry.npmmirror.com
                                    npm install --legacy-peer-deps
                                '''
                            }
                            sh '''
                                echo "🚀 开始编译..."
                                npm run build
                                echo "📦 打包 (使用 tar 代替 zip,无需安装软件)..."
                                tar -czvf frontend-dist-${BUILD_VERSION}.tar.gz dist/
                            '''
                        }
                    }
                }
                // 构建完成后,将产物存入暂存区
                stash name: "frontend-artifact-${BUILD_VERSION}", includes: "frontend/frontend-dist-${BUILD_VERSION}.tar.gz"
            }
        }

        // ================== 阶段 3: 上传到 Nexus ==================
        stage('☁️ 上传前端制品') {
            steps {
                script {
                    // 恢复前端制品
                    unstash "frontend-artifact-${BUILD_VERSION}"
                    echo "🚀 开始上传前端 tar.gz 到 Nexus..."
                    def gzFile = findFiles(glob: "frontend/frontend-dist-${BUILD_VERSION}.tar.gz")[0]

                    nexusArtifactUploader(
                        nexusVersion: 'nexus3',
                        protocol: "${NEXUS_PROTOCOL}",
                        nexusUrl: "${NEXUS_TOOL_URL}",
                        // 前端通常作为 Raw 类型上传,这里手动指定 GroupId
                        groupId: 'com.company.frontend',
                        version: "${BUILD_VERSION}",
                        repository: "${NEXUS_RAW_REPO}",
                        credentialsId: "${NEXUS_CREDENTIALS_ID}",
                        artifacts: [
                            [
                                artifactId: 'frontend-dist',
                                classifier: '',
                                file: "${gzFile.path}",
                                type: 'tar.gz'
                            ]
                        ]
                    )
                }
            }
        }

        // ================== 阶段 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: 5, unit: 'MINUTES') {
                        input message: '确认要部署前端到 ${params.DEPLOY_ENV} 环境吗?', ok: '确认部署'
                    }

                    dir('ansible-playbooks') {
                        // 传递参数给 Ansible,告诉剧本只做前端
                        def ansibleCmd = """ 
                            ansible-playbook \
                            -i inventory/${params.DEPLOY_ENV}/hosts.ini \
                            deploy.yml \
                            --tags frontend \ #通过tags 参数指定要运行的paly
                            -vvv 
                        """
                        
                        sshagent (credentials: ['deploy-ssh-key']) {
                            sh "${ansibleCmd}"
                        }
                    }
                }
            }
        }
    }

    // --- 统一清理 ---
    post {
        always {
            cleanWs()
        }
    }
}

到此我们通过一个剧本结合参数化构建,分别实现了对前后端的构建和发布。符合企业级使用。当然和真正企业级的还有差距比如添加代码质量,构建通知等等。继续学习

相关推荐
风之舞_yjf1 小时前
Vue基础(33)_web Storage(web存储)
前端·javascript·vue.js
梦想的颜色1 小时前
Docker 入门指南:从零开始掌握容器化技术
运维·服务器·vscode·python·算法·docker·云原生
jiayong231 小时前
CI/CD与DevOps、Jenkins、K8s关系深度解析
运维·git·ci/cd
遇见火星1 小时前
Jenkins + Ansible 集成实战:把配置管理焊进流水线里
运维·ansible·jenkins
日取其半万世不竭1 小时前
Uptime Kuma 应该放哪台机器?
java·docker·容器·https
夜白宋1 小时前
【Redis深入】二、高性能
java·前端·redis
被考核重击1 小时前
前端高频面试题总结_性能_工程化_网络
前端·网络·性能优化·工程化
运维瓦工1 小时前
DevOps 生态介绍(八):docker &dockerfile 命令介绍及构建项目的第一个镜像
java·docker·devops