同一条 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 样本、限制并发,并修复或隔离泄漏进程。
文件描述符上限应该统一设置成多少?
没有适用于所有流水线的固定值。先测量单任务峰值,再按并发数计算预算并保留约 30% 余量;常见起点可用 65536,但必须确认 launchd 硬限制和实际启动上下文允许该值。
为什么交互式终端正常,CI 任务却仍然报错?
终端 shell 与由 launchd 启动的构建代理可能继承不同的资源限制。应在代理任务内部执行 ulimit -n,并检查代理进程 PID 的 launchd 启动配置,而不是只看登录终端。
独享物理云端 Mac
把可复现的开发环境部署到独享物理节点
选择配置、租期和五个节点之一,用于 Xcode 构建、自动化测试、远程开发或模型推理。