
从自由式项目迁到声明式 Pipeline算是我用 Jenkins 这些年做过的回报率最高的一次改造。以前自由式项目里塞一长串 Execute shell构建、测试、发布全揉在一个脚本里换台机器跑就崩新人接手改一个参数都胆战心惊。后来把 CI/CD 定义全部改成 Jenkinsfile 里的声明式语法结构清爽了流程也变成了可以评审、可以版本化、可以随时回溯的代码。这篇文章是系列的第二篇上一篇把 Pipeline 的基础概念和适用场景讲清楚了这一步直接深入到声明式语法本身从结构骨架到常用指令再到完整可落地的示例和生产环境的排坑记录一次讲透。1. 为什么偏偏用声明式先搞清楚这套语法解决了什么问题1.1 声明式和脚本式到底差在哪很多刚接触 Jenkins Pipeline 的人最大的困惑就是既然都能写为什么官方现在力推声明式而不是继续让所有人用脚本式这俩的本质区别类比一下你用 Markdown 写文档和硬写 HTML 的区别。声明式给你一套固定结构你只需要往指定的格子里填内容脚本式把整棵语法树全部暴露给你怎么写完全取决于你的想象力。从 Jenkins 的角度具体拆开看对比维度声明式 Pipeline脚本式 Pipeline语法本质结构化 DSL严格区分指令区块基于 Groovy 的完整编程脚本学习曲线只需记住少数核心块和指令需要理解 Groovy 语法、闭包、方法调用可读性缩进和区块整齐别人读起来很直观自由度大风格因人而异流程控制提供 when、input、post 等现成指令自己用 if/else、try/catch 实现复用能力配合共享库和片段生成器标准化程度高复用靠抽象函数灵活但难统一容错表现语法校验器能提前发现很多低级错误很多错误要等运行到那一行才暴露脚本式不是不好它的灵活度确实无可替代。写一段复杂的循环遍历多个仓库、动态拼接流水线步骤、根据外部接口返回结果动态决定后续执行内容这些场景脚本式要方便得多。但在实际团队协作里百分之八十的 CI/CD 流程并不需要这种级别的动态能力。大家更需要的是这个阶段干什么、报错在哪、怎么加参数、怎么加人工确认一眼就能看明白。声明式的结构约束正好把这种“规范感”变成了语法的一部分这就已经赢了一大半。1.2 声明式对团队协作的隐藏价值声明式语法另一个被低估的好处是它天然适合代码评审。以前改自由式项目的构建配置负责 CI 的人在自己机器上点一点页面保存完事其他人根本不知道构建流程改了哪里。换成 Jenkinsfile 之后任何改动都走 Git 提交和 Pull Request改动是哪个 stage、动了哪个参数、影响哪些分支评审人打开 diff 就能看清楚。这条价值在团队规模超过三个人以后会体现得非常明显流水线的演进全程留痕出问题能用 git bisect 的思路定位是哪一次改动导致的。再加上 Jenkins 给声明式配套的周边工具很成熟。Declarative Linter 可以在运行前做静态语法检查Snippet Generator 能可视化生成指令片段Blue Ocean 界面里 stage 的渲染也天然和声明式结构对齐。这些工具共同降低了使用门槛让不熟悉 Groovy 的测试同学、运维同学都能快速看懂甚至上手修改流水线。对一个长期维护的 CI/CD 系统来说这种可维护性带来的价值往往比灵活性更重要。2. 声明式 Pipeline 的骨架从最小可用流水线开始2.1 五大核心块各自管什么声明式 Pipeline 的所有内容都必须包在pipeline {}根块里这是铁律。根块下面有五个常用区块agent、stages、stage、steps、post。它们各管一摊组合起来就是一条完整流水线。agent指定整个流水线或某个 stage 在哪类节点上执行。可以是any任意可用节点、指定 label、docker 镜像、kubernetes pod甚至none表示当前阶段不分配执行节点。stages存放所有执行阶段的容器里面至少得有stage才能工作。stage定义一个阶段比如“编译”“测试”“部署”。一个流水线可以有多个 stagestage 内部可以嵌套多个更小的 stage 形成阶段树。steps阶段内真正干活的地方里面是sh、echo、bat这些具体步骤。post流水线或 stage 结束后执行的条件分支根据构建结果成功、失败、不稳定等触发后续处理。最小可运行流水线长这样pipeline { agent any stages { stage(Build) { steps { echo 构建中... } } stage(Test) { steps { echo 测试中... } } stage(Deploy) { steps { echo 部署中... } } } post { always { echo 构建流程结束 } } }这段代码拆开看并不复杂。agent any告诉 Jenkins 随便找一台代理节点来跑三个 stage 就是三个串行的逻辑阶段最后post.always无论成功失败都会打印结束日志。把这段提交到仓库根目录命名为 Jenkinsfile然后在 Jenkins 里创建流水线任务指向这个仓库一次最小的声明式 Pipeline 就跑起来了。2.2 两条运行时规则必须装进脑子里实际写多了会发现声明式语法的大多数报错都来自同一个原因把指令放错了位置。这里有两条规则请直接记死。第一steps只能出现在stage内部stage只能出现在stages内部。你不能在pipeline顶层直接写sh也不能在pipeline顶层挂一个独立的stage。所有执行逻辑必须层层包好pipeline 包 stagesstages 包 stagestage 包 steps。少数特殊情况会在script {}块里直接写 Groovy 代码但那是显式的突破约束不是日常写法。第二个别指令有自己的限定层级。比如environment既可以放在pipeline顶层作为全局环境变量也可以放在stage内部作为阶段环境变量options同理可有全局和阶段两级when只能放在stage级别用来控制一个 stage 是否执行input也挂在 stage 级别做人工确认卡点。把这些指令的使用边界刻在脑子里写 Jenkinsfile 时大概率不会报错。另外要理解一个 stage 内部的实际执行顺序。一个典型的 stage 在执行时会依次走这些环节先看是否有when条件不满足就直接跳过满足的话再进入agent分配节点然后执行environment设置环境变量、options处理超时重试接着跑steps里的具体动作最后无论成败都会走post。知道这个顺序很重要比如你要在when里判断环境变量这个变量是来自全局环境还是上一阶段写入的决定你的判断条件能不能拿到预期的值。3. 常用指令逐个拆解环境变量、参数与执行条件3.1 environment 与凭据注入的正确姿势环境变量在流水线里是到处都要用的东西构建版本号、镜像仓库地址、部署路径、测试环境 URL这些值如果散落在各种sh字符串里那维护起来就是一场灾难。environment指令可以把它们集中声明并且支持两级作用域放在 pipeline 下所有 stage 都能读放在 stage 里只对本阶段生效。pipeline { agent any environment { APP_NAME order-service IMAGE_REPO registry.example.com/library // 引用 Jenkins 内置环境变量 DOCKER_TAG ${env.BRANCH_NAME}-${env.BUILD_NUMBER} } stages { stage(Build) { environment { // 只在 Build 阶段生效 MAVEN_OPTS -Xmx2048m } steps { sh echo ${APP_NAME} ${DOCKER_TAG} } } } }这里有一个非常容易踩的细节环境变量的字符串插值。在双引号字符串里${env.BRANCH_NAME}和${BRANCH_NAME}在声明式 Pipeline 中都能解析因为流水线会把环境变量自动放入上下文。但到了单引号字符串或者sh脚本内部Groovy 的插值规则就不一定管用了。稳妥的做法是Groovy 层需要拼接变量就用双引号sh 脚本内部需要引用环境变量就确保它在当前 shell 环境里真的有导出然后直接用$VAR的形式。我见过太多同事在 sh 里写${params.BRANCH}取不到值调半天才发现是 Groovy 插值和 shell 变量的区别没理清。凭据注入是另一个高频场景。不要在 Jenkinsfile 里写明文密码、Token正确姿势是通过 Jenkins 凭据管理器保存然后在流水线里引用。最简单的方式是在environment中用credentials()方法绑定pipeline { agent any environment { // 凭据 ID 在 Jenkins 凭据管理里配置 DOCKER_HUB_CRED credentials(docker-hub-token) GIT_SSH_KEY credentials(git-deploy-key) } stages { stage(Push) { steps { sh docker login --username${DOCKER_HUB_CRED_USR} --password${DOCKER_HUB_CRED_PSW} registry.example.com } } } }注意credentials(xxx)绑定用户名密码型凭据时Jenkins 会自动生成两个变量变量名_USR和变量名_PSW。如果绑的是 SSH 私钥或秘钥文件类型那变量本身就是一个临时文件路径用于ssh-add或--key参数。实际使用中要提前确认你绑定的凭据类型和你脚本里的取法匹配否则等构建跑起来才发现连不上仓库白等半天。3.2 parameters 与 triggers让流水线学会等待和定期执行如果一个流水线只能手动点击“立即构建”再一路跑到底那它的自动化程度还差得远。parameters指令负责给流水线定义外部输入triggers负责让流水线按计划或事件自动运行。参数常见的四种类型string普通文本输入比如指定要部署的分支或版本号。booleanParam布尔开关比如是否跳过测试。choice下拉单选比如选择部署环境 dev/test/prod。password密码输入框值不会明文显示在构建历史里。pipeline { agent any parameters { string(name: BRANCH, defaultValue: main, description: 要构建的分支) choice(name: ENV, choices: [dev, test, prod], description: 部署环境) booleanParam(name: RUN_TESTS, defaultValue: true, description: 是否执行单元测试) password(name: DEPLOY_TOKEN, defaultValue: , description: 部署用 Token) } stages { stage(Prepare) { steps { echo 分支${params.BRANCH}环境${params.ENV} } } } }参数在流水线里的取值统一用${params.XXX}。注意 parameter 定义里choices是一个列表第一个值作为默认值。有一次我在 choice 里只写了一个选项运行时发现下拉框只有一项才意识到选择列表需要把所有候选项一次给全。triggers里我最常用的是cron和pollSCM。很多人分不清这俩cron是纯粹按时间计划跑不管代码有没有变pollSCM是周期性地检查远端仓库代码有没有变化有变化才触发构建。需要注意的是 Jenkins 的 cron 语法里H是一种 hash 散列写法目的是让多个任务不要在同一秒集中触发。比如H 2 * * *表示每天凌晨 2 点多一点的时间点执行具体几分由 Jenkins 根据任务名算出来。H/5 * * * *表示每 5 分钟内的某个偏移点检查一次。pipeline { agent any triggers { // 每天晚上固定时间跑一次完整构建 cron(H 2 * * *) // 每 5 分钟检查代码变动有变化才触发 pollSCM(H/5 * * * *) } // ... }如果你的项目已经用上了 Webhook 方式GitHub、GitLab 插件推送事件触发那pollSCM基本可以不用配了轮询反而增加 Jenkins 主控的负载。我在团队里是优先配 Webhookcron仅保留在需要定时执行的场景比如每天清晨跑一次全量回归。3.3 when 与 input把流程分支和人工卡点管起来when指令是声明式 Pipeline 做条件控制的利器。以前在脚本式里你要写一堆 if/else 包住整个 stage 的内容声明式里直接在 stage 上挂一个when不满足条件这个 stage 直接跳过代码可读性完全不是一个档次。常用的条件写法pipeline { agent any stages { stage(Deploy-Prod) { when { branch release environment name: DEPLOY_ENV, value: prod } steps { echo 仅当 release 分支且环境变量为 prod 时才执行 } } stage(Deploy-Dev) { when { anyOf { branch dev branch feature/* } beforeAgent true } steps { echo dev 或 feature 分支都走这里 } } } }branch判断执行分支environment判断环境变量expression可以写一段返回布尔值的 Groovy 表达式anyOf/allOf/not用来组合多个条件。还有一个容易被忽略的beforeAgent true表示在分配执行节点之前先判断条件。默认情况下when是在节点分配之后执行的如果条件明显不满足加上beforeAgent true可以省去等待和占用一次 agent 的开销对多分支流水线大量分支同时扫描的场景很有用。input指令则是人机交互卡点。比如发布到生产环境前要有人确认一下版本号和影响范围就可以在对应 stage 上挂inputstage(Deploy) { input { message 确认发布到生产环境 ok 开始部署 parameters { string(name: RELEASE_DESC, defaultValue: , description: 发布说明) } } steps { echo 发布说明${params.RELEASE_DESC} } }运行到这个 stage 时流水线会暂停等待人工点击确认点击后会弹出定义好的参数让操作者填写。这里有两个生产环境必备的操作一是在options里给流水线设置全局timeout否则有人忘了点确认agent 会一直被这个 stage 占着二是可以在 input 里通过submitterParameter记录是谁点的确认方便审计。我一般会写成这样options { timeout(time: 1, unit: HOURS) }配合 input 使用超过一小时没人处理这条构建直接失败并释放资源比挂在那里干等着强太多。4. 实战一套可用于日常项目的声明式 Pipeline 全流程4.1 场景设定与完整语法示例理论说再多不如把一份完整 Jenkinsfile 摆出来对着拆。假设我们有一个典型的后端服务项目代码分两个目录backend是 Java Spring Boot 应用deploy里放着 Helm Charts。要求是 dev 分支 push 后自动构建镜像并部署到测试环境main 分支部署到生产环境需要人工确认每天凌晨跑一次全量回归。pipeline { agent none options { timestamps() timeout(time: 1, unit: HOURS) buildDiscarder(logRotator(numToKeepStr: 30)) disableConcurrentBuilds() skipDefaultCheckout() } environment { APP_NAME order-service IMAGE_REPO registry.example.com/library DOCKER_TAG ${env.BRANCH_NAME}-${env.BUILD_NUMBER} } stages { stage(检出代码) { agent { label linux } steps { checkout scm } } stage(后端测试) { agent { label linux } steps { dir(backend) { sh mvn test } } post { always { junit testResults: backend/target/surefire-reports/*.xml, allowEmptyResults: true } } } stage(后端打包) { agent { label linux } steps { dir(backend) { sh mvn clean package -DskipTests } } post { success { archiveArtifacts artifacts: backend/target/*.jar, fingerprint: true } } } stage(构建推送镜像) { agent { label docker-builder } when { anyOf { branch dev branch main } } steps { sh docker build -t ${IMAGE_REPO}/${APP_NAME}:${DOCKER_TAG} -f backend/Dockerfile . docker push ${IMAGE_REPO}/${APP_NAME}:${DOCKER_TAG} } } stage(部署测试环境) { agent { label linux } when { branch dev } steps { sh helm upgrade --install ${APP_NAME} ./deploy -n staging \ --set image.repository${IMAGE_REPO}/${APP_NAME} \ --set image.tag${DOCKER_TAG} } } stage(部署生产环境) { agent { label linux } when { branch main } input { message 确认将构建推送至生产环境 ok 发布 parameters { string(name: RELEASE_NOTE, defaultValue: , description: 发布说明) } } steps { sh helm upgrade --install ${APP_NAME} ./deploy -n production \ --set image.repository${IMAGE_REPO}/${APP_NAME} \ --set image.tag${DOCKER_TAG} } } } post { always { cleanWs() echo 构建地址${env.BUILD_URL} } failure { echo 构建失败通知相关同学 // 实际的钉钉/飞书 Webhook 调用可以放这里 } changed { echo 构建结果与上一次不同额外执行通知 } } }这份 Jenkinsfile 是我在某次内部项目重构时实际用过的结构脱敏掉业务细节后放出来基本能覆盖大部分微服务项目的日常发布诉求。后面拆开讲里面几个值得琢磨的设计。4.2 关键环节说明构建、镜像、部署是怎么串起来的第一段看options里的配置。timeout(time: 1, unit: HOURS)是整个流水线的最大执行时长超过一小时自动中止。这个值要设得宽一点不然高峰期队列排队时间也算进去很容易误杀。buildDiscarder(logRotator(numToKeepStr: 30))是只保留最近 30 次构建记录仓库越来越大时可省不少磁盘空间。disableConcurrentBuilds()防止同一个分支的构建同时跑——假如多次 push 触发重跑旧的在执行、新的也在执行资源白白浪费最后有用的还是最新的构建。skipDefaultCheckout()是告诉 Jenkins 我不要在流水线开始时自动把仓库代码拉一遍因为我自己会在“检出代码”stage 里用checkout scm手动控制。这里有一个大家经常纠结的问题为什么不用默认 checkout要单独一个 stage因为当声明式的执行节点分散在不同 label 上时默认 checkout 只在你给第一个 stage 分配的节点上执行后续 stage 如果跑在另一个节点上工作目录是全新的代码并不会带过去。所以实际多节点场景下要么每个 stage 都自己 checkout要么把准备代码的工作集中在一个 stage后续节点用可共享制品比如将后端构建产物存为 Jenkins artifact镜像阶段直接用镜像上面的示例就是走“集中检出 镜像作为产物”的路线。再看DOCKER_TAG的设计。用${env.BRANCH_NAME}-${env.BUILD_NUMBER}组合成镜像版本号分支名保证可读构建号保证唯一既方便排查是哪个构建产出的镜像也天然支持回滚。有人喜欢用 Git commit SHA 做 tag这也行但记录一次发布对应哪个构建号没有${BRANCH}-${BUILD_NUMBER}直观。多分支流水线里还有个坑分支名里带/时比如feature/pay-v2直接拼接会导致镜像 tag 不合法或者路径层级被误解所以我在真实项目里通常会把分支名里的/替换成-或者直接用安全的简短分支名。when { anyOf { branch dev; branch main } }这段的意思是 dev 和 main 分支才做镜像构建。这样做是因为 Spark 分支、临时分支不值得每次 push 都打一次镜像占仓库空间。实际你可以按照团队规范调整但原则是一个不是所有分支的所有提交都需要完整的 CI/CD 流程给不必要的场景留后门只会浪费资源和时间。4.3 从脚本式迁移到声明式的几条经验如果你手头已经有一套脚本式 Pipeline 想转成声明式不要想着一次性重写很容易把线上流程搞挂。我自己的经验是分三步走。第一步把所有复杂的 Groovy 逻辑退回到最简单形式。脚本式里经常会看到这种写法def version 1.0.0 if (env.BRANCH_NAME main) { version 2.0.0 }迁移到声明式时先用条件 stage 代替这些逻辑分支而不是硬塞回script {}块。比如把“不同分支打不同镜像”变成两个 stage各自用when { branch xxx }控制逻辑一目了然。第二步把sh中长长的构建脚本按阶段拆分。如果一段 shell 里既在编译又在跑测试还在发通知你需要把这一个 sh 拆成多个 stage 的多个 sh。这个过程其实也是在重新梳理流程边界对后期维护的帮助比语法迁移本身更大。第三步复杂运算和不能表达的逻辑用共享库封装。声明式毕竟是 DSL遇到真正的 Groovy 循环、字符串处理、外部 API 调用你可以在 steps 里用script {}包一层或者更优雅的做法是把这些逻辑抽到 Jenkins 共享库的变量和方法里Jenkinsfile 本身保持简洁。比如上面第四节的部署逻辑实际生产里我不会写这么长的 sh而是把 helm 命令封装成一个deployByHelm()共享库方法Jenkinsfile 里只留一行调用。这样流水线读起来像配置文件真正的复杂控制逻辑都在测试过的共享库里团队接手成本最低。5. 常见坑与排查技巧我在生产环境踩过的五个问题5.1 指令用错层级流水线直接罢工声明式语法最典型的问题就是指令放错位置。最常见的报错信息是Unknown stage section或Unexpected input: ...基本上都是因为把environment、options、when这些指令写到了它不该出现的层级。举个例子有人会在steps里写steps { environment { FOO bar } sh echo ${FOO} }这一定会报错environment不是 steps 里的一个步骤它是一个指令只能放在 pipeline 顶层或 stage 顶层。遇到这类报错先别急着搜报错信息打开 Jenkins 官方语法速查表核对一下这个指令的合法位置大部分问题立刻就能定位。另一个排查技巧是看 Blue Ocean 或经典界面里的流水线日志声明式语法错误往往在第一段日志里就会直接给出具体行号比你肉眼扫代码快得多。5.2 文件目录与仓库检出路径不一致第二个高频坑和文件路径有关。比如在 stage 里直接执行steps { sh cat backend/pom.xml }如果这个 stage 的 agent 和检出代码的 agent 不是同一个节点或者你设置了skipDefaultCheckout()当前工作目录根本没有backend目录cat直接失败。即使在同一节点不同 stage 之间 exec 的默认工作目录也可能不同docker agent 里根目录是/home/jenkins/agent/workspace/job2这样的路径和你本地开发目录结构相差甚远。我的经验是不要依赖“当前工作目录”的隐式假设要么用dir()明确包裹steps { dir(backend) { sh mvn clean package } }要么在sh开头显式cd。还要注意默认检出代码是不包含子模块的如果项目用了 git submodule需要在 checkout 时额外配置submodulesCfg或在启动命令里加git submodule update --init --recursive否则源码不完整构建直接报找不到模块。5.3 凭据变量不生效多半是作用域不对credentials()绑定凭据后变量的存在范围很受限制。我碰到过一个同学把凭据定义在某个 stage 的environment里然后在post { failure { ... } }里想用这个凭据发钉钉告警结果变量显示空值。因为post的执行阶段和environment定义的 stage 作用域并不重叠stage 结束后局部环境变量就销毁了。要保证凭据在多个环节可用定义在 pipeline 顶层environment里是最省心的做法。同样的问题也会出现在script {}块里用withCredentials时。withCredentials包裹的代码块内部能访问块外面就是空。如果你在多个步骤里反复需要同一个凭据优先提升到 pipeline 级environment。另外凭据绑定会在日志里自动打码但如果自己手动拼接 String 后打印敏感信息可能以字符串形式泄露出来。确认过日志里没有意外把 Token 打出来。5.4 并发与重试简单实现背后的隐患retry和parallel这两个指令看起来简单用不好会让整个流水线行为变得难以理解。retry(3)放在 stage 的options里表示这个 stage 失败后最多重试 3 次每次失败后不会对 workspace 做清理如果构建脚本本身有残留文件第二次重试可能是在一个半污染的状态下执行的。如果你的测试阶段反复失败建议先人工确认是不是测试本身有偶发性再决定是否要开重试否则会让隐藏的随机失败一直潜伏。parallel在声明式里的写法容易踩两个坑不可以在 stage 外面并行只能在 stage 内部声明parallel {}并行分支里不能再嵌套parallel需要走脚本式script块。资源开销也要提前估算五个并行 stage 同时申请 agent如果 agent 资源池不够大流水线会长时间卡在等待节点上。我通常会给并行分支的 agent 指定 label避免所有分支都涌向同一个 label 下压力不均。5.5 语法校验与本地调试的实用方法写完 Jenkinsfile 不经过校验直接提交运行是最浪费时间的做法。Jenkins 自带一个命令行校验工具在服务端执行curl -X POST -F jenkinsfileJenkinsfile \ http://jenkins-server/pipeline-syntax/validate返回Jenkinsfile successfully validated.说明语法没问题如果返回错误信息一般会指向具体的行和原因。这个校验只做静态语法和结构检查不能发现业务逻辑问题但足以过滤掉百分之七八十的低级错误。还有一个经常被忽视的工具是 Snippet Generator路径在http://jenkins-server/pipeline-syntax/。它可以在线选择步骤、填参数然后自动生成对应的声明式片段直接复制到 Jenkinsfile 里。对于不熟悉某些步骤参数的场景这个比翻文档猜参数名快多了。此外真正复杂的输出问题可以临时在脚本里加echo打印关键变量再配合 Blue Ocean 按 stage 查看日志基本上所有问题都能定位到具体阶段。我个人在实际操作中的体会是声明式 Pipeline 的语法并不难难的是养成“先想清楚阶段边界再动手写”的习惯。遇到不确定的写法先翻 Snippet Generator遇到复杂逻辑先抽共享库遇到重复流程先做模板沉淀。玩熟了之后CI/CD 流水线会从工程负担变成团队里真正可信赖的基础设施。之后再想深入的话可以继续看看 Shared Libraries 的高级用法、Declarative Pipeline 的matrix与多分支组合那些就是另一个层级的话题了。