一台云端 Mac 昨晚完成了构建,今天却可能因为磁盘余量不足、Xcode 选择路径被改动、模拟器残留或僵死子进程,在领取下一项任务后才失败。此时流水线已经占用执行槽,真正的错误还会被依赖安装、编译日志和重试信息淹没。更稳妥的做法是在调度器分配任务前设置一道准入门禁:检查通过才接单,失败则退出队列并留下状态快照。
把健康检查放在任务领取之前
准入检查应短、确定且无副作用。它不是完整巡检,也不负责“顺手修复”机器。建议只回答五个问题:
- 开发工具路径是否存在,
xcodebuild能否启动。 - 工作卷是否有足够的可用空间。
- 内存压力是否已经影响新任务。
- 模拟器服务能否返回设备与运行时列表。
- 上一个任务是否遗留受管进程或任务锁。
VMCache 上的 Runner 可以由自托管代理、定时调度程序或团队自己的队列管理器控制。无论采用哪一种,门禁都应位于“领取任务”之前,而不是构建脚本的第一步。后者已经算作一次失败任务,会污染成功率与重试统计。
健康检查失败的正确结果是暂时停止接单并保存证据,而不是立即删除缓存、杀死所有进程或重启整台机器。
先建立可审计的检查脚本
下面的脚本只读取状态,并用非零退出码表示拒绝接单。阈值应按项目体积调整,不要照搬成所有 Runner 的统一常量。
#!/bin/zsh
set -u
WORK_VOLUME="${WORK_VOLUME:-/}"
MIN_FREE_GB="${MIN_FREE_GB:-40}"
LOCK_FILE="${RUNNER_LOCK_FILE:-/tmp/vmcache-ci-job.lock}"
failures=0
fail() {
print -u2 "FAIL: $1"
failures=$((failures + 1))
}
developer_dir="$(xcode-select -p 2>/dev/null || true)"
[[ -n "$developer_dir" && -d "$developer_dir" ]] \
|| fail "developer directory is unavailable"
if ! xcodebuild -version >/dev/null 2>&1; then
fail "xcodebuild cannot start"
fi
free_kb="$(df -Pk "$WORK_VOLUME" | awk 'NR==2 {print $4}')"
if [[ -z "$free_kb" ]]; then
fail "free disk space cannot be read"
else
free_gb=$((free_kb / 1024 / 1024))
(( free_gb >= MIN_FREE_GB )) \
|| fail "free disk space is below ${MIN_FREE_GB} GB"
fi
if [[ -e "$LOCK_FILE" ]]; then
fail "a previous job lock still exists"
fi
if ! xcrun simctl list devices available >/dev/null 2>&1; then
fail "simulator service is not responding"
fi
exit $((failures > 0 ? 20 : 0))
不要在脚本里使用 sudo rm -rf、killall 或自动切换 Xcode。准入阶段的职责是判定,不是改变现场。这样即使门禁自身配置错误,也不会扩大影响。
固定退出码语义
建议把 0 定义为可接单,20 定义为环境不满足要求,30 定义为检查器本身无法完成。调度器看到 20 时将 Runner 标记为暂停;看到 30 时则报告检查基础设施故障。不要让所有异常都返回 1,否则无法区分节点故障与脚本错误。
检查磁盘与内存,但不要边查边清
磁盘门槛应覆盖一次任务的源代码、依赖、DerivedData、归档和临时文件峰值。可以从历史成功任务的峰值反推,再留出回滚空间。检查对象也不能只看根卷;如果工作区、缓存或模拟器数据位于其他 APFS 卷,应逐个执行 df -Pk。
内存检查不宜只读取“空闲内存”。macOS 会主动利用内存做文件缓存,更值得关注的是持续内存压力与交换活动。轻量门禁可以保存 memory_pressure 和 vm_stat 输出,由监控侧比较趋势:
snapshot_dir="${RUNNER_STATE_DIR:-$HOME/ci-state}"
mkdir -p "$snapshot_dir"
{
date -u "+%Y-%m-%dT%H:%M:%SZ"
xcode-select -p
xcodebuild -version
df -h /
memory_pressure
vm_stat
} > "$snapshot_dir/preflight-latest.txt" 2>&1
不要根据单次页计数直接拒绝任务。更可靠的规则是:门禁先记录状态,只有已确认会导致当前工作负载失败的条件才阻止接单。对于 MLX 推理或大型链接任务,还应把统一内存需求写入对应队列的独立策略,而不是让所有任务共享一个阈值。
识别残留任务而不是误杀系统进程
残留检测必须限定进程归属。仅按 xcodebuild、swift 或 Simulator 名称执行全局终止,可能杀掉交互会话或其他执行器的任务。更安全的方式是让每个任务创建包含 PID、任务编号和开始时间的锁文件,并在退出陷阱中删除。
job_lock="${RUNNER_LOCK_FILE:-/tmp/vmcache-ci-job.lock}"
cleanup() {
rm -f "$job_lock"
}
if ! ( set -o noclobber; print "$$ ${CI_JOB_ID:-unknown}" > "$job_lock" ) 2>/dev/null; then
print -u2 "another managed job owns the runner"
exit 20
fi
trap cleanup EXIT INT TERM
发现旧锁后先验证其中的 PID 是否仍存在,再核对进程启动时间和命令行。PID 会复用,不能只凭数字决定终止。若进程已经不存在,可以把锁文件连同检查时间移入诊断目录,再由受控恢复步骤解除阻塞。
为模拟器检查设置边界
simctl list 成功只表示服务可通信,不代表特定测试目标可用。门禁负责验证服务层;项目层还应在任务内用明确的 destination 查找目标设备。不要在准入脚本中自动创建、删除或抹除设备,这些操作耗时且会改变后续测试的基线。
接入调度器并设计恢复路径
调度器应先把 Runner 标记为“检查中”,执行脚本,再原子地切换为“可接单”或“隔离”。如果代理无法提供原子状态,可以先暂停领取,再检查,最后恢复领取,避免检查过程中插入新任务。
恢复动作按风险分层:
- 旧锁且 PID 不存在:归档锁文件后解除。
- 可重建目录超过容量预算:确认无活动任务后按目录归属清理。
- 模拟器服务无响应:保存诊断,再执行团队批准的服务恢复流程。
- Xcode 路径错误或磁盘状态异常:保持隔离,人工确认。
- 同一故障连续出现:停止自动恢复,保留最近几次快照比较差异。
最后给门禁本身做一次故障演练:临时提高最小磁盘阈值,确认 Runner 会退出队列;恢复阈值后,确认它重新进入可接单状态。再制造一个旧锁文件,验证系统只隔离节点而不会误杀进程。健康检查的价值不在于覆盖所有故障,而在于让故障发生在任务分配之前,并留下足够清晰的下一步。
常见问题
健康检查应该在每个构建任务中运行吗?
应在 Runner 领取任务前运行轻量检查,并在任务结束后执行清理验证。耗时较长的完整诊断可按固定间隔运行,不必阻塞每次构建。
磁盘空间不足时可以直接删除全部 DerivedData 吗?
不建议。先停止节点接单,再按工作区归属和最后使用时间清理可重建目录;不要在其他构建仍运行时删除共享缓存。
检查失败后应自动重启 Runner 吗?
只有已知可恢复且操作幂等的故障适合自动处理。Xcode 路径错误、磁盘异常或反复出现的残留进程应保留证据并转为人工检查。
为构建、测试与 MLX 推理选择云端 Mac
按实际任务选择 M4、内存、存储、节点与租期。每份租用对应独享物理机,非虚拟机。