# GitHub Actions 私有倉庫分鐘不夠？VPS 雙 Self-hosted Runner 配置與隔離

> Canonical: https://vxi.me/zh-tw/posts/github-actions-vps-dual-runner/
> Locale: zh-tw
> Published: 2026-10-19
> Updated: 2026-10-19
> Markdown: https://vxi.me/zh-tw/posts/github-actions-vps-dual-runner.md

<picture class="runner-hero"><source media="(max-width: 640px)" srcset="/images/posts/github-actions-vps-dual-runner/dual-runner-trust-boundaries.mobile.zh-tw.svg" width="420" height="626" type="image/svg+xml"><img src="/images/posts/github-actions-vps-dual-runner/dual-runner-trust-boundaries.zh-tw.svg" alt="GitHub 將不可信 PR 分流到隔離 Runner，經審查的部署與 CI 使用同一 VPS 的獨立 Runner，CI 有 systemd 限額。" width="1200" height="590" loading="eager" fetchpriority="high" decoding="async" style="display:block;width:100%;height:auto"></picture>

*依現有雙 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 計費文件](https://docs.github.com/en/billing/concepts/product-billing/github-actions)目前的規則是：Self-hosted Runner 作業使用免費；私有倉庫的標準 GitHub-hosted Runner 先消耗方案包含的分鐘，超額再收費。按目前官方額度，GitHub Free 每月包含 2,000 分鐘，Pro 和 Team 為 3,000 分鐘，具體仍要以倉庫擁有者的帳戶為準。以標準 Linux x64、2 vCPU 為例，[官方標價](https://docs.github.com/en/billing/reference/actions-runner-pricing)是 **$0.006/分鐘**，託管 Job 的不足一分鐘部分向上取整。

這件事得拆成三筆：

| 成本 | 把任務遷到 VPS 後會怎樣 |
| --- | --- |
| GitHub-hosted 計算分鐘 | 遷走的 Self-hosted Job 不再消耗這項託管分鐘 |
| VPS 計算與維護 | CPU、記憶體、磁碟、頻寬和運維成本由自己承擔 |
| GitHub 其他 SKU | Artifact、Cache 等超額儲存及其他託管任務仍需單獨核算 |

還有個容易被舊新聞帶偏的時間點。[GitHub 原本宣布](https://github.blog/changelog/2025-12-16-coming-soon-simpler-pricing-and-a-better-experience-for-github-actions/)要對 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 文件](https://docs.github.com/en/actions/concepts/runners/self-hosted-runners)有完整機制說明。

我從已有 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 帳戶、獨立工作目錄和最小權限，並分別設定標籤，例如：

```yaml
# 已审查的生产部署
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** 上採用的初始限制：

```ini
[Service]
CPUQuota=75%
MemoryHigh=700M
MemoryMax=1050M
OOMScoreAdjust=400
Nice=10
```

這裡有個容易理解錯的細節。按 [systemd 的資源控制文件](https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html)，`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 服務步驟](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application?learnProduct=actions&platform=linux)，分別進入已註冊好的生產、CI Runner 安裝目錄，使用管理員權限對各自的 `svc.sh` 執行 `install <專用用戶>`，然後 `start`。例如在**CI Runner 自己的安裝目錄**（已註冊、`svc.sh` 已存在）執行：

```bash
sudo ./svc.sh install ci-runner
sudo ./svc.sh start
sudo ./svc.sh status
```

`ci-runner` 是示例帳戶，不要複用生產帳戶；若已有執行中的服務，**不要重複 install**。完成後再查找真實的 **CI unit** 並在維護窗口設定限制：

```bash
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 方案：

```yaml
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](https://github.com/actions/checkout/tree/v7) 與 [setup-node v7](https://github.com/actions/setup-node/tree/v7) 有對應說明。

約 586 MiB 可用記憶體、5 GiB 可用磁碟是這台小 VPS 的准入門檻。腳本會列印剩餘量和失敗原因，方便區分記憶體不足與磁碟不足。

**這一步只能檢查，不能預留。** 兩個倉庫的 `concurrency` 組不是跨倉庫全域鎖。我的選擇是讓工程 CI 受獨立 systemd cgroup 的 CPU/記憶體上限約束，在資源不足時失敗而不是無限膨脹；生產服務保持自己的獨立權限和資源邊界。對大建構或必須嚴格避免兩倉庫同時執行的場景，最可靠的方案是**改用單一受控任務佇列或獨立 VPS**，由單一調度者串列分發工作；不要把跨倉庫 `concurrency` 同名誤當成全域互斥。想實現真正的主機級串列化，可以另外設計受審查的 `flock` 包裝執行器，但它必須鎖住**全部資源密集型任務的入口**，只在其中一個 Job 前加鎖沒有效果；此方案未在本機部署。這樣做的代價就是任務需要排隊，不能同時保持雙倉庫併發吞吐。

真要在**同一主機**串列化兩倉庫的重型步驟，可以選一個單獨審查的共享檔案鎖方案。先由管理員建立僅供鎖使用的系統組，把兩個可信 Runner 使用者加入組，並配置共享鎖檔案（只用於協調建構；不要讓組獲得生產目錄權限）：

```bash
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 不能被隨意中斷。接著**兩個倉庫都必須**把各自資源密集的命令放進同一把鎖的作用範圍：

```bash
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 的[安全指南](https://docs.github.com/en/actions/reference/security/secure-use)提醒：持久化 Self-hosted Runner 執行不受信任程式碼，有可能留下跨 Job 的持久污染，或讓腳本接觸宿主機檔案及權限。如果未來允許外部協作者、Fork PR 或其他不受信任程式碼進入驗證鏈路，應該把它們和生產機分開：

```text
不受信任 PR -> GitHub-hosted / 全新隔离 Runner -> 审查
受保护 main -> 专用生产 Self-hosted Runner -> 部署
```

這意味著部分 PR 驗證可能繼續花 GitHub-hosted 分鐘。但為省下幾分鐘就允許陌生腳本接近部署憑證，是不划算的交換。即便倉庫是私有的，也要看實際協作者權限、分支保護和誰能修改 Workflow。

## Runner 掉線或 VPS 故障，怎樣恢復部署

自建 Runner 的成本不僅是月租，還包括宕機、更新和恢復時間。一個特別實際的區別是：**Self-hosted Runner 離線，匹配它標籤的 Job 會排隊，不會自動回落到 `ubuntu-latest`**。[GitHub 文件](https://docs.github.com/en/actions/reference/runners/self-hosted-runners)說明，無可用 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 用量；需要細帳時，可以按[官方方法](https://docs.github.com/en/billing/how-tos/products/view-productlicense-use)匯出報告。將超出免費額度的託管分鐘、Artifact/Cache 等儲存、額外的 VPS 租金和維護成本分開算。

我看這類遷移，順序始終是：**先看方案分鐘是否真的緊張，再看 VPS 是否原本常駐，最後核對執行程式碼的信任邊界。** 現有 VPS 上穩定、頻繁的可信建構適合嘗試 Self-hosted；CI 不多、免費分鐘用不完的專案，繼續用 GitHub-hosted 可能更划算。

費用判斷可以直接代入自己的數字：**每月超出方案的可遷移託管分鐘 × 對應 SKU 單價 − 新增 VPS/運維成本**。例如假設 1000 分鐘全部為標準 Linux 2-core 的超額用量，清單價是 $6；若它們本就在免費額度內，邊際帳單可能是 $0。只把可信、可維護、能恢復的任務遷走，才算划算。

之前的[ChatGPT Web + GitHub + Self-hosted Runner 實踐](/posts/chatgpt-web-github-self-hosted-runner/)講的是開發入口與部署出口；這篇則是另一道題：**GitHub-hosted 分鐘怎麼省，以及一台 VPS 上多個 Runner 怎麼不互相拖垮。**

*時間說明：計費與官方公告按 2026 年 10 月 19 日核對；Job 時間來自既有執行記錄，systemd 數值來自已有安裝腳本，並非新進行的宿主機壓力測試。*
