1. 为什么若依项目的前端值得单独搭一套 Jenkins 自动打包部署
前端的打包部署这件事,说简单也简单,说折磨人也真折磨人。我在带团队做若依项目的过程中,前后经历过三种状态:最早是本地npm run build:prod,然后打开 Xftp 手动拖 dist 目录上去;后来改成写一个 shell 脚本,登服务器手动执行;再后来才把 Jenkins 这套东西补齐,做成前端自动打包部署的完整链路。每一次升级,省下的都不是十分钟二十分钟,而是"改一行文案要发一次版"这种恶心事带来的心理消耗。
若依(RuoYi)作为一套在国内使用面很广的 Java 快速开发框架,前端这块有几条并行的分支:RuoYi-Vue 走的是 Vue2 加 Element UI 加 vue-cli 的老组合,RuoYi-Vue3 走的是 Vue3 加 Element Plus 加 Vite 加 TypeScript 的新组合,另外还有 RuoYi-Cloud 微服务版,前端工程基本沿用 ruoyi-ui 的结构。也就是说,同一个团队很可能同时维护两套前端,一套 Node 14/16,一套 Node 18/20,打包命令、环境变量前缀、构建产物结构都不一样。这时候如果没有一套统一的自动打包部署流程,光在"哪台机器装哪个 Node 版本"上就能吵起来。
这套 Jenkins 自动打包部署方案要解决的核心问题其实就三个:第一,前端代码合并之后不再依赖某个人本地环境,由 Jenkins 在固定的构建节点上完成依赖安装和打包;第二,打包产物能自动分发到目标服务器,并配合 Nginx 完成发布,中间不出现"人肉复制粘贴";第三,出问题能快速回滚,不用重新走一遍打包。适合谁来参考?我觉得是三类人:手里有若依项目、目前还在手工发版的前端或运维;刚开始接触 Jenkins、想找一个真实项目练手的人;以及正在做多环境、多分支发布规范化的团队。
这里先给一个预期:整套流程的搭建时间,如果 Jenkins 服务器和构建节点已经就绪,第一遍跑通大概两三个小时;如果从零开始装 Jenkins、配 Node、通 Git 凭据,那一两天也正常。真正花时间的不是写 Jenkinsfile,而是把那些"看起来无关紧要"的环境细节对齐。
1.1 若依前端工程的打包特性决定了流程怎么设计
要把自动部署做扎实,先得把若依前端工程的构建模型吃透。RuoYi-Vue 这一支用的是 vue-cli 体系,package.json里通常能看到build:prod和build:stage两个脚本,分别对应vue-cli-service build和vue-cli-service build --mode staging。这套体系依赖.env.production、.env.staging这类环境文件,里面用VUE_APP_作为变量前缀,比如后端接口地址VUE_APP_BASE_API。而 RuoYi-Vue3 这一支换成了 Vite,脚本变成vite build和vite build --mode staging,环境变量前缀改成了VITE_,配置读取方式和注入时机都不一样。
这个差异直接影响了 Jenkins 任务的设计。Vue2 版本里,VUE_APP_BASE_API是编译期注入的,也就是说不同环境必须打不同的包,你没法用一份 dist 同时适配测试和生产。Vite 版本同理,import.meta.env也是编译期静态替换。所以 Jenkins 任务必须把"部署环境"做成一个参数,根据参数选择不同的构建命令,而不是打完包再改配置文件——那样改出来的产物和实际打包产物之间有偏差,出了问题很难复盘。
还有一个容易被忽略的点:若依前端的产物是纯静态资源,但它不是"放上去就能用"。前端页面里的请求地址是相对的/prod-api或者某个绝对地址,后者需要 Nginx 侧做反向代理到后端服务。这意味着前端部署和 Nginx 配置是一对绑定关系,Jenkins 在发布阶段不仅要传文件,往往还要保证目标机的 Nginx 配置是对的。我的习惯是把 Nginx 的 server 配置文件也纳入版本管理,Jenkins 发布时用同一份模板渲染后下发,避免"某台机器的配置和别人不一样"。
再就是构建产物的大小。若依前端引入了 Element UI 或 Element Plus 全家桶,加上若依自己封装的组件和工具库,一个正常的 dist 目录两三兆到十几兆很常见。如果直接npm run build:prod而不做任何优化,构建时间在普通 2 核 4G 的构建节点上可能要三到五分钟,内存占用也容易碰到 Node 默认堆上限。所以后面我会专门讲构建参数和内存配置,这不是可选项,是必选项。
1.2 手工发版的坑,我按发生频率列一遍
在动手搭 Jenkins 之前,我把过去两年手工发版踩过的坑整理了一遍,这些坑也正是自动化要解决的问题。按发生频率从高到低:第一,忘记清理上一次的构建产物,dist目录里混进了旧 hash 的文件,浏览器缓存策略一乱,用户看到的是新旧混合的页面;第二,本地 Node 版本和服务器或者同事不一致,node-sass、sass-loader这类原生模块直接编译失败,报一堆看不懂的 gyp 错误;第三,打包时没注意.env.production里的接口地址,把测试地址打进了生产包,上线之后接口全 404;第四,拖文件拖了一半网络断了,dist 目录不完整,页面白屏;第五,发布完忘记 reload Nginx,静态资源还是旧的;第六,出问题要回滚,发现没有留存历史版本,只能重新打包再发一遍。
这六条里,只有第二条属于"环境问题",其他五条都是流程问题。流程问题的解法就是把动作固化下来,让机器按固定顺序执行,不给人犯错的空间。Jenkins 的价值不在"自动化"这三个字本身,而在于它把这一串动作变成了一段可以版本管理、可以审计、可以回放的代码。
提示:如果你的团队现在还处在"谁手快谁发版"的阶段,不要一上来就追求全自动流水线。先把"打包"和"部署"两个动作拆开,让 Jenkins 只负责打包和归档,人还是手动取包部署。跑稳一两周之后,再把部署也接进来。步子太大容易摔。
1.3 方案选型:为什么是 Jenkins,而不是其他
这个问题我被问过很多次。前端圈子里做 CI/CD 的选择其实不少,比如 GitLab CI、GitHub Actions、Drone、或者干脆写个脚本配合 Webhook。选 Jenkins 的理由,在若依这种项目场景下大概有三条。
第一条是环境一致性。若依项目本身后端就是 Java,很多团队的构建服务器上已经跑着 Jenkins 做后端服务的打包了,前端的构建节点复用同一套 Jenkins 是最省事的。不用额外维护一套 CI 系统,权限、凭据、通知这些基础设施都是现成的。第二条是内网适配。相当一部分若依项目部署在完全隔离的内网环境里,GitHub Actions 这种云端方案根本用不了,GitLab CI 又要求 GitLab Runner 的部署和维护,Jenkins 作为老牌自托管方案,在离线安装、插件本地化这些方面资料最全。第三条是可观测性。Jenkins 的构建日志、历史记录、产物归档、构建参数留痕,在排查"这次上线到底发了哪个 commit"这类问题时非常好用,尤其是配合 Git 插件显示的变更记录。
当然 Jenkins 也有它的毛病:界面老、插件生态虽然大但质量参差、Groovy 语法对前端同学不友好。我的建议是,如果你只是一个人维护一两个前端项目,脚本加 Webhook 也许更轻;但只要是团队协作、多环境、要留痕,Jenkins 的投入产出比仍然是划算的。
2. 动手前的环境盘点与关键决策
这一节讲的是"搭之前要想清楚的事"。我见过太多人上来就装 Jenkins,装完发现 Node 版本不对、服务器连不上、Git 拉不下来,然后开始到处打补丁。正确的顺序是先盘点,再安装。盘点清单至少包括:Jenkins 跑在哪台机器(独立节点还是和构建节点合一)、要支持哪几个 Node 版本、代码仓库是什么协议(HTTP 还是 SSH)、构建产物发到哪台服务器、用什么账号发、目标机的目录结构怎么规划。
2.1 Jenkins 安装方式与插件源调整
安装方式上,我推荐两条路:Docker 安装和 WAR 包安装。Docker 安装的好处是环境干净、升级方便,docker run -d -p 8080:8080 -p 50000:50000 -v jenkins_home:/var/jenkins_home jenkins/jenkins:lts-jdk17一条命令就能起来,注意挂载卷一定要做,否则重启后配置全丢。WAR 包的场景是内网离线,下载jenkins.war之后java -jar jenkins.war --httpPort=8080直接跑,也可以用 systemd 托管。如果服务器上已经有一套 Jenkins 在跑后端,那就别折腾了,直接在里面加一个前端任务。
装完第一件必须做的事是把插件更新源改掉。默认的更新中心在国内访问速度堪忧,插件列表经常加载不出来。进入"系统管理 - 插件管理 - 高级",把升级站点改成清华大学或者华为云的 Jenkins 镜像地址,保存之后点"立即获取",插件列表刷新速度会明显改善。这一步不做,后面装插件会非常痛苦。
需要装的插件清单,我按重要性排列:Git plugin(拉代码)、Pipeline(流水线)、Pipeline: Stage View(阶段视图)、Credentials Binding(凭据绑定)、NodeJS(管理多个 Node 版本)、Publish Over SSH(远程发布,可选)、Workspace Cleanup(清理工作空间)、Timestamper(给日志打时间戳)、DingTalk(钉钉通知,可选)。其中 NodeJS 插件是重点,它允许你在 Jenkins 全局工具配置里预装多个 Node 版本,构建时按需切换,不用在服务器上手工管理 nvm。
注意:如果服务器完全离线,插件只能用
.hpi文件手工安装。下载好对应版本的 hpi 放进JENKINS_HOME/plugins/目录,重启 Jenkins 生效,注意插件之间的依赖关系,装 Pipeline 之前先装它的依赖。这个过程很磨人,建议离线环境一次把插件清单备齐。
2.2 Node 版本管理:为什么必须装两个
如果你同时维护 RuoYi-Vue 和 RuoYi-Vue3,Node 版本必须分开。Vue2 那套用 vue-cli 4/5 加 node-sass 的组合,官方支持到 Node 16,上到 Node 18 之后 node-sass 基本编译不过;Vue3 那套 Vite 体系要求 Node 18 起步。硬要在同一台机器上装一个版本,必然有一边挂掉。
我的做法是在 Jenkins 的全局工具配置里装两个 NodeJS,命名成node16和node20,勾选自动安装。然后在流水线的tools块或者用withEnv切换 PATH。这么做的代价是每个版本都要下载一份到 Jenkins 工作目录,磁盘占用大一点,但换来的是任务之间互不干扰,非常值。
如果你不想用 Jenkins 的自动安装,也可以在构建节点上用 nvm 管理,然后在流水线里source $NVM_DIR/nvm.sh && nvm use 16。这种方式更灵活,但要求 Jenkins 执行 shell 的账号有 nvm 的环境变量,容易踩坑,我倾向于前者。
2.3 Git 凭据与代码拉取方式
凭据这块,先决定用 HTTP 还是 SSH。HTTP 方式用账号密码或者访问令牌,配置简单,缺点是每次拉代码都要带凭据,仓库大了速度也一般。SSH 方式需要在 Jenkins 服务器上生成密钥对,把公钥配到代码平台的部署密钥里,然后把私钥作为 SSH Username with private key 类型的凭据存进 Jenkins。若依项目通常放在内网 GitLab 上,我一般用 SSH 方式,稳定且不涉及密码轮换。
凭据命名也要规范一点。我见过凭据列表里全是git-credential-1、git-credential-2这种名字,过两个月自己都不知道哪个是哪个。建议按"用途-仓库-环境"的规则命名,比如gitlab-ruoyi-ui-readonly,一眼能看懂。凭据的 ID 在流水线里要用到,改名字之前记得全局搜索一下。
还有一个细节:如果 GitLab 那边开启了 Webhook 触发构建,需要在 GitLab 项目设置里填 Jenkins 的地址,并且 Jenkins 侧要装 GitLab 插件、在系统配置里配好 GitLab 连接。Webhook 的好处是合并代码就自动触发,比轮询 SCM 实时得多,也不浪费构建资源。如果网络不通或者不可配置,退而求其次用轮询,H/5 * * * *表示每五分钟检查一次。
2.4 发布目标机的目录规范
目录规范这件事看起来小,但它决定了你的回滚能不能做。我的建议是每个前端项目在目标机上有一个独立根目录,下面按版本号建子目录,再用一个软链接指向当前生效的版本。结构大概是这样:
/data/web/ruoyi-ui/ ├── releases/ │ ├── 20240518.12/ │ ├── 20240519.13/ │ └── 20240520.14/ ├── current -> releases/20240520.14 └── shared/ └── nginx/ruoyi-ui.confNginx 的 root 指向/data/web/ruoyi-ui/current。发布时新建一个版本目录,把 dist 内容推进去,然后用ln -sfn原子地把 current 切过去。回滚就是把链接指回上一个版本,一秒完成,不需要重新打包。这套结构我用了三年,绝大多数回滚场景都能覆盖。
发布账号方面,别用 root。建一个专门的 deploy 账号,只给 releases 目录的写权限和 nginx reload 的 sudo 权限。Jenkins 通过 SSH 密钥登录这个账号,权限最小化,出问题影响面可控。
3. 构建任务的核心配置拆解
环境准备好了,接下来是任务本身。我强烈建议用 Pipeline 而不是自由风格任务,原因不是 Pipeline 更"高级",而是 Pipeline 的配置可以进版本库。Jenkinsfile 跟着代码走,谁改了流程在 Git 记录里能看到,新人接手打开文件就明白整个流程,这个价值在团队协作里太大了。自由风格任务的所有配置都在 Jenkins 的 XML 里,改了什么根本查不出来。
3.1 参数化构建怎么设计才够用
参数设计的原则是"够用就好,别把选择权交给执行人"。很多人喜欢把分支、环境、后端地址全做成参数,结果每次发版都要填一堆,填错一次就是事故。我的做法是把稳定不变的东西写死在配置里,把真正需要人决策的做成参数,而且尽量用下拉选择而不是文本框。
具体到若依前端,我通常只开放两个参数:DEPLOY_ENV(部署环境,选项为prod、staging)和BRANCH(构建分支,选项为master、release)。如果项目用了 Git 参数插件,可以让BRANCH从仓库里动态拉分支列表,避免手输拼错。剩下的比如 Node 版本、构建命令、目标服务器地址,全部根据DEPLOY_ENV在流水线内部映射,不给人改的机会。
这么做还有一个好处:参数化构建的每一次执行都会记下参数值,出了事故可以直接翻构建历史,看当时选的是哪个环境哪个分支。这比事后问"你当时选的是啥"靠谱一百倍。
3.2 构建环境注入与依赖源设置
依赖安装这一步,最大的变数是网络。国内环境直接从 npm 官方源拉包,速度慢且不稳定,构建时间能差出好几倍。所以流水线里第一件事就是把 registry 指向国内镜像源,比如npm config set registry https://registry.npmmirror.com。
这里有个经验:不要在 Jenkins 全局配 registry,而是在流水线的构建阶段临时设置。因为不同项目对源的要求可能不同,全局配置容易互相污染,而且改全局配置需要管理员权限,前端同学没这个权限就卡住了。临时设置的方式很简单,在 shell 步骤里执行npm config set registry ...就行,它是针对当前用户和当前环境的。
另外,如果构建节点和你本地一样会保留 npm 缓存,那构建速度会快很多。Jenkins 的工作空间默认会被复用,node_modules和 npm 缓存都还在,npm install时能命中大量缓存。但这也带来了问题:如果缓存脏了,构建会出现诡异错误。我的处理方式是在流水线里加一个手动参数CLEAN_CACHE,默认 false,出问题时勾上重新构建,执行npm cache clean --force并删除node_modules。
3.3 打包命令与内存参数
打包阶段,Vue2 和 Vue3 的命令不同,需要在流水线里做判断。Vue2 走npm run build:prod或npm run build:stage,Vue3 走npm run build:prod(内部是vite build)。命令本身不复杂,真正需要注意的是内存。
Node 默认的堆内存上限在 64 位系统上大约 2GB(新版本可能更高),若依前端打包时如果开了 source map 或者依赖特别多,很容易触顶,报错JavaScript heap out of memory。解决方案是在执行构建命令前设置NODE_OPTIONS=--max-old-space-size=4096。这条环境变量对 vue-cli 和 Vite 都有效。
不过要注意,--max-old-space-size的值不能超过构建节点的物理内存,否则进程会被系统 OOM Killer 干掉,表现是构建突然中断没有任何日志。我一般把 4G 作为默认值,构建节点内存 8G 起步。如果构建节点只有 4G,那就设 2048,同时考虑把 source map 关掉,能在vue.config.js或vite.config.ts里配置productionSourceMap: false,产物体积和内存占用都会明显下降。
还有一个细节是构建时的时间戳和版本号。我喜欢在打包前把当前 commit 短哈希写进环境变量,通过VUE_APP_BUILD_VERSION或者VITE_BUILD_VERSION注入到前端,页面上某个角落显示出来。这样用户反馈"页面有问题"时,第一句话就能问清是哪个版本,效率极高。
3.4 环境变量文件与多环境区分
前面提过,Vue2 用.env.production,Vue3 用.env.production,文件格式类似但变量前缀不同。这些文件一般跟着代码走,但有些值(比如后端地址)不同环境不一样,如果全部提交到仓库,就得靠 Jenkins 在构建前覆盖。
我见过两种做法。一种是把所有环境的值都写进仓库,用不同的 mode 区分,.env.staging对应 staging,.env.production对应 prod,构建时选命令即可。另一种是仓库里只放模板,Jenkins 在构建前用 Shell 脚本根据参数生成真实的.env.production。前者简单但是环境配置暴露在代码里,后者灵活但需要维护模板和生成逻辑。
我倾向于前者,理由是简单可追溯。若依这类项目通常只有两三套环境,多写两个文件成本很低。只有在环境数量多、且后端地址经常变动的情况下才值得用生成方式。如果确实要给不同环境注入不同的值,可以在流水线里用sh命令往环境文件追加内容,比如echo "VUE_APP_BASE_API=$API_BASE" >> .env.production,注意追加之后要重新执行构建,不能在构建后改。
4. Pipeline 落地:从拉代码到发布上线
前面铺垫了这么多,这一节直接把完整流程摆出来。我不打算给一个"最佳实践模板"让你复制粘贴,因为每个团队的目标机结构、Nginx 配置、通知方式都不一样。我会给一个我实际在用的版本,然后逐段解释为什么这么写,你可以按自己的情况裁剪。
4.1 Jenkinsfile 完整示例与逐段说明
下面这份 Jenkinsfile 是我给 RuoYi-Vue3 前端工程用的,Vue2 版本只需要把构建命令换掉、Node 版本改掉即可。
pipeline { agent any parameters { choice(name: 'DEPLOY_ENV', choices: ['prod', 'staging'], description: '部署环境') choice(name: 'BRANCH', choices: ['master', 'release'], description: '构建分支') booleanParam(name: 'CLEAN_CACHE', defaultValue: false, description: '是否清理缓存') } options { timestamps() buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '10')) disableConcurrentBuilds() timeout(time: 30, unit: 'MINUTES') } environment { NODE_BIN = tool name: 'node20', type: 'jenkins.plugins.nodejs.tools.NodeJSInstallation' TARGET_HOST = '10.0.0.21' TARGET_DIR = '/data/web/ruoyi-ui' } stages { stage('拉取代码') { steps { checkout([$class: 'GitSCM', branches: [[name: "*/${params.BRANCH}"]], userRemoteConfigs: [[ url: 'git@gitlab.internal:web/ruoyi-ui.git', credentialsId: 'gitlab-ruoyi-ui-readonly' ]] ]) script { env.GIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim() } } } stage('准备环境') { steps { withEnv(["PATH+TOOL=${NODE_BIN}/bin"]) { sh ''' node -v npm -v ''' } } } stage('安装依赖') { steps { withEnv(["PATH+TOOL=${NODE_BIN}/bin"]) { sh ''' npm config set registry https://registry.npmmirror.com npm config set sass_binary_site https://registry.npmmirror.com/-/binary/node-sass if [ "$CLEAN_CACHE" = "true" ]; then rm -rf node_modules npm cache clean --force fi npm ci --no-audit --no-fund ''' } } } stage('打包构建') { steps { withEnv(["PATH+TOOL=${NODE_BIN}/bin", "NODE_OPTIONS=--max-old-space-size=4096"]) { sh """ if [ "${params.DEPLOY_ENV}" = "prod" ]; then npm run build:prod else npm run build:stage fi """ } } post { success { sh "du -sh dist && find dist -maxdepth 1 -type f | head -20" } } } stage('归档产物') { steps { archiveArtifacts artifacts: 'dist/**', fingerprint: true, allowEmptyArchive: false } } stage('发布到目标机') { steps { sh """ RELEASE_DIR=${TARGET_DIR}/releases/\$(date +%Y%m%d).\${BUILD_NUMBER} ssh deploy@${TARGET_HOST} "mkdir -p \$RELEASE_DIR" rsync -az --delete dist/ deploy@${TARGET_HOST}:\$RELEASE_DIR/ ssh deploy@${TARGET_HOST} "ln -sfn \$RELEASE_DIR ${TARGET_DIR}/current && sudo nginx -t && sudo nginx -s reload" """ } } } post { always { cleanWs(deleteDirs: true, patterns: [[pattern: 'node_modules/**', type: 'INCLUDE']]) } success { echo "构建成功,版本 ${env.GIT_SHORT},环境 ${params.DEPLOY_ENV}" } failure { echo "构建失败,请查看日志" } } }逐段说几个关键点。parameters里我把BRANCH做成了 choice,实际项目中建议用 Git Parameter 插件动态拉取;options里的disableConcurrentBuilds很重要,前端打包很吃资源,两个构建同时跑很容易把内存打满;buildDiscarder保留 30 次构建记录和 10 次产物,磁盘不至于爆炸;timeout防止卡死占用执行器。
environment里的NODE_BIN用了tool语法,这是 NodeJS 插件提供的,会自动把配置好的 Node 版本路径填进来。下面每个阶段用withEnv把它加到 PATH 前面,这样node、npm命令就走这个版本,不影响系统其他部分。
拉取代码阶段末尾用git rev-parse --short HEAD取短哈希存进环境变量,后面发通知时带上,非常实用。
安装依赖阶段我用了npm ci而不是npm install。npm ci严格按照package-lock.json安装,保证每次构建的依赖树完全一致,而且速度更快。前提是仓库里必须有 lock 文件,没有的话要先跑一次npm install生成并提交。
发布到目标机阶段用的是 rsync over SSH,注意目标机上 deploy 账号需要配好免密登录,并且sudo nginx -s reload要在 sudoers 里放开。这里我先nginx -t校验配置,通过才 reload,避免坏配置上线。
4.2 产物归档与远程分发的几种方式
除了 rsync,还有几种分发方式值得知道。一种是Publish Over SSH插件,配置图形化,适合不熟悉命令行的人,但它在流水线里的写法比较别扭,而且传输大文件时性能不如 rsync。另一种是先scp传到临时目录再在目标机解压,适合产物被打成了 tar.gz 的情况,网络传输效率更高。还有一种是把 dist 传到对象存储,Nginx 直接从对象存储回源,这种适合多节点部署的场景。
我选 rsync 的原因是它支持增量传输和--delete。前者让第二次之后的发布快很多,后者保证目标目录和源目录严格一致,不会残留旧文件。注意--delete要慎用,路径写错会删掉不该删的东西,所以我在 rsync 前先确保目标目录是新创建的版本目录。
产物归档这块,archiveArtifacts会把 dist 存进 Jenkins 的构建记录里。好处是任何时候都能从 Jenkins 界面下载到某次构建的产物,回滚时不用重新打包。缺点是占磁盘,所以artifactNumToKeepStr要设一个合理的值。我的习惯是保留最近 10 次。
4.3 Nginx 目录切换与缓存策略
发布完成后,Nginx 那边还有两个细节要处理。第一个是try_files。若依前端是单页应用,路由用的是 history 模式,直接访问非根路径会 404,所以 Nginx 配置里必须有try_files $uri $uri/ /index.html;。这条不加,用户刷新页面就白屏,是新手最常踩的坑之一。
第二个是缓存策略。前端产物经过构建后,JS 和 CSS 文件名里带哈希,内容变了文件名就变,所以可以放心地给它们设长缓存,比如expires 1y。但index.html绝对不能缓存,否则用户拿到的是旧页面,引用的是已经不存在的旧资源文件,页面直接崩。所以我的配置一般是:
location / { root /data/web/ruoyi-ui/current; try_files $uri $uri/ /index.html; index index.html; } location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ { root /data/web/ruoyi-ui/current; expires 365d; add_header Cache-Control "public, immutable"; } location = /index.html { root /data/web/ruoyi-ui/current; add_header Cache-Control "no-cache, no-store, must-revalidate"; } location /prod-api/ { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8080/; }这里index.html单独用location =精确匹配并加不缓存头,其他静态资源走长缓存。/prod-api/是若依前端默认的接口前缀,代理到后端服务,注意proxy_pass末尾的斜杠,有没有斜杠决定了路径拼接方式,写错会导致接口 404,这个坑我踩过不止一次。
4.4 构建通知与结果回传
构建完成之后,团队需要知道结果。最轻量的方式是 Jenkins 自带的邮件通知,但邮件在国内团队里的打开率不高。我一般会接钉钉群机器人,配置很简单:在钉钉群里添加自定义机器人,拿到 Webhook 地址,Jenkins 里装 DingTalk 插件,在系统配置里填好,然后在流水线的 post 块里发送消息。
消息内容我会带上这几个字段:任务名、构建号、环境、分支、commit 短哈希、构建耗时、结果、构建链接。这样群里一看就知道发了什么、成没成。如果构建失败,最好把最后几十行日志也带上,省得大家都要点进去看。
除了通知,我还会在构建成功后调用一个接口刷新 CDN 缓存。如果项目用了 CDN,前端发布之后不刷缓存,用户可能还在访问旧文件。刷缓存的操作一般是调用云厂商的 API,在流水线的 post success 里执行一个 curl 即可。这一步容易被忘,但忘记的后果是"明明发了版用户说没变化"。
5. 高频问题排查速查
不管流程多顺,问题总会来。我按"构建阶段"和"部署阶段"分开整理,都是真实遇到过的,附带排查思路。
5.1 构建阶段典型报错
最常见的三个:JavaScript heap out of memory、node-sass编译失败、Module not found。
第一个前面讲过,加NODE_OPTIONS=--max-old-space-size=4096,如果还不行要检查构建节点内存和是否同时跑了多个构建。第二个通常出现在 Vue2 项目上,原因是 Node 版本过高或者sass_binary_site没配,解决方式是降 Node 版本到 16 并配置镜像源的二进制地址。第三个大多是依赖没装全或者 lock 文件冲突,先删node_modules和 lock 重装试试,还不行就看看是不是某个人在package.json里引了私有包但没配源。
还有一类是"本地能打包,Jenkins 上不行"。这种基本可以断定是环境差异,排查顺序是:Node 版本、npm 版本、环境变量、时区、文件大小写。大小写这个问题特别隐蔽,因为 Linux 区分大小写而 Windows 不区分,本地import Hello from './hello'能过,服务器上找不到./Hello,报错信息还不直观。
5.2 部署阶段典型报错
Permission denied (publickey)是 SSH 免密没配好,检查 Jenkins 服务器的公钥是否在目标机 deploy 用户的authorized_keys里,权限位是否是 600。rsync: mkdir failed一般是目标目录权限不足,检查 deploy 用户对 releases 目录有没有写权限。
nginx: [emerg] unknown directive是 Nginx 配置写错了,reload 之前一定跑nginx -t。ln: failed to create symbolic link说明软链接已经存在且指向别处,用ln -sfn强制覆盖(注意-n不能少,否则会把链接建到目标目录里面去)。
还有一类是发布成功但页面 404。先看 Nginx 的 error log,大概率是 root 路径不对或者try_files没配。其次是看目录软链接指向是否正确,ls -l current一眼就能看出来。
5.3 问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 构建 OOM | 堆内存不足或并发构建 | 加NODE_OPTIONS,开disableConcurrentBuilds |
| node-sass 编译失败 | Node 版本过高、二进制源不通 | 降到 Node 16,配sass_binary_site |
| 依赖安装超时 | 源不通、网络抖动 | 换国内镜像源,重试 |
| SSH 连接被拒 | 免密未配、权限位不对 | 检查 authorized_keys 和 600 权限 |
| 发布后页面白屏 | 资源 404、index 缓存 | 查 Nginx 日志,确认 index 不缓存 |
| 刷新页面 404 | history 模式缺 try_files | 补try_files $uri $uri/ /index.html |
| 接口 404 | proxy_pass 斜杠问题 | 检查末尾斜杠和前缀 |
| 用户看到旧页面 | CDN 或浏览器缓存 | 刷 CDN,确认 index 不缓存 |
| 构建日志时间对不上 | 容器时区是 UTC | 挂载/etc/localtime或设 TZ |
这张表我建议直接贴到团队文档里,出问题时先扫一遍,多数情况能自己解决。
5.4 独家避坑技巧
说几个不太常见但很值钱的技巧。第一,给 Jenkins 容器设时区。默认容器是 UTC,构建日志的时间和你沟通时说的"刚才那次构建"对不上,排查问题非常痛苦。挂载宿主机的/etc/localtime或者在启动参数里加-e TZ=Asia/Shanghai就能解决。
第二,把 Node 版本检测加进流水线。在准备环境阶段打印node -v && npm -v,一旦版本不是预期的,日志里立刻能看出来,比翻半天源码强。
第三,给 Jenkinsfile 加timestamps()。构建日志前面会带时间戳,定位"哪一步耗时长"非常直观,配合 Stage View 看每一阶段的耗时,优化目标一目了然。
第四,发布前用nginx -t做校验。这个动作只要两秒,但能拦住 90% 的配置事故。我就见过有人改了 Nginx 配置直接 reload,结果因为一个分号写错导致整个站挂掉。
第五,保留历史版本目录并且限制数量。我用一个定时任务每周清理超过 30 天的 releases 子目录,既保证了回滚能力,又不会把磁盘塞满。
6. 我踩过的坑与可复用的经验
最后这一节不写总结,只聊点具体的。
关于缓存和 lock 文件,我的态度是:package-lock.json必须提交,构建必须用npm ci。曾经有个项目为了"避免冲突"把 lock 文件加进了.gitignore,结果同一份代码在三个人手里装出三个不同的依赖树,线上偶发问题时根本没法复现。后来把 lock 补上,问题消失。代价是每次升级依赖都要处理冲突,但这个代价值得。
关于并发构建,我吃过一次大亏。当时图省事没开disableConcurrentBuilds,两个同学同时触发构建,两个npm install同时往同一个node_modules目录写,结果装出来的依赖是混乱的,打包产物直接报错。更惨的是那次构建成功了,上线之后页面报奇怪的undefined is not a function,排查了大半天。从那以后,所有前端任务我都加上串行限制。
关于回滚,我的体会是:回滚能力的上限,取决于你在设计阶段留了多少余地。用软链接切换的方案,回滚就是改一个链接指向,一秒完成;如果用覆盖式发布,回滚就得重新打包再传一遍,五分钟起步,还要承担"打包环境已经变了"的风险。所以哪怕项目再小,我也建议在第一天就把目录结构规划成带 releases 的形态,前期多花十分钟,后面省无数事。
关于 Node 版本,还有个小经验:如果构建节点上同时装了两个 Node,一定要在流水线里显式声明用哪个,不要依赖系统默认。我见过一个任务因为系统默认 Node 被升级,从 16 变成 18,第二天全部 Vue2 项目打包失败。显式声明之后,Node 升级再也不会影响存量项目。
再分享一个后续可以扩展的方向:如果团队规模上来了,多分支、多环境、多项目会让 Jenkins 任务数量爆炸,这时候可以考虑把 Jenkinsfile 抽成共享库,用@Library引入公共的构建逻辑,各个项目只保留差异部分。再往前一步,是把发布和 K8s 结合起来,前端产物做成镜像,用 Deployment 滚动更新,回滚就变成了镜像版本回退。不过那是另一个话题了,等这套 Jenkins 流程跑稳半年之后,再考虑也不迟。
我个人在实际维护这套流程的过程中最大的感受是:自动化的价值不在于省下的那点操作时间,而在于它把"发布"这件事从依赖某个人的记忆和手感,变成了依赖一段可以被 review、被版本管理的代码。前者会随着人员流动而消失,后者会一直留在仓库里。