工程文章

雲端 Mac CI 的 Unix Socket 路徑過長排查實戰

雲端 Mac CI 的 Unix Socket 路徑過長排查實戰

CI 日誌顯示服務程序已經啟動,客戶端卻反覆回報 connect failedNo such file or directory,即使重試也沒有改善。確認權限與程序狀態都正常後,真正的故障點可能藏在工作目錄中:工具將 Unix Socket 建立在多層儲存庫路徑、工作編號與暫存目錄之下,導致最終的絕對路徑超過 macOS 可接受的長度。

這類問題常見於平行測試程序、Ruby 工具、Node.js 服務、建置輔助程式及本機程序間通訊。錯誤訊息不一定會直接指出「路徑過長」,因此排查重點不該是繼續增加重試次數,而是確認 Socket 實際建立在哪裡,以及路徑經 UTF-8 編碼後占用多少位元組。

先確認故障來自 Socket 路徑

先同時檢查程序、Socket 檔案與已開啟的 Unix 連線。不要只看啟動指令碼的結束代碼:父程序成功並不代表負責監聽的子程序已經完成綁定。

pgrep -fl 'xcodebuild|swift|ruby|node'
find "${TMPDIR:-/private/tmp}" -type s -print 2>/dev/null
lsof -U -n -P | grep "$USER"

如果日誌提供了 .sock 路徑,請直接檢查其上層目錄。檔案不存在可能有三種原因:監聽程序尚未建立檔案、檔案建立後被提早清除,或是 bind() 在路徑檢查階段就已失敗。此時可用短路徑執行相同工具來重現問題;若短路徑成功、原始路徑失敗,通常比反覆調整權限更能說明真正原因。

macOS 的 Unix Socket 路徑限制是按位元組計算,而不是按畫面上看到的字元數計算。中文目錄、組合字元與較長的工作識別碼,都會讓位元組數增加得更快。

量測絕對路徑的 UTF-8 位元組數

不要直接用 ${#path} 下結論,因為它通常取得的是字元數量。以下指令碼會先展開絕對路徑,再依檔案系統編碼計算位元組數:

candidate="$PWD/.ci/runtime/worker/session/control.sock"

python3 - "$candidate" <<'PY'
import os
import sys

path = os.path.abspath(sys.argv[1])
encoded = os.fsencode(path)
print(f"bytes={len(encoded)}")
print(f"path={path}")
PY

Darwin 的 sockaddr_un.sun_path 空間有限,因此在工程設計上,不應讓目錄長度逼近極限。可以將 90 位元組設為預警線,替工具附加程序編號、隨機後綴與子目錄預留空間。這個數字是內部安全線,不是所有工具共用的正式上限;部分框架還會在傳入的路徑後繼續串接檔名。

同時記錄失敗路徑的各個組成部分:工作區根目錄、儲存庫名稱、流水線工作名稱、平行分片名稱,以及工具本身的執行目錄。只縮短末尾的 control.sock,通常無法省下足夠空間。

為每項工作建立短路徑根目錄

穩定的解法是讓工作直接在較短的根目錄下執行,而不是在原本的深層目錄外再套一層符號連結。每項工作使用獨立目錄,將權限設為 700,並明確把暫存檔案、快取與建置輸出指向該目錄。

job_root="$(mktemp -d /private/tmp/vmown-ci.XXXXXX)"
chmod 700 "$job_root"

export TMPDIR="$job_root/tmp"
export XDG_CACHE_HOME="$job_root/cache"
export DERIVED_DATA="$job_root/dd"

mkdir -p "$TMPDIR" "$XDG_CACHE_HOME" "$DERIVED_DATA"

cleanup() {
  rm -rf -- "$job_root"
}

trap cleanup EXIT HUP INT TERM

Xcode 建置可以明確傳入 DerivedData 路徑,SwiftPM 則使用獨立的 scratch 目錄。這樣不僅能縮短路徑,也可避免平行工作爭用同一個建置資料庫。

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -derivedDataPath "$DERIVED_DATA" \
  build

swift build --scratch-path "$job_root/spm"

儲存庫本身也應簽出到短路徑,例如 $job_root/src。如果仍從很深的原始目錄執行,只移動 TMPDIR 不一定有效,因為部分工具會根據專案的絕對路徑產生 Socket。

避免符號連結與共用目錄陷阱

符號連結適合快速驗證,但不應作為唯一的修復方式。有些程式會呼叫 realpath,最後仍使用連結背後的長路徑;另一些程式會將真實路徑寫入快取鍵,導致同一項工作同時出現長、短兩套路徑。

共用 /private/tmp/ci 也不可取。平行工作可能建立同名 Socket,舊工作殘留的監聽檔案也可能讓新工作誤判服務已經就緒。更安全的原則是「一項工作一個根目錄」,並讓 Socket 檔名保持簡短且可預測。

清理時不要使用範圍過大的萬用字元,也不要刪除 /private/tmp 下整個共用前綴。mktemp 傳回的目錄應保存在目前程序的變數中,再由同一項工作透過 trap 精準回收。如果工作遭到強制終止而無法執行清理,可由獨立的清掃工作依目錄擁有者與修改時間處理,但必須先確認目錄內沒有仍在執行的程序。

將檢查設為流水線前置門檻

只修復一次並不夠。當儲存庫名稱、分支名稱或工作範本變長後,問題可能再次發生。建議在啟動測試服務前檢查所有候選 Socket,並在超過內部預警線時立即讓工作失敗。

check_socket_path() {
  python3 - "$1" <<'PY'
import os
import sys

limit = 90
path = os.path.abspath(sys.argv[1])
size = len(os.fsencode(path))

if size > limit:
    print(f"socket path exceeds guard: {size} bytes")
    raise SystemExit(1)

print(f"socket path accepted: {size} bytes")
PY
}

check_socket_path "$TMPDIR/test-worker.sock"

驗收時至少應涵蓋四項:路徑低於預警線;平行工作的根目錄彼此不同;工作失敗或中斷後能夠清除目錄;工具日誌中使用的路徑與預期一致。最後再同時執行兩項工作,確認沒有 Socket 同名、快取混用,或清除另一項工作目錄的情況。

路徑治理的目標不是把所有檔案都塞進暫存目錄,而是讓需要程序間通訊的部分擁有簡短、私有且生命週期明確的根路徑。完成這層約束後,許多看似隨機的連線失敗,都會變成可重現、可提前阻擋的設定錯誤。

常見問題

為什麼只縮短 Socket 檔名仍無法修復?

核心檢查的是完整絕對路徑的 UTF-8 位元組數,而非單一檔名。儲存庫路徑、工作編號、暫存目錄與工具產生的子目錄都會計入。

使用符號連結可以徹底解決路徑過長嗎?

無法保證。部分工具會先解析符號連結,再於較長的真實路徑建立 Socket。直接在短根目錄下建立工作區較可靠。

多個 CI 工作可以共用同一個短暫存目錄嗎?

不建議。每個工作應以 mktemp 建立權限為 700 的獨立目錄,並在正常退出、失敗或收到終止訊號時清除。

獨享雲端 Mac

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

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

選擇租用方案