繁

维护一个 PR 时,我被一次 k3s 安装失败卡住了。从没有重跑权限,到用假的 curl 稳定复现问题,再第一次主动找维护者讨论,记录这次 CI 排查的过程。

CI 红了,不一定是代码错了:第一次排查 Apache ShenYu CI 故障
25 分钟
4967 字

这次修复,是从另一个 PR 的红灯里找出来的。

当时我正在维护 Apache ShenYu PR #7289。它改动比较多,每次提交都要等 CI 跑不少模块。有一次,k8s-examples-http 又失败了,日志最后只留下了一句:

cat: /etc/rancher/k3s/k3s.yaml: No such file or directory

这次测试还没跑到业务代码,安装 k3s 的步骤就已经出问题了。我第一反应是想再跑一次看看,但我没有重跑 ShenYu CI 的权限。继续等着,也不知道下一次会不会还是同样的结果。

于是我开始认真看这段 workflow:为什么安装失败了,却只在最后读取文件时才报错?原来写好的重试,又为什么没有运行?

顺着这两个问题,我第一次把 CI 从“提交之后等结果”的黑盒,拆成了自己可以读、可以复现、也可以测试的脚本。最后这次修复单独提交为 PR #7380,并被合并。对我来说,值得记下来的不只是那几行 Bash,还有从“能不能帮我重跑一下”,到自己拿着日志和复现去讨论问题的过程。

先看失败在哪一层,再回头检查代码h2

以前看到 CI 红了,我通常会先打开自己的 diff,找是不是哪里写错了。但这次日志指向的是环境初始化:workflow 要先安装 k3s,再读取 /etc/rancher/k3s/k3s.yaml 作为 kubeconfig。这个文件不存在,后面的 Kubernetes 测试自然也就跑不起来。

CI 里的失败不只有业务代码这一种来源。Runner、下载源、网络、Docker 和依赖仓库,都可能影响一次运行。我开始意识到,红灯只是结果,先弄清楚它停在哪一步,才知道该往哪里找。

继续读 workflow 时,我发现它其实已经有重试逻辑。把和问题有关的部分摘出来,大致是这样:

Terminal window
# 原 workflow 的关键结构,省略版本参数和失败日志
install_k3s() {
curl -sfL https://get.k3s.io | sh -
}
for attempt in 1 2 3; do
if install_k3s; then
break
fi
# 第三次失败时退出;前两次分别等待 15、30 秒
done

第一次失败等 15 秒,第二次失败等 30 秒,第三次再失败就退出。看起来并没有少写重试。

但实际那次 Install k8s 很快就结束了,日志里没有 k3s install failed on attempt 1,也没有等到第一次重试的 15 秒。Issue #7379 里记录的时间和日志,都和“正常执行过重试”对不上。

这让我换了一个方向:会不会不是循环没写好,而是它根本没有收到“安装失败”这个信号?

写了重试,不代表失败真的会进入重试h2

问题就在那条看起来很普通的命令里:

Terminal window
curl -sfL https://get.k3s.io | sh -

我原来很容易把它理解成“下载脚本,然后执行脚本”,所以直觉上下载失败,整条命令也应该失败。

但没有开启 pipefail 时,Bash 默认取 pipeline 最后一个命令的退出状态。如果 curl 下载失败,没有把脚本送给后面的 sh -,sh 读到空输入,什么也没执行,却可以正常退出。

结果就是:前面的 curl 失败,后面的 sh 返回 0,整条 pipeline 也返回 0。if install_k3s 看到的是成功,于是直接 break,直到后面的 cat 才发现 kubeconfig 根本不存在。

图 1 · 同样一次下载失败,退出状态决定了重试能不能接住它
下载失败的状态传递对比 curl 下载失败且没有脚本输出,sh 读取空输入后返回零。未启用 pipefail 时,pipeline 返回零,重试误判为安装成功;启用 pipefail 后,pipeline 返回非零,安装尝试判为失败,进入重试或在次数耗尽后退出。 curl 失败,没有输出脚本 sh - 读到空输入,仍可返回 0 没有 pipefail 开启 pipefail pipeline 返回 0 只看到最后的 sh 成功 pipeline 返回非零 下载失败被传递出来 误判成功,提前 break 本次尝试失败 最后读取 kubeconfig 才报错 进入重试 次数耗尽时明确退出
这里展示下载失败且没有脚本输出的路径。小屏可在图内左右滑动。

这里也不是只补一个 set -e 就能解决。原来的 step 已经用 bash -e 运行,安装函数却处在 if 的条件判断里;我需要让 pipeline 正确返回失败,再由重试逻辑处理,而不是期待某个 Shell 选项替我判断“安装到底有没有完成”。

