ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Jenkins声明式Pipeline完全指南:从核心语法到CI/CD实战

Jenkins声明式Pipeline完全指南:从核心语法到CI/CD实战 你迟早得把 Jenkins 的 Pipeline 彻底吃透。项目里一旦上了多环境部署、并行构建、自动化测试这些需求再靠自由风格项目里那堆配置项拼凑流程基本就是给自己埋雷。我自己的实操感受是真正能扛住复杂 CI/CD 流程的写法就是声明式 Pipeline。这个语法看似有一套固定结构实际上灵活度很高而且比脚本式 Pipeline 更容易读、更容易维护。这篇文章我基于多年实操经验把声明式 Pipeline 的语法体系完整拆一遍从核心骨架到常用指令从踩坑记录到排错技巧尽量写得能让你看完就上手。1. 声明式 Pipeline 到底是什么1.1 从脚本式到声明式的演进Pipeline 本质上是把构建、测试、部署这类原本散落在各个插件和任务配置里的操作用代码的方式统一描述出来。Jenkins 早期的 Pipeline 是脚本式Scripted Pipeline直接基于 Groovy 的 DSL写起来非常自由但正因为自由一旦流程复杂起来代码结构就很容易失控。你可能在别人的项目里见过那种一长串的node { }嵌套里面塞满了stage、sh、catchError逻辑确实能跑但排查起来极其痛苦。声明式 Pipeline 更像是 Jenkins 官方为工程化需求专门设计的一套“受限语法”。它把流水线结构规范成pipeline → agent → stages → steps这种固定骨架再配合environment、options、parameters、when、post这些指令去丰富每个环节。这种设计带来的好处是代码结构一目了然团队协作时更容易 reviewJenkins 也更容易在图形界面上把每个阶段渲染出来。1.2 为什么我最终选定了声明式我最早也是从脚本式上手 Pipeline 的Groovy 写起来确实顺手但后来同时维护好几个项目的流水线时发现脚本式的老代码只在当时写的人脑子里是清晰的过两个月再看就完全不是那个味道了。声明式语法把流程强制切成了清晰的 stage每个 stage 有明确的 steps这种“结构即文档”的特性对我来说价值是最高的。另一个原因是在线排障效率。声明式 Pipeline 在执行过程中Blue Ocean 界面能很清楚地展示每个阶段的状态某个 stage 挂了点进去直接看日志就能定位。脚本式你要是没做好边界控制失败信息会被吞掉或者散落在各个步骤里排查一个问题能搭进去半天。还有一个我觉得很重要声明式 Pipeline 天生就在引导你使用 Jenkinsfile。你把流水线的定义放进代码仓库和源码一起版本管理任何一次流水线的改动都有迹可循这比在 Jenkins 网页上改配置要安全太多了。脚本式当然也能用 Jenkinsfile但没有声明式这么自然。所以我的建议是新项目、新团队一律从声明式开始。如果你担心声明式表达力不够完全可以嵌套script步骤来写 Groovy 脚本两者并不冲突。后面我会专门讲这个组合用法。2. 核心语法骨架逐段拆解2.1 最外层 pipeline 块和 agent声明式 Pipeline 的代码以一个pipeline块开始整个文件就是一个完整的 Jenkinsfile。这个块内部只能出现声明式语法支持的指令不能随意写 Groovy 代码。第一眼看到这个约束可能觉得死板实际上这就是为了让你“在框架内自由”反而不容易写出奇形怪状的流程。pipeline { agent any stages { stage(Build) { steps { echo Hello World } } } }agent是整套语法的起点。它的作用是指定整个流水线或者某段 stage 在哪个执行节点上运行。最常见的写法是agent any任意可用节点、agent { label linux }指定标签和agent { docker { image maven:3.8.1-jdk-11 } }直接跑在容器里。容器化 agent 是我建议优先掌握的用法。它能让每个项目的构建环境完全隔离项目 A 用 JDK 8项目 B 用 JDK 21互不影响你不再需要在一台服务器上维护一堆互相冲突的全局环境。不过要注意容器 agent 的每次启动都会有拉取镜像的开销使用前权衡一下成本。agent既可以放在顶层给整个流水线用也可以单独放在某个stage级别让不同阶段跑在不同的节点或容器上。比如单元测试在普通节点跑构建镜像这个阶段必须跑在有 Docker 环境的节点上这种混合编排就很实用。2.2 stages / stage / steps流水线的核心三段式stages是整条流水线的核心容器内部按顺序声明一个或多个stage每个stage是一个独立的生命周期阶段比如拉取代码、编译、测试、部署。从 Jenkins 的界面日志看每个 stage 会生成一条独立的阶段记录执行时间一目了然部署失败时定位问题很有帮助。stage内部必须有steps。steps是真正干活的地方里面写一条条的执行步骤。最常用的步骤就是sh用来执行 Shell 命令。你可以在里面做编译、跑测试、传输文件、调用 API几乎一切最终都会落到sh上。pipeline { agent any stages { stage(Build) { steps { sh ./gradlew clean build } } stage(Test) { steps { sh ./gradlew test } } } }这里有个很容易忽略的细节同一个stage里的多个steps是按顺序从上到下执行的如果中间某个步骤失败后面的步骤不会继续执行整个阶段标记为失败。如果你要求一段逻辑里某个步骤失败后还要继续执行剩余步骤不能简单堆steps需要用catchError或script块做容错这个我在后面的常见问题里会细说。2.3 一个最简可运行的声明式语法例子上面都是片段我这里给一个真正能用的最小 Jenkinsfile包含从拉代码到构建的一条完整流程pipeline { agent any stages { stage(Checkout) { steps { checkout scm } } stage(Build) { steps { sh make } } stage(Archive) { steps { archiveArtifacts artifacts: **/target/*.jar, fingerprint: true } } } }这段代码在任何一个 Jenkins 项目上只要配置了 Git 仓库地址基本都能直接跑。checkout scm表示自动从配置的源码仓库拉取代码archiveArtifacts则是把构建产物归档到 Jenkins 里方便后续下载或跨步骤引用。真到了实际项目里还需要补充environment、post、when这些指令才能让流水线更可控哪些分支要触发、哪些环境下要做什么、失败了要不要通知这些控制逻辑都靠它们实现。3. 常用指令的细节与取舍3.1 environment 与 credentials环境变量统一管理干净的项目一定不能把密码、Token 写在 Jenkinsfile 里。environment指令专门用来声明环境变量而且它和环境注入有非常好的配合。pipeline { agent any environment { APP_NAME my-service DOCKER_REGISTRY registry.example.com DOCKER_CREDENTIALS credentials(docker-hub-credentials) } stages { stage(Build) { steps { sh docker build -t ${DOCKER_REGISTRY}/${APP_NAME}:latest . docker login -u ${DOCKER_CREDENTIALS_USR} -p ${DOCKER_CREDENTIALS_PSW} ${DOCKER_REGISTRY} docker push ${DOCKER_REGISTRY}/${APP_NAME}:latest } } } }credentials(docker-hub-credentials)这种方式会读取 Jenkins 凭据管理里配置的“用户名/密码”类型凭据。赋给一个变量后Jenkins 会同时生成变量名_USR和变量名_PSW两个变量分别对应用户名和密码。注意这里的变量名只在这一段 pipeline 内有效不能跨流水线引用。如果是 SSH 私钥类凭据同样可以用credentials()读取拿到的是私钥文件在 agent 节点上的路径再用到ssh-agent或scp命令里。不同凭据类型的读取结果是不同的网上很多报错就是这里理解错了。environment还可以放在stage级别作用范围只是那一个阶段。比如部署阶段才需要设置KUBECONFIG那就在部署的stage里定义不污染全局环境。3.2 options 和 triggers控制执行行为和触发方式options指令用于设置流水线的全局行为。我每次都会加的几项timestamps()给每行日志加上时间戳排查耗时问题时非常关键。disableConcurrentBuilds()同一个分支同一时间只允许一个构建执行防止并发部署互相踩踏。timeout(time: 1, unit: HOURS)整个流水线最长执行时间超时自动终止避免任务卡死浪费节点资源。buildDiscarder(logRotator(numToKeepStr: 10))只保留最近 10 次构建记录防止 Jenkins 磁盘被撑爆。pipeline { agent any options { timestamps() disableConcurrentBuilds() timeout(time: 1, unit: HOURS) buildDiscarder(logRotator(numToKeepStr: 10)) } stages { stage(Build) { steps { sh make } } } }triggers负责定义流水线的触发方式。除了最常见的pollSCM定期检查代码变更和upstream上游任务触发我个人特别推荐cron定时触发的写法triggers { cron(0 2 * * *) }这就是每天凌晨两点定期执行这条流水线。我要提醒一点这里的 cron 表达式语法和 Linux 的 crontab 几乎一致但在 Jenkins 里默认时区是服务器本地时区排定时任务前务必确认服务器时区否则很容易出现“定时没跑”或“时间对不上”的诡异情况。3.3 parameters 和 input让流水线支持手动输入声明式 Pipeline 支持在触发构建时传入参数也可以在建到某个节点时暂停等待人工确认。parameters定义构建参数界面触发构建时会弹出参数字段。pipeline { agent any parameters { string(name: BRANCH_NAME, defaultValue: main, description: 要构建的分支名) choice(name: ENVIRONMENT, choices: [dev, test, prod], description: 部署环境) } stages { stage(Deploy) { steps { sh deploy.sh --env ${params.ENVIRONMENT} --branch ${params.BRANCH_NAME} } } } }这里注意获取参数的方式是${params.参数名}在steps里要保证参数在双引号字符串里才能正常展开。input适合用在部署这种高风险阶段。比如某个 stage 执行前暂停等负责人确认后继续stage(Deploy to Prod) { input { message 确认部署到生产环境 ok 确认部署 parameters { string(name: RELEASE_TAG, defaultValue: , description: 发布版本号) } } steps { sh deploy.sh --tag ${RELEASE_TAG} } }input块在 Blue Ocean 里会显示为一个确认弹窗团队作业时这种“人在环上”的机制能有效避免误操作。不过我不建议每个阶段都加input否则流水线几乎等于人工点击器自动化就失去意义了。真正高风险的“生产环境部署”加上就够。3.4 when 条件判断按分支或环境执行不同逻辑when是声明式 Pipeline 里我个人最喜欢的一个指令它让条件分支清晰得不像 Jenkins。最常见的用法是基于分支名做策略stage(Deploy) { when { branch main } steps { sh deploy.sh } }上面这段的意思是只有在当前检出的分支为main时才执行 Deploy 阶段。when支持很多条件子句比如branch、environment、expression、not、allOf、anyOf。我常用的组合场景是这样的stage(Integration Test) { when { allOf { branch develop expression { params.ENVIRONMENT test } } } steps { sh run-integration-tests.sh } }要提醒的是when是在 stage 执行前做条件判断的判断失败该 stage 会显示为 skipped而不会影响整个流水线的成功状态。如果你需要根据条件决定“某一个 step”是否执行而不是整个 stage那就不能用when得包一层script里写条件判断或者把它拆成独立的 stage。3.5 post 收尾处理构建结束后一定要做的事声明式 Pipeline 用post来声明流水线结束后的处理逻辑。无论构建成功、失败、还是被中断post都会执行。我把它看作流水线的安全网。pipeline { agent any stages { stage(Build) { steps { sh make } } } post { always { cleanWs() } success { echo 构建成功 } failure { echo 构建失败 } } }post支持五种条件块always无论结果都执行、success成功、failure失败、unstable不稳定通常指测试有失败但构建成功、aborted被人为中止。cleanWs()是我强烈建议每次构建都执行的步骤。它会清空 agent 工作区里的临时文件避免上一次构建的产物残留影响下一次。没有这步很多“本地没问题流水线上偶发失败”的现象根本查不出原因因为工作区被污染了。post还常用于发送通知。比如失败时调用企业微信/钉钉/邮件接口成功时触发下游流水线。逻辑不复杂但把通知全部收拢在post块里看代码的人第一眼就知道构建结束后会发生什么。4. 实操过程搭一条可用的部署流水线4.1 场景设定与整体结构这里我用一个实际场景把上面的语法串起来。假设你有一个 Java 服务用 Gradle 构建Docker 打包镜像测试环境用 Kubernetes 部署。需求是main 分支提交代码后自动构建镜像并部署到 dev 环境。只允许特定标签如 v1.0.0部署到生产环境。构建产物自动归档。失败时通知项目群。这个需求的流水线结构我拆成四个 stageBuild、Test、Build Image、Deploy。第二个 Deploy 用when做了分支判断第三个 Deploy 加了input人工确认。整个 Jenkinsfile 长这样pipeline { agent any options { timestamps() disableConcurrentBuilds() timeout(time: 30, unit: MINUTES) buildDiscarder(logRotator(numToKeepStr: 20)) } environment { DOCKER_REGISTRY registry.example.com IMAGE_REPO my-service IMAGE_TAG ${env.BRANCH_NAME}-${env.BUILD_NUMBER} } stages { stage(Build) { steps { sh ./gradlew clean build -x test } } stage(Test) { steps { sh ./gradlew test } } stage(Build Image) { steps { sh docker build -t ${DOCKER_REGISTRY}/${IMAGE_REPO}:${IMAGE_TAG} . docker push ${DOCKER_REGISTRY}/${IMAGE_REPO}:${IMAGE_TAG} } } stage(Deploy to Dev) { when { branch main } steps { sh kubectl set image deployment/my-service my-service${DOCKER_REGISTRY}/${IMAGE_REPO}:${IMAGE_TAG} -n dev } } stage(Deploy to Prod) { when { expression { env.TAG_NAME ! null env.TAG_NAME.startsWith(v) } } input { message 确认发布 ${IMAGE_TAG} 到生产环境 ok 确认 } steps { sh kubectl set image deployment/my-service my-service${DOCKER_REGISTRY}/${IMAGE_REPO}:${IMAGE_TAG} -n prod } } } post { always { cleanWs() } success { echo Deploy success } failure { echo Deploy failed } } }4.2 每段代码的落地细节这段流水线里有一些值得注意的地方。首先是环境变量。我把镜像仓库和镜像标签定义在顶层environment里IMAGE_TAG组合了分支名和构建号。这样做的好处是如果在多阶段里想引用同一个镜像标识不需要重复拼字符串后面一旦要调整命名规则只改这一处就行。sh块里我用的是三引号字符串里面可以自由换行。注意${DOCKER_REGISTRY}和${IMAGE_TAG}这些变量必须用双引号包裹的字符串或三引号才能被 Groovy 插值。如果你不小心用了单引号那么 Jenkins 执行时会把$当作普通字符传给 ShellShell 又找不到这个变量最后会出现镜像名缺失这种很难一眼发现的错误。Deploy to Prod阶段的when判断里我用到了env.TAG_NAME。这个变量只有在构建由标签触发时才非空。如果你对仓库设置了对标签推送做自动构建那么 Jenkinsfile 里就能通过这个变量区分当前来源是分支还是标签。TAG_NAME是 Jenkins 内置变量不需要自己定义但这个变量在普通分支构建时是 null必须用env.TAG_NAME ! null判断否则startsWith会空指针。我还特意用了startWith这样只有以v开头的标签才匹配避免手滑打了个test-1.0就触发了生产发布。4.3 我在真实项目中最常用的两种模式这种部署流水线的写法对很多中小团队来说已经够用了但有两个模式是我强烈建议你尽快加进去的。第一个是 Shared Library共享库。当你维护的仓库一多重复的 Jenkinsfile 逻辑就会让人头疼了比如构建镜像、发通知、调 kubectl 这些步骤在每个项目里粘贴一遍后期一旦要改脚本需要同步改动十几个仓库。Shared Library 可以让你把通用逻辑抽成独立的代码库在 Jenkinsfile 里像调用函数一样使用。声明式 Pipeline 中使用共享库的方式是Library(my-shared-lib) _然后你就可以在 steps 里调用库中封装的方法了。Library(my-shared-lib) _ pipeline { agent any stages { stage(Build) { steps { buildDockerImage(my-service) } } } }第二个是script块。声明式语法虽然规范但总有需求是规范之外的特例比如动态生成 stage、循环遍历一组参数执行不同步骤这些用纯声明式写要么很别扭要么根本做不到。这时可以在steps里包一个script块在里面的写 Groovy 代码。stage(Deploy Multiple Services) { steps { script { def services [service-a, service-b, service-c] for (svc in services) { sh kubectl rollout restart deployment/${svc} -n dev } } } }注意script块的存在是为了兜底如果你发现自己每个 stage 都套script说明你已经写成脚本式 Pipeline 了这时候应该回头审视一下是不是可以拆成多个 stage让流程在界面上更清晰。我见过有人把整条流水线塞在一个script块里那样声明式的优势就全没了我只能说这是给自己挖坑。5. 常见问题与排查技巧5.1 语法报错的几种典型场景先列出我实际遇到的最高频的几类声明式语法报错。第一类WorkflowScript: N: Invalid input xxx, expected stage or step。这通常是因为你在stages之外写了步骤或者在一个stage外面直接写sh命令。声明式对结构要求非常严格所有执行步骤只能放在steps块内。每当报这种错我建议先把文件按最小格式重新排一遍。第二类String interpolation is not allowed。这个错误是因为你在单引号字符串里用了${}单引号里的$不会被 Groovy 插值而是当作普通字符。如果需要变量插值请用双引号或三引号。第三类No such DSL method sh。大概率是因为sh被用在了steps之外或者某个指令块写错了层级。sh是步骤不是指令它只能出现在steps、post内或者共享库方法里。第四类env.XXX变量取不到值。这通常是因为变量名拼写错误或者该变量在该阶段还未被注入。比如TAG_NAME只在标签触发的构建中才有值分支构建里拿到的就是 null。5.2 在流水线里调试的技巧调试 Pipeline 和调试普通程序有很多不同。你先要在 Jenkins 上有充分的日志信息所以我把options里的timestamps()当成必选项另外还可以在 Shell 命令前加set -x让 Shell 输出每条命令的展开结果你才能真正看到最终 exec 的是什么内容。遇到变量传参问题最直接的方式是加一个调试 stage 打印变量stage(Debug) { steps { sh echo Branch is ${BRANCH_NAME} echo Build number is ${BUILD_NUMBER} } }在 Jenkins 的构建日志里看到这些输出后再去排查是变量定义错了、拼写错了还是环境注入没到位。如果你在when里的 expression 写的比较长可以先把它简化成一个临时环境变量打印出来确认值符合预期后再塞回when里。判断条件不满足时 stage 会直接 skipped且不会打印太多细节这是排查时最容易被忽视的一点。5.3 怎么查官方文档和资料和有经验的朋友交流时我一般会提醒这样一件事声明式 Pipeline 的语法不是靠背的而是靠查的。Jenkins 官方有一个“Pipeline Syntax”页面入口在任意 Pipeline 项目的左侧菜单里。这个页面非常强大它可以根据你选择的步骤自动生成正确的代码片段。你只要在页面里选择需要的步骤比如sh、withCredentials、archiveArtifacts填好参数它会直接生成对应的声明式片段你复制到 Jenkinsfile 里就能用。我在写内部文档、培训新人时都会让他们首先使用这个语法生成器而不是去看大段的插件文档。对于多个插件组合使用的场景比如需要 SSH 到远程服务器执行命令我会建议先去插件主页看清版本要求再在 Pipeline Syntax 页面上生成样例。很多时候不是语法写错了而是插件版本和 Jenkins 版本之间的兼容性出了问题。6. 一些基于个人经验的提醒踩了无数坑之后我总结出几条对新人帮助最大、也最容易忽略的实践经验这里一次性列出来。第一Jenkinsfile 也是一个需要 review 的源码文件。它应该和项目代码一起提交、一起走代码评审不能当作一次性脚本随手写随手改。第二不要把敏感信息放在环境变量或代码库中。密钥、密码、Token 一律走 Jenkins 凭据管理读取方式就是前面写的credentials()或者withCredentials。一旦 Jenkinsfile 被推送到公开仓库任何硬编码的密码都会变成事故。第三所有构建产物和日志要有保留策略。项目一多构建产物动辄数 GB不配置buildDiscarder磁盘很快就报警而且很难清理。第四流水线里用到的 Shell 脚本尽量抽成独立的脚本文件放在代码仓库的scripts/目录里。这样 Jenkinsfile 本身会非常简洁Shell 脚本也能单独做单元测试、单独维护。很多人把几百行 Shell 直接写进 Jenkinsfile看起来省事实际上维护成本高得惊人。最后持续学习。CI/CD 工具链更新很快Kubernetes、Argo CD、Tekton 这些技术都在影响流水线的写法。但无论工具怎么变声明式 Pipeline 这套“把流程代码化、可视化、可复用”的思路是不会过时的。把 Jenkinsfile 写好本质上是在帮团队把交付流程沉淀成工程资产这件事的价值会随着项目规模增长越来越高。
返回列表