有段时间我特别喜欢在 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里的每一个Step:sh、git、checkout、junit、timeout、input……这些都是 Step。
每个 Step 都是一个可以独立完成、独立返回结果的最小单元。sh步骤负责在 agent 上启动一个 shell 进程,checkout scm步骤负责拉取代码,它们都遵循同一个规则:执行完成后返回一个结果,流水线才会继续往下走。
这就引出了最关键的执行模型——CPS(Continuation-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 与非 0:Unix 世界最朴素的约定
现在进入每个命令都绕不开的概念:退出码(exit code)。Unix 世界里,一个进程结束时会留下一个整数给它的父进程,这就是退出码。约定很简单:0 表示成功,非 0 表示失败。这个数字不只是对错,还经常承担传递错误类型的职责。
| 退出码 | 常见含义 |
|---|---|
| 0 | 正常结束 |
| 1 | 通用错误,比如某个校验失败 |
| 2 | 用法错误、参数错误(shell 内置命令常见) |
| 126 | 命令存在但不可执行 |
| 127 | 命令找不到 |
| 130 | 被 Ctrl+C 中断 |
| 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 客户端进程把代码拉完并且返回 0;make的结束标志是编译器进程退出,退出码 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 标记为失败。注意这里有个非常实用但很多人不知道的参数——activity:
timeout(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 2>&1 &命令瞬间返回,退出码 0,sh 步骤成功,stage 结束,下一阶段马上开始检查服务端口。结果当然是失败的——服务进程还在启动过程中,端口根本没监听。
你要理解 Jenkins 的视角:sh步骤等的是那个 shell 进程本身,Shell 派生出的后台子进程它不管。shell 把子进程甩到后台之后自己就退场了,Jenkins 收到“shell 已退出,退出码 0”的回执,自然认为步骤结束了。这个机制本身没错,是我们把“命令结束”和“业务就绪”混为一谈了。
docker run -d也有同样的脾气。你执行:
docker run -d -p 8080:8080 myapp命令返回的是容器 ID,Docker 客户端退出,退出码 0。但容器内部的应用可能还在初始化,甚至可能初始化失败。Jenkins 以为部署成功了,实际上容器可能马上就会退出。
4.2 端口通了不等于服务可用:未就绪的典型案例
就算你等到了端口可连接,也不代表服务已经就绪。Java 服务监听端口到真正能处理业务请求,中间可能还有几秒到几十秒的初始化时间;数据库连接池没建立好时,端口虽然通了,但请求会大量报错。
所以在“服务启动完成”和“服务真正可服务”之间,一定要有一道健康检查。最简单的做法是循环重试,直到某个健康检查接口返回 OK:
for i in $(seq 1 30); do if curl -sf http://localhost:8080/api/health; then exit 0 fi sleep 2 done exit 1这段脚本的退出码就是“服务是否就绪”的最终裁决。脚本返回 1,sh 步骤抛异常,阶段失败,后续流程不会继续。这样阶段结束的信号才和业务真实状态对上了。
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 改成健康检查,把“命令返回”改成“业务就绪”,把后台任务改成前台等待,流水线的稳定性会有质的提升。希望能帮你理清这套机制,下次再遇到“阶段到底跑完没有”的疑问,你能直接指出消息链条里断在哪儿了。