工程文章

用加密稀疏映像隔離雲端 Mac CI 工作區

用加密稀疏映像隔離雲端 Mac CI 工作區

同一台雲端 Mac 連續執行多個建置任務時,最難察覺的問題往往不是編譯失敗,而是後一個任務讀到了前一個任務遺留的原始碼、臨時憑證、測試資料或快取。只靠每次執行 rm -rf 並不夠可靠:程序可能仍占用檔案、隱藏目錄容易漏刪,異常結束時也可能直接跳過清理步驟。更明確的隔離邊界,是為每個任務掛載獨立的加密稀疏映像,將原始碼簽出、建置與臨時產物全部限制在這個檔案系統內。

為什麼選擇稀疏映像隔離

稀疏映像可設定較大的邏輯容量,但在宿主磁碟上只會隨實際寫入量增長。它本身仍是一般檔案,方便依任務編號定位、統計與刪除;掛載後則會呈現為獨立的 APFS 卷宗,既有建置指令碼通常只需調整工作目錄。

加密處理的是映像尚未掛載時的資料暴露風險,獨立掛載點則建立任務之間的路徑邊界。兩者都不能取代執行帳號隔離與最小權限原則,但相較於共用一個長期存在的工作目錄,這種做法更容易稽核。

不要把「映像已加密」理解為任務執行期間無法存取。映像掛載後,具備相應檔案權限的程序仍可讀取其中內容,因此 runner 使用者、日誌與金鑰注入方式都必須一併治理。

方案異常結束後的殘留任務邊界適用情況
共用目錄後刪除容易漏掉被占用的檔案與隱藏目錄依賴指令碼正確性不含敏感資料的短期任務
每個任務使用一般目錄目錄仍留在宿主卷宗路徑分開,但檔案系統未分開低風險的並行建置
加密稀疏映像可辨識殘留掛載與映像檔案獨立檔案系統需要明確清理邊界的 CI

建立以任務命名的 APFS 工作區

任務編號必須先限制為允許的字元,避免斜線、空白或命令替換進入路徑。映像目錄應放在只有 runner 使用者能讀寫的位置,並為每個任務分別建立掛載點。

set -euo pipefail
set +x
umask 077

JOB_KEY="$(printf '%s' "${CI_JOB_ID:?}" | tr -cd 'A-Za-z0-9._-')"
IMAGE_ROOT="$HOME/ci-images"
IMAGE_PATH="$IMAGE_ROOT/$JOB_KEY.sparsebundle"
MOUNT_PATH="/Volumes/ci-$JOB_KEY"

mkdir -p "$IMAGE_ROOT" "$MOUNT_PATH"
chmod 700 "$IMAGE_ROOT"

printf '%s' "${CI_VOLUME_PASSWORD:?}" |
  hdiutil create \
    -size 80g \
    -type SPARSEBUNDLE \
    -fs APFS \
    -volname "ci-$JOB_KEY" \
    -encryption AES-256 \
    -stdinpass \
    "$IMAGE_PATH"

80g 是邏輯容量上限,不代表會立即占用 80GB。這個上限應涵蓋原始碼、相依套件、衍生資料與封存尖峰,並為失敗日誌保留餘裕。不要把密碼寫入命令參數、檔名或建置日誌;先關閉命令回顯,再透過標準輸入從 CI 的受保護變數傳入。

掛載後立即驗證

成功建立映像,不代表掛載點一定正確。指令碼應確認目標路徑確實是已掛載的卷宗,並核對檔案系統類型。

printf '%s' "$CI_VOLUME_PASSWORD" |
  hdiutil attach \
    -stdinpass \
    -nobrowse \
    -mountpoint "$MOUNT_PATH" \
    "$IMAGE_PATH"

mount | grep -F "on $MOUNT_PATH "
diskutil info "$MOUNT_PATH" | grep -E 'File System Personality|Volume Name'
mkdir -p "$MOUNT_PATH/src" "$MOUNT_PATH/output" "$MOUNT_PATH/tmp"

接著將原始碼簽出目錄、建置輸出與任務層級的臨時目錄全部指向這個卷宗。套件管理器共用的唯讀快取可以留在卷宗外,但任何可能被任務修改的快取都應複製到卷宗內,以免並行寫入造成污染。

