雲端 Mac CI Runner 接任務前的健康檢查與准入門檻

雲端 Mac CI Runner 接任務前的健康檢查與准入門檻

一台雲端 Mac 昨晚還能順利完成建置,今天卻可能因磁碟剩餘空間不足、Xcode 選取路徑遭到變更、模擬器狀態殘留,或子行程僵死,而在接下下一項任務後才失敗。此時流水線已經占用執行槽位,真正的錯誤還可能淹沒在相依套件安裝、編譯輸出與重試記錄之中。更穩妥的做法,是在排程器分派任務前設置准入門檻:檢查通過後才接收任務;檢查失敗則退出佇列,並保存當下的狀態快照。

把健康檢查放在任務分派之前

准入檢查應該簡短、結果明確,而且不產生副作用。它不是完整的系統巡檢,也不負責在檢查途中「順便修復」機器。建議只回答以下五個問題:

  1. 開發工具路徑是否存在,xcodebuild 能否啟動。
  2. 工作卷宗是否有足夠的可用空間。
  3. 記憶體壓力是否已經會影響新任務。
  4. 模擬器服務能否傳回裝置與執行階段清單。
  5. 上一項任務是否留下受管行程或任務鎖。

VMCache 上的 Runner 可以由自託管代理程式、定時排程程式,或團隊自行建置的佇列管理器控制。無論採用哪一種方式,門檻都應設在「接收任務」之前,而不是建置指令碼的第一個步驟。後者已經會被計為一次失敗任務,進而污染成功率與重試統計。

健康檢查失敗時,正確的處理方式是暫停接收任務並保存證據,而不是立即刪除快取、終止所有行程,或重新啟動整台機器。

先建立可稽核的檢查指令碼

下列指令碼只會讀取狀態,並以非零退出碼表示拒絕接收任務。門檻值應依專案規模調整,不要直接套用為所有 Runner 共用的固定常數。

#!/bin/zsh
set -u

WORK_VOLUME="${WORK_VOLUME:-/}"
MIN_FREE_GB="${MIN_FREE_GB:-40}"
LOCK_FILE="${RUNNER_LOCK_FILE:-/tmp/vmcache-ci-job.lock}"
failures=0

fail() {
  print -u2 "FAIL: $1"
  failures=$((failures + 1))
}

developer_dir="$(xcode-select -p 2>/dev/null || true)"
[[ -n "$developer_dir" && -d "$developer_dir" ]] \
  || fail "developer directory is unavailable"

if ! xcodebuild -version >/dev/null 2>&1; then
  fail "xcodebuild cannot start"
fi

free_kb="$(df -Pk "$WORK_VOLUME" | awk 'NR==2 {print $4}')"
if [[ -z "$free_kb" ]]; then
  fail "free disk space cannot be read"
else
  free_gb=$((free_kb / 1024 / 1024))
  (( free_gb >= MIN_FREE_GB )) \
    || fail "free disk space is below ${MIN_FREE_GB} GB"
fi

if [[ -e "$LOCK_FILE" ]]; then
  fail "a previous job lock still exists"
fi

if ! xcrun simctl list devices available >/dev/null 2>&1; then
  fail "simulator service is not responding"
fi

exit $((failures > 0 ? 20 : 0))

不要在指令碼中使用 sudo rm -rfkillall,也不要自動切換 Xcode。准入階段的職責是做出判定,而不是改變現場狀態。如此一來,即使門檻本身設定錯誤,也不會進一步擴大影響。

固定退出碼的語意

建議將 0 定義為可接收任務,20 定義為環境不符合要求,30 則表示檢查器本身無法完成。排程器收到 20 時,應將 Runner 標記為暫停;收到 30 時,則應回報健康檢查基礎設施故障。不要讓所有異常都回傳 1,否則將無法區分節點故障與指令碼錯誤。

檢查磁碟與記憶體,但不要邊檢查邊清理

磁碟門檻應涵蓋單次任務在原始碼、相依套件、DerivedData、封存檔與暫存檔方面的空間峰值。可以根據過往成功任務的峰值反推所需容量,再預留足夠的回復空間。檢查時也不能只查看根卷宗;如果工作區、快取或模擬器資料位於其他 APFS 卷宗,應逐一執行 df -Pk

記憶體檢查不應只讀取「可用記憶體」。macOS 會主動使用記憶體作為檔案快取,因此持續性的記憶體壓力與交換活動更值得關注。輕量級門檻可以保存 memory_pressurevm_stat 的輸出,再由監控端比較變化趨勢:

