云端 Mac CI 任务隔离:专用用户、ACL 与 launchctl

云端 Mac CI 任务隔离:专用用户、ACL 与 launchctl

同一台云端 Mac 连续承接构建任务时,最难发现的问题往往不是编译失败,而是上一项任务留下的状态让下一项任务“偶然成功”:缓存里已有依赖、后台服务仍占用端口、临时凭据尚未清除,或者图形会话中残留了用户级代理。这样的流水线短期看起来稳定,换一台机器或调整任务顺序后却会集中暴露。解决思路不是在结尾追加一条粗暴的删除命令,而是先划清运行身份、目录、缓存和会话域,再为每条边界安排验收。

先定义需要隔离的边界

至少要检查五类状态:运行用户、工作目录、临时目录、缓存以及后台进程。CI 服务本身可以由系统级守护进程启动,但实际构建不应长期使用管理员身份。更稳妥的做法是创建一个无管理权限的标准用户,例如 ci_runner,让检出、编译和测试都由它执行。

隔离的目标不是让任务“看不到整台机器”,而是让一次任务产生的可变状态无法在未经声明的情况下影响下一次任务。

先记录基线,后续排查才有参照:

id
umask
printf 'HOME=%s\nTMPDIR=%s\n' "$HOME" "$TMPDIR"
launchctl print "gui/$(id -u)" >/tmp/launchctl-baseline.txt 2>&1 || true
ps -axo user,pid,ppid,command

如果 runner 需要执行少量特权操作,应把命令收敛到受控脚本,并为特定动作配置权限,避免直接让整个构建以高权限运行。编译脚本也不应修改系统级工具链、全局网络设置或其他用户目录。

为每次任务创建私有工作区

任务目录应由不可预测但可审计的任务标识生成,并拒绝斜杠、空格和控制字符。目录权限设为 700,同时把临时文件放进任务内部,避免多个并发任务共享系统临时路径。

set -eu

case "${RUN_ID:-}" in
  ""|*[!A-Za-z0-9._-]*)
    echo "Invalid RUN_ID" >&2
    exit 64
    ;;
esac

RUN_ROOT="/Users/ci_runner/Jobs/$RUN_ID"
install -d -m 700 "$RUN_ROOT"
install -d -m 700 "$RUN_ROOT/src" "$RUN_ROOT/tmp" "$RUN_ROOT/cache"

export HOME="/Users/ci_runner"
export TMPDIR="$RUN_ROOT/tmp/"
export XDG_CACHE_HOME="$RUN_ROOT/cache"
umask 077

不要把工作区放进所有任务都可写的共享目录。若必须共享只读种子缓存,可以在任务开始时复制到本地缓存,再让构建过程只修改副本。这样既能利用预热结果,也不会让一个失败任务污染公共基线。

用权限而不是命名约定隔离

目录名带有任务编号并不等于安全。可用 stat -f '%Su %Sp %N' "$RUN_ROOT" 核对所有者与权限,并用 ls -lde 检查额外 ACL。发现继承规则时,应先确认来源,再通过受控初始化流程移除不需要的授权,不能在构建脚本里递归放宽权限。

把缓存分成三种寿命

缓存全部清空会拖慢流水线,全部复用又会放大污染。实际可按寿命划分:

| 类型 | 示例 | 推荐边界 | 退出处理 | |---|---|---|---| | 任务级 | 临时派生文件、测试附件 | 仅当前 `RUN_ID` | 验收后删除 | | 分支级 | 可重建的依赖下载 | 仓库与分支组合键 | 超期轮换 | | 机器级 | 只读工具包、固定 SDK 索引 | 管理员维护的种子目录 | 构建任务不可写 |

缓存键至少应包含工具链版本、依赖锁文件摘要和目标架构。仅用分支名作为键,会让工具链切换后的旧产物继续命中。恢复缓存后要做轻量验收,例如核对锁文件摘要、关键二进制架构和目录所有者;不符合条件就丢弃该副本,而不是尝试原地修补。

