云端 Mac CI Runner 接单前健康检查与准入门禁

云端 Mac CI Runner 接单前健康检查与准入门禁

一台云端 Mac 昨晚完成了构建,今天却可能因为磁盘余量不足、Xcode 选择路径被改动、模拟器残留或僵死子进程,在领取下一项任务后才失败。此时流水线已经占用执行槽,真正的错误还会被依赖安装、编译日志和重试信息淹没。更稳妥的做法是在调度器分配任务前设置一道准入门禁:检查通过才接单,失败则退出队列并留下状态快照。

把健康检查放在任务领取之前

准入检查应短、确定且无副作用。它不是完整巡检,也不负责“顺手修复”机器。建议只回答五个问题:

  1. 开发工具路径是否存在,xcodebuild 能否启动。
  2. 工作卷是否有足够的可用空间。
  3. 内存压力是否已经影响新任务。
  4. 模拟器服务能否返回设备与运行时列表。
  5. 上一个任务是否遗留受管进程或任务锁。

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 -rfkillall 或自动切换 Xcode。准入阶段的职责是判定,不是改变现场。这样即使门禁自身配置错误,也不会扩大影响。

固定退出码语义

建议把 0 定义为可接单,20 定义为环境不满足要求,30 定义为检查器本身无法完成。调度器看到 20 时将 Runner 标记为暂停;看到 30 时则报告检查基础设施故障。不要让所有异常都返回 1,否则无法区分节点故障与脚本错误。

检查磁盘与内存,但不要边查边清

磁盘门槛应覆盖一次任务的源代码、依赖、DerivedData、归档和临时文件峰值。可以从历史成功任务的峰值反推,再留出回滚空间。检查对象也不能只看根卷;如果工作区、缓存或模拟器数据位于其他 APFS 卷,应逐个执行 df -Pk

内存检查不宜只读取“空闲内存”。macOS 会主动利用内存做文件缓存,更值得关注的是持续内存压力与交换活动。轻量门禁可以保存 memory_pressurevm_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 推理或大型链接任务,还应把统一内存需求写入对应队列的独立策略,而不是让所有任务共享一个阈值。

识别残留任务而不是误杀系统进程

残留检测必须限定进程归属。仅按 xcodebuildswiftSimulator 名称执行全局终止,可能杀掉交互会话或其他执行器的任务。更安全的方式是让每个任务创建包含 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 标记为“检查中”,执行脚本,再原子地切换为“可接单”或“隔离”。如果代理无法提供原子状态,可以先暂停领取,再检查,最后恢复领取,避免检查过程中插入新任务。

恢复动作按风险分层:

  1. 旧锁且 PID 不存在:归档锁文件后解除。
  2. 可重建目录超过容量预算:确认无活动任务后按目录归属清理。
  3. 模拟器服务无响应:保存诊断,再执行团队批准的服务恢复流程。
  4. Xcode 路径错误或磁盘状态异常:保持隔离,人工确认。
  5. 同一故障连续出现:停止自动恢复,保留最近几次快照比较差异。

最后给门禁本身做一次故障演练:临时提高最小磁盘阈值,确认 Runner 会退出队列;恢复阈值后,确认它重新进入可接单状态。再制造一个旧锁文件,验证系统只隔离节点而不会误杀进程。健康检查的价值不在于覆盖所有故障,而在于让故障发生在任务分配之前,并留下足够清晰的下一步。

常见问题

健康检查应该在每个构建任务中运行吗?

应在 Runner 领取任务前运行轻量检查,并在任务结束后执行清理验证。耗时较长的完整诊断可按固定间隔运行,不必阻塞每次构建。

磁盘空间不足时可以直接删除全部 DerivedData 吗?

不建议。先停止节点接单,再按工作区归属和最后使用时间清理可重建目录;不要在其他构建仍运行时删除共享缓存。

检查失败后应自动重启 Runner 吗?

只有已知可恢复且操作幂等的故障适合自动处理。Xcode 路径错误、磁盘异常或反复出现的残留进程应保留证据并转为人工检查。

独享物理节点

为构建、测试与 MLX 推理选择云端 Mac

按实际任务选择 M4、内存、存储、节点与租期。每份租用对应独享物理机,非虚拟机。

选择租用方案