當同一筆提交同時被 Webhook 重試、人工重新執行和排程任務觸發時,兩個 Xcode 建置可能在幾秒內進入同一個工作區。它們不一定會立刻失敗,更常見的情況是同時改寫 DerivedData、覆蓋封存目錄,最後造成偶發且難以重現的錯誤。雲端 Mac 長期保持上線後,這類重複觸發比單次命令失敗更值得提前處理。
先確認真正需要互斥的資源
不要一開始就為整個節點加上全域鎖。鎖定範圍應由共用寫入目標決定,而不是由儲存庫名稱決定。只要兩個任務共用下列任一資源,就應使用相同的鎖定鍵:
- 同一工作區中的產生檔案或相依套件目錄;
- 同一個 DerivedData 路徑;
- 同一個封存、匯出或上傳目錄;
- 只能依序存取的模擬器裝置集;
- 同一個最終發布目標。
反過來,如果測試分片各自擁有獨立的 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;節點重新啟動後,舊檔案中的數字也可能對應到完全無關的新程序。因此,清理程式至少必須綜合檢查以下三項:
- 心跳修改時間是否已超過管線的最長執行時間;
- 任務記錄是否已被佇列標記為結束或逾時;
- 受保護目錄是否仍由相關建置程序寫入。
心跳過舊只能表示「需要調查」,不能直接表示「可以搶占鎖」。如果建置因磁碟阻塞而暫停,程序可能仍然持有檔案;此時強制啟動第二個建置,會進一步擴大損壞範圍。更穩妥的做法是讓獨立的回收任務執行清理,而不是讓正在等待鎖的任務自行刪除前一個任務的狀態。
為鎖加入可觀測欄位
owner 檔案可以繼續記錄提交雜湊、任務編號、Scheme 和工作目錄,但不要寫入權杖、簽署資料或其他敏感值。失敗日誌至少應輸出鎖定鍵、開始時間和任務編號,讓工程師可以判斷這是正常排隊,還是上一次執行異常結束。
分開處理等待、逾時與取消
互斥不代表所有較晚抵達的任務都必須失敗。實際的佇列通常有三種策略:
- 發布封存:保留先到的任務,後續任務等待;
- 分支驗證:取消同一分支的舊任務,保留最新提交;
- 排程建置:發現相同鎖定鍵的任務正在執行時,就跳過本輪。
無論採用哪一種策略,都應為等待設定上限。等待逾時只能結束等待者,不能刪除持有者的鎖。取消持有者時,則應先傳送正常終止訊號,讓 trap 完成清理;只有在程序無法結束時,才進入人工或獨立回收流程。
也要避免在持有鎖時執行無關工作。程式碼下載、唯讀驗證和參數準備都可以放在加鎖前,等到真正會修改共用目錄的階段再取得鎖。這樣不僅能縮短臨界區,也能減少佇列壅塞。
上線前的故障演練
先在非發布任務中連續觸發兩次指令碼,確認只有一個任務進入 xcodebuild。接著分別模擬正常完成、命令失敗和收到終止訊號,檢查鎖定目錄是否已刪除。最後手動保留一個舊鎖,驗證等待中的任務只會回報鎖已被占用,而不會擅自清理。
驗收時可以依照以下清單執行:
- 鎖定鍵能準確對應共用資源;
- 取得鎖只依賴一次原子操作;
- 解鎖前會驗證隨機擁有者權杖;
EXIT、INT、TERM和HUP都有清理路徑;- 心跳只用於診斷,不會單獨觸發搶占鎖;
- 日誌能定位占用鎖的任務,但不包含敏感內容;
- 等待逾時不會影響正在執行的建置;
- 獨立任務擁有不同的工作區和輸出目錄。
完成這些檢查後,重複觸發就會成為可解釋的排隊事件,而不再是隨機破壞建置狀態的隱蔽競態。後續若要提高並行度,應優先拆分共用目錄和發布目標,再細化鎖定鍵,而不是直接移除互斥保護。
常見問題
為什麼不能只靠 PID 檔案判斷建置是否仍在執行?
PID 可能被系統重複使用,檔案也可能留下不完整狀態。應以原子目錄競爭鎖,再用隨機擁有者權杖與心跳檔案輔助診斷。
所有 Xcode 建置都需要共用同一把全域鎖嗎?
不需要。只有共用工作區、DerivedData、封存目錄或發布目標的任務需要互斥;資源完全隔離的分片可以使用不同鎖鍵並行。
心跳停止後可以立即刪除鎖目錄嗎?
不可以。必須先確認任務已超過流水線逾時,且沒有相關程序繼續寫入目標目錄,再由獨立回收步驟移除鎖。
選擇獨享實體 Mac 節點,滿足建置、開發與實驗需求
比較兩種 Mac mini M4 配置、四種租期與五個可訂購節點,依照實際工作流程做出合適選擇。