負載型 flaky test:CI 測試隨機撞 testTimeout

核心結論:CI 測試「隨機」timeout、本機全過、且不只一條 MR 中招 → 不是測試邏輯壞,是執行環境的 wall clock 被拉長(負載型 flaky)。但具體機制要靠診斷驗證,別靠猜——souffle 這次先猜「vitest worker 超賣(讀到宿主機核心數)」,實測卻推翻它:pod 根本沒有 CPU quota、零 throttling、只有 8 核。真正原因是 node 層級鄰居競爭 + CI 慢核心 + 自身 7 個 fork。修法(釘 --maxWorkers)仍有效,但生效原因跟一開始想的不同。

一、現象

2026-08-18 上午起 souffle MR pipeline test:unit 隨機 fail,簽章一致、無斷言錯誤:Error: Test timed out in 10000ms.

觀察內容
失敗集合浮動同一 commit 連跑兩次:15 檔/42 例/934s → 重跑剩 2 檔/2 例/447s,第一次掛的檔重跑全過
本機正常那兩檔本機 83 測試全過、26.8s
非單一 MR同時段別人的 MR 同簽章失敗;同日稍早仍 success

→ 三點合起來:邏輯沒壞,是環境 wall clock 被拉長。(大方向確定,但機制未定。)

二、初步推論(後來被推翻)

假說:vitest 4 預設 forks pool 用 os.availableParallelism() 決定 worker 數,而 os.availableParallelism() 不讀 cgroup 回傳宿主機核心數,在 k8s pod 會讀到宿主機的 32/64 核 → fork 幾十個 process 搶 1~2 核 → CPU 被 throttle → 撞 timeout。旁證:backend madeleine 在同批 runner 從沒中招(它把 worker 數釘死)。

假說很合理,但沒有實測數據。為了驗證,在 CI 埋了診斷輸出(見下)再改。

三、診斷驗證(實測推翻假說)

第一版診斷指令其實壞掉且靜默失敗(review subagent 抓到):YAML plain scalar 讓 \" 原樣傳給 shell,單引號內反斜線是字面值 → node SyntaxError,但 echo 仍 exit 0 → 綠燈卻印不出數字。拿掉多餘反斜線、並加 after_scriptcpu.stat(測試 timeout 時 script 會中斷,而那正是最需要看 throttling 的時候)。

修好後跑出來(job 203913):

[diag] availableParallelism=8 nproc=8
[diag] cpu.max=n/a cpu.weight=n/a
[diag] cpu.stat: nr_periods 0  nr_throttled 0  throttled_usec 0
Duration 613.27s,265 檔/2517 測試全過,timeout 0 次

逐條打臉假說:

  • 只有 8 核,不是 32/64 → 預設 7 forks,只是設定值(2)的 3.5 倍,不是 16~32 倍。「宿主機核心數外洩」不成立。
  • cpu.max/cpu.weight 讀不到、cpu.stat 讀得到 = cgroup v2 root cgroup 特徵 → pod 身上根本沒套任何 CPU quota
  • nr_throttled=0 → 完全沒有 CFS throttling。最強的訊號測到的是零。

四、真正的根因

沒有 quota → 就沒有 throttling 計數器可看,這反指向 node 層級的鄰居競爭:同 node 其他 job 搶 CPU 時,我們的 pod 只是「分到的時間片變少」,這在任何計數器上留不下痕跡(throttling 只計 quota 造成的,不計排隊等到的)。加上:

  • CI 單核比本機慢 ~2.7 倍(同 --maxWorkers=2:本機 228s vs CI 613s)
  • 預設 7 個 fork 自己人搶自己人
CI 慢核心 + 7 forks 自身競爭 + node 上鄰居 job 競爭(不可見、不可測)
  → 個別測試 wall clock 被拉長超過 10s
  → 鄰居忙 → 934s + 42 timeout;鄰居閒 → 447s 幾乎全過(解釋浮動)

「負載型 flaky」大方向對,但機制是 node 競爭,不是 worker 超賣

五、為什麼釘 worker 仍然有效

testTimeout 算的是 wall clock 不是 CPU 時間。一個測試的 CPU 工作量固定(如 300ms),worker 開太多時每個只分到一小片核,那 300ms 被 context switch 切散、牆上時間膨脹到撞 10s。把 worker 從 7 降到 2,消掉「自身造成」的那份競爭,每個測試拿到的連續 CPU 變多、wall clock 縮短 → 不再撞 timeout。它沒有修掉 node 層級那份(那個我們看不到也控制不了)。結果:613s、0 timeout,落在歷史區間(447~934s)偏慢端。

注意:worker 少反而更省總 CPU(本機 800→693 CPU 秒),因為每個 fork 都要各自 import 一次 server.deps.inline 進來的 @chakra-ui/@zag-js,worker 越多重複越多。

六、修了什麼(MR !1194,兩個 commit)

# package.json
-  "test": "vitest run --coverage",
+  "test": "vitest run --maxWorkers=50%",   # 本機 DX:CPU 讓出 2 核(另一 commit)
+  "test:ci": "vitest run --maxWorkers=2",  # CI 釘死
  • .gitlab-ci.ymltest:unit 改跑 test:ci + 診斷輸出 + after_scriptcpu.stat;coverage 移到 main-only、allow_failure: truetest:coverage job。
  • review subagent 另抓到:新增的 test:coverage 落在 test stage(排在 build/deploy 前),若不加 allow_failure,一個 flaky timeout 會擋掉 main 的 dev 部署。
  • 本機 DX:實測記憶體不是瓶頸(2.9GB/16GB=18%),CPU 才是(978%)→ 降到 50%(6 worker)只慢 9%(81.9→89.6s),換回 35% 記憶體 + 2 核。與 CI 票拆成兩個 commit。

七、刻意不做

  • 不調高 testTimeout:純 jsdom 單測 10s 綽綽有餘,調高只把「浮動紅燈」換成「浮動慢」。
  • 不設 KUBERNETES_CPU_*:runner 不開放覆寫;且只設 limit 不釘 worker 更糟(憑空多一道 cgroup 硬牆)。診斷證明 pod 連 CPU request 都沒有 → 這是跟 DevOps 要資源的籌碼
  • 不加 retry:會遮真正的 flaky 測試。
  • 不做分片(決策因診斷而轉向):原本想「先止血再分片」;但既然機制是 node 競爭,4 個 shard pod 若被排到同一台 node,就把鄰居變成自己人,可能更糟。改成先觀察幾天——duration 穩定在 613s 才值得分片;若持續在 400900s 跳,該回去找 DevOps 要 CPU request 而非分片。

八、可複用心法

  1. 診斷訊號:隨機 timeout + 本機過 + 非單一 MR → 先懷疑環境 wall clock,不是測試邏輯。
  2. 合理的假說 ≠ 已證實:先埋便宜的診斷再改。cpu.statnr_throttled/throttled_usec 一個 cat 就能分辨「quota throttling」與「node 競爭」——兩者都長得像「負載型 flaky」,但修法不同。
  3. cgroup v2 root cgroup 特徵cpu.max/cpu.weight 缺席但 cpu.stat 可讀 = pod 沒套任何 quota。
  4. testTimeout 算 wall clock:釘 worker≈實際可用核心能縮短牆上時間;worker 太多是自我稀釋。
  5. 診斷指令自己要先驗對:YAML plain scalar + 巢狀 shell quoting 很容易讓 echo …$(…) 靜默失敗還回綠燈。
  6. 跨 repo 對照是強旁證(同 runner 下 backend 釘 worker 就免疫)。

相關