工程记录

用 APFS 写时复制克隆快速重置云端 Mac 工作区

用 APFS 写时复制克隆快速重置云端 Mac 工作区

同一台云端 Mac 连续执行依赖升级、工程迁移和破坏性脚本时,最麻烦的往往不是构建本身,而是如何可靠地回到一个干净工作区。重新检出大型仓库会重复读取大量小文件,直接复用旧目录又可能带回未跟踪文件、生成产物和错误权限。若工作目录位于 APFS 卷,可以先维护一份经过验收的基线,再通过写时复制克隆为每个任务创建可修改副本。

APFS 克隆解决的是什么问题

APFS 克隆最初让两个文件共享底层数据块;只有一方发生写入时,文件系统才为变化部分分配新块。因此,包含源码、依赖和静态工具的基线可以较快地复制成独立目录,而后续修改不会写回基线。

它与硬链接不同:克隆后的文件拥有独立目录项,修改副本不会改变原文件。它也不是备份,因为基线和副本仍在同一卷上。卷损坏、误删基线或整机数据丢失时,克隆不能提供异地恢复能力。

把 APFS 克隆看成“低成本创建工作副本”的机制,而不是缓存命中保证、快照系统或数据保护方案。

du -sh 展示的是目录的逻辑大小,不能直接说明克隆实际新增了多少物理空间。观察容量变化时,应同时查看 APFS 卷的剩余空间,并在任务持续写入时设置清理阈值。

先制作一份可验证的基线

基线不应是某次构建结束后随手留下的目录。先检出固定提交,恢复依赖,再清除所有只属于单次任务的状态。至少确认工作树干净、子模块位置正确,并记录基线提交。

set -euo pipefail

ROOT="$HOME/ci-workspaces"
BASE="$ROOT/baseline"
mkdir -p "$ROOT"

git -C "$BASE" status --porcelain
git -C "$BASE" submodule status --recursive
git -C "$BASE" rev-parse HEAD > "$BASE/.baseline-revision"
test -z "$(git -C "$BASE" status --porcelain)"

.baseline-revision 本身会成为未跟踪文件,因此应把它放在仓库外,或预先加入团队认可的忽略规则。更稳妥的布局是让 $ROOT/metadata 保存提交号,让 $BASE 始终满足干净工作树检查。

哪些内容不要放进基线

不要把 DerivedData、测试结果包、归档文件、临时钥匙串、运行日志和正在使用的套接字放进基线。这些内容常包含绝对路径、进程状态或任务专属凭据。依赖目录是否纳入基线,则取决于其内容能否由锁文件确定,以及安装脚本是否会写入机器级路径。

基线还应只由更新任务写入。普通构建账户可以读取它,但不应在其中执行构建命令。否则一次误操作就会污染后续所有克隆。

为每次任务创建独立克隆

先确认根目录确实位于 APFS,再用任务编号生成唯一目录。目标路径必须不存在,任务编号也要限制为安全字符,避免把外部输入直接拼进删除路径。

set -euo pipefail

ROOT="$HOME/ci-workspaces"
BASE="$ROOT/baseline"
JOB_ID="${BUILD_ID:?BUILD_ID is required}"

case "$JOB_ID" in
  *[!A-Za-z0-9._-]*) exit 64 ;;
esac

test "$(stat -f %T "$ROOT")" = "apfs"

WORK="$ROOT/jobs/$JOB_ID"
DERIVED="$ROOT/derived/$JOB_ID"

test ! -e "$WORK"
mkdir -p "$ROOT/jobs" "$DERIVED"
cp -cR "$BASE" "$WORK"

xcodebuild \
  -workspace "$WORK/App.xcworkspace" \
  -scheme App \
  -derivedDataPath "$DERIVED" \
  build

cp -cR 会请求克隆复制,但实际行为仍取决于源路径、目标路径和文件系统。源目录与目标目录必须位于同一 APFS 卷;若跨卷执行,就不应把它当成写时复制流程。部署前可用一个包含大文件的测试目录观察复制耗时与卷容量变化。

隔离并发任务与可变状态

工作区独立并不代表所有状态都已隔离。构建工具还可能读写用户目录下的缓存、临时目录和配置文件。至少应为每个任务分配独立的 DerivedData 与结果目录,并避免多个任务共用相同模拟器设备集或同一输出路径。

给并发量设置容量预算

克隆刚创建时新增用量较小,但编译对象、依赖重写和归档会快速产生新块。并发上限应由最坏情况下的可写增量决定,而不是由基线逻辑大小决定。可以在启动任务前检查卷空间,在构建结束后记录变化:

df -h "$ROOT"
du -sh "$DERIVED" "$WORK"

若任务会重写大量依赖文件,克隆的空间优势会逐渐降低。此时应把不可变依赖留在基线,把频繁变化的生成目录移到任务专属路径,而不是为了维持克隆率去共享可写缓存。

安全回收并定期重建基线

清理脚本的首要目标不是“删得快”,而是绝不越过任务根目录。删除前同时验证前缀、目录存在性和任务编号;不要对可能为空的变量直接执行递归删除。

set -euo pipefail

ROOT="$HOME/ci-workspaces"
WORK="$ROOT/jobs/${BUILD_ID:?BUILD_ID is required}"
DERIVED="$ROOT/derived/${BUILD_ID:?BUILD_ID is required}"

case "$WORK" in
  "$ROOT"/jobs/*) rm -rf -- "$WORK" ;;
  *) exit 64 ;;
esac

case "$DERIVED" in
  "$ROOT"/derived/*) rm -rf -- "$DERIVED" ;;
  *) exit 64 ;;
esac

基线更新应通过新目录完成:检出目标提交、安装确定性依赖、执行验收,再替换当前基线。不要在已有基线上原地拉取并继续服务,因为更新中断会留下无法判断的新旧混合状态。

最终检查包括四项:基线提交可追溯,工作树保持干净;每个任务拥有唯一工作区和生成目录;卷剩余空间能够覆盖最大并发写入;清理逻辑只接受受控路径。满足这些条件后,APFS 克隆才真正成为可重复的工程步骤,而不是一次看起来很快的复制技巧。

常见问题

APFS 克隆可以代替构建缓存或备份吗?

不可以。它适合从同卷基线快速创建可修改副本,但不能代替远端备份;随着文件被改写,副本仍会持续占用新的物理空间。

为什么不把 DerivedData 一起放进基线?

DerivedData 含有绝对路径、索引和任务相关状态,复用后容易制造难以解释的构建差异。更稳妥的做法是为每个任务指定独立目录。

独享物理节点

在云端 Mac 上继续验证这套工作流

从三档 Apple Silicon 配置中选择合适的内存、存储、节点与租用周期。

选择配置并下单