Jenkins流水线阶段结束判定机制:退出码、后台进程与可靠结束

发布时间:2026/10/4 2:10:59
Jenkins流水线阶段结束判定机制:退出码、后台进程与可靠结束 有段时间我特别喜欢在 Jenkinsfile 里写sleep 30不是想拖时间是真的怕下一步跑太快。那时候我在维护一套 C 项目的自动构建流水线编译完要拷贝产物、再触发下游测试总觉得上一个阶段刚结束、下一个阶段就开始操作文件容易撞上“文件还没写完”的窗口。后来我把那个 sleep 删掉改成真正去理解 Jenkins 对“阶段运行结束”的判定机制流水线反而更稳了。这个标题看着像新手问题Jenkins 流水线怎么可能不知道阶段结束它执行完不就自然进入下一步了吗但真去抠里面的机制你会发现“阶段结束”这四个字里藏着不少东西——它既不是靠日志猜的也不是简单看时间而是一套从命令退出码到流水线调度模型层层配合的结果。这篇文章我会从执行模型讲起把 sh 步骤、退出码、超时中断、后台进程这些最容易误解的边界全部拆开说清楚。适合正在写 Jenkinsfile 的人、被后台进程坑过的人、以及所有想把流水线做得更可靠的同学。1. 先搞清楚执行模型Jenkins 不是“看着”命令跑完而是自己决定阶段何时结束1.1 为什么很多人要靠 sleep 和轮询才能“等”到阶段结束先说个我在各种团队的 Jenkinsfile 里反复见到的现象stage(构建) { steps { sh ./build.sh sleep 30 sh ./deploy.sh } }问他们为什么加 sleep答案几乎一致“怕 build.sh 还没真正写完产物deploy 就开始拷文件了。”这个想法本身没问题问题在于他们把“阶段结束”当成一个模糊的、需要额外等待的事情。实际上Jenkins 对每个阶段的结束判定是有明确信号的如果命令真的跑完了下一步本来就会在瞬间衔接上如果命令还没跑完你 sleep 30 秒大概率也会等出问题——比如构建突然失败了你还在那儿白白睡半分钟。这个误区背后是对执行模型的误解。所以先别急着记命令得先把 Jenkins 的执行单元搞清楚。1.2 步骤Step才是执行单元stage 只是容器声明式流水线写出来是这种层级pipeline { agent any stages { stage(构建) { steps { sh ./build.sh } } } }看起来 Jenkins 是在执行“构建”这个阶段但你要知道stage本身只是逻辑容器它负责把一组操作归拢在一个名字下方便展示、方便在 Blue Ocean 里看泳道、方便写post条件。真正被 Jenkins 调度和执行的是steps里的每一个Stepsh、git、checkout、junit、timeout、input……这些都是 Step。每个 Step 都是一个可以独立完成、独立返回结果的最小单元。sh步骤负责在 agent 上启动一个 shell 进程checkout scm步骤负责拉取代码它们都遵循同一个规则执行完成后返回一个结果流水线才会继续往下走。这就引出了最关键的执行模型——CPSContinuation-Passing Style延续传递风格。Jenkins 会把你的 Groovy 脚本转换成一种可以被暂停和恢复的形式。当一个步骤被调用时它会挂起当前的流程把流程状态保存下来等这个步骤的真实工作完成后Jenkins 唤醒整个流程从之前停下的位置继续执行。这跟写普通脚本“从上往下跑”的直觉不一样它更像一个主持人拿着节目单主持人按顺序喊节目每个节目演完、演员给出明确的“我下台了”的信号主持人才能喊下一个。整个晚会stage结束的标志就是最后一个节目演完、最后一个演员也谢了幕。1.3 “阶段结束”在运行记录里到底发生了什么这个“谢幕信号”在 Jenkins 内部是有具体数据结构的。流水线每执行一步都会在运行记录里生成一个FlowNode记录这个步骤的当前状态等待中、运行中、已完成、失败等。当 stage 内所有步骤的 FlowNode 都进入了终结状态这个 stage 才会被标记为结束流水线才会继续评估下一个 stage。所以“阶段运行结束”这个问题在 Jenkins 的数据模型里给出的答案非常确切该阶段内所有步骤的执行队列已经清空每一个步骤都返回了结果成功或者失败。不是“看起来差不多跑完了”也不是“日志里最后一行打了 done”而是步骤本身的终结。另外这个机制在 agent 端还有一个叫 durable task 的设计。sh步骤在 agent 上跑的 shell 进程是由一套“可持久化任务”体系托管的。哪怕 Jenkins 主节点在命令执行到一半时重启了agent 上的 shell 进程也不会被清理掉主节点恢复后会重新连接到那个还在跑的进程继续跟踪它的退出状态。这也是为什么 Jenkins 能比较靠谱地回答“阶段到底跑完没有”这个问题——不是看日志猜的而是真的有一个进程在那儿Jenkins 在等它的退出。2. 退出码是命令写给 Jenkins 的“回执”sh 步骤就是那个收信人2.1 退出码 0 与非 0Unix 世界最朴素的约定现在进入每个命令都绕不开的概念退出码exit code。Unix 世界里一个进程结束时会留下一个整数给它的父进程这就是退出码。约定很简单0 表示成功非 0 表示失败。这个数字不只是对错还经常承担传递错误类型的职责。退出码常见含义0正常结束1通用错误比如某个校验失败2用法错误、参数错误shell 内置命令常见126命令存在但不可执行127命令找不到130被 CtrlC 中断137被 SIGKILL 强杀常见于内存不足被 OOM Killer 干掉你在脚本里写exit 10就是在手动给 Jenkins或者说给 shell 的父进程递一张写着“我是因为超时/配置错误/资源不足才挂的”的回执。后面我们会看到这张回执在实际项目里非常有用。2.2 sh 步骤默认的-xe行为与退出码传播Jenkins 的sh步骤在 Unix 类系统上执行时默认是带着-xe标志的。-x表示每条命令执行前先打印出来所以你在构建日志里能看到一堆以开头的行-e表示只要脚本里任何一条命令返回非零退出码shell 立刻终止并且把那个非零退出码作为整个脚本的退出码。这里必须说一个很多人困惑过的点“为什么我脚本里中间的某条命令明明失败了流水线还是绿的”如果你遇到过这个现象十有八九是你在脚本里写了set e或者用了命令 || true或者干脆把多条命令用;串起来并且最后一条命令成功了。在set e的状态下shell 不会因为中间命令失败而退出整个脚本的退出码只取决于最后一条命令的退出码。比如false || true这条命令会成功因为|| true把失败的退出码吞掉了。但默认情况下sh步骤带着-e所以中间任何一条命令失败都会让整个脚本以失败结束。要注意这个默认行为也会带来另一个坑日志里会打印命令本身如果你的脚本里写了明文密码或者 Token它们会跟着-x一起暴露在构建日志里。我见过有人踩过这个坑后来养成了习惯在涉密脚本开头先set x或者尽量把敏感信息放到 Jenkins 的凭证里用withCredentials注入环境变量而不是写死在命令行里。2.3 returnStatus 与 returnStdout把回执拿在手里做判断大多数时候你不需要关心退出码数值sh 步骤自己会根据退出码决定抛不抛异常。但有些场景你必须拿到这个“回执”来做更精细的判断这时候就要用returnStatus或returnStdout参数了。def code sh(script: ./build.sh, returnStatus: true) if (code ! 0) { unstable(构建脚本返回了非零状态${code}) }设置returnStatus: true之后sh 步骤不会因为非零退出码直接抛异常而是把退出码作为返回值交给你。有了这个数字你就能区分“构建是因为测试失败挂的”还是“因为资源不够挂的”——如果你在脚本里对不同错误设了不同退出码的话。returnStdout: true则是把命令的标准输出作为字符串返回同时退出码仍然会参与成败判定。比如def version sh(script: git rev-parse --short HEAD, returnStdout: true).trim()这种写法在需要把命令输出传给后续步骤时非常常用。有一点需要注意returnStatus和returnStdout不能同时设为 true这是 API 的硬性限制。我建议新人在流水线里多用几次这两个参数你会对“阶段是否结束”有完全不同的体感——原来 Jenkins 的判断完全是命令结果驱动的。3. “结束”不是一种状态而是四种结局正常、失败、跳过、超时中断说了执行模型和退出码现在可以更完整地回答标题的问题了。一个阶段“运行结束”在 Jenkins 眼里其实包含好几种完全不同的结局它们对流水线后续的影响也不一样。3.1 正常结束所有步骤顺序返回stage 标记为 SUCCESS最理想的情况stage 里的所有步骤都顺序执行完没有步骤抛异常此时 stage 被标记为 SUCCESS。比如一个典型的构建流水线stage(构建) { steps { checkout scm sh make sh ./run_tests.sh } }checkout scm的结束标志是 Git 客户端进程把代码拉完并且返回 0make的结束标志是编译器进程退出退出码 0 意味着编译产物已经落盘run_tests.sh结束则意味着测试进程跑完了全部用例。每一个步骤的“进程退出”就是它的结束信号Jenkins 收到信号就执行下一步。这几个信号环环相扣任何一个步骤失败后续步骤就不会执行——这是流水线最核心的价值之一。3.2 异常结束步骤抛异常导致 stage 立刻失败当sh步骤拿到非零退出码这个步骤会向流水线抛出一个异常。一旦异常抛出stage 的剩余步骤立刻不再执行整个 stage 标记为 FAILURE流水线进入收尾阶段。这里有个实用技巧你可以在 Groovy 里用try/catch捕获这个异常也可以声明式地用catchError改变 stage 的最终结论。stage(测试) { steps { catchError(buildResult: UNSTABLE, stageResult: SUCCESS) { sh ./run_tests.sh } } }这样即使测试脚本抛了异常stage 本身也不会被打成 FAILURE而是把整条流水线的结果改为 UNSTABLE。这个机制会直接影响“阶段结束”的最终定义物理上步骤已经失败结束了但逻辑上你可以把这个结束包装成另一种结论。很多团队的规矩是“测试挂了流水线标黄编译挂了标红”靠的就是这个。3.3 被跳过的结束when 条件不满足Jenkins 也算它“完事”了还有一种很容易被忽视的“结束”跳过。声明式流水线里的when条件决定一个 stage 要不要执行stage(部署到生产) { when { branch main } steps { sh ./deploy.sh } }如果当前分支不是 main这个 stage 根本不会执行但 Jenkins 依然会“结束”它——状态是 SKIPPED不是 SUCCESS也不是 FAILURE。从数据结构上看这个 stage 的 FlowNode 同样进入了终结状态流水线会继续往后走。这告诉我们一件事“阶段结束”和“阶段执行”是两个概念。你看到的流水线图上那个被划掉的灰色阶段就是“结束但未执行”的典型。理解这一点你在写post条件时就不会假设 SKIPPED 的阶段一定会进post { always { } }——实际上声明式流水线里被跳过的 stage 默认不会执行该 stage 内部的post块。3.4 超时与等待不是自然结束而是 Jenkins 强制收尾正常结束是命令自己跑完了失败结束是命令跑挂了还有一种情况是命令一直没跑完Jenkins 那边先受不了了。timeout步骤就是干这个的timeout(time: 10, unit: MINUTES) { sh ./long_running_task.sh }如果 10 分钟到了命令还没结束Jenkins 会中断步骤的执行把这个 stage 标记为失败。注意这里有个非常实用但很多人不知道的参数——activitytimeout(time: 5, unit: MINUTES, activity: true) { sh ./watch_log.sh }设置activity: true之后超时计时器只在“有日志输出”的时候走如果进程卡死、什么都不输出它是不会触发超时的。反过来想这个特性也可以用来防一种特殊状况某个后台任务日志疯狂输出但一直不退出你可以用 timeout 把它兜住。我见过有团队用这招限制那些“死循环打日志”的异常任务效果很好。另外和“等待”相关的还有input步骤它会在 stage 里弹出一个待确认的界面等人点击“继续”或“中止”。在这个等待期间stage 一直挂在 RUNNING 状态直到收到输入信号才结束。严格说这也属于阶段结束的一种形态——结束被一个外部动作延后了。4. 最容易骗过 Jenkins 的“假结束”后台进程、未就绪服务与粗糙轮询前面说的都是正常调度逻辑接下来要讲的是实战中真正害人的部分命令明明返回了但你要的东西还没准备好。这是“假结束”是无数流水线事故的根源。4.1 后台进程shell 结束了程序还在暗处跑这是我在自动部署流水线里踩得最惨的一个坑。当时为了不阻塞构建我在 sh 步骤里写了这样一段nohup ./start_server.sh server.log 21 命令瞬间返回退出码 0sh 步骤成功stage 结束下一阶段马上开始检查服务端口。结果当然是失败的——服务进程还在启动过程中端口根本没监听。你要理解 Jenkins 的视角sh步骤等的是那个 shell 进程本身Shell 派生出的后台子进程它不管。shell 把子进程甩到后台之后自己就退场了Jenkins 收到“shell 已退出退出码 0”的回执自然认为步骤结束了。这个机制本身没错是我们把“命令结束”和“业务就绪”混为一谈了。docker run -d也有同样的脾气。你执行docker run -d -p 8080:8080 myapp命令返回的是容器 IDDocker 客户端退出退出码 0。但容器内部的应用可能还在初始化甚至可能初始化失败。Jenkins 以为部署成功了实际上容器可能马上就会退出。4.2 端口通了不等于服务可用未就绪的典型案例就算你等到了端口可连接也不代表服务已经就绪。Java 服务监听端口到真正能处理业务请求中间可能还有几秒到几十秒的初始化时间数据库连接池没建立好时端口虽然通了但请求会大量报错。所以在“服务启动完成”和“服务真正可服务”之间一定要有一道健康检查。最简单的做法是循环重试直到某个健康检查接口返回 OKfor i in $(seq 1 30); do if curl -sf http://localhost:8080/api/health; then exit 0 fi sleep 2 done exit 1这段脚本的退出码就是“服务是否就绪”的最终裁决。脚本返回 1sh 步骤抛异常阶段失败后续流程不会继续。这样阶段结束的信号才和业务真实状态对上了。4.3 粗糙的轮询陷阱轮询到的是半成品还有人用轮询代替健康检查但轮询条件选得很糙。举个我见过的例子有人在部署脚本里等一个文件出现脚本长这样while [ ! -f /opt/app/deploy.done ]; do sleep 5 done看起来没什么问题但等他部署完去检查发现文件确实存在了里面内容却是空的——因为那个文件是另一个进程用“先创建文件、再往里写内容”的顺序生成的轮询到“文件存在”时内容还没写完。这就是典型的“轮询到了半成品”。更隐蔽的是轮询日志文件里的关键字。比如等日志里出现 “Server started”结果那个日志文件刚好被 logrotate 切走了旧文件还在但已经没有新内容追加轮询就永久卡住了。所以我现在的原则是轮询的必须是业务状态而不是文件或日志的形迹。最可靠的是调一次服务的健康接口或者校验一个文件的完整性比如 md5而不是只看它“存在不存在”。4.4 把“就绪条件”当阶段结束判定一个共享函数的设计这些经验沉淀下来我建议你把这些逻辑封装成流水线共享库里的一组“等待函数”不要每次现写。下面这个函数是我实际在用的核心思路就是把“等待就绪”的职责从业务脚本移动到流水线步骤里def waitForHttp(String url, int timeoutSeconds 60) { sh for i in \$(seq 1 ${timeoutSeconds}); do if curl -sf ${url}; then echo \[OK] ${url} is ready\ exit 0 fi sleep 1 done echo \[ERROR] ${url} did not become ready in ${timeoutSeconds}s\ exit 1 }为什么放在sh里而不是用 Groovy 循环因为sh步骤天然用退出码说话成功失败直接映射到阶段结果日志输出也更干净Groovy 循环在 CPS 模式下虽然也能跑但异常处理和退出码映射都绕了一层调试起来不如 shell 直观。用的时候配合 timeout 更稳timeout(time: 2, unit: MINUTES) { waitForHttp(http://localhost:8080/api/health, 60) }这样你等的是一个明确的业务就绪信号而不是撞大运式的 sleep。把这一步做好“阶段结束”的含金量会高很多。5. 阶段结束判定在真实项目中的边界并行、重试与“结束之后”最后再往深走一层。真实项目里的流水线很少是一条直线并行分支、重试逻辑、结束后的通知处理都会影响“阶段结束”这个概念的呈现方式。5.1 并行分支所有分支都返回stage 才算合并结束声明式流水线里一个 stage 可以包含多个并行分支stage(质量检查) { parallel { stage(单元测试) { steps { sh ./run_unit_tests.sh } } stage(静态扫描) { steps { sh ./run_static_analysis.sh } } } }Jenkins 对并行分支的“结束”判定是所有分支都进入终结状态这个 stage 才结束。如果一个分支失败其他分支仍会继续跑完除非设置failFast true。结束之后整体怎么标记取决于最终结果有任何一个分支失败整个 stage 就是失败的。这个语义很关键很多人以为并行是“谁先跑完谁先结束整体以最快的那个为准”正好搞反了。并行 stage 像一场接力里的 400 米小组赛所有选手冲线小组赛才算完。你在写依赖并行结果的后续步骤时必须确保所有分支都跑完再继续。5.2 retry 与 catchError结束的结论可以被改写再来看重试逻辑。retry的本质是第一次失败时阶段还没真正进入“失败结束”状态它会尝试重跑步骤直到重试次数耗尽才把异常抛出去。也就是说retry 可以把“失败的结束”延迟甚至可能把它变成“成功的结束”。retry(3) { sh ./flaky_test.sh }这个机制在实际项目里很有用但也很危险。如果重试的原因是资源竞争重试三次确实能提高成功率如果原因是代码缺陷重试只是浪费时间。所以我的经验是retry 只用来对抗暂时性故障网络抖动、资源竞争、服务重启不要用来掩盖确定的失败。在 retry 外层再配一个 timeout防止单次尝试卡死整个流程。前面提过的 catchError 也是改写结束结论的手段。它把异常吞掉把一个失败的结束改成 UNSTABLE。这套组合拳下来你会发现“阶段结束”的最终结论其实是一层层包装出来的底层是退出码中层是异常传播上层是 retry 和 catchError 的后处理。5.3 post 块阶段结束后 Jenkins 做的第一件事“结束”之后的动作写在post里它是阶段结束后自动触发的收尾逻辑stage(部署) { steps { sh ./deploy.sh } post { success { notify(部署成功) } failure { notify(部署失败) } always { cleanWorkspace() } } }post的执行时机就是在阶段判定结束之后根据结束的具体状态选择对应的分支。你要注意一点post里的步骤如果失败会覆盖当前结果导致原本成功的 stage 变成失败这个我踩过。所以我在 post 里尽量只放通知、清理这类低风险动作需要做高风险收尾的套上catchError保护好。5.4 一段实际项目的编排组合下面是我比较常用的一套组合模式把前面讲的东西都串起来你可以直接参考stage(部署并验证) { steps { script { try { timeout(time: 15, unit: MINUTES) { retry(2) { sh docker run -d --name myapp -p 8080:8080 myapp:latest } waitForHttp(http://localhost:8080/api/health, 60) } } catch (Exception e) { currentBuild.result UNSTABLE error(部署验证失败: ${e.getMessage()}) } } } post { always { sh docker logs myapp --tail 200 || true } } }这套组合解决的是启动容器、等待容器就绪、超时兜底、失败重试、最后无论结果如何都收集日志。这里每一步的“结束”都是有明确业务语义的而不是靠 sleep 赌出来的。回到标题的问题——Jenkins 是怎么知道阶段运行结束的本质就是两步第一步agent 上的进程/步骤向主节点返回一个明确结果通常是退出码第二步主节点按 CPS 调度的规则恢复流程把 stage 标记为对应的最终状态。如果这中间有什么环节失真比如后台进程、粗糙轮询、超时没兜住那阶段结束的信号就和真实业务脱节了。我自己实践下来的体会是不要纠结于“怎么判断结束”要把精力放在“让结束的信号准确反映业务状态”上。把 sleep 改成健康检查把“命令返回”改成“业务就绪”把后台任务改成前台等待流水线的稳定性会有质的提升。希望能帮你理清这套机制下次再遇到“阶段到底跑完没有”的疑问你能直接指出消息链条里断在哪儿了。