雲端 Mac CI 工作隔離:專用使用者、權限與 launchctl

雲端 Mac CI 工作隔離:專用使用者、權限與 launchctl

同一台雲端 Mac 連續執行多項建置工作時,最難察覺的問題往往不是編譯失敗,而是上一項工作留下的狀態讓下一項工作「碰巧成功」:快取中已有相依套件、背景服務仍占用連接埠、暫時憑證尚未清除,或圖形工作階段中殘留了使用者層級的代理程式。這類流水線短期內看似穩定,一旦換機器或調整工作順序,問題卻可能集中爆發。解決方法不是在結尾附加一條粗暴的刪除命令,而是先劃清執行身分、目錄、快取和工作階段的邊界,再逐一安排驗收。

先定義需要隔離的邊界

至少要檢查五類狀態:執行使用者、工作目錄、暫存目錄、快取,以及背景程序。CI 服務本身可以由系統層級的守護程序啟動,但實際建置不應長期使用管理者身分。更穩妥的方式是建立一個沒有管理權限的標準使用者,例如 ci_runner,並由它執行簽出、編譯和測試。

隔離的目的不是讓工作「看不到整台機器」,而是確保單次工作產生的可變狀態,無法在未經宣告的情況下影響下一項工作。

先記錄基準狀態,後續疑難排解時才有依據可供比對:

id
umask
printf 'HOME=%s\nTMPDIR=%s\n' "$HOME" "$TMPDIR"
launchctl print "gui/$(id -u)" >/tmp/launchctl-baseline.txt 2>&1 || true
ps -axo user,pid,ppid,command

如果 runner 需要執行少量具特殊權限的操作,應將命令集中至受控指令碼,並只為特定動作設定權限,避免讓整個建置程序直接以高權限執行。編譯指令碼也不應修改系統層級工具鏈、全域網路設定或其他使用者的目錄。

為每項工作建立私有工作區

工作目錄應以不可預測但可稽核的工作識別碼產生,並拒絕包含斜線、空格或控制字元的值。目錄權限設為 700,同時將暫存檔案置於工作目錄內,避免多個並行工作共用系統暫存路徑。

set -eu

case "${RUN_ID:-}" in
  ""|*[!A-Za-z0-9._-]*)
    echo "Invalid RUN_ID" >&2
    exit 64
    ;;
esac

RUN_ROOT="/Users/ci_runner/Jobs/$RUN_ID"
install -d -m 700 "$RUN_ROOT"
install -d -m 700 "$RUN_ROOT/src" "$RUN_ROOT/tmp" "$RUN_ROOT/cache"

export HOME="/Users/ci_runner"
export TMPDIR="$RUN_ROOT/tmp/"
export XDG_CACHE_HOME="$RUN_ROOT/cache"
umask 077

不要把工作區放進所有工作都能寫入的共用目錄。如果必須共用唯讀的種子快取,可以在工作開始時將其複製到本機快取,再讓建置程序只修改副本。如此既能利用預熱結果,也不會讓失敗的工作污染共用基準。

以權限而非命名慣例進行隔離

目錄名稱包含工作編號並不代表安全。可透過 stat -f '%Su %Sp %N' "$RUN_ROOT" 核對擁有者與權限,並使用 ls -lde 檢查額外的 ACL。若發現繼承規則,應先確認來源,再透過受控的初始化流程移除不需要的授權,不能直接在建置指令碼中遞迴放寬權限。

將快取分成三種生命週期

清空所有快取會拖慢流水線,全部重複使用則會擴大污染風險。實務上可依生命週期區分:

| 類型 | 範例 | 建議邊界 | 結束處理 | |---|---|---|---| | 工作層級 | 暫時衍生檔案、測試附件 | 僅限目前的 `RUN_ID` | 驗收後刪除 | | 分支層級 | 可重新下載的相依套件 | 儲存庫與分支的組合鍵 | 逾期輪替 | | 機器層級 | 唯讀工具套件、固定 SDK 索引 | 由管理者維護的種子目錄 | 建置工作不可寫入 |

快取鍵至少應包含工具鏈版本、相依套件鎖定檔摘要,以及目標架構。若只以分支名稱作為鍵,切換工具鏈後仍可能命中舊產物。還原快取後應進行輕量驗收,例如核對鎖定檔摘要、關鍵二進位檔架構及目錄擁有者;若不符合條件,應捨棄該副本,而不是嘗試就地修補。

