构建前端
记录错误
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),但问题在于:
- 文件所有权冲突 :容器内的 root 创建的文件(
frontend-dist-13.tar.gz),在宿主机上可能属于root用户,而 Jenkins slave 进程是以jenkins用户运行的 - 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 目录在宿主机上可能:
- 不存在
- 权限不是 1000:1000
- 或者之前被 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()
}
}
}
到此我们通过一个剧本结合参数化构建,分别实现了对前后端的构建和发布。符合企业级使用。当然和真正企业级的还有差距比如添加代码质量,构建通知等等。继续学习