简

維護一個 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