同一台云端 Mac 连续执行多个 CI 任务时,最难复现的故障往往不是代码错误,而是上一个任务留下的临时文件。旧套接字、未释放的锁、半写入缓存或重名导出目录,都可能让下一次构建在相同提交上得到不同结果。独享物理节点避免了其他账户干扰,却不会自动隔离同一账户内的并发任务,因此文件系统边界仍要由流水线明确建立。
先识别会跨任务泄漏的目录
不要把“工作区已清空”等同于环境已恢复。macOS 工具会把状态分散到工作区之外,排查时至少检查以下四类位置:
TMPDIR下的临时文件、套接字与下载片段;- Xcode 的 DerivedData、索引和构建数据库;
- Clang 模块缓存及 SwiftPM 的任务级缓存;
- 脚本自行创建的导出、日志和测试附件目录。
先在一次正常构建前后分别执行 env | sort,记录实际使用的 HOME、TMPDIR 和工作区路径。再用 du -sh 比较相关目录体积,而不是一开始就递归删除。若失败只在并发时出现,应优先搜索固定文件名、固定端口和固定输出路径。
隔离的目标不是每次删除所有缓存,而是让任务明确拥有自己的可写目录,并且只清理自己创建的内容。
为每个任务创建唯一根目录
任务标识应来自 CI 系统提供的唯一编号;没有可靠编号时,可以由 mktemp 生成。不要仅使用分支名,因为同一分支可能同时触发多次构建。
#!/bin/bash
set -euo pipefail
job_id="${CI_JOB_ID:-manual}"
job_root="$(mktemp -d "${TMPDIR:-/tmp}/surevm-ci.${job_id}.XXXXXX")"
export TMPDIR="${job_root}/tmp"
export DERIVED_DATA="${job_root}/DerivedData"
export MODULE_CACHE="${job_root}/ModuleCache"
export RESULT_BUNDLE="${job_root}/Results/Test.xcresult"
mkdir -p "$TMPDIR" "$DERIVED_DATA" "$MODULE_CACHE" "$(dirname "$RESULT_BUNDLE")"
cleanup() {
local status=$?
rm -rf "$job_root"
exit "$status"
}
trap cleanup EXIT INT TERM
mktemp 创建的目录默认不会与另一个任务重名。清理函数先保存退出码,再删除目录并用原状态退出,避免测试失败被清理命令掩盖。路径变量要加引号,防止工作区名称包含空格时被拆成多个参数。
不要盲目覆盖 HOME
把 HOME 指向任务目录看似隔离彻底,但可能切断当前用户的 Keychain、工具偏好和已完成的系统初始化。普通编译可为特定包管理工具指定独立缓存;涉及签名时,通常保留 HOME,只隔离构建产物和明确可迁移的缓存。
把 Xcode 的写入路径显式接入
仅设置 TMPDIR 不会自动迁移 DerivedData。构建命令应把关键路径作为参数传入,确保日志能够直接显示本次任务使用了哪个目录。
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-derivedDataPath "$DERIVED_DATA" \
-clonedSourcePackagesDirPath "${job_root}/SourcePackages" \
COMPILER_INDEX_STORE_ENABLE=NO \
CLANG_MODULE_CACHE_PATH="$MODULE_CACHE" \
test \
-resultBundlePath "$RESULT_BUNDLE"
CI 不需要代码索引时,可关闭索引存储以减少无关写入。测试结果应放在任务根目录内,但失败后若要上传附件,必须先完成归档,再触发清理。更稳妥的流程是“执行测试—复制诊断文件到流水线产物区—校验复制结果—退出”。
共享缓存只读,任务缓存可写
确实需要复用依赖时,可把经过校验的共享缓存视为种子:任务开始时复制或恢复到自己的目录,构建过程只写任务副本,成功后再由单独步骤更新共享版本。不要让两个 xcodebuild 进程同时写同一模块缓存或构建数据库。
让异常退出也能留下证据
直接使用 trap 'rm -rf ...' EXIT 会在失败瞬间删除现场。实践中应先生成一份小型诊断清单,包括退出码、磁盘余量、目录大小和未结束的相关进程,再决定保留哪些文件。日志中不要输出环境变量全集,因为其中可能含令牌或签名相关值。
可以在清理前执行:
{
echo "exit_status=$status"
df -h "$job_root"
du -sh "$job_root"/* 2>/dev/null || true
find "$job_root" -type s -print
} > "${ARTIFACT_DIR}/job-cleanup.txt"
ARTIFACT_DIR 应位于任务根目录之外,并由流水线负责上传和定期清理。若需要保留失败现场,可将诊断目录压缩后再删除原目录,且要对日志和路径做脱敏。
用残留审计验证隔离是否生效
改造完成后,连续运行两次相同提交,并在第二次开始前确认不存在上一任务标识对应的目录。并发运行时,再检查两个任务的 TMPDIR、DerivedData 和结果包路径是否完全不同。
验收清单可以固定为五项:每个任务拥有唯一根目录;Xcode 路径全部显式指定;共享缓存没有并发写入;成功与失败都执行退出钩子;诊断产物先复制、后清理。若磁盘仍持续增长,可按任务标识反查未纳入边界的工具目录,而不是扩大删除范围。
在 SureVM 的独享物理 Mac 上,这套方法尤其适合长期运行的构建节点:保留机器级工具链,同时把每次任务的可变状态限制在可追踪、可删除的目录中。最终判断标准很简单——前一个任务无论成功、失败或被中止,都不应改变下一个任务的输入条件。
常见问题
为什么不能让所有 CI 任务共用系统临时目录?
系统临时目录可以作为上级目录,但每个任务应创建独立子目录。直接共用固定路径会让并发任务覆盖文件,也会让失败任务留下的套接字、锁文件和中间产物影响后续构建。
隔离 TMPDIR 后还需要单独设置 DerivedData 吗?
需要。Xcode 的 DerivedData、模块缓存和临时文件并不完全使用同一位置。应通过命令行参数或环境变量分别指定目录,才能形成完整的任务边界。
签名任务是否应该覆盖 HOME?
通常不应直接覆盖。签名流程可能依赖当前用户的 Keychain 与既有安全配置,更稳妥的做法是只隔离临时目录、构建目录和明确可迁移的工具缓存。
为构建、开发与实验选择独享物理 Mac 节点
比较两档 Mac mini M4 配置、四种租期和五个可订购节点,再按实际工作流完成选择。