os.availableParallelism() 不讀 cgroup 回傳宿主機核心數

概念(gotcha):Node 的 os.availableParallelism()(及 os.cpus().length不讀 cgroup CPU quota。在容器/k8s pod 裡,它回傳的是宿主機(node)的核心數,而不是這個 container 實際被分到的份量。

為什麼會咬人

很多工具用它當「預設併發數」——vitest(pool=forks)、Jest、Jest workers、piscina、各種 thread pool。若 runner node 是 32/64 core、pod 只分到 12 核,這些工具就會 fork 3264 個 process 去搶那 1~2 核 → 超賣(oversubscription):每個 process 的 CPU 時間沒變,但 wall clock 被 context switch 拉長,表現成「隨機變慢/隨機 timeout」。

怎麼確認

在容器內比對兩個數字,落差大就是超賣:

node -p "require('os').availableParallelism()"      # 讀到宿主機核心數
cat /sys/fs/cgroup/cpu.max                           # cgroup v2 的 quota(如 "200000 100000" = 2 核)
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us              # cgroup v1

怎麼處理

  • 明確釘併發數,別用預設:vitest --maxWorkers=N / poolOptions.forks.maxForks、jest --maxWorkers/--runInBand
  • 有能力就宣告 pod 的 KUBERNETES_CPU_REQUEST/LIMIT,讓 N 對齊真實配額;但釘配額和釘 worker 數要一起做(只設 limit 不釘 worker 會撞 cgroup 硬 quota 更糟)。
  • Node 22+ 可用 --cpu-count 或部分 runtime 會讀 cgroup,但不要依賴,明確指定最保險。

出處與一個反例

這個 gotcha 本身永遠成立,但不代表每次 flaky 都是它。CarbonX souffle CI flaky 案例(負載型 flaky test:CI 測試隨機撞 testTimeout)一開始就是拿它當假說,實測卻發現 pod 只看到 8 核(availableParallelism=8)且沒有任何 CPU quota(cgroup v2 root)——所以那次的超賣說被推翻,真因是 node 層級鄰居競爭。教訓:這條要靠 availableParallelism vs cgroup quota 的實測落差來證實,不能只憑「它不讀 cgroup」就下結論。