敏感資料不屬於快取。短期憑證應透過工作環境注入,並只存在於需要它的子程序範圍內;記錄檔也不得輸出完整環境。工作結束時應明確對相關變數執行 unset,並確認產生的檔案未進入封存目錄。

正確理解 launchctl 工作階段網域

macOS 的背景代理程式可能隸屬於系統網域、使用者網域或圖形工作階段網域。建置由 ci_runner 執行,並不代表它啟動的所有代理程式都會自動位於預期網域。請先檢查目前的使用者識別碼,再查看目標網域:

uid="$(id -u ci_runner)"
sudo -u ci_runner launchctl print "user/$uid" >/tmp/user-domain.txt

if launchctl print "gui/$uid" >/dev/null 2>&1; then
  sudo -u ci_runner launchctl print "gui/$uid" >/tmp/gui-domain.txt
fi

純命令列建置通常不依賴圖形工作階段。需要模擬器或圖形介面的測試,則應先確認有效工作階段存在,不能藉由反覆啟動代理程式來掩蓋工作階段缺失。工作暫時啟動的服務應保存 PID,並在退出階段傳送正常終止訊號;依程序名稱批次終止,可能會誤傷同一台機器上的其他工作。

識別跨工作殘留程序

在工作開始前後分別擷取 ps 快照,並依使用者、父程序和工作目錄建立關聯。只檢查連接埠並不足夠,因為沒有監聽連接埠的檔案監看器和測試守護程序同樣會消耗資源。工作退出後若仍有程序存在,且其命令列指向目前的 RUN_ROOT,應判定為失敗,而不是默默忽略。

建立可失敗的退出驗收

清理階段必須保留原始工作的退出碼,同時讓清理異常可被觀察。建議依固定順序拆分檢查:

  1. 停止由工作啟動的子程序,並等待其退出。
  2. 檢查工作區內是否出現通訊端、掛載點或權限異常的檔案。
  3. 將測試報告複製到工作區以外的受控封存目錄。
  4. 清除工作的暫時憑證和環境變數。
  5. 驗證路徑前綴後刪除工作目錄。
  6. 再次擷取程序與 launchctl 狀態,並與基準狀態比較差異。

刪除前務必加入路徑保護:

cleanup() {
  case "$RUN_ROOT" in
    /Users/ci_runner/Jobs/*)
      rm -rf -- "$RUN_ROOT"
      ;;
    *)
      echo "Refusing unsafe cleanup path" >&2
      return 1
      ;;
  esac
}
trap cleanup EXIT HUP INT TERM

驗收指令碼不應為了維持綠燈狀態而吞掉所有錯誤。封存失敗、殘留程序或目錄擁有者變更,都應產生明確的非零狀態。對於在 SoarMac 上持續執行的 CI,應先在控制台確認目前可選的配置,再依並行工作的記憶體與磁碟壓力,決定是否拆分 runner;無論使用哪一種配置,身分與狀態邊界都應保持一致。

上線前檢查清單

首次接入時,連續執行兩項內容不同但步驟相同的工作,並刻意讓第一項工作建立快取檔案、背景程序和暫時變數。第二項工作必須無法讀取未宣告的檔案,也不能繼承前一項工作的程序和憑證。最後再核對以下結果:

當這些檢查能夠穩定重複時,流水線的成功才是來自已宣告的輸入,而非機器上碰巧殘留的狀態。之後無論調整並行數量、遷移工作或更換執行目錄,都能使用同一套邊界快速判斷差異來源。

常見問題

每個 CI 工作都需要建立新的 macOS 使用者嗎?

通常不需要。先建立 runner 專用且沒有管理權限的標準使用者,再為每個工作配置權限為 700 的獨立目錄。只有互不信任的團隊或安全等級不同的流程才需要拆分使用者。

為什麼只刪除工作目錄仍然不夠?

工作可能在暫存目錄、使用者快取、憑證、背景程序與 launchctl 工作階段留下狀態。退出階段應逐項檢查,並且只刪除已完成路徑驗證的工作目錄。

執行下一項任務

依工作負載選擇雲端 Mac

確認晶片、記憶體、儲存空間、租期與節點後,訂購一台 Apple Silicon 獨享實體機。

選擇機型並訂購