將卸載納入生命週期,而不是當成收尾命令

清理不能只放在指令碼的最後一行,因為編譯錯誤、逾時與終止訊號都可能讓任務提前結束。應透過結束鉤子統一執行同步、占用檢查與卸載,並在失敗時保留足夠的診斷資訊。

cleanup_workspace() {
  set +e
  sync
  if mount | grep -Fq "on $MOUNT_PATH "; then
    lsof +D "$MOUNT_PATH" > "$IMAGE_ROOT/$JOB_KEY.lsof.txt" 2>/dev/null
    hdiutil detach "$MOUNT_PATH"
  fi
  rmdir "$MOUNT_PATH" 2>/dev/null
}

trap cleanup_workspace EXIT INT TERM
export TMPDIR="$MOUNT_PATH/tmp"
cd "$MOUNT_PATH/src"

目錄很大時,lsof +D 可能執行得較慢。可以先檢查已知的建置、測試與封裝程序,只有在卸載失敗時才進行完整掃描。不要一開始就強制卸載,否則可能掩蓋仍在寫入的程序,並留下不完整的產物。

處理並行、容量與失敗殘留

同一個任務重試時,舊映像可能仍然存在。安全的做法不是直接覆寫,而是先確認它是否仍處於掛載狀態:若已掛載,就阻止新任務並記錄衝突;若未掛載,則依保留政策封存或刪除。任務編號也應包含本次執行序號,避免兩次執行指向同一個映像。

建立三項檢查

第一,在任務開始前列出 hdiutil info,確認沒有同名卷宗。第二,建置期間同時監控宿主卷宗與掛載卷宗的剩餘空間;稀疏映像會持續增長,邏輯卷宗仍有空間,不代表宿主卷宗也有足夠空間。第三,任務結束後掃描映像目錄,只允許保留已明確標記為診斷用途的失敗樣本。

可以使用以下命令區分兩層容量:

df -h "$MOUNT_PATH"
df -h "$IMAGE_ROOT"
du -sh "$IMAGE_PATH"
hdiutil info

如果某類任務經常觸及容量上限,應先拆分不必長期保留的中間產物,再調整邏輯容量。盲目擴大映像,只會把宿主磁碟耗盡的時間往後延。

安全邊界與上線檢查清單

金鑰只應在建立與掛載階段進入程序,完成後要立即在目前的 shell 中取消匯出。建置指令碼不得輸出環境變數,也不應將密碼複製到卷宗內。映像檔案權限應維持在 600 或更嚴格,映像根目錄則維持 700

上線前逐項確認:

  • runner 使用獨立的非管理員帳號;
  • 任務編號經過允許清單過濾,路徑不會發生穿越;
  • 建立、掛載、建置與卸載都能各自失敗,並回傳明確狀態;
  • EXITINTTERM 都會進入同一個清理函式;
  • 卸載失敗時先記錄占用程序,不立即強制處理;
  • 同時監控 APFS 卷宗、稀疏映像檔案與宿主卷宗容量;
  • 為失敗樣本設定保留期限,到期後刪除整個映像;
  • 日誌不包含密碼、私密金鑰、完整憑證或敏感原始碼片段。

先使用一個不含敏感資料的小型專案,演練正常完成、編譯失敗、手動終止與磁碟接近容量上限這四條路徑。只有在四種情況下都能找到映像、說明其狀態並完成回收,這套隔離機制才算真正具備可維運性。

常見問題

加密稀疏映像可以取代作業系統權限隔離嗎?

不可以。它主要保護靜態工作區資料,仍應搭配獨立執行使用者、最小檔案權限、受控密鑰注入,以及任務結束後的可靠卸載。

任務失敗後映像仍被掛載該怎麼處理?

先以 lsof 找出占用掛載點的程序,只停止該任務相關程序並執行 sync,再嘗試正常卸載;確認沒有寫入後,才考慮強制卸載。

獨享雲端 Mac

為開發、建置與遠端桌面選擇一台獨享實體機

比較三種 Apple Silicon 設定,並在下單時選擇節點、租用期間與儲存空間加購項目。

選擇租用方案