ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Jenkins声明式Pipeline语法详解:从结构到实战的维护指南

Jenkins声明式Pipeline语法详解:从结构到实战的维护指南 维护 CI/CD 流水线这件事搞到后面往往不是任务执行不下去而是那段 Groovy 脚本你根本不想再去动了。我自己接过一条跑了快三年的脚本式 Pipeline里面塞满了 sharedLibrary 封装、try/catch 嵌套、动态 stage 生成同事每次想改一行都要在群里喊我先看一下。后来团队统一把流水线切到声明式 Pipeline 语法花了一个多月把所有任务重写了一遍最大的变化不是运行变快了而是新同学也能打开 Jenkinsfile 看懂每一步在干什么了。这篇是这个系列的第二篇上一篇我们整体对比过脚本式与声明式的差异这篇就把声明式 Pipeline 的语法彻底掰开讲。你不需要提前掌握 Groovy只要手头有一个能跑 Jenkins 的环境顺着下面的示例和踩坑记录基本能把自己的第一条声明式流水线搭起来。我会从整体骨架讲起把常用指令逐个拆开最后给出一套完整的前端部署示例并把容易踩的坑单独列出来。1. 为什么我从脚本式 Pipeline 转向声明式维护成本的真实对比先聊一个很多人心里会犯嘀咕的问题脚本式 Pipeline 那么自由为什么非要用声明式我在之前的项目里见过一种典型场景一开始只有一个人写 Pipeline他水平很高用 Groovy 写了一套很精巧的动态构建流程根据 Git 提交信息自动决定发版还是跑测试。业务方看得高兴但半年后这个人一休假其他人连新增一个环境变量都要查半天文档。脚本式 Pipeline 的本质就是把 Groovy 的完全编程能力暴露给你这既是优点也是失控点。你可以在里面写任意循环、任意闭包、任意 throw exception甚至做文件 IO但 Jenkins 的可视化界面、Blue Ocean 视图、构建步骤拆分都很难精准表达这些动态逻辑。声明式 Pipeline 换了一种思路把语法收紧成一个结构化的骨架。你只能在规定的位置写规定的块比如 agent、stages、steps、post基本结构是固定的。这让它看起来像一份配置而不是一段程序但也正因为这种约束Jenkins 可以在保存时做语法校验可以在界面上优雅地把每个 stage 渲染出来团队里任何人接手都不至于面对一堆天马行空的代码。对比项脚本式 Pipeline声明式 Pipeline语法自由度高完全暴露 Groovy 能力受限只能按声明块组织学习成本需要系统学习 Groovy掌握几个关键指令即可静态校验弱运行时才暴露问题保存时可做模型校验可视化支持一般动态逻辑难以展示每个 stage 清晰展示Blue Ocean 效果好团队可维护性依赖个人能力结构一致交接成本低复杂逻辑实现任意实现需要借助 script 块或共享库所以我的建议是如果你的团队超过一个人维护 Jenkins 任务或者公司有Pipeline 代码要被审计、被团队共享的诉求声明式是更稳妥的默认选择。只有当你确实需要大量动态 stage 生成、复杂的状态机流转时才考虑在声明式里嵌入 script 块或者干脆改用脚本式。大多数 CI/CD 场景其实用不到那么复杂的逻辑声明式反而是最省心的。2. 声明式 Pipeline 的整体骨架从最小示例开始读懂语法2.1 一段能立即运行的最小声明式 Pipeline先看一段最简单的 Jenkinsfilepipeline { agent any stages { stage(Build) { steps { echo 正在构建... } } } }这段代码可以直接粘贴到一个 Freestyle Job 的 Pipeline 脚本里运行。它表达的意思是在任何可用的 agent 上执行一个叫 Build 的阶段步骤是输出一行字。它的语法树非常直白pipeline最外层固定根节点所有声明式内容都必须包在里面。agent告诉 Jenkins 在哪台机器或容器上跑。stages阶段容器里面放一个或多个stage。stage(Build)定义一个具体阶段括号里是阶段名。steps该阶段内要执行的步骤列表。echo一个内建步骤打印信息。这棵树是理解声明式一切语法的基础。你写的任何 Pipeline无论看起来多复杂本质上都是在往这棵树的不同分支里填内容。先记住一点声明式 Pipeline 不是按从上到下的顺序随便写指令每个指令都有自己的归属位置。比如agent只能在pipeline最顶层或某个stage内post可以挂在pipeline顶层也可以挂在某个stage内但你不能在steps里去声明一个新的stage。理解了树这个概念报错就少一半。2.2 顶层可以放置哪些块在pipeline的花括号里可以出现这些主要块pipeline { agent any // 在哪台机器跑 options { ... } // 超时、重试、并发控制等 tools { ... } // 指定 JDK、Maven、Node 等工具 environment { ... } // 全局环境变量 parameters { ... } // 构建参数 triggers { ... } // 触发方式定时、轮询等 stages { ... } // 核心阶段 post { ... } // 结束后处理 }虽然 Jenkins 对块顺序有一定容忍度但我建议还是按官方推荐的顺序写agent、options、tools、environment、parameters、triggers、stages、post。这样写代码时容易形成肌肉记忆别人 review 时也方便对照。2.3 保存前一定要做语法校验声明式 Pipeline 的语法可以在 Jenkins 页面保存时被校验。如果你把一段不完整的stage写错了比如step写了单数保存时 Jenkins 大概率会直接标红。还有一个在线生成器叫 Pipeline Syntax通常在 Job 配置页下拉菜单里能找到/pipeline-syntax/它可以帮你生成sh、git、withCredentials等步骤的代码片段非常实用。本地开发时我还会用 VS Code 加 Jenkins Pipeline Linter 插件保存前自动 POST 到 Jenkins 的校验接口。这个习惯帮我挡掉了很多低级错误。总之上线一个 Jenkinsfile 前先在 UI 里跑一次 Dry Run 是最稳妥的。3. 核心指令逐一拆解agent、environment、parameters、post 到底怎么配3.1 agent给流水线找一台合适的机器agent是声明式 Pipeline 里最基本也最容易理解错的指令。最省事的写法是agent any表示 Jenkins 任意分配一个可用的执行节点。但真实项目一般不会这么做因为你可能希望构建任务跑在有对应代码、工具链的机器上。下面是几种常用写法// 指定标签为 linux-node 的节点 agent { label linux-node } // 用 Docker 容器作为执行环境 agent { docker { image node:18-alpine args -v /etc/localtime:/etc/localtime:ro } } // 顶层不指定每个 stage 分别指定 agent noneagent none是我觉得最灵活的一种模式。顶层写成none然后在每个stage内部单独指定agent。这样可以让 checkout、编译、部署分别跑在不同的节点或容器上比如编译用大内存节点部署用带内网权限的节点。有一点要注意如果顶层是agent none某个stage里又忘了写agent这个 stage 会直接报错因为 Jenkins 不知道把它调度到哪里。3.2 environment集中管理环境变量而不是到处写死环境变量是流水线里最容易脏乱差的环节。声明式推荐的做法是把它们集中放在environment块里。environment { APP_NAME my-web-app NODE_ENV production }environment可以放在pipeline顶层这样所有 stage 都可见也可以放在某个stage内部只对该 stage 生效。如果在不同层级有同名变量stage 内部的优先这点和大多数编程语言的作用域规则一致。有一个真实项目中非常高频的用法从 Jenkins 凭据里读取密钥。environment { // credentials 是在 Jenkins 里配置的 Credentials ID GITLAB_TOKEN credentials(gitlab-token) ALIYUN_AK credentials(aliyun-access-key) }这样写完之后在任意steps里就能直接通过$GITLAB_TOKEN引用凭据的值而不用自己在代码里硬编码任何密钥。踩坑提醒在environment里面不要写动态计算逻辑比如不要试图执行sh date来生成一个时间戳变量。environment的取值默认是字符串要么是固定值要么是credentials()的引用。真要动态计算放到第一个 stage 里去执行再把结果写到env变量。3.3 parameters把硬编码变成可选项如果每次部署都要去改 Jenkinsfile 里的分支名那说明parameters没用好。声明式 Pipeline 的parameters块定义构建触发时的输入项。parameters { string(name: BRANCH, defaultValue: develop, description: 需要构建的分支) choice(name: ENV, choices: [dev, staging, prod], description: 部署到哪个环境) booleanParam(name: SKIP_TEST, defaultValue: false, description: 是否跳过单元测试) }这些参数在 stage 中怎么引用直接用params对象stage(Checkout) { steps { echo 当前分支${params.BRANCH} echo 目标环境${params.ENV} } }一个常见的坑是参数默认值类型。string的参数即使写的是数字在params里也是字符串。如果你后面要用它做数值比较或者算断言先把类型转换了或者直接用字符串判断params.BUILD_NUM 512否则很容易出现看起来相等但实际就是不等于的怪问题。3.4 stages / stage / steps把任务切分成可读的单元stages是stage的集合每个stage至少要有一个steps里面放具体的构建步骤。stages { stage(Install) { steps { sh npm install } } stage(Test) { steps { sh npm run test:unit } } stage(Build) { steps { sh npm run build } } }每个stage的名称建议用动词 对象的格式比如Build frontend、Deploy to dev而不是简单的Step1、Step2。因为 Jenkins 的构建历史界面会直接显示 stage 名名字写清楚别人看构建失败在哪个环节一眼就知道问题出在哪。很多人刚开始写声明式时容易在steps里写 Groovy 的if、for然后直接报语法错误。这是必然的因为steps里只能放步骤step不能写流程控制。你需要两种补救方式使用声明式的when来做条件判断。使用script块包一层 Groovy 代码在脚本块里写if、for、try/catch。steps { script { if (params.SKIP_TEST) { echo 跳过测试 } else { sh npm run test:unit } } }script块是声明式里的逃生舱。它能让你在需要时绕过语法限制但也提醒一句script 块用得越多声明的配置化优势就越弱。能用when解决的尽量用when实在不行再上script。3.5 post每个阶段结束之后的处理别忘记清理post是声明式里非常有价值的一块用来在某个阶段或整个 Pipeline结束后执行兜底逻辑。它可以挂在pipeline顶层也可以挂到某个stage内部。post { always { echo 无论成功失败都会执行 cleanWs() } success { echo 当前 Pipeline 成功完成 archiveArtifacts artifacts: dist/**, fingerprint: true } failure { echo 当前 Pipeline 失败 slackSend channel: #ci-alert, message: 构建失败 } unstable { echo 构建结果不稳定比如测试有失败未阻止 } changed { echo 构建结果相比上一次发生了变化 } }这里有一个容易被忽略的点post块里的条件如 success、failure判断的是整条流水线当前的结果状态所以写在顶层时它的成功失败是整个构建的结果。但如果你把post放在某个stage内部它就只针对该 stage 的状态。我在真实项目中比较常用的组合是整个 Pipeline 顶层挂一个always块做cleanWs()工作区清理避免磁盘被你们长期不清理的 node_modules 塞满。在部署 stage 内部挂failure块通知团队当前环境部署失败。在测试 stage 内部挂unstable块处理测试有失败但没阻断构建的情况。另外注意post块里不能放when、agent之类的指令它本质上是一个步骤容器所以if这种逻辑还是要放script块里。3.6 options 与 triggers超时、重试、并发控制和定时触发options块用来控制流水线的运行行为我几乎每天都会用到这几个options { timestamps() // 日志中给每行加上时间戳 timeout(time: 30, unit: MINUTES) // 整体超时时间超过 30 分钟自动终止 disableConcurrentBuilds() // 同一任务不允许并发跑 buildDiscarder(logRotator(numToKeepStr: 10)) // 只保留最近 10 次构建 retry(2) // 整个 Pipeline 失败后重试 2 次 }其中我认为最应该养成习惯的是timeout和disableConcurrentBuilds。前者防止某次改动卡住后一直占用构建节点后者避免提交频繁时多个构建同时抢占同一目录导致文件互相覆盖。triggers块决定流水线如何被触发常见的是定时构建triggers { cron(H 2 * * *) }注意 Jenkins 的 cron 语法比 Linux 的 cron 多了一个 H 参数。你写死0 2 * * *会导致所有任务都在凌晨 2 点整同时开始把节点瞬间打满。用H 2 * * *的意思是在 2 点这个小时内随机选一个时间点执行这样可以打散任务避免资源争抢。如果你不确定自己的 cron 表达式写得对不对可以在 Job 配置页看到下一次运行时间。4. when 与 input条件执行和人工审批的进阶组合4.1 when用声明的方式做条件判断when是声明式 Pipeline 里替代if的首选方案。它写在stage块内部、steps之前用来决定该 stage 要不要执行。stage(Deploy) { when { branch main } steps { sh ./deploy.sh } }这段表示只在 main 分支上执行部署。注意branch这个条件在多分支流水线Multibranch Pipeline里才有意义。如果你在普通 Job 里写branch它可能一直没有预期效果因为普通 Job 没有 SCM 分支信息。对你自己的参数化任务更可控的判断方式是expressionstage(Deploy to Prod) { when { expression { params.ENV prod } } steps { sh ./deploy.sh --prod } }when还支持组合逻辑when { allOf { branch main environment name: APP_ENV, value: prod expression { currentBuild.buildNumber % 2 0 } } }还有一点经常被人忽略when默认是在进入 stage、分配 agent 之后才判断的。如果你希望先在控制器上判断条件、避免不必要的 agent 调度可以在when里加beforeAgent truestage(Heavy Task) { agent { label big-server } when { beforeAgent true expression { params.RUN_HEAVY yes } } steps { sh ./heavy-task.sh } }这个优化在 agent 资源紧张时特别有价值可以避免大量条件不满足的 stage 白白申请重资源机器。4.2 input在流水线里加一道人工确认发布到生产环境前通常需要人工点一下确认。声明式 Pipeline 的input指令就是干这个的。stage(Deploy to Prod) { input { message 确认发布到生产环境 ok 发布 submitter ops,admin // 只有这些用户组的成员可以确认 } steps { sh ./deploy.sh --prod } }当构建走到这个 stage 时Jenkins 会挂起等待指定的人点确认。submitter也可以省略省略时任何有构建权限的人都能确认。这里有一个我自己踩过的大坑input指令没有直接设置超时的方式如果没人点确认任务会一直挂着构建节点被白白占住。解决方法是改用步骤形式的input把它放进steps里外面包一层timeoutstage(Deploy to Prod) { steps { timeout(time: 30, unit: MINUTES) { input message: 确认发布到生产环境, ok: 发布 } sh ./deploy.sh --prod } }这样 30 分钟内没人点任务就会自动失败退出执行器也会被释放。4.3 把 when 和 input 串起来用实际发布流程中我更常写的是这种stage(Deploy to Prod) { when { beforeAgent true expression { params.ENV prod } } steps { timeout(time: 15, unit: MINUTES) { input message: 生产环境部署确认负责人${env.APP_NAME}, submitter: ops,admin } sh ./deploy.sh --prod } }先用expression判断当前参数是不是生产环境只有生产环境才进入人工确认流程。测试环境的部署直接自动执行不用每回都让人去点按钮。这是生产实践里非常常见的组合也是声明式语法能优雅组织的地方。5. 一套可直接改的前端部署 Pipeline完整示例与典型坑理论说完了给一套我实际用过的简化版前端部署流水线。这套流水线覆盖了参数化构建、安装依赖、单元测试、打包、人工确认、部署、清理这几个最常见的环节你可以直接复制后把项目名、分支、仓库地址和命令替换掉。pipeline { agent { label fe-builder } options { timestamps() timeout(time: 30, unit: MINUTES) disableConcurrentBuilds() buildDiscarder(logRotator(numToKeepStr: 10)) } parameters { string(name: BRANCH, defaultValue: develop, description: 需要构建的分支) choice(name: ENV, choices: [dev, staging, prod], description: 部署到哪个环境) booleanParam(name: SKIP_TEST, defaultValue: false, description: 是否跳过单元测试) } environment { APP_NAME my-web-app NODE_REGISTRY https://registry.npm.example.com } stages { stage(Checkout) { steps { git branch: ${params.BRANCH}, url: gitgitlab.example.com:frontend/my-web-app.git, credentialsId: gitlab-deploy-key } } stage(Install) { steps { sh export PATH/usr/local/node/bin:\$PATH npm config set registry ${NODE_REGISTRY} npm install } } stage(Test) { when { expression { !params.SKIP_TEST } } steps { sh export PATH/usr/local/node/bin:\$PATH npm run test:unit } } stage(Build) { steps { sh export PATH/usr/local/node/bin:\$PATH npm run build:${params.ENV} } } stage(Deploy) { when { beforeAgent true expression { params.ENV prod } } steps { timeout(time: 15, unit: MINUTES) { input message: 确认将 ${APP_NAME} 部署到生产环境, submitter: ops,admin } withCredentials([sshUserPrivateKey(credentialsId: prod-server-key, keyFileVariable: SSH_KEY)]) { sh export PATH/usr/local/node/bin:\$PATH scp -i ${SSH_KEY} -r dist/* deployprod-server:/var/www/${APP_NAME}/ } } } } post { always { echo 清理工作区${APP_NAME} cleanWs() } failure { echo 流水线失败请检查构建日志 } } }这个例子里的几个写法值得单独说明。双引号和单引号的选择在sh块里如果要在脚本里插入params.ENV、APP_NAME这些变量就用双引号如果脚本里有些地方想保留原始的$PATH这种 shell 变量就在$前面加反斜杠转义比如上面代码里的\$PATH。这是声明式里最容易踩的字符串插值坑不加转义Jenkins 会在 Groovy 层就把$PATH替换成空字符串。withCredentials的使用部署时要用 SSH 私钥直接放在 Jenkins 机器的文件系统里不安全用 Jenkins 凭据管理。withCredentials步骤会把私钥写入一个临时文件通过keyFileVariable把文件路径传给变量然后你在sh脚本里通过${SSH_KEY}引用。这样才能保证密钥不会出现在构建日志里。when的beforeAgent true我在 Deploy 阶段加了beforeAgent true这样当环境不是 prod 时这个阶段连 agent 都不会申请就直接跳过省时间也省调度资源。这套流水线跑通之后你把自己的仓库地址、构建命令、部署服务器信息换一下就能用。如果想扩展多环境部署脚本只需要在Build和Deploy里根据params.ENV做分支或动态拼接参数就可以。6. 语法层面最常见的报错排查把容易卡住新手的地方一次性说清6.1 stages 里脚本块与声明式块混用有同学喜欢在stages下直接写if判断整个 stage 是否执行这样写都是语法错误。声明式里 stage 的执行条件统一走when步骤内的流程控制走script。记住这个边界报错率至少降一半。6.2 branch 不生效branch条件只对多分支流水线生效。如果是在普通 Job 里手动参数构建判断分支应该用params.BRANCH或者env.BRANCH_NAME不要指望when { branch xxx }能正确匹配。这是我被问过很多次的问题。6.3 在 environment 里插值变量有人在environment里写environment { MY_URL http://${env.SOMETHING}/path }运气好的时候能工作但稍复杂场景下会因为env对象还没初始化完全而拿到空值。稳妥的做法是在 stage 的steps里通过sh ...来生成并导出变量比如steps { script { env.MY_URL http://${SOMETHING}/path } }6.4 input 一直卡住前面提过了input指令没有超时配置要么用timeout包一层步骤版input要么在options里给整个流水线设一个timeout兜底。千万不要让生产发布等一个永远不点确认的人。6.5 cleanWs 与 workspace 锁定cleanWs()虽然好用但如果多个分支或并发任务共用同一个 agent可能出现相互清理工作区的问题。配合disableConcurrentBuilds()或者每个分支独立 workspace 会安全一些。我的习惯是普通集成任务加disableConcurrentBuilds()发布类任务单独用固定 label 节点。6.6 curl 调校验接口也方便如果你已经把 Jenkinsfile 放在代码库想快速校验最新改动可以本地用 curl 调用 Jenkins 的校验接口Pipeline Syntax 页面会提示具体地址curl -X POST -F jenkinsfileJenkinsfile \ http://your-jenkins/pipeline-model-converter/validate返回正常就说明语法没有问题不需要辛辛苦苦创建一个临时 Job 去跑一遍。6.7 维护习惯把可变内容集中把复杂逻辑下沉最后说一个我现在养成的习惯。声明式 Pipeline 的维护重在一个整洁。我一般要求 Jenkinsfile 里只出现流水线结构stages 顺序环境相关的全局变量parameters、environment业务步骤的调用。真正复杂的脚本逻辑写成独立脚本文件放进项目仓库比如scripts/deploy.sh由 Jenkinsfile 调用而不是把几百行 shell 直接塞进sh步骤里。这样 Jenkinsfile 可读性高业务逻辑又能走代码评审和单元测试。如果你的团队已经引入了 Shared Library同样可以把一些通用方法封装好然后在声明式里用一行调用。这并不违背声明式的初衷反而让它更干净。从实际使用体验来说声明式 Pipeline 语法最大的价值不是限制你而是把整个流水线变成了一张清晰的路线图。编译、测试、打包、部署、通知每个环节在那里、什么时候执行、失败后怎么处理任何人打开 Jenkinsfile 都能读懂。后面我的习惯是所有新任务一律用声明式哪怕是临时跑一次的清理任务也保持结构完整。这样时间久了Jenkins 里的任务才不会是某个人脑子里的黑盒而是一个团队能共同维护的资产。
返回列表