敏感材料不属于缓存。短期凭据应通过任务环境注入,只在需要它的子进程范围内存在,日志也不得打印完整环境。任务结束时应显式 unset 相关变量,并确认生成文件没有落入归档目录。

正确理解 launchctl 会话域

macOS 的后台代理可能属于系统域、用户域或图形会话域。构建由 ci_runner 执行,并不自动意味着它启动的所有代理都位于预期域中。先检查当前用户标识,再查看目标域:

uid="$(id -u ci_runner)"
sudo -u ci_runner launchctl print "user/$uid" >/tmp/user-domain.txt

if launchctl print "gui/$uid" >/dev/null 2>&1; then
  sudo -u ci_runner launchctl print "gui/$uid" >/tmp/gui-domain.txt
fi

纯命令行构建通常不依赖图形会话。需要模拟器或图形界面的测试则应先确认有效会话存在,不能通过反复启动代理来掩盖会话缺失。任务临时启动的服务要保存 PID,并在退出阶段发送正常终止信号;按进程名批量终止可能误伤同机其他任务。

识别跨任务残留进程

启动任务前后分别采集 ps 快照,并按用户、父进程和工作目录关联。仅检查端口并不充分,因为无监听端口的文件观察器、测试守护进程同样会消耗资源。退出后仍存在且命令行指向当前 RUN_ROOT 的进程,应视为失败而不是静默忽略。

建立可失败的退出验收

清理阶段必须保留原始任务退出码,同时让清理异常可以被观察。推荐把检查拆成固定顺序:

  1. 停止由任务启动的子进程,并等待退出。
  2. 检查工作区内是否出现套接字、挂载点或权限异常文件。
  3. 将测试报告复制到工作区之外的受控归档目录。
  4. 清除任务临时凭据和环境变量。
  5. 验证路径前缀后删除任务目录。
  6. 再次采集进程与 launchctl 状态,比较基线差异。

删除前一定要做路径保护:

cleanup() {
  case "$RUN_ROOT" in
    /Users/ci_runner/Jobs/*)
      rm -rf -- "$RUN_ROOT"
      ;;
    *)
      echo "Refusing unsafe cleanup path" >&2
      return 1
      ;;
  esac
}
trap cleanup EXIT HUP INT TERM

验收脚本不应为了保持绿色而吞掉所有错误。归档失败、残留进程、目录所有者变化都应产生明确的非零状态。对于 SoarMac 上持续运行的 CI,先在控制台确认当前可选配置,再依据并发任务的内存与磁盘压力决定是否拆分 runner;无论使用哪档配置,身份与状态边界都应保持一致。

上线前检查清单

首次接入时,用两条内容不同但步骤相同的任务连续执行,并故意让第一条任务创建缓存文件、后台进程和临时变量。第二条任务必须无法读取未声明的文件,也不能继承前一任务的进程和凭据。最后再核对以下结果:

当这些检查可以稳定重复,流水线的成功才来自声明过的输入,而不是机器上恰好残留的状态。之后无论调整并发、迁移任务或更换运行目录,都能用同一套边界快速判断差异来自哪里。

常见问题

每个 CI 任务都需要创建一个新的 macOS 用户吗?

通常不需要。先为 runner 建立不具备管理员权限的专用标准用户,再为每个任务创建权限为 700 的独立工作目录。只有互不信任的团队或安全等级不同的流水线,才值得拆成多个运行用户。

为什么只清空工作目录仍然不够?

任务还可能在临时目录、用户缓存、钥匙串、后台进程和 launchctl 会话域中留下状态。退出阶段应同时检查子进程、临时文件、挂载点和敏感环境变量,并仅删除经过路径校验的任务目录。

运行下一项任务

按工作负载选择云端 Mac

核对芯片、内存、存储、租期和节点后,订购一台 Apple Silicon 独享物理机。

选择机型并订购