snapshot_dir="${RUNNER_STATE_DIR:-$HOME/ci-state}"
mkdir -p "$snapshot_dir"

{
  date -u "+%Y-%m-%dT%H:%M:%SZ"
  xcode-select -p
  xcodebuild -version
  df -h /
  memory_pressure
  vm_stat
} > "$snapshot_dir/preflight-latest.txt" 2>&1

不要根據單次頁面計數直接拒絕任務。更可靠的規則是先由門檻記錄狀態,只有在確認某項條件會導致目前工作負載失敗時,才阻止 Runner 接收任務。對於 MLX 推論或大型連結任務,還應把統一記憶體需求寫入對應佇列的獨立策略,而不是讓所有任務共用同一個門檻值。

識別殘留任務,而不是誤殺系統行程

殘留行程檢測必須限定行程歸屬。只依 xcodebuildswiftSimulator 等名稱進行全域終止,可能會誤殺互動式工作階段,或其他執行器正在處理的任務。更安全的方式,是讓每項任務建立一個包含 PID、任務編號與開始時間的鎖定檔,並在退出陷阱中刪除。

job_lock="${RUNNER_LOCK_FILE:-/tmp/vmcache-ci-job.lock}"

cleanup() {
  rm -f "$job_lock"
}

if ! ( set -o noclobber; print "$$ ${CI_JOB_ID:-unknown}" > "$job_lock" ) 2>/dev/null; then
  print -u2 "another managed job owns the runner"
  exit 20
fi

trap cleanup EXIT INT TERM

發現舊鎖後,應先確認其中的 PID 是否仍然存在,再核對行程的啟動時間與命令列。PID 可能被重複使用,不能只根據數字決定是否終止行程。如果行程已不存在,可以把鎖定檔連同檢查時間移至診斷目錄,再透過受控的復原步驟解除阻塞。

為模擬器檢查設定邊界

simctl list 成功只代表服務可以通訊,不表示特定測試目標可用。門檻負責驗證服務層;專案層則應在任務內透過明確的 destination 尋找目標裝置。不要在准入指令碼中自動建立、刪除或清除裝置,這些操作不僅耗時,也會改變後續測試的基準狀態。

接入排程器並設計復原路徑

排程器應先將 Runner 標記為「檢查中」,執行指令碼,再以不可分割的方式切換為「可接收任務」或「隔離」。如果代理程式無法提供不可分割的狀態切換,可以先暫停接收任務,完成檢查後再恢復,避免檢查期間有新任務插入。

復原動作應按風險分層:

  1. 存在舊鎖但 PID 不存在:封存鎖定檔後解除阻塞。
  2. 可重建目錄超出容量預算:確認沒有執行中的任務後,再依目錄歸屬進行清理。
  3. 模擬器服務沒有回應:保存診斷資料,再執行團隊核准的服務復原流程。
  4. Xcode 路徑錯誤或磁碟狀態異常:維持隔離,交由人工確認。
  5. 同一項故障連續發生:停止自動復原,保留最近數次快照以比較差異。

最後,還要對門檻本身進行一次故障演練:暫時調高最低磁碟門檻,確認 Runner 會退出佇列;恢復原有門檻後,再確認它會重新進入可接收任務的狀態。接著建立一個舊鎖定檔,驗證系統只會隔離節點,而不會誤殺行程。健康檢查的價值不在於涵蓋所有故障,而在於讓故障發生在任務分派之前,並留下足夠清楚的後續處理依據。

常見問題

每次建置前都要執行完整健康檢查嗎?

接任務前執行低成本檢查即可;耗時較長的完整診斷可定期執行,或在准入失敗後再啟動。

磁碟不足時可以直接刪除全部 DerivedData 嗎?

不建議。應先停止 Runner 接收工作,再依目錄擁有者與最後使用時間清理可重建資料,且不可影響仍在執行的建置。

檢查失敗後應自動重新啟動 Runner 嗎?

只有原因已知且復原動作具冪等性時才適合。反覆磁碟異常或 Xcode 路徑錯誤應保留證據並交由人工檢查。

獨享實體節點

為建置、測試與 MLX 推論選擇雲端 Mac

依實際任務選擇 M4、記憶體、儲存空間、節點與租用期限。每份租用方案皆對應獨享實體機,並非虛擬機。

選擇租用方案