
新接触Jenkins流水线的人几乎都会在某个深夜盯着那条蓝色、灰色或者红色的状态条心里冒出一个特别朴素的问题一个stage里明明只写了sh make build我什么都没说Jenkins到底是怎么知道这个阶段“跑完了”的是看命令执行时间是看agent机器上有没有生成新文件还是靠某个神秘的“心跳”信号都不是。真正决定“阶段结束”的是Jenkins流水线引擎内部一套完整的“步骤完成信号”机制。这个机制可以从两个层面去理解第一个层面是用户写的Jenkinsfile——每个stage块就是一段Groovy代码这个大括号闭合的过程就是阶段天然的终点第二个层面是引擎本身——流水线里所有看起来同步的代码实际上跑在一个可以随时暂停、随时恢复、由异步事件驱动的CPS模型上。把第二层搞懂你才算真正看透了流水线。这篇博文适合正在用Jenkins做自动部署、C或Java构建发布、Docker镜像推送的人也适合刚背完基础、准备深入Pipeline的初学者。我会把“阶段结束”这件事拆成六块来聊从最外层的代码边界一直深入到引擎内部的FlowNode最后再给你几套能直接拿到生产环境用的状态捕捉方案。看完之后你会发现一件事这问题表面上是“按钮和圆点”底子上是“完成信号和状态机”。1. 先别聊原理把“阶段边界”这件事说清楚1.1 阶段结束的本质代码块执行完毕不管你是用声明式流水线还是脚本化流水线一个阶段的起点和终点都写在代码里。声明式流水线里一个阶段是长这样的pipeline { agent any stages { stage(构建) { steps { sh make build } } } }这里的stage(构建) { ... }就是一个完整的阶段。steps里可以写一堆步骤比如sh、bat、echo、docker、withCredentials。当这个stage代码块里的最后一个步骤执行完毕、大括号闭合这个阶段就被标记为“正常结束”。脚本化流水线更直白stage就是个闭包stage(构建) { sh make build }所以从用户视角看“阶段结束”就是“代码块执行完”。这话看起来像废话但它引出了下一个问题steps里的sh是什么时候算“执行完”的要知道shell命令本身是外部进程Jenkins并不能直接感知你终端里的回车键。它必须依赖一个明确的信号这个信号才是整条流水线的命根子。1.2 “步骤完成”又是一个信号传递问题我们拿最常用的sh步骤举例。当你执行sh make build时Jenkins会做这几件事在agent节点上启动一个Java进程用来执行shell命令通过ProcessBuilder真正拉起一个操作系统进程然后调用Process.waitFor()等待这个进程退出。进程退出后会返回一个退出码也就是我们常说的exit code。退出码为0表示shell命令成功结束sh步骤会以“成功”状态返回结果退出码非0表示shell命令出错sh步骤会抛出一个AbortException异常这个异常会直接向上传导最终让当前阶段变成“失败”状态。所以与其问“阶段怎么知道结束了”不如换个问法每一步怎么报告自己结束了。Jenkins里几乎所有的操作都是一种“步骤”echo是步骤git是步骤timeout是步骤retry是步骤。每个步骤完成时引擎都会收到一条完成信号这个信号会驱动整个流水线往前推进。1.3 一个容易被忽略的细节流水线是“同步写法”的异步引擎这是初学流水线时最绕的一个弯你写sh make build感觉它就是一行同步调用脚本停在那里等shell跑完再往下走。但实际上真正执行时这段调用会被“挂起”等agent节点上的进程结束后由事件驱动“继续”后面的代码。为什么要这么设计因为构建环境天然是分布式的。流水线的主控逻辑跑在Jenkins master上而shell命令跑在agent上两边通过网络通信。如果让master线程傻傻地阻塞等待agent返回那一个master就要同时扛住几百个并发构建的线程很快内存就会爆掉。CPS执行模型的存在就是为了让流水线脚本能够随时保存现场、让出线程、等外部事件到了再恢复运行。2. 底层机制Jenkins怎么“暂停/恢复”一段Groovy脚本2.1 CPS执行模型把脚本变成可暂停的状态机如果你翻过Jenkins源码会看到一个名字CpsFlowExecution全称是Continuation Passing Style Flow Execution。它做的事情简单说就是把一段Groovy脚本编译成一种可以暂停、可以恢复执行的指令流。这里有个很形象的类比你把流水线脚本想象成一个会“断点续传”的下载任务。普通程序在执行sh make build时会阻塞等待结果CPS模型不一样它执行到sh这一步时先把自己的整个调用栈保存下来包括局部变量、当前执行到第几行然后向执行引擎注册一个“完成回调”再把当前线程让出来。等agent那边的shell命令结束Jenkins收到结果再去这个保存好的“断点”处恢复执行。所以你在流水线里写的每一句话本质上都不是按普通程序方式跑的而是被切分成了一堆可恢复的小片断。阶段代码块执行完了意味着这个片断序列走到了最后一个位置没有被中断。2.2 步骤完成时发生了什么StepExecution的onSuccess/onFailure每个步骤都有一个执行上下文对象叫StepExecution。流水线引擎在执行一个步骤时会调用它的start()方法然后等待它触发完成事件。当agent端命令结束后Jenkins会回调对应的StepExecution随即通知“成功完成”或“失败完成”。这一步很关键因为它会触发两件事第一把结果写进FlowNode节点里持久化到Jenkins的工作区目录形成一个执行图execution graph第二把脚本从挂起点唤醒继续执行下一个步骤。如果失败了抛出的异常对象也会被记录在这个节点的error字段里。FlowNode是个特别重要的概念。整个流水线的每次执行都会被记录成一棵由节点组成的树每个阶段、每个步骤、每个分支都对应一个节点节点之间是父子关系。你甚至可以在/job/项目名/流水线名/的控制台里看到每个阶段的执行图。2.3 三种“结束信号”正常完成、异常完成、被打断阶段结束的信号归根结底有三种来源信号类型触发场景阶段最终状态正常完成步骤按预期执行完毕未抛出任何异常SUCCESS异常完成步骤抛出了非中止类异常比如shell非0退出、找不到文件、网络超时FAILURE被打断用户点击中止按钮、超时被触发、agent失联导致执行被取消ABORTED这里要特别注意“被打断”这条路。你点击Jenkins界面上的中止按钮本质上是调用了FlowExecution.abort()它会触发当前正在运行的所有步骤收到一个AbortException。如果你正在跑sh sleep 600这个shell进程会被强制终止阶段就会记录成ABORTED而不是FAILURE。3. 退出码、异常与阶段状态判定逻辑的实际运转3.1 sh步骤返回码是怎么变成“阶段结束信号”的现在我们把镜头拉回到最常用的sh步骤。它对你的shell命令退出码处理方式直接决定了阶段是绿还是红。默认情况下sh步骤把退出码提交给结果回调0代表成功非0会直接抛一个AbortException让当前阶段标记为失败。这就是为什么你写sh exit 1流水线会立刻报错、阶段变红即使你没有写任何显式的“判断失败”代码。如果只想要退出码而不想让流水线当场失败可以用returnStatus: truedef code sh(script: exit 1, returnStatus: true) echo 上一条命令的退出码是${code}如果只想要命令输出用returnStdout: truedef result sh(script: echo hello world, returnStdout: true).trim()这个设计背后的逻辑很清晰Jenkins把“命令执行”抽象成步骤把“退出码”抽象成完成信号。至于你拿到这个信号之后是让它炸掉当前阶段还是拿去做条件判断那是脚本作者的自由。我自己的使用习惯是在构建、编译、单测这些“必须成功才能继续”的环节用默认模式让非0直接失败只有在收集产物信息、探活、发送通知这类“失败不致命”的环节才用returnStatus或者包一层catchError。3.2 catchError你以为救回了构建但它只是改了阶段结果这是流水线里最容易被误解的步骤之一。很多人用catchError包住一条可能失败的命令以为这样构建就能“安全通过”。实际上catchError的作用是捕获内部步骤抛出的异常并把当前阶段的StageResult设置成UNSTABLE默认从而让构建不至于当场被中断。举个真实场景。我在测试阶段跑过一个这样的流水线stage(测试) { steps { catchError(buildResult: UNSTABLE, stageResult: UNSTABLE) { sh npm test } } }当npm test以非0退出时catchError接住了异常构建没有中断后续的post和下一个阶段照常执行。但请注意阶段结果已经变成了UNSTABLE整个构建的结果也被标记成UNSTABLE。外部系统如果只看lastSuccessfulBuild数据就出不来了。所以catchError并没有“把失败变成成功”它只是改变了失败的表现形式。真正的失败信号那个异常依然被记录在FlowNode里。这引出一个判断技巧当你看到某个阶段显示UNSTABLE但没有打印异常堆栈时第一反应应该是“某个步骤被catchError接住了”。3.3 timeout、retry、parallel包装步骤对“结束信号”的影响流水线里很多步骤并不是单纯执行一条命令而是“包装”了另一个或多个步骤。包装器会影响结束信号的传递方式这也是阶段状态飘忽的常见来源。先看timeout。它会在到达时间上限时主动去中止正在运行的内容然后抛出一个AbortException。所以被timeout包住的步骤如果超时阶段状态通常是ABORTED不是FAILURE。但这里有个细微差别超时后包在里面的shell进程会被强杀这个进程如果自己也返回了非0退出码同一次执行里既有“被中止”又有“失败”的信号最终状态取决于哪个信号先被记录、以及外层的处理逻辑。再看retry。它会在内部步骤失败时自动重新执行只有最后一次执行还失败才算真的失败。所以“结束信号”被延迟了最后一次成功之前的所有失败都不会反映到阶段状态上。parallel更特殊。它开启多个并行分支每个分支拥有独立的执行线程和独立的FlowNode。父阶段必须等所有分支都发出“结束信号”才会结束。默认情况下哪怕某个分支已经失败了其他分支还是会继续跑完只有设置failFast: true某个分支失败才会主动中止其他分支提前结束父阶段。4. 声明式流水线的“糖衣”与真实状态机4.1 声明式stage怎么被翻译成步骤序列声明式流水线刚出来时大家都觉得它是另一种全新的东西。其实它只是包在脚本化流水线外面的一层“宣言式语法糖”。当你提交一个pipeline {}块时Jenkins会通过ModelInterpreter把它解释成一段脚本化流水线来执行。也就是说你写的stage(构建) { steps { ... } }最终底层还是会变成脚本化流水线里的stage(构建) { ... }闭包来跑。既然如此前面讲的所有关于“步骤完成信号”的机制对声明式流水线完全适用。但声明式有一个额外的东西它在每个阶段外面包了一层“结果聚合器”。每个阶段执行完时引擎会把该阶段的StageResult汇总到整个构建里。这也解释了为什么声明式流水线的post块可以在阶段结束时拿到success、failure、unstable、aborted这些条件——它们读的正是信号汇总后的结果。4.2 when/post的数据来源阶段结果与构建结果when块在阶段开始之前判断。它读的是环境变量、构建参数、当前构建结果等数据。如果条件不满足这个阶段会被标记为“跳过”状态是NOT_BUILT。注意被跳过的阶段不会发出“正常完成”信号它的FlowNode状态是“SKIPPED”。post块则在阶段结束之后执行它读的就是阶段结果。你可以这样用stage(构建) { steps { sh make build } post { success { echo 构建阶段正常结束 } failure { echo 构建阶段失败 } unstable { echo 构建阶段不稳定 } aborted { echo 构建阶段被中止 } always { echo 无论如何都会执行 } } }注意一个细节声明式流水线的post块只能嵌在声明式结构里脚本化流水线没有这个语法。如果你在脚本化流水线里想做阶段结束通知要么写try/catch/finally要么在stage闭包里自己处理异常。4.3 UI上那个彩色圆点到底是谁在画Blue Ocean和经典Stage View都会通过REST API去读取执行图然后绘制阶段卡片。它们读的主要是WorkflowRun下的FlowNode列表。每个阶段节点都有自己的status、startTimeMillis、durationMillis和error字段。这也是为什么有时候UI上的阶段状态看起来和日志对不上日志里明明看到报错了但阶段卡片还是蓝的。多半是因为UI读的FlowNode状态还没更新或者你看到的报错来自一个已经被catchError吞掉的异常。5. 实操篇在流水线内外精准捕捉阶段结束5.1 流水线内读取状态currentBuild、STAGE_NAME、post条件想在流水线里拿当前阶段的状态最常用的是这几个变量currentBuild.result // 整个构建的结果 currentBuild.currentResult // 构建当前最新结果 env.STAGE_NAME // 当前正在运行的阶段名在post块里可以直接按条件分支写逻辑。这是我经常用的一段结构pipeline { agent any stages { stage(构建) { steps { catchError(buildResult: UNSTABLE, stageResult: UNSTABLE) { sh make build } } post { unstable { echo 阶段 ${env.STAGE_NAME} 没通过但构建继续 } success { echo 阶段 ${env.STAGE_NAME} 正常完成 } } } } }这里有个实操点catchError里的stageResult: UNSTABLE会直接改变当前阶段的StageResult而post块就是靠这个StageResult来判断执行哪个分支的。所以两者是联动的。5.2 流水线外的实时感知REST API与FlowNode遍历阶段结束信号不只是给UI看的它也是外部系统协调流水线的重要接口。最常用的外部接口是Blue Ocean的REST APIcurl -u user:password http://jenkins地址/job/项目名/wfapi/describe返回的JSON里有一个stages数组每个元素大致长这样{ name: 构建, status: SUCCESS, startTimeMillis: 1720000000000, durationMillis: 35231, stageFlowNodes: [] }外部脚本可以定时轮询这个接口拿到每个阶段的结束时间、状态、耗时。我在做自动部署触发时就是让下游脚本轮询这个接口确认“部署”阶段状态变成了SUCCESS之后才放行下一批任务。比去解析控制台日志靠谱得多。如果想在Jenkins内部通过脚本遍历FlowNode可以用workflow-api插件提供的类。大致写法是这样def build Jenkins.instance.getItemByFullName(my-job).getBuildByNumber(123) def exec build.getExecution() def walker new org.jenkinsci.plugins.workflow.graph.FlowGraphWalker(exec) { boolean visit(org.jenkinsci.plugins.workflow.graph.FlowNode node) { // 每个节点都有 getId() 和 getError() if (node instanceof org.jenkinsci.plugins.workflow.cps.nodes.StepNode) { println 步骤节点: ${node.getId()} } return true } } walker.visit()这种方案适合你有Jenkins脚本权限并且需要在构建结束后做深度分析时用。5.3 把阶段状态推送到工单/IM阶段结束信号还能用来做外部通知。最常见做法是在post块里调用httpRequest步骤或IM插件把stage结果推出去。我在生产环境里用的方案是在发布流水线的“部署”阶段结束后往自己团队的API网关发一个结构化JSONstage(部署) { steps { sh ./deploy.sh } post { success { httpRequest( url: http://api.internal/release/callback, httpMode: POST, requestBody: { \stage\:\${env.STAGE_NAME}\, \job\:\${env.JOB_NAME}\, \build\:\${env.BUILD_NUMBER}\, \status\:\SUCCESS\ }, contentType: APPLICATION_JSON ) } } }这样下游系统收到的不是“构建号”而是“哪个阶段结束了、状态是什么”。对于还要接dify之类知识库流水线、或者后续要触发C构建任务的场景这种细粒度的阶段级信号特别有用。5.4 复杂场景下“结束”如何定义更可靠有些阶段不是单纯跑一条命令而是需要等到某个外部条件满足才算“真的结束”。比如“部署”阶段调起了一个异步发布任务shell命令本身返回了0但服务还没真正起来。这时候如果只看sh步骤的退出码你的“结束信号”是不完整的。我惯用的处理方式有两种。第一种比较土但可靠在部署命令后面加一个探活循环一直等到服务接口返回预期状态码再让sh步骤返回0第二种是把异步任务的结果位置写入一个文件或远程存储在阶段末尾显式读取并判断。这里想额外提醒一句不要只在阶段结束前打一行echo就万事大吉。那句echo会正常返回0但它只是告诉引擎“这段脚本执行完了”不代表业务真的完成了。业务层面的“结束”需要你自己在脚本里完成闭环验证。6. 常见误判与排查实录含真实案例6.1 最经典的绿色假象最后一行命令成功这是我在好几个团队里都见过的问题。shell脚本中间某条命令失败了但由于脚本默认不会因前一条命令失败而中断最后一行可能是个echo build done退出码是0。引擎一看退出码为0就觉得sh步骤“成功结束”阶段是绿的。遇到这种“明明日志里有报错阶段却是绿色”的现象先检查一件事你的shell脚本有没有加set -e。加了这个开关shell会在任一条命令失败时立即退出并返回非0退出码才能真正当“结束信号”用。如果你不想改历史脚本也可以在sh步骤里显式声明sh #!/bin/bash set -ex make build 6.2 timeout背锅侠阶段状态飘忽不定有一次排查一个部署流水线脚本里写的是timeout(time: 5, unit: MINUTES) { sh sleep 600 }现象是比如某次构建实际只跑了十几秒就报“超时失败”阶段卡片显示ABORTED另外一次却显示FAILURE。为什么同一个timeout会有两种表现原因是超时触发时timeout会主动中止内部步骤抛AbortException。被中止的sleep进程可能返回非0退出码但这个退出码还没来得及上报整个步骤就已经被标记成ABORTED了。在一些临界状态下非0退出码和AbortException同时存在最终哪个信号被记进FlowNode取决于线程调度顺序。要规避这个问题建议在timeout超时逻辑里明确指定超时时如何处理并尽量只让timeout作为最外层包装不要把timeout和sh的失败判断混在一起用。6.3 并行阶段的“结果合并规则”到底怎么算并行阶段特别容易踩的坑是“父阶段何时结束”。默认情况下父阶段会等所有并行分支都结束哪怕某个分支已经失败其他分支依然继续跑。如果你希望一个分支失败就立刻中止其他分支需要显式指定failFastparallel( a: { sh step_a }, b: { sh step_b }, failFast: true )failFast的作用是当某个分支失败时主动去中止尚未结束的其他分支让父阶段尽快结束。没有它那其他分支可能会白白跑很久而且父阶段的结束信号会一直等下去。6.4 一次agent失联后的排查记录最后分享一次真实排查过程。某天发布流水线卡在“构建”阶段UI显示“进行中”持续了很长时间。打开控制台日志停在一行“正在连接到agent”。按理说agent连接超时是有一套默认处理逻辑的但那次卡了很久才报错。排查时我先确认了几件事第一agent所在机器是否还在线用Jenkins节点管理页面看了一眼连接状态第二这次构建的FlowNode图上“构建”阶段节点的error字段是什么第三agent端的连接超时配置有没有被改过。最终定位是agent节点短暂网络中断加上本地配置了比较长的重连等待时间导致阶段长时间“悬停”在连接中。后来我在流水线开头加了一个“节点探活”步骤先跑一个轻量命令比如pwd确认agent可用再进入真正的构建阶段。这个步骤虽然看起来简单但能大幅减少“阶段悬停”的体感问题。另外还有一个经验如果agent长期失联Jenkins会把阶段标记为失败但不会是FAILURE而是类似ABORTED或NOT_BUILT的状态。外部同步脚本如果只按“失败”或者“成功”两种情况判断就会漏掉这个状态。建议把“异常中断”单独拉一个分支去处理。最后说点实际的体会。在把“阶段结束信号”这件事彻底搞清楚之后我做了一个比较大的改动把发布流水线的每个关键阶段末尾都加了一个结构化状态输出形如{stage:deploy,status:SUCCESS,ts:...}下游的自动部署触发脚本和知识库流水线不再靠轮询UI或者解析控制台文本而是直接读这份状态文件和wfapi接口返回的status字段。如果你现在正在排查一个莫名其妙的“阶段状态”问题我建议从三个角度入手第一打开/wfapi/describe看阶段节点到底记录了什么状态第二查这一步有没有抛异常、有没有被catchError包过第三确认shell脚本是否显式处理了退出码。很多问题的答案不在“Jenkins知不知道阶段跑完了”而在我们有没有按它要求的信号格式把“结束”这件事上报干净。