工程文章

用加密稀疏映像隔离云端 Mac CI 工作区

用加密稀疏映像隔离云端 Mac CI 工作区

同一台云端 Mac 连续执行多个构建任务时,最难发现的问题往往不是编译失败,而是上一个任务留下的源码、临时证书、测试数据或缓存被下一个任务读到。仅靠每次执行 rm -rf 不够稳:进程可能仍占用文件,隐藏目录容易漏删,异常退出还会跳过清理步骤。一个更明确的边界,是为每个任务挂载独立的加密稀疏映像,把检出、构建和临时产物都限制在这个文件系统中。

为什么选择稀疏映像隔离

稀疏映像的逻辑容量可以较大,宿主磁盘只按实际写入量增长。它仍是普通文件,便于按任务编号定位、统计和删除;挂载后则表现为独立 APFS 卷,现有构建脚本通常只需修改工作目录。

加密解决的是映像未挂载时的数据暴露风险,独立挂载点解决的是任务之间的路径边界。两者都不能替代运行用户隔离和最小权限,但比共享一个长期存在的工作目录更容易审计。

不要把“映像已加密”理解成任务运行期间不可访问。映像挂载后,拥有相应文件权限的进程仍能读取其中内容,因此 runner 用户、日志和密钥注入方式必须一起治理。

方案异常退出后的残留任务边界适用情况
共享目录后删除容易漏掉占用文件和隐藏目录依赖脚本正确性无敏感数据的短任务
每任务普通目录目录仍留在宿主卷路径分开,文件系统未分开低风险并发构建
加密稀疏映像可识别残留挂载与映像文件独立文件系统需要明确清理边界的 CI

创建按任务命名的 APFS 工作区

任务编号必须先经过字符收敛,避免斜杠、空格或命令替换进入路径。映像目录应位于仅 runner 用户可读写的位置,挂载点则为每个任务单独创建。

set -euo pipefail
set +x
umask 077

JOB_KEY="$(printf '%s' "${CI_JOB_ID:?}" | tr -cd 'A-Za-z0-9._-')"
IMAGE_ROOT="$HOME/ci-images"
IMAGE_PATH="$IMAGE_ROOT/$JOB_KEY.sparsebundle"
MOUNT_PATH="/Volumes/ci-$JOB_KEY"

mkdir -p "$IMAGE_ROOT" "$MOUNT_PATH"
chmod 700 "$IMAGE_ROOT"

printf '%s' "${CI_VOLUME_PASSWORD:?}" |
  hdiutil create \
    -size 80g \
    -type SPARSEBUNDLE \
    -fs APFS \
    -volname "ci-$JOB_KEY" \
    -encryption AES-256 \
    -stdinpass \
    "$IMAGE_PATH"

80g 是逻辑上限,不代表立即占用 80GB。上限应覆盖源码、依赖、派生数据和归档峰值,并给失败日志留余量。不要把密码写进命令参数、文件名或构建日志;关闭命令回显后,再从 CI 的受保护变量通过标准输入传递。

挂载后立即验证

创建成功不等于挂载点正确。脚本应检查目标路径确实是挂载卷,并确认文件系统类型。

printf '%s' "$CI_VOLUME_PASSWORD" |
  hdiutil attach \
    -stdinpass \
    -nobrowse \
    -mountpoint "$MOUNT_PATH" \
    "$IMAGE_PATH"

mount | grep -F "on $MOUNT_PATH "
diskutil info "$MOUNT_PATH" | grep -E 'File System Personality|Volume Name'
mkdir -p "$MOUNT_PATH/src" "$MOUNT_PATH/output" "$MOUNT_PATH/tmp"

之后把源码检出目录、构建输出和任务级临时目录统一指向该卷。包管理器的共享只读缓存可以留在卷外,但任何可能被任务修改的缓存都应复制到卷内,避免并发写入污染。

把卸载写成生命周期而非收尾命令

清理不能只放在脚本最后一行,因为编译错误、超时和终止信号都会提前结束任务。用退出钩子统一执行同步、占用检查和卸载,失败时保留足够诊断信息。

cleanup_workspace() {
  set +e
  sync
  if mount | grep -Fq "on $MOUNT_PATH "; then
    lsof +D "$MOUNT_PATH" > "$IMAGE_ROOT/$JOB_KEY.lsof.txt" 2>/dev/null
    hdiutil detach "$MOUNT_PATH"
  fi
  rmdir "$MOUNT_PATH" 2>/dev/null
}

trap cleanup_workspace EXIT INT TERM
export TMPDIR="$MOUNT_PATH/tmp"
cd "$MOUNT_PATH/src"

lsof +D 在目录很大时可能较慢,可以先检查已知的构建、测试和打包进程,再在卸载失败时执行完整扫描。不要一开始就使用强制卸载,它可能掩盖仍在写入的进程,并留下不完整产物。

处理并发、容量和失败残留

同一任务重试时,旧映像可能仍存在。安全做法不是直接覆盖,而是先检查它是否已挂载:已挂载则阻止新任务并记录冲突;未挂载则按保留策略归档或删除。任务编号也应包含本次执行序号,避免两次运行指向同一映像。

建立三项检查

第一,任务开始前列出 hdiutil info,确认没有同名卷。第二,构建中同时监控宿主卷和挂载卷剩余空间;稀疏映像会增长,逻辑卷有空间不代表宿主卷有空间。第三,任务结束后扫描映像目录,只允许保留明确标记为诊断用途的失败样本。

可以用以下命令区分两层容量:

df -h "$MOUNT_PATH"
df -h "$IMAGE_ROOT"
du -sh "$IMAGE_PATH"
hdiutil info

若某类任务经常触及上限,应先拆分无需长期保留的中间产物,再调整逻辑容量。盲目扩大映像只会把宿主磁盘耗尽的时点向后推移。

安全边界与落地检查单

密钥应只在创建和挂载阶段进入进程,完成后立即从当前 shell 取消导出。构建脚本不得打印环境变量,也不应把密码复制到卷内。映像文件权限保持 600 或更严格,映像根目录保持 700

上线前逐项确认:

  • runner 使用独立的非管理员账户;
  • 任务编号经过白名单过滤,路径不会发生穿越;
  • 创建、挂载、构建和卸载都能独立失败并返回明确状态;
  • EXITINTTERM 均进入同一清理函数;
  • 卸载失败时先记录占用进程,不立即强制处理;
  • 同时监控 APFS 卷、稀疏映像文件和宿主卷容量;
  • 失败样本设定保留期限,到期后删除整个映像;
  • 日志不包含密码、私钥、完整凭据或敏感源码片段。

先用一个无敏感数据的小型工程演练正常完成、编译失败、手动终止和磁盘接近上限四条路径。只有四种情况下都能找到映像、解释状态并完成回收,这套隔离才真正进入可运维状态。

常见问题

加密稀疏映像能代替构建机上的权限隔离吗?

不能。它主要隔离工作区数据并降低离线残留暴露风险,仍需配合独立运行用户、最小文件权限、受控密钥注入和任务结束后的挂载回收。

任务异常退出后无法卸载映像怎么办?

先用 lsof 定位仍占用挂载点的进程,停止对应任务进程并执行 sync,再正常卸载;只有确认没有写入后才考虑强制卸载,并保留诊断记录。

独享云端 Mac

为开发、构建与远程桌面选择一台独享物理机

比较三档 Apple Silicon 配置,并在下单时选择节点、租期与存储附加项。

选择租用方案