SureVM 工程笔记

云端 Mac CI 原子任务锁与重复构建防护

云端 Mac CI 原子任务锁与重复构建防护

同一个提交被 Webhook 重试、人工重跑和定时任务同时触发时,两条 Xcode 构建可能在几秒内进入同一工作区。它们未必立刻失败,更常见的是同时改写 DerivedData、覆盖归档目录,最后留下一个偶发且难复现的错误。云端 Mac 长期在线后,这类重复触发比单次命令失败更值得提前治理。

先确定真正需要互斥的资源

不要一上来给整台节点加全局锁。锁的范围应由共享写入目标决定,而不是由仓库名称决定。两个任务只要共用下列任一资源,就应使用同一个锁键:

反过来,如果测试分片拥有独立 checkout、DerivedData 和模拟器设备集,就可以使用不同锁键并行执行。建议把锁键写成“仓库、Scheme、动作”的组合,例如 client-release-archive,不要直接包含分支名,否则两个分支仍可能争用同一个归档目标。

场景 建议锁粒度 是否允许并行
共用 DerivedData 的归档 仓库 + Scheme + archive
独立设备集的测试分片 仓库 + shard 编号
上传同名发布产物 应用 + 发布通道
只读静态检查 通常无需锁

锁保护的是共享副作用,不是 CPU。为了提高并发而绕开必要的锁,通常只会把等待时间变成清理时间。

用 mkdir 建立原子竞争点

macOS 自带的 mkdir 可以作为简单可靠的竞争点:多个进程同时创建同一路径时,只有一个会成功。相比“先检查文件是否存在,再写入 PID”,它没有检查与写入之间的竞态窗口。

下面的脚本为锁创建随机所有者令牌,并每隔 15 秒更新一次心跳。退出时只有令牌仍匹配的进程可以删除锁,避免旧任务误删新任务的锁。

#!/bin/bash
set -euo pipefail

LOCK_ROOT="${HOME}/Library/Caches/ci-locks"
LOCK_KEY="client-release-archive"
LOCK_DIR="${LOCK_ROOT}/${LOCK_KEY}.lock"
TOKEN="$(uuidgen)"

mkdir -p "$LOCK_ROOT"

if ! mkdir "$LOCK_DIR" 2>/dev/null; then
  echo "build lock is already held"
  test -f "$LOCK_DIR/owner" && cat "$LOCK_DIR/owner"
  exit 75
fi

printf 'token=%s
pid=%s
started=%s
' \
  "$TOKEN" "$$" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$LOCK_DIR/owner"
touch "$LOCK_DIR/heartbeat"

heartbeat() {
  while true; do
    touch "$LOCK_DIR/heartbeat"
    sleep 15
  done
}

heartbeat &
HEARTBEAT_PID=$!

cleanup() {
  kill "$HEARTBEAT_PID" 2>/dev/null || true
  if grep -Fq "token=${TOKEN}" "$LOCK_DIR/owner" 2>/dev/null; then
    rm -rf "$LOCK_DIR"
  fi
}

trap cleanup EXIT
trap 'exit 130' INT TERM HUP

xcodebuild \
  -workspace Client.xcworkspace \
  -scheme Client \
  -configuration Release \
  -derivedDataPath "$PWD/.ci/DerivedData" \
  archive

退出码 75 可明确表示“任务暂不可执行”。队列层可以延迟重试,但不应在同一秒内无限重放。所有路径都要加引号,锁根目录也应放在当前执行账户可写、不会被仓库清理命令删除的位置。

不要只靠 PID 判断过期

PID 文件适合诊断,不适合单独证明所有权。macOS 可能复用 PID;节点重启后,旧文件中的数字也可能对应一个完全无关的新进程。因此,清理程序至少要联合检查三项:

  1. 心跳修改时间是否超过流水线的最大执行时长;
  2. 任务记录是否已经被队列标记为结束或超时;
  3. 受保护目录是否仍被相关构建进程写入。

心跳变旧只能说明“需要调查”,不能直接说明“可以抢锁”。如果构建因为磁盘阻塞而暂停,进程可能仍持有文件;此时强制启动第二条构建会扩大损坏范围。更稳妥的方式是让独立回收任务执行清理,而不是让等待锁的任务自行删除前任状态。

给锁设置可观测字段

owner 文件可以继续记录提交哈希、任务编号、Scheme 和工作目录,但不要写令牌、签名资料或其他敏感值。失败日志至少输出锁键、开始时间和任务编号,这样工程师能判断是正常排队,还是上一次异常退出。

把等待、超时和取消分开

互斥不代表所有后来任务都要失败。实际队列通常有三种策略:

无论采用哪一种,都应给等待设置上限。等待超时只结束等待者,不能删除持有者的锁。取消持有者时,则应先发送正常终止信号,让 trap 完成清理;只有进程无法退出时,才进入人工或独立回收流程。

还要避免在锁内执行无关工作。代码下载、只读校验和参数准备可以放在加锁前,真正会修改共享目录的阶段再获取锁。这样既缩短临界区,也减少队列拥堵。

上线前的故障演练

先在非发布任务中连续触发两次脚本,确认只有一个进入 xcodebuild。随后分别模拟正常完成、命令失败和收到终止信号,检查锁目录是否被删除。最后手工保留一个旧锁,验证等待任务只报告占用而不会擅自清理。

验收时可按以下清单执行:

完成这些检查后,重复触发会变成可解释的排队事件,而不是随机损坏构建状态的隐蔽竞态。后续若要提高并发,应优先拆分共享目录和发布目标,再细化锁键,而不是直接移除互斥保护。

常见问题

为什么不只用 PID 文件判断构建是否仍在运行?

PID 会被系统复用,而且文件可能在进程启动前后留下不一致状态。更稳妥的做法是用原子目录竞争锁,再以随机所有者令牌和心跳文件辅助诊断。

所有 Xcode 构建都必须共用一把全局锁吗?

不需要。只有共享工作区、DerivedData、归档目录或发布目标的任务才应互斥;资源完全隔离的测试分片可以使用不同锁键并行运行。

发现心跳停止后可以立即删除锁目录吗?

不可以。应先确认任务已超过流水线超时,且没有相关构建进程仍在写入目标目录,再由独立回收步骤删除锁,不能只凭心跳年龄抢锁。

SureVM 云端 Mac

为构建、开发与实验选择独享物理 Mac 节点

比较两档 Mac mini M4 配置、四种租期和五个可订购节点,再按实际工作流完成选择。

选择租用方案