GitHub Actions 私有仓库分钟不够?VPS 双 Self-hosted Runner 配置与隔离
图:依据已有双 Runner 架构、历史成功 Job 与 systemd 配置整理;两类 Runner 标签为示意,非真实生产标签。 动画首次演示任务分流和资源边界,之后仅保留低频连接光效;开启系统“减少动态效果”时显示静态架构图。
私有仓库的 CI 跑得勤,GitHub Actions 套餐里的免费分钟就容易成为一个要盯的数字。如果手边已经有一台常开的 VPS,能不能让 GitHub 继续管理 Workflow,把真正的构建和测试交给这台机器?
可以。我维护的两套工作流已经把一部分确定性的任务交给同一台 VPS:一个 Runner 负责博客的构建与部署,另一个跑工程工具的检查。GitHub 负责触发与记录,计算发生在自己的服务器。GitHub 当前不收 Self-hosted Runner 的作业计算分钟费;但这不等于 VPS 免费,更不意味着应该把所有 PR 都扔进生产机。
要不要这样做,我会先问三个问题:
- 免费分钟真的快用完了吗? 如果还绰绰有余,GitHub-hosted 的省心可能比自建更值钱。
- VPS 原本就要全天在线吗? 复用已有算力,与为 CI 新租一台机器,是两本不同的账。
- 要运行的代码可信到什么程度? 受保护主分支的固定任务可以考虑自建;不受信任的 PR 先交给隔离执行环境。
前两个问题决定值不值得折腾,第三个问题决定会不会为了省几分钟把生产环境一起赔进去。
Self-hosted 免费的是计算分钟,不是整张账单
GitHub Actions 计费文档目前的规则是:Self-hosted Runner 作业使用免费;私有仓库的标准 GitHub-hosted Runner 先消耗套餐包含的分钟,超额再收费。按当前官方额度,GitHub Free 每月包含 2,000 分钟,Pro 和 Team 为 3,000 分钟,具体仍要以仓库所有者的账户为准。以标准 Linux x64、2 vCPU 为例,官方标价是 $0.006/分钟,托管 Job 的不足一分钟部分向上取整。
这件事得拆成三笔:
| 成本 | 把任务迁到 VPS 后会怎样 |
|---|---|
| GitHub-hosted 计算分钟 | 迁走的 Self-hosted Job 不再消耗这项托管分钟 |
| VPS 计算与维护 | CPU、内存、磁盘、带宽和运维成本由自己承担 |
| GitHub 其他 SKU | Artifact、Cache 等超额存储及其他托管任务仍需单独核算 |
还有个容易被旧新闻带偏的时间点。GitHub 原本宣布要对 Self-hosted Runner 增收每分钟 $0.002 的平台费,随后更新公告,推迟实施这项收费。旧文里“2026 年 3 月开始收费”不能直接当现行规则;反过来,也不能保证它永远不会变。
我的三个历史 Job 可以拿来做一笔假设:如果它们换到标准 2 vCPU Linux 托管 Runner 后恰好同样用时,分别向上取整为 9、13、2 分钟,合计 24 分钟。只有当这些分钟全是超额付费分钟时,参考价才是 $0.144。 这只是对照清单价的算式,不是实际节省金额;同周期的账单和套餐已包含用量要在账户 Usage 里核对。
一台 VPS、两个 Runner:不是改个 runs-on 就算落地
Self-hosted Runner 是在本机运行、主动连接 GitHub 领取任务的进程,并非 GitHub 通过 SSH 登录 VPS。官方 Runner 文档有完整机制说明。
我从已有 GitHub Actions Job 记录里拿出三条成功执行的任务:
| 实际任务 | Runner 角色 | 结果 | Job 日志首末跨度 |
|---|---|---|---|
| 博客构建与生产部署 | 生产 Runner | Success | 8 分 32 秒 |
| 博客 PR 内容验证(第 3 次尝试) | 同一个生产 Runner | Success | 12 分 12 秒 |
| 工程工具安全和契约检查 | 独立 CI Runner | Success | 1 分 27 秒 |
两个 Runner 在 GitHub 上拥有不同身份,Job 日志指向同一台 VPS。这至少说明双仓库确实利用了自有计算资源,而不是只写好一段 YAML。表中的耗时是历史 Job 的日志跨度,适合核对执行记录;容量问题还得靠资源限制和运维策略解决。
一台配置不大的 VPS 最容易出问题的地方,反而是资源互抢:CI 一跑大型构建,常驻生产服务突然变得迟钝。所以我把 Runner 身份、宿主机限制和 Workflow 准入分开处理。
两个 Runner 标签还不够:systemd 真正负责限制资源
两个仓库可以分别在 Settings → Actions → Runners 建立自己的 Repo-level Runner,从 GitHub 当前页面取得下载地址、校验值和短期注册 Token。生产和 CI 使用不同的非 root Linux 账户、独立工作目录和最小权限,并分别设置标签,例如:
# 已审查的生产部署
runs-on: [self-hosted, linux, x64, deploy-prod]
# 受信任的工程检查
runs-on: [self-hosted, linux, x64, ci-lab]
标签只负责选人,不负责隔离。 如果两个 Runner 都能读生产密钥,或者都拿着不受限制的 sudo、Docker Socket 权限,名字再不同也保护不了 VPS。
我给低优先级 CI Runner 写的安装脚本里,实际包含这样一段 systemd service drop-in。这是 2 vCPU、约 2.4 GiB 内存的共享小 VPS 上采用的初始限制:
[Service]
CPUQuota=75%
MemoryHigh=700M
MemoryMax=1050M
OOMScoreAdjust=400
Nice=10
这里有个容易理解错的细节。按 systemd 的资源控制文档,CPUQuota=75% 指相对于单个 CPU 核心的 75% CPU 时间,不是双核 VPS 整机的 75%。MemoryHigh=700M 是内存回收压力开始增大的软阈值;MemoryMax=1050M 是硬边界,超过后可能触发 OOM、让 CI Job 失败。Nice=10 和 OOMScoreAdjust=400 让这套 CI 更倾向于向关键服务让路,但无法承诺生产永不受影响。
前提是宿主机使用支持内存控制器的 cgroup v2 与相应 systemd 版本。先运行 test -f /sys/fs/cgroup/cgroup.controllers && grep -qw memory /sys/fs/cgroup/cgroup.controllers && systemctl --version,确认统一层级、内存控制器和 systemd 版本;老环境不要照抄后就默认限额已经生效。
配置 Runner 的 ./config.sh 只负责注册,不会自动创建 systemd 服务。按照 GitHub 官方 Linux 服务步骤,分别进入已注册好的生产、CI Runner 安装目录,使用管理员权限对各自的 svc.sh 执行 install <专用用户>,然后 start。例如在CI Runner 自己的安装目录(已注册、svc.sh 已存在)执行:
sudo ./svc.sh install ci-runner
sudo ./svc.sh start
sudo ./svc.sh status
ci-runner 是示例账户,不要复用生产账户;若已有运行中的服务,不要重复 install。完成后再查找真实的 CI unit 并在维护窗口设置限制:
systemctl list-unit-files 'actions.runner.*.service'
# 替换为上一行显示的真实 CI unit,切勿照抄占位名称
UNIT='actions.runner.EXAMPLE-REPO.ci-01.service'
sudo systemctl edit "$UNIT"
# 在编辑器里保存上面的 [Service] 配置
sudo systemctl daemon-reload
sudo systemctl restart "$UNIT"
systemctl cat "$UNIT"
systemctl show "$UNIT" -p CPUQuotaPerSecUSec -p MemoryHigh -p MemoryMax
restart 会中断这个 Runner 正在执行的 Job,先确认它空闲,且不要误重启生产 Runner。重启后还要在仓库 Runners 页面确认它恢复 Online,并核对 systemctl show 的生效值。撤销配置时只撤销这次新增的 CI drop-in,不要顺手清空已有服务定制。注册码、主机名、服务账户和生产目录都无需写进 Workflow,也不应该出现在公开文章里。
在 Workflow 开始前再检查一次资源
systemd 给服务进程组设边界,但不能帮你凭空腾出磁盘。我的工程 CI 还有一道资源准入:读取主机的可用内存,以及工作目录所在文件系统的可用空间。下面是一份面向可信 main 分支的精简 Node.js 方案:
name: Trusted main validation
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: trusted-main-validation
cancel-in-progress: false
jobs:
test:
runs-on: [self-hosted, linux, x64, ci-lab]
timeout-minutes: 12
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check available resources
shell: bash
run: |
set -euo pipefail
mem_kib="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
disk_kib="$(df -Pk "$GITHUB_WORKSPACE" | awk 'NR==2 {print $4}')"
echo "available memory=${mem_kib} KiB, disk=${disk_kib} KiB"
if [ "$mem_kib" -lt 600000 ]; then
echo "::error::Memory below 600000 KiB: ${mem_kib} KiB"
exit 1
fi
if [ "$disk_kib" -lt 5242880 ]; then
echo "::error::Disk below 5242880 KiB: ${disk_kib} KiB"
exit 1
fi
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm test
这份示例采用 actions/checkout@v7 与 actions/setup-node@v7;它们使用 Node.js 24 作为 Action 运行时,要求 Actions Runner 版本至少 v2.327.1。迁移现有机器前先查看 Runner 版本,别只升级 YAML。checkout v7 与 setup-node v7 有对应说明。
约 586 MiB 可用内存、5 GiB 可用磁盘是这台小 VPS 的准入门槛。脚本会打印剩余量和失败原因,方便区分内存不足与磁盘不足。
这一步只能检查,不能预留。 两个仓库的 concurrency 组不是跨仓库全局锁。我的选择是让工程 CI 受独立 systemd cgroup 的 CPU/内存上限约束,在资源不足时失败而不是无限膨胀;生产服务保持自己的独立权限和资源边界。对大构建或必须严格避免两仓库同时执行的场景,最可靠的方案是改用单一受控任务队列或独立 VPS,由单一调度者串行分发工作;不要把跨仓库 concurrency 同名误当成全局互斥。想实现真正的主机级串行化,可以另外设计受审查的 flock 包装执行器,但它必须锁住全部资源密集型任务的入口,只在其中一个 Job 前加锁没有效果;此方案未在本机部署。这样做的代价就是任务需要排队,不能同时保持双仓库并发吞吐。
真要在同一主机串行化两仓库的重型步骤,可以选一个单独审查的共享文件锁方案。先由管理员创建仅供锁使用的系统组,把两个可信 Runner 用户加入组,并配置共享锁文件(只用于协调构建;不要让组获得生产目录权限):
sudo groupadd --system ci-queue
sudo usermod -aG ci-queue runner-a
sudo usermod -aG ci-queue runner-b
sudo install -d -o root -g ci-queue -m 3770 /var/lock/ci-queue
sudo install -o root -g ci-queue -m 0660 /dev/null /var/lock/ci-queue/build.lock
这里的账户名只是占位符,加入新组后应在维护窗口重启相关 Runner 服务,让它们取得新的附加组权限;运行中的 Job 不能被随意中断。接着两个仓库都必须把各自资源密集的命令放进同一把锁的作用范围:
flock -w 900 /var/lock/ci-queue/build.lock \
bash -e -c 'npm ci && npm test'
这样第二个参与者最多等待 900 秒后失败,而不是无限挂起。它只保护被同一把锁包住的命令,不会自动串行化 GitHub Job 的所有步骤,更不能抵御不可信工作流主动跳过锁。也因此,我现有双 Runner 没有采用这一套锁;它是需要独立评估的可复制方案,不应说成已经运行的实测结果。
不要为让一次 CI 变绿就无上限调大 MemoryMax;长期超过余量的任务应移到单独机器。
PR 与生产共用 Runner,是另外一笔风险账
翻看博客的现有 Workflow,我发现内容 PR 验证与生产部署仍使用同一套生产 Runner 标签。在只有受信任维护者的私有仓库里,它可能暂时能用;但不能当成开放外部协作的安全教程。
GitHub 的安全指南提醒:持久化 Self-hosted Runner 运行不受信任代码,有可能留下跨 Job 的持久污染,或让脚本接触宿主机文件及权限。如果未来允许外部协作者、Fork PR 或其他不受信任代码进入验证链路,应该把它们和生产机分开:
不受信任 PR -> GitHub-hosted / 全新隔离 Runner -> 审查
受保护 main -> 专用生产 Self-hosted Runner -> 部署
这意味着部分 PR 验证可能继续花 GitHub-hosted 分钟。但为省下几分钟就允许陌生脚本接近部署凭据,是不划算的交换。即便仓库是私有的,也要看实际协作者权限、分支保护和谁能修改 Workflow。
Runner 掉线或 VPS 故障,怎样恢复部署
自建 Runner 的成本不仅是月租,还包括宕机、更新和恢复时间。一个特别实际的区别是:Self-hosted Runner 离线,匹配它标签的 Job 会排队,不会自动回落到 ubuntu-latest。GitHub 文档说明,无可用 Runner 的作业最长等待约 24 小时后失败。
恢复顺序可以很简单:先看 GitHub 仓库 Runners 页面是否 Online,再看 CI 对应的 ./svc.sh status、journalctl -u <真实unit> -n 100 --no-pager 和磁盘余量;若出现 cgroup OOM,先缩小工作量或等待空闲,不要直接取消限制。Runner 应用通常自动更新,但关闭自动更新后需要自行跟进版本;官方规定超过更新窗口或遇到关键安全更新时,旧 Runner 可能不再接收任务。
生产部署最好另有明确的恢复通道:比如在受保护的发布入口提供单独审批的 GitHub-hosted 构建 + 受控部署步骤,或通过现有运维通道恢复 VPS 后再重试。GitHub-hosted Runner 默认无法访问 VPS 本地目录或持有原生产部署凭据,所以只改 runs-on: ubuntu-latest 并不是能工作的回退。没有另行演练之前,不要把这个备用方案写成已验证的自动容灾。
真正有没有省钱,最后看 Usage
绿色的 Actions 只能证明 Job 跑完了,不能替你核算月账单。在 GitHub 的 Billing & licensing → Usage 里按计费周期查看 Actions 用量;需要细账时,可以按官方方法导出报告。将超出免费额度的托管分钟、Artifact/Cache 等存储、额外的 VPS 租金和维护成本分开算。
我看这类迁移,顺序始终是:先看套餐分钟是否真的紧张,再看 VPS 是否原本常驻,最后核对执行代码的信任边界。 现有 VPS 上稳定、频繁的可信构建适合尝试 Self-hosted;CI 不多、免费分钟用不完的项目,继续用 GitHub-hosted 可能更划算。
费用判断可以直接代入自己的数字:每月超出套餐的可迁移托管分钟 × 对应 SKU 单价 − 新增 VPS/运维成本。例如假设 1000 分钟全部为标准 Linux 2-core 的超额用量,清单价是 $6;若它们本就在免费额度内,边际账单可能是 $0。只把可信、可维护、能恢复的任务迁走,才算划算。
之前的ChatGPT Web + GitHub + Self-hosted Runner 实践讲的是开发入口与部署出口;这篇则是另一道题:GitHub-hosted 分钟怎么省,以及一台 VPS 上多个 Runner 怎么不互相拖垮。
时间说明:计费与官方公告按 2026 年 10 月 19 日核对;Job 时间来自既有运行记录,systemd 数值来自已有安装脚本,并非新进行的宿主机压力测试。