还有一个让排查更难的细节:原来的 curl -sfL 用了 -s,错误信息也被静默了。改成 curl -sSfL,加上的 -S 才能让错误继续出现在日志里。

那次日志没有留下具体的 curl 错误,所以我没法再倒推出究竟是哪一种下载故障。但“下载失败被误判为成功”这条路径,后来可以用离线测试稳定复现。

我这才意识到,日志最后报错的位置,不一定就是问题最早发生的位置。失败可能已经发生了一会儿,只是没有被正确传下去。

退出码是信号,安装结果才是成功条件h2

开启 pipefail 后,下载失败终于能进入重试。但我继续想了一步:如果安装脚本返回 0,却没有生成 kubeconfig 呢?循环还是会提前结束,最后仍然读不到文件。

所以这里的成功条件不能只有“命令返回了成功”,还要检查这一步实际需要的结果。最终判断是:

Terminal window
if install_k3s && [[ -s "${kubeconfig_file}" ]]; then
break
fi

[[ -s file ]] 检查文件存在且非空。两边都满足,才结束重试;否则最多尝试三次,前两次分别等待 15、30 秒,最后明确报错退出。

图 2 · 重试判断的,不只是命令有没有返回 0
k3s 安装的成功条件与重试 执行安装后,同时检查安装命令成功和 kubeconfig 文件存在且非空。满足条件则复制 kubeconfig 并结束安装脚本。否则判断尝试次数,未满三次时等待十五或三十秒再试,第三次仍失败则返回非零。 执行 install_k3s pipefail 传递失败,curl -S 留下错误 安装成功,而且 kubeconfig 非空? install_k3s && [[ -s file ]] 是 结束重试 复制配置 install -m 600 否 已经是第三次尝试? 是 否 记录失败,等待后再试 第一次 15 秒 / 第二次 30 秒 记录最终失败 exit 1,不再复制配置 只在前两次失败后等待,最多执行三次安装。

最终的改动没有继续堆在 workflow 的 run: 里,而是抽成 .github/scripts/install-k3s.sh。workflow 调用脚本,脚本负责明确的成功判断、重试和失败日志。

另外,原来会把 kubeconfig 直接打印到 CI 日志,再用 cp 复制到用户目录。这次也去掉了打印,改用 install -m 600 写到 job 用户的 .kube/config。后续命令仍然能读取,不需要把配置内容留在日志里。

以前我写这类脚本,容易把“命令执行完了”当作“事情完成了”。这次让我开始多问一句:这一步完成以后,下一步需要的东西,真的已经有了吗?

让失败稳定发生,比等一次绿灯更有用h2

修复方向有了,但怎么确认它真的解决了问题?我没有重跑权限,网络失败也不是想遇到就能遇到。即使下一次 CI 变绿,也不等于下载失败这条分支被验证过。

后来我没有继续等网络出错,而是在临时目录里放了一个假的 curl,把这个目录排到 PATH 最前面。安装脚本调用的名字还是 curl,实际执行的却是测试准备好的 stub。

想模拟下载失败,就让它直接返回非零,并且不输出安装脚本:

#!/bin/sh
# 模拟 curl 下载失败的最小片段,不访问网络
exit 22

想模拟“安装器成功,但没有产生 kubeconfig”,就让假的 curl 输出一段只会 exit 0 的安装脚本。这样,下载和执行可以看起来都成功,但我故意不提供安装结果。

原来难以等到的故障,就变成了我每次运行都能制造的条件。再把相同输入交给旧逻辑和修复后的逻辑,差别就不再是猜测了。

最后的 .github/scripts/install-k3s-test.sh 覆盖了四种情况:

测试输入安装尝试次数等待次数预期结果
curl 下载失败32明确失败,不复制配置
安装器返回成功,但没有 kubeconfig32明确失败,不复制配置
安装器生成空的 kubeconfig32明确失败,不复制配置
安装器成功,并生成非空 kubeconfig10成功,复制配置

测试里的 sleep 也换成了 stub,记录调用而不真的等待,所以不需要每个失败场景都花 45 秒。成功场景还检查复制后的内容和 600 权限,失败场景则检查最终报错,以及没有写出目标配置。

这是我第一次比较认真地模拟 CI 故障。让我印象深的不是 stub 写得多复杂,而是思路变了:不再等一次运行替我证明结果,而是自己控制输入,看看失败会不会按预期被处理。

把 workflow 拆开,CI 就没那么像黑盒了h2

排查过程中,我原来闲置的阿里云开发机也派上了用场。我开始把它当作自己的 Linux 测试环境,用来跑从 workflow 里拆出来的 Shell 命令,模拟 CI 的执行过程。

以前遇到这类问题,很容易变成:改一点,push,等 GitHub Actions,再看结果。等待久倒还不是最烦的,主要是有时等完了,还是不知道自己的判断对不对。

