同一條 Xcode 流水線連續執行數十次後,可能突然出現 Too many open files、EMFILE,或測試程序無預警結束;重新執行後卻又可能恢復正常。這類故障通常不是磁碟損壞,而是建置代理、測試程序與檔案監聽器共同耗盡了程序可用的檔案描述符。要徹底解決問題,不能只在終端機中暫時執行一次 ulimit,而必須找出哪些程序的用量持續增長、代理繼承了哪些限制,以及目前的並行程度是否超出預算。
先確認故障發生在哪一層
檔案描述符不只對應一般檔案,也涵蓋通訊端、管線、目錄控制代碼,以及部分程序間通訊物件。當相依套件解析、平行編譯、模擬器測試與日誌收集同時進行時,描述符數量會迅速上升。
先在失敗工作所使用的同一執行環境中記錄限制:
printf 'soft limit: '
ulimit -Sn
printf 'hard limit: '
ulimit -Hn
launchctl limit maxfiles
sysctl kern.maxfiles kern.maxfilesperproc
ulimit 顯示目前 shell 及其子程序可繼承的限制;launchctl limit 用來核對啟動環境;sysctl 則反映系統層級的邊界。三者不能互相取代。若互動式終端機顯示 65536,但 CI 日誌中仍是 256,就表示代理並未繼承終端機的設定。
可先依下表判斷常見現象:
| 現象 | 優先檢查 | 可能原因 |
|---|---|---|
| 每次都在相近階段失敗 | 單一程序的開啟數量 | 固定工作尖峰超過軟限制 |
| 執行時間越長越容易失敗 | 連續取樣的增長趨勢 | 檔案、通訊端或管線未釋放 |
| 提高並行度後才發生 | 同時執行的工作數 | 總預算不足 |
| 終端機正常、背景代理失敗 | 代理的啟動方式 | launchd 繼承的限制不同 |
不要把「重新執行成功」視為問題已修復。並行時序改變後,洩漏或尖峰可能只是暫時未觸及上限,但故障條件仍然存在。
以程序證據找出增長來源
先取得建置代理的 PID,再依程序統計開啟項目。以下命令不會改變執行狀態:
runner_pid="$(pgrep -n -f 'ci-runner|build-runner')"
test -n "$runner_pid" || exit 1
lsof -nP -p "$runner_pid" > "/tmp/runner-lsof-${runner_pid}.txt"
lsof -nP -p "$runner_pid" | awk 'NR > 1 {count[$5]++} END {
for (type in count) print type, count[type]
}' | sort
代理通常還會衍生 xcodebuild、測試宿主與指令碼子程序,只檢查父程序會漏掉問題。可以每隔十秒記錄程序樹中各 PID 的描述符數量:
root_pid="$runner_pid"
for sample in 1 2 3 4 5 6; do
ps -axo pid=,ppid=,command= |
awk -v root="$root_pid" '$1 == root || $2 == root {print $1}' |
while read -r pid; do
count="$(lsof -nP -p "$pid" 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"
printf '%s pid=%s open=%s
' "$(date '+%H:%M:%S')" "$pid" "$count"
done
sleep 10
done
重點不在於某次取樣的數字很大,而在於工作結束後是否回落。若同一個子程序在每輪測試後持續增加,請保留完整的 lsof 輸出,再依 REG、IPv4、IPv6、PIPE 等類型縮小範圍。不要在蒐證前直接終止程序,否則最有價值的現場資訊會隨之消失。
為代理設定明確的啟動限制
只在登入 shell 的設定檔中加入 ulimit -n 65536,對由 launchd 啟動的代理通常沒有作用。較穩妥的方式,是讓代理入口指令碼先驗證限制,再啟動工作:
#!/bin/zsh
set -euo pipefail
required=65536
hard="$(ulimit -Hn)"
if [[ "$hard" != "unlimited" ]] && (( hard < required )); then
print -u2 "File descriptor hard limit is below ${required}"
exit 78
fi
ulimit -Sn "$required"
exec /Users/runner/ci/bin/runner
若代理由使用者層級的 LaunchAgent 管理,可以在其 plist 中明確宣告:
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>65536</integer>
</dict>
<key>HardResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>65536</integer>
</dict>
修改後,請在對應的使用者工作階段中重新載入,而不要假設目前終端機的設定會自動傳遞:
uid="$(id -u)"
plist="$HOME/Library/LaunchAgents/com.minibin.ci-runner.plist"
launchctl bootout "gui/${uid}" "$plist" 2>/dev/null || true
launchctl bootstrap "gui/${uid}" "$plist"
launchctl kickstart -k "gui/${uid}/com.minibin.ci-runner"
接著必須在實際 CI 工作中再次輸出 ulimit -Sn 與 ulimit -Hn。如果代理不是在圖形使用者工作階段中執行,應依其實際所屬的 launchd 網域調整載入方式,不能直接套用 gui/<uid>。
依尖峰用量反推安全並行度
提高上限不代表可以無限制增加並行度。先以單一工作執行三至五輪,記錄穩定階段與尖峰值。例如,單一工作的尖峰值為 8200,背景代理與收集程序的基準值為 1800,並希望同時執行 4 個工作,則預算為:
8200 × 4 + 1800 = 34600
34600 × 1.3 = 44980
這表示 65536 仍有餘裕,但也應同步觀察記憶體、磁碟 I/O 與模擬器數量。若描述符尖峰已受控,但工作仍不穩定,應減少測試分片或編譯並行度,而不是繼續只調高檔案上限。
可以在每個工作開始前設定硬性門檻:
limit="$(ulimit -Sn)"
open_now="$(lsof -nP -p $$ | tail -n +2 | wc -l | tr -d ' ')"
reserve=$((limit - open_now))
if (( reserve < 10000 )); then
printf 'Insufficient descriptor reserve: %s
' "$reserve" >&2
exit 75
fi
門檻應根據實際尖峰值決定,而不是永久寫死一個適用於所有專案的數字。大型測試矩陣與輕量編譯的需求差異很大。
將修復轉化為可驗收的基準
完成調整後,使用同一個提交、同一組測試與固定並行度連續執行。每輪都要記錄工作開始值、尖峰值、結束值,以及失敗程序的 PID。驗收至少應涵蓋以下項目:
- CI 工作內部的軟限制符合預期。
- 硬限制不低於軟限制,且代理重新啟動後仍然有效。
- 單一工作結束後,開啟項目數量會回到可解釋的基準值。
- 連續多輪執行時,不存在單調增長的子程序。
- 達到目標並行度時,仍保有已計算的餘裕。
- 失敗日誌會同時保留程序樹、
lsof樣本與工作階段。 - 降低並行度後,尖峰值的變化符合工作數量的變化。
如果只有特定測試套件持續增長,應將其獨立執行,並逐步縮小測試案例範圍;如果所有工作都在相近的臨界值失敗,則應優先檢查啟動限制與並行預算。最終目標不是讓錯誤暫時消失,而是讓限制、尖峰值與並行度之間建立可重新檢查的計算關係。
常見問題
提高 ulimit -n 就能永久解決 EMFILE 嗎?
不能。提高限制只會增加緩衝空間;若測試程序持續洩漏檔案、通訊端或管線,使用量仍會上升。必須同時保存 lsof 樣本並修正或隔離洩漏程序。
Mac CI 執行器的檔案描述符上限應設多少?
先量測單一工作的峰值,再乘以預計並行數並保留約30%餘量。65536可作為起點,但必須先確認launchd硬限制與實際啟動環境。
為何互動式終端正常,CI 工作仍會失敗?
終端shell與launchd啟動的執行器可能繼承不同限制。應在真正的CI工作內輸出ulimit -n,並檢查執行器程序,而不是只查看登入終端。
獨享實體雲端 Mac
將可重現的開發環境部署至獨享實體節點
選擇設定、租期與五個節點之一,用於 Xcode 建置、自動化測試、遠端開發或模型推論。