一台雲端 Mac 昨晚還能順利完成建置,今天卻可能因磁碟剩餘空間不足、Xcode 選取路徑遭到變更、模擬器狀態殘留,或子行程僵死,而在接下下一項任務後才失敗。此時流水線已經占用執行槽位,真正的錯誤還可能淹沒在相依套件安裝、編譯輸出與重試記錄之中。更穩妥的做法,是在排程器分派任務前設置准入門檻:檢查通過後才接收任務;檢查失敗則退出佇列,並保存當下的狀態快照。
把健康檢查放在任務分派之前
准入檢查應該簡短、結果明確,而且不產生副作用。它不是完整的系統巡檢,也不負責在檢查途中「順便修復」機器。建議只回答以下五個問題:
- 開發工具路徑是否存在,
xcodebuild能否啟動。 - 工作卷宗是否有足夠的可用空間。
- 記憶體壓力是否已經會影響新任務。
- 模擬器服務能否傳回裝置與執行階段清單。
- 上一項任務是否留下受管行程或任務鎖。
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 -rf、killall,也不要自動切換 Xcode。准入階段的職責是做出判定,而不是改變現場狀態。如此一來,即使門檻本身設定錯誤,也不會進一步擴大影響。
固定退出碼的語意
建議將 0 定義為可接收任務,20 定義為環境不符合要求,30 則表示檢查器本身無法完成。排程器收到 20 時,應將 Runner 標記為暫停;收到 30 時,則應回報健康檢查基礎設施故障。不要讓所有異常都回傳 1,否則將無法區分節點故障與指令碼錯誤。
檢查磁碟與記憶體,但不要邊檢查邊清理
磁碟門檻應涵蓋單次任務在原始碼、相依套件、DerivedData、封存檔與暫存檔方面的空間峰值。可以根據過往成功任務的峰值反推所需容量,再預留足夠的回復空間。檢查時也不能只查看根卷宗;如果工作區、快取或模擬器資料位於其他 APFS 卷宗,應逐一執行 df -Pk。
記憶體檢查不應只讀取「可用記憶體」。macOS 會主動使用記憶體作為檔案快取,因此持續性的記憶體壓力與交換活動更值得關注。輕量級門檻可以保存 memory_pressure 與 vm_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 推論或大型連結任務,還應把統一記憶體需求寫入對應佇列的獨立策略,而不是讓所有任務共用同一個門檻值。
識別殘留任務,而不是誤殺系統行程
殘留行程檢測必須限定行程歸屬。只依 xcodebuild、swift 或 Simulator 等名稱進行全域終止,可能會誤殺互動式工作階段,或其他執行器正在處理的任務。更安全的方式,是讓每項任務建立一個包含 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 標記為「檢查中」,執行指令碼,再以不可分割的方式切換為「可接收任務」或「隔離」。如果代理程式無法提供不可分割的狀態切換,可以先暫停接收任務,完成檢查後再恢復,避免檢查期間有新任務插入。
復原動作應按風險分層:
- 存在舊鎖但 PID 不存在:封存鎖定檔後解除阻塞。
- 可重建目錄超出容量預算:確認沒有執行中的任務後,再依目錄歸屬進行清理。
- 模擬器服務沒有回應:保存診斷資料,再執行團隊核准的服務復原流程。
- Xcode 路徑錯誤或磁碟狀態異常:維持隔離,交由人工確認。
- 同一項故障連續發生:停止自動復原,保留最近數次快照以比較差異。
最後,還要對門檻本身進行一次故障演練:暫時調高最低磁碟門檻,確認 Runner 會退出佇列;恢復原有門檻後,再確認它會重新進入可接收任務的狀態。接著建立一個舊鎖定檔,驗證系統只會隔離節點,而不會誤殺行程。健康檢查的價值不在於涵蓋所有故障,而在於讓故障發生在任務分派之前,並留下足夠清楚的後續處理依據。
常見問題
每次建置前都要執行完整健康檢查嗎?
接任務前執行低成本檢查即可;耗時較長的完整診斷可定期執行,或在准入失敗後再啟動。
磁碟不足時可以直接刪除全部 DerivedData 嗎?
不建議。應先停止 Runner 接收工作,再依目錄擁有者與最後使用時間清理可重建資料,且不可影響仍在執行的建置。
檢查失敗後應自動重新啟動 Runner 嗎?
只有原因已知且復原動作具冪等性時才適合。反覆磁碟異常或 Xcode 路徑錯誤應保留證據並交由人工檢查。
為建置、測試與 MLX 推論選擇雲端 Mac
依實際任務選擇 M4、記憶體、儲存空間、節點與租用期限。每份租用方案皆對應獨享實體機,並非虛擬機。