把脚本拿到自己能控制的环境里以后,我就可以先看退出码、准备缺失文件的场景、确认重试次数,再提交修改。不需要每调整一处判断,都等整个项目重新跑一遍。

我没有把自己的服务器当成 GitHub Runner 的完整替代品。它对这次排查最有用的地方,是让我能把一个具体的失败条件单独拿出来,反复看它怎么执行。

当我开始这样读 workflow,CI 就没以前那么神秘了。至少其中的 run: 不再只是页面上的一段配置,而是一组我也能拿出来运行、检查和测试的命令。

准备好证据,再去找维护者讨论h2

还有一件事,我自己挺想保留下来:这次是我第一次主动联系 ShenYu 的维护者讨论 CI 问题。

当时我已经有了初步判断,但没有重跑权限。我犹豫了一阵,还是把失败的位置、为什么怀疑 k3s 下载,以及目前还不能确定的地方简单说明了一下,想确认这种环境问题能不能单独提出来处理。

对方回复的大意是,如果是 k3s 环境的问题,可以发出来;有其他明确的问题,也可以单独提 PR。后来还问我是在读书还是已经工作了,我说自己还是学生。他也鼓励我继续在社区里多看看、多处理一些问题。

对别人来说,这可能只是很普通的一次交流。但对当时的我,它确实减轻了不少心理压力。以前想到找维护者,我会先担心:自己是不是懂得太少,问题是不是太小,会不会判断错了,又会不会打扰别人。

这次让我发现,正常讨论一个工程问题,并不需要先把整个项目都摸透。先自己查过,把日志、判断和能复现的部分准备好,再把不确定的地方说清楚,就已经比一句“CI 挂了”更容易继续讨论。

后来 Aias00 的 review 也明确认可了这个方向:把安装器抽出来,加上非空 kubeconfig 判断和专门的重试测试,让原本隐含在 workflow 里的行为变得可以验证。

我开始觉得,证据不仅是用来证明自己没改错,也能让别人更快理解问题,和我一起把事情往前推进。

看 CI 绿灯,也要看哪些步骤真正跑过h2

这次还有一个容易忽略的地方。PR 修改的是 .github/**,相关 workflow 会启动,新增的离线测试也会执行,但真实的 Install k8s 仍受 path filter 和 case resolver 控制。

也就是说,离线测试通过,说明这些失败输入下的重试行为符合预期;真实下载和安装是否执行过,还要看 run_k8s_examples 的输出,不能只看页面最上面的绿勾。

图 3 · 离线重试测试与真实安装,是两条不同的验证路径
离线测试与真实安装的执行范围 同一 workflow 中,离线重试测试不受 k8s case 条件限制,使用 curl 和 sleep stub 验证四个场景。真实安装受路径过滤和 case resolver 控制,run_k8s_examples 为 true 才安装。只修改 .github 的本次 PR 执行离线测试,但跳过真实安装。 离线重试测试 install-k3s-test.sh 不受 k8s case 条件限制 真实 Install k8s install-k3s.sh 受路径与 case resolver 控制 curl / sleep stub 四种输入,不需要真实网络 run_k8s_examples == true 满足条件才下载与安装 k3s 本次 PR 执行了 验证失败处理与成功路径 本次 PR 跳过了 只修改 .github/** 不触发安装

我把这个范围也写进了 PR 描述。它让我养成了一个更具体的检查习惯:不只看任务是否通过,也看看自己关心的那一步,到底是执行了,还是被跳过了。

从等人重跑,到自己解释它为什么失败h2

如果只看最终修改,这个 PR 是三个文件:安装脚本、离线测试脚本,以及调用它们的 workflow。它没有改 Java 业务逻辑,但排查过程中,我对“CI 配置”的看法确实变了一点。

以前 .github/workflows/*.yml 更像项目附带的配置。现在我会留意,它里面一样有分支、退出状态、成功条件和外部依赖,也会有“代码看起来写了,实际上却没有生效”的问题。

这次最开始,我只是希望有人能帮我再跑一次。后来我能说清楚:失败停在哪里,为什么怀疑 pipeline,怎样不依赖真实网络复现,以及修复以后要检查什么。

对我来说,这种变化比“又合并了一个 PR”更值得记下来。没有重跑权限时,我也不一定只能等;先把能控制的部分拿出来,准备好证据,再去讨论剩下的问题,事情就能继续往前走。

以后看到 CI 红灯,我还是会检查自己的代码,但会先问一句:它到底失败在哪一层? 如果重新跑一次就绿了,我也想再弄清楚,第一次失败的信号为什么没有早点出现在日志里。

这次算是我真正开始接触 CI 工程的起点。不是一下子懂了所有 workflow,而是终于开始把它当成一段自己也需要读懂、测试和维护的程序。

相关链接h2