SureVM 工程筆記

雲端 Mac CI 原子任務鎖與重複建置防護

雲端 Mac CI 原子任務鎖與重複建置防護

當同一筆提交同時被 Webhook 重試、人工重新執行和排程任務觸發時,兩個 Xcode 建置可能在幾秒內進入同一個工作區。它們不一定會立刻失敗,更常見的情況是同時改寫 DerivedData、覆蓋封存目錄,最後造成偶發且難以重現的錯誤。雲端 Mac 長期保持上線後,這類重複觸發比單次命令失敗更值得提前處理。

先確認真正需要互斥的資源

不要一開始就為整個節點加上全域鎖。鎖定範圍應由共用寫入目標決定,而不是由儲存庫名稱決定。只要兩個任務共用下列任一資源,就應使用相同的鎖定鍵:

反過來,如果測試分片各自擁有獨立的 checkout、DerivedData 和模擬器裝置集,就能使用不同的鎖定鍵平行執行。建議將鎖定鍵設計成「儲存庫、Scheme、動作」的組合,例如 client-release-archive,不要直接包含分支名稱,否則兩個分支仍可能爭用同一個封存目標。

情境 建議的鎖定粒度 是否允許平行執行
共用 DerivedData 的封存 儲存庫 + Scheme + archive
使用獨立裝置集的測試分片 儲存庫 + shard 編號
上傳同名發布成品 應用程式 + 發布通道
唯讀靜態檢查 通常不需要鎖

鎖保護的是共用副作用,而不是 CPU。為了提高並行度而繞過必要的鎖,通常只會把等待時間變成清理時間。

使用 mkdir 建立原子競爭點

macOS 內建的 mkdir 可以作為簡單可靠的競爭點:當多個程序同時建立相同路徑時,只有一個會成功。相較於「先檢查檔案是否存在,再寫入 PID」,這種做法不會產生檢查與寫入之間的競態視窗。

以下指令碼會為鎖建立隨機擁有者權杖,並每隔 15 秒更新一次心跳。結束時,只有權杖仍然相符的程序可以刪除鎖,避免舊任務誤刪新任務的鎖。

#!/bin/bash
set -euo pipefail

LOCK_ROOT="${HOME}/Library/Caches/ci-locks"
LOCK_KEY="client-release-archive"
LOCK_DIR="${LOCK_ROOT}/${LOCK_KEY}.lock"
TOKEN="$(uuidgen)"

mkdir -p "$LOCK_ROOT"

if ! mkdir "$LOCK_DIR" 2>/dev/null; then
  echo "build lock is already held"
  test -f "$LOCK_DIR/owner" && cat "$LOCK_DIR/owner"
  exit 75
fi

printf 'token=%s
pid=%s
started=%s
' \
  "$TOKEN" "$$" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$LOCK_DIR/owner"
touch "$LOCK_DIR/heartbeat"

heartbeat() {
  while true; do
    touch "$LOCK_DIR/heartbeat"
    sleep 15
  done
}

heartbeat &
HEARTBEAT_PID=$!

cleanup() {
  kill "$HEARTBEAT_PID" 2>/dev/null || true
  if grep -Fq "token=${TOKEN}" "$LOCK_DIR/owner" 2>/dev/null; then
    rm -rf "$LOCK_DIR"
  fi
}

trap cleanup EXIT
trap 'exit 130' INT TERM HUP

xcodebuild \
  -workspace Client.xcworkspace \
  -scheme Client \
  -configuration Release \
  -derivedDataPath "$PWD/.ci/DerivedData" \
  archive

結束碼 75 可以明確表示「任務目前無法執行」。佇列層可以延遲重試,但不應在同一秒內無限重播。所有路徑都必須加上引號,鎖定根目錄也應放在目前執行帳號可寫入、且不會被儲存庫清理命令刪除的位置。

不要只靠 PID 判斷是否逾期

PID 檔案適合用於診斷,卻不適合單獨用來證明所有權。macOS 可能重複使用 PID;節點重新啟動後,舊檔案中的數字也可能對應到完全無關的新程序。因此,清理程式至少必須綜合檢查以下三項:

  1. 心跳修改時間是否已超過管線的最長執行時間;
  2. 任務記錄是否已被佇列標記為結束或逾時;
  3. 受保護目錄是否仍由相關建置程序寫入。

心跳過舊只能表示「需要調查」,不能直接表示「可以搶占鎖」。如果建置因磁碟阻塞而暫停,程序可能仍然持有檔案;此時強制啟動第二個建置,會進一步擴大損壞範圍。更穩妥的做法是讓獨立的回收任務執行清理,而不是讓正在等待鎖的任務自行刪除前一個任務的狀態。

為鎖加入可觀測欄位

owner 檔案可以繼續記錄提交雜湊、任務編號、Scheme 和工作目錄,但不要寫入權杖、簽署資料或其他敏感值。失敗日誌至少應輸出鎖定鍵、開始時間和任務編號,讓工程師可以判斷這是正常排隊,還是上一次執行異常結束。

分開處理等待、逾時與取消

互斥不代表所有較晚抵達的任務都必須失敗。實際的佇列通常有三種策略:

無論採用哪一種策略,都應為等待設定上限。等待逾時只能結束等待者,不能刪除持有者的鎖。取消持有者時,則應先傳送正常終止訊號,讓 trap 完成清理;只有在程序無法結束時,才進入人工或獨立回收流程。

也要避免在持有鎖時執行無關工作。程式碼下載、唯讀驗證和參數準備都可以放在加鎖前,等到真正會修改共用目錄的階段再取得鎖。這樣不僅能縮短臨界區,也能減少佇列壅塞。

上線前的故障演練

先在非發布任務中連續觸發兩次指令碼,確認只有一個任務進入 xcodebuild。接著分別模擬正常完成、命令失敗和收到終止訊號,檢查鎖定目錄是否已刪除。最後手動保留一個舊鎖,驗證等待中的任務只會回報鎖已被占用,而不會擅自清理。

驗收時可以依照以下清單執行:

完成這些檢查後,重複觸發就會成為可解釋的排隊事件,而不再是隨機破壞建置狀態的隱蔽競態。後續若要提高並行度,應優先拆分共用目錄和發布目標,再細化鎖定鍵,而不是直接移除互斥保護。

常見問題

為什麼不能只靠 PID 檔案判斷建置是否仍在執行?

PID 可能被系統重複使用,檔案也可能留下不完整狀態。應以原子目錄競爭鎖,再用隨機擁有者權杖與心跳檔案輔助診斷。

所有 Xcode 建置都需要共用同一把全域鎖嗎?

不需要。只有共用工作區、DerivedData、封存目錄或發布目標的任務需要互斥;資源完全隔離的分片可以使用不同鎖鍵並行。

心跳停止後可以立即刪除鎖目錄嗎?

不可以。必須先確認任務已超過流水線逾時,且沒有相關程序繼續寫入目標目錄,再由獨立回收步驟移除鎖。

SureVM 雲端 Mac

選擇獨享實體 Mac 節點,滿足建置、開發與實驗需求

比較兩種 Mac mini M4 配置、四種租期與五個可訂購節點,依照實際工作流程做出合適選擇。

選擇租用方案