遠端團隊經常將雲端 Mac 上的 CI runner 視為長期在線的基礎設施,但 runner 本身同樣需要升級。最危險的做法,是在工作執行到一半時直接覆蓋程式目錄並重新啟動:編譯程序可能仍在使用舊檔案,排程程序卻已載入新元件,最終形成難以重現的混合狀態。更穩妥的方式,是先排空工作,再透過不可變版本目錄與原子符號連結完成切換。
先定義更新的安全邊界
一次可控的更新至少應滿足四個條件:不再領取新工作、目前工作自然結束、新舊版本可以並存,以及切換失敗後能立即回復。這裡所說的「排空」並不是清空工作區,也不是終止編譯,而是在工作邊界暫停 runner。
更新期間最重要的狀態不是「服務是否已啟動」,而是「舊工作是否已結束、新工作是否尚未進入」。
開始前,記錄目前版本、runner 程序 ID、正在執行的工作識別碼與工作目錄。若工作包含 Xcode 編譯,還要確認是否存在 xcodebuild、測試程序或封存匯出子程序。不要只看頂層 runner 是否閒置,因為它退出後,錯誤的指令碼仍可能讓子程序留在背景執行。
建議將升級判定寫成明確規則:目前工作最長允許執行多久、等待多久後轉由人工檢查,以及哪些冒煙測試通過後才能恢復接單。這些規則應由團隊依專案耗時制定,而不是等到故障發生時才臨時決定。
拆分程式、設定與工作區
runner 程式應依版本放入獨立目錄,長期設定與建置工作區則放在版本目錄之外。簡單的目錄配置如下:
/opt/ci-runner/
├── current -> releases/runner-current
├── previous-target
├── drain
├── active.pid
├── config/
├── work/
└── releases/
├── runner-old/
└── runner-current/
config 儲存註冊資訊與非公開設定,權限應限制為僅供 runner 使用者存取;work 儲存簽出目錄與暫時建置內容;releases 只存放程式。不要將權杖複製到每個版本目錄,也不要讓更新套件覆蓋工作區。
完成拆分後,升級只需變更 current 的指向。舊版本仍保持完整,回復時不必重新下載或安裝。清理舊版本也應延後到觀察期結束,並至少保留最近一個已通過驗證的版本。
使用 wrapper 在工作邊界排空
最容易控制的模型,是讓 runner 每次只執行一個工作,退出後再由 wrapper 決定是否領取下一項。不同 runner 的單次工作參數可能不同,先將實際啟動命令封裝為 $RUNNER_CMD,再使用統一迴圈:
#!/bin/zsh
set -u
root=/opt/ci-runner
lock="$root/wrapper.lock"
mkdir "$lock" 2>/dev/null || exit 1
cleanup() {
rm -f "$root/active.pid"
rmdir "$lock" 2>/dev/null
}
trap cleanup EXIT INT TERM
while [[ ! -e "$root/drain" ]]; do
"$root/current/bin/$RUNNER_CMD" run --once &
child=$!
print -r -- "$child" > "$root/active.pid"
if wait "$child"; then
status=0
else
status=$?
fi
rm -f "$root/active.pid"
if (( status != 0 )); then
sleep 10
fi
done
準備更新時執行 touch /opt/ci-runner/drain。wrapper 會允許目前的子程序完成,但不會啟動下一輪。接著讀取 active.pid,使用 kill -0 PID 判斷程序是否仍然存在。PID 檔案為空不代表機器已閒置,還應檢查與該工作相關的編譯及測試子程序。
如果 runner 不支援完成單次工作後退出的模式,應使用其原生的暫停接單功能,並驗證暫停後不會預先擷取工作。若無法確認,先在隔離環境中模擬一個長時間工作,再觀察排空行為。
驗證更新套件並原子切換
將新版本解壓縮到暫存目錄,完成驗證後再重新命名並移入 releases。下載套件的校驗值應來自團隊信任的發布記錄,不能在同一個未經驗證的壓縮檔旁臨時產生。
root=/opt/ci-runner
release="$root/releases/runner-next"
archive=/tmp/runner-next.tar.gz
expected="$EXPECTED_SHA256"
actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
[[ "$actual" = "$expected" ]] || exit 2
mkdir -p "$release"
tar -xzf "$archive" -C "$release"
"$release/bin/$RUNNER_CMD" --version || exit 3
old="$(readlink "$root/current")"
print -r -- "$old" > "$root/previous-target"
ln -sfn "$release" "$root/current.next"
mv -fh "$root/current.next" "$root/current"
切換符號連結前,應確認新目錄中的程式可以執行、架構正確,並且能讀取外置設定。切換動作本身應盡量簡短,不要將解壓縮、相依套件安裝或網路下載放進臨界區。若更新還需要遷移設定,應先複製到暫存檔案,驗證格式後再進行原子替換。
避免權限隨解壓縮漂移
壓縮套件可能帶有不同的擁有者或權限。切換前,檢查程式檔案不可由 runner 使用者以外的帳號修改;敏感設定通常應設為 600,設定目錄通常應設為 700。同時確認工作目錄仍由原 runner 使用者擁有,避免新版首次執行時才發現無法寫入。
冒煙驗收與快速回復
切換後先不要刪除 drain。手動啟動一次受控冒煙工作,至少驗證:runner 能讀取設定、能建立暫時工作區、能呼叫開發工具、能完成一次最小建置、能儲存產物,並能正確清理及退出。
檢查日誌時應特別留意路徑。新程序應從 current 指向的新目錄啟動,但快取、設定與工作區仍應位於固定路徑。若日誌中混用舊版與新版路徑,表示仍有舊程序尚未退出,此時不能恢復接單。
失敗時,從 previous-target 讀取舊符號連結的目標,使用相同的暫存符號連結方式切回,再重新執行最小工作。不要直接在新版目錄中一邊修改一邊測試,這會破壞不可變發布的前提。確認舊版本已恢復後,再刪除失敗版本並重新製作發布套件。
驗收通過後執行 rm -f /opt/ci-runner/drain,由程序管理器重新啟動 wrapper。最後至少觀察一個真實工作的領取、編譯、產物封存與退出狀態,並將版本號、切換結果和回復目標寫入更新記錄。至此更新才算完成,而不是看到 runner 顯示在線就提前結束作業。
常見問題
為什麼不應直接覆蓋原本的 CI runner 目錄?
原地覆蓋可能讓執行中的工作同時讀取新舊檔案,也會失去可靠的回復基準。獨立版本目錄能讓切換與清理更可控。
排空 runner 時要終止正在執行的建置嗎?
不用。排空只應阻止下一項工作進入,當前子程序應自然完成;若超過既定時限,先保留日誌與程序狀態,再決定是否人工處理。
版本切換後至少要檢查什麼?
應檢查版本、註冊狀態、工作區寫入、最小建置、產物封存及敏感設定權限;任何一項失敗,都先停止接收工作並回復舊版。
需要一台獨享 Apple Silicon 建置機?
按日、週、月或季租用獨享實體節點,用於 Xcode 建置、iOS CI、遠端開發與自動化工作。