同一台雲端 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 使用獨立的非管理員帳號;
- 任務編號經過允許清單過濾,路徑不會發生穿越;
- 建立、掛載、建置與卸載都能各自失敗,並回傳明確狀態;
EXIT、INT、TERM都會進入同一個清理函式;- 卸載失敗時先記錄占用程序,不立即強制處理;
- 同時監控 APFS 卷宗、稀疏映像檔案與宿主卷宗容量;
- 為失敗樣本設定保留期限,到期後刪除整個映像;
- 日誌不包含密碼、私密金鑰、完整憑證或敏感原始碼片段。
先使用一個不含敏感資料的小型專案,演練正常完成、編譯失敗、手動終止與磁碟接近容量上限這四條路徑。只有在四種情況下都能找到映像、說明其狀態並完成回收,這套隔離機制才算真正具備可維運性。
常見問題
加密稀疏映像可以取代作業系統權限隔離嗎?
不可以。它主要保護靜態工作區資料,仍應搭配獨立執行使用者、最小檔案權限、受控密鑰注入,以及任務結束後的可靠卸載。
任務失敗後映像仍被掛載該怎麼處理?
先以 lsof 找出占用掛載點的程序,只停止該任務相關程序並執行 sync,再嘗試正常卸載;確認沒有寫入後,才考慮強制卸載。
為開發、建置與遠端桌面選擇一台獨享實體機
比較三種 Apple Silicon 設定,並在下單時選擇節點、租用期間與儲存空間加購項目。