远程团队常把云端 Mac 上的 CI runner 当成长期在线的基础设施,但 runner 自身也要升级。最危险的做法,是在任务执行到一半时直接覆盖程序目录并重启:编译进程可能继续使用旧文件,调度进程却已经加载新组件,最终留下难以复现的混合状态。更稳妥的方式是先排空任务,再用不可变版本目录和原子软链接完成切换。
先定义更新的安全边界
一次可控更新至少满足四个条件:不再领取新任务、当前任务自然结束、新旧版本可以并存、切换失败后能立即恢复。这里的“排空”不是清空工作区,也不是终止编译,而是在任务边界上暂停 runner。
更新期间最重要的状态不是“服务是否启动”,而是“旧任务是否结束、新任务是否尚未进入”。
开始前记录当前版本、runner 进程号、正在执行的任务标识和工作目录。若任务包含 Xcode 编译,还要确认是否存在 xcodebuild、测试进程或归档导出子进程。不要只看顶层 runner 是否空闲,因为它退出后,错误的脚本仍可能把子进程留在后台。
建议把升级判定写成明确规则:当前任务最长允许运行多久、等待多久转人工检查、哪些冒烟测试通过后才能恢复接单。规则应由团队按项目耗时制定,而不是在故障发生时临时决定。
拆分程序、配置与工作区
runner 程序应按版本放入独立目录,长期配置和构建工作区则放在版本目录之外。一个简单布局如下:
/opt/ci-runner/
├── current -> releases/runner-current
├── previous-target
├── drain
├── active.pid
├── config/
├── work/
└── releases/
├── runner-old/
└── runner-current/
config 保存注册信息和非公开配置,权限应限制到 runner 使用者;work 保存检出目录和临时构建内容;releases 只放程序。不要把令牌复制进每个版本目录,也不要让更新包覆盖工作区。
这样拆分后,升级只改变 current 指向。旧版仍保持完整,回滚不依赖重新下载或重新安装。清理旧版本也应延后到观察期结束,并至少保留最近一个经过验证的版本。
用 wrapper 在任务边界排空
最容易控制的模型是让 runner 每次只执行一个任务,退出后再由 wrapper 决定是否领取下一项。不同 runner 的单任务参数可能不同,先把实际启动命令封装为 $RUNNER_CMD,再使用统一循环:
#!/bin/zsh
set -u
root=/opt/ci-runner
lock="$root/wrapper.lock"
mkdir "$lock" 2>/dev/null || exit 1
cleanup() {
rm -f "$root/active.pid"
rmdir "$lock" 2>/dev/null
}
trap cleanup EXIT INT TERM
while [[ ! -e "$root/drain" ]]; do
"$root/current/bin/$RUNNER_CMD" run --once &
child=$!
print -r -- "$child" > "$root/active.pid"
if wait "$child"; then
status=0
else
status=$?
fi
rm -f "$root/active.pid"
if (( status != 0 )); then
sleep 10
fi
done
准备更新时执行 touch /opt/ci-runner/drain。wrapper 会允许当前子进程完成,但不会启动下一轮。随后读取 active.pid,用 kill -0 PID 判断进程是否仍存在。PID 文件为空不等于机器空闲,还应检查与该任务关联的编译和测试子进程。
如果 runner 不支持单任务退出模式,应使用其原生的暂停接单能力,并验证暂停后不会预取任务。不能确认时,先在隔离环境模拟一个长任务,再观察排空行为。
校验更新包并原子切换
把新版本解压到临时目录,完成校验后再改名进入 releases。下载包的校验值应来自团队可信的发布记录,不能从同一个未验证压缩包旁边临时生成。
root=/opt/ci-runner
release="$root/releases/runner-next"
archive=/tmp/runner-next.tar.gz
expected="$EXPECTED_SHA256"
actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
[[ "$actual" = "$expected" ]] || exit 2
mkdir -p "$release"
tar -xzf "$archive" -C "$release"
"$release/bin/$RUNNER_CMD" --version || exit 3
old="$(readlink "$root/current")"
print -r -- "$old" > "$root/previous-target"
ln -sfn "$release" "$root/current.next"
mv -fh "$root/current.next" "$root/current"
软链接切换前要确认新目录中的程序可执行、架构正确,并能读取外置配置。切换动作本身应保持很短,不要把解压、依赖安装或网络下载放进临界区。若更新还需要迁移配置,应先复制到临时文件,验证格式后再原子替换。
避免权限随解压漂移
压缩包可能携带不同的属主或权限。切换前检查程序文件不可被非 runner 用户修改,敏感配置通常应为 600,配置目录通常应为 700。同时确认工作目录仍归原 runner 用户所有,避免新版首次执行才暴露写入失败。
冒烟验收与快速回滚
切换后先不要删除 drain。手工启动一次受控冒烟任务,至少验证:runner 能读取配置、能创建临时工作区、能调用开发工具、能完成一个最小构建、能保存产物,并能正确清理退出。
验证日志时重点检查路径。新进程应从 current 指向的新目录启动,但缓存、配置和工作区仍应落在固定路径。若日志出现旧版与新版路径混用,说明仍有旧进程未退出,此时不能恢复接单。
失败时从 previous-target 读取旧链接目标,以同样的临时链接方式切回,再重跑最小任务。不要在新版目录上边修边试,这会破坏不可变发布的前提。确认旧版恢复后,再删除失败版本并重新制作发布包。
验收通过后执行 rm -f /opt/ci-runner/drain,由进程管理器重新启动 wrapper。最后观察至少一个真实任务的领取、编译、产物归档和退出状态,并把版本号、切换结果和回滚目标写入更新记录。至此,更新才算结束,而不是看到 runner 显示在线就提前收工。
常见问题
为什么不能直接在原目录覆盖 CI runner?
原地覆盖可能让正在运行的任务同时读取新旧文件,也会破坏快速回滚条件。按版本使用独立目录,并通过软链接切换,故障边界更清晰。
排空 runner 时需要终止正在执行的构建吗?
不需要。排空标记只阻止 wrapper 领取下一项任务,当前子进程应自然结束;只有超过团队设定的超时并完成现场保全后,才考虑人工终止。
更新后最少应验证哪些内容?
至少检查 runner 版本、注册状态、临时工作区写入、一次最小构建、产物归档以及敏感配置权限,任一失败都应停止接收新任务并回滚。
需要一台独享 Apple Silicon 构建机?
按天、周、月或季租用独享物理节点,用于 Xcode 构建、iOS CI、远程开发和自动化任务。