GitHub Actions 私有倉庫分鐘不夠?VPS 雙 Self-hosted Runner 配置與隔離
依現有雙 Runner、歷史成功作業及 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 數值來自已有安裝腳本,並非新進行的宿主機壓力測試。