工程实践

云端 Mac 构建 I/O 抖动排查:Spotlight 索引与文件监听治理

云端 Mac 构建 I/O 抖动排查:Spotlight 索引与文件监听治理

同一提交在云端 Mac 上连续构建三次,第一次正常,第二次突然变慢,第三次又恢复;CPU 没有持续满载,网络下载也早已结束。这类波动常被误判为配置不足,实际原因可能是 Spotlight 正在扫描 DerivedData、某个编辑器递归监听整个工作区,或多个 CI 任务同时写入同一缓存目录。解决它的关键不是先清空所有缓存,而是保留现场、量化 I/O,再逐项缩小竞争范围。

先建立可比较的构建基线

排查前固定代码提交、Xcode 版本、构建目标和缓存路径。不要把首次依赖下载与后续增量构建混在一组数据里,也不要一边构建一边执行清理脚本。

set -o pipefail

WORKSPACE="$HOME/ci/work/app"
DERIVED="$HOME/ci/derived/app-main"
PACKAGES="$HOME/ci/packages/app"

mkdir -p "$DERIVED" "$PACKAGES"
cd "$WORKSPACE"

xcodebuild \
  -resolvePackageDependencies \
  -clonedSourcePackagesDirPath "$PACKAGES"

for run in 1 2 3; do
  /usr/bin/time -lp xcodebuild \
    -workspace App.xcworkspace \
    -scheme App \
    -configuration Debug \
    -destination 'generic/platform=iOS Simulator' \
    -derivedDataPath "$DERIVED" \
    -clonedSourcePackagesDirPath "$PACKAGES" \
    build 2>&1 | tee "$HOME/ci/build-$run.log"
done

记录 wall time、最大常驻内存、日志中的依赖解析阶段,以及波动发生时是否存在其他任务。三次结果都慢,通常要检查项目或工具链;只有个别轮次异常,更像后台 I/O 竞争。

观察结果 优先检查
CPU 不高但构建停顿 文件系统等待、目录锁
mdsmdworker 活跃 Spotlight 索引范围
编辑器关闭后恢复 递归文件监听
并行任务才出现 共享 DerivedData 或包缓存

用系统工具保留 I/O 现场

先查看索引状态,再在异常构建期间抓取文件访问。fs_usage 需要管理员权限,输出量很大,应先限制进程,再按目录关键词过滤。

mdutil -s /

sudo fs_usage -w -f filesys mds mdworker_shared |
  grep -E 'DerivedData|SourcePackages|/ci/work/'

另开终端检查相关进程:

ps -axo pid,ppid,%cpu,%mem,etime,command |
  grep -E 'mds|mdworker|xcodebuild|swift-frontend|SourceKit' |
  grep -v grep

如果 mdworker 只是短暂读取新拉取的源码,不足以证明它是根因。更有价值的证据是:异常窗口内持续扫描大量中间产物,并且扫描路径与构建写入路径重合。

不要在取证前执行 rm -rf DerivedData。清空缓存可能让下一次构建更慢,也会抹掉判断“谁在反复访问哪些文件”的线索。

收窄 Spotlight 的索引边界

不建议直接关闭系统卷索引。开发者仍可能依赖系统搜索,而全局修改也会掩盖真正有问题的目录布局。更稳妥的方式是把高频生成、可随时重建的缓存放到独立 APFS 卷,再只调整该卷。

确认缓存卷挂载点后执行:

mdutil -s /Volumes/CICache
sudo mdutil -i off /Volumes/CICache
mdutil -s /Volumes/CICache

源码、文档和需要搜索的资料继续保留索引;DerivedData、包下载缓存、测试附件与归档临时目录放入该卷。若后续要恢复:

sudo mdutil -i on /Volumes/CICache
sudo mdutil -E /Volumes/CICache

不要把签名材料、长期产物和临时缓存混在同一清理边界内。索引排除只解决扫描竞争,不替代权限控制与数据分类。

治理递归文件监听与共享缓存

编辑器、代码生成器和开发服务器常会监听仓库根目录。如果监听规则包含 .git、DerivedData、测试附件或包缓存,每次构建都可能触发数万次无意义事件。

缩小监听范围

将监听目标限制到源码和配置目录,并显式排除以下内容:

先退出可疑编辑器或代理,再运行同一基线。如果波动消失,逐个恢复进程,比一次修改所有工具更容易确认责任方。

为并行任务分配独立写路径

串行构建可以复用项目缓存;并行 CI 不应让多个任务写入同一个 DerivedData。路径至少包含仓库与任务标识:

SAFE_REPO="${REPO_NAME//[^a-zA-Z0-9_-]/_}"
SAFE_JOB="${JOB_ID//[^a-zA-Z0-9_-]/_}"

export DERIVED_DATA="$HOME/ci/derived/$SAFE_REPO/$SAFE_JOB"
export PACKAGE_CACHE="$HOME/ci/packages/$SAFE_REPO"

mkdir -p "$DERIVED_DATA" "$PACKAGE_CACHE"

包缓存可共享读取,但更新依赖时仍可能产生写入竞争。高并发环境可以先在受控步骤完成依赖解析,再让构建任务使用稳定的解析结果。

清理与回归验收要分开

清理脚本应避开正在运行的 xcodebuild,按任务目录删除过期缓存,而不是每天无条件清空顶层目录。先只列出候选项:

DERIVED_ROOT="$HOME/ci/derived"

if ! pgrep -x xcodebuild >/dev/null; then
  find "$DERIVED_ROOT" \
    -mindepth 2 -maxdepth 2 \
    -type d -mtime +7 -print
fi

确认层级和保留周期后,再把删除动作放进独立任务。验收时重新运行三次固定基线,并同时观察 fs_usage。合格结果不是某一次特别快,而是索引进程不再持续扫构建目录、并行任务没有共享写路径、连续构建的阶段分布保持一致。

最后把 Xcode 版本、提交号、DerivedData 路径、包缓存路径、活跃监听进程和索引状态写入构建诊断记录。以后再遇到波动,可以先比较环境差异,而不是从清缓存重新猜起。

常见问题

关闭整个系统盘的 Spotlight 能解决构建抖动吗?

可能暂时降低索引活动,但不建议把它作为默认方案。更稳妥的做法是先确认 mds 或 mdworker 是否持续访问构建目录,再只对独立缓存卷调整索引,并保留系统盘的正常搜索能力。

DerivedData 应该按项目共享还是按任务隔离?

串行构建可以按项目复用;并行 CI 应按仓库、分支或任务编号隔离写入路径。多个任务同时修改同一目录,通常会增加锁竞争、缓存失效和难以复现的清理问题。

独享物理 Mac mini

为下一条开发流水线选择云端 Mac

比较三档固定配置与四个亚洲节点,按实际工作负载选择租用周期。每笔租用对应一台独享物理机,不是虚拟机。

选择配置并租用