MiniBin 工程笔记

云端 Mac CI 文件描述符耗尽排查与并发治理

云端 Mac CI 文件描述符耗尽排查与并发治理

同一条 Xcode 流水线连续运行几十次后,突然出现 Too many open filesEMFILE,或者测试进程无规律退出。重新执行又可能恢复。这类故障往往不是磁盘损坏,而是构建代理、测试进程和文件监听器共同耗尽了进程可用的文件描述符。要解决它,不能只在终端里临时执行一次 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 输出,再按 REGIPv4IPv6PIPE 等类型缩小范围。不要在取证前直接结束进程,否则最有价值的现场会消失。

让代理获得明确的启动限制

仅在登录 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 -Snulimit -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 构建、自动化测试、远程开发或模型推理。

选择配置并订购