未信頼PRは隔離環境へ、審査済み配備とCIは同じVPS上の別Runnerへ振り分けます。CIにはsystemd制限があります。

実際の双Runner構成、過去の成功Job、systemd設定を基にした図です。ラベルは例示です。 アニメーションは最初にタスクの振り分けとリソース境界を示し、その後は低頻度の接続の発光のみを残します。システムの「動きの軽減」がオンの場合は静止した構成図が表示されます。

プライベートリポジトリでCIを頻繁に回すと、GitHub Actionsのプランに含まれる無料分が「気になる数字」になりがちです。手元に常時起動のVPSがあるなら、GitHubには引き続きWorkflowの管理を任せ、実際のビルドとテストをそのVPSにやらせることはできないでしょうか。

できます。私が保守する2つのワークフローは、既に決定的なタスクの一部を同じVPSに任せています。1つのRunnerがブログのビルドとデプロイを担当し、もう1つがエンジニアリングツールのチェックを回します。GitHubはトリガーと記録を受け持ち、計算は自分のサーバーで起きます。GitHubは現在、Self-hosted Runnerのジョブに計算分の課金をしていません。しかし、だからといってVPSが無料になるわけでも、すべてのPRを本番機に投げてよいわけでもありません。

やるべきかどうか、私はまず3つの質問を立てます。

  • 無料分は本当に使い切りそうですか? まだ余裕があるなら、GitHub-hostedの手軽さの方が自前の構築より価値があるかもしれません。
  • VPSはもともと常時オンラインですか? 既存の計算資源を流用するのと、CIのために新たに1台借りるのでは、まったく別の帳簿になります。
  • 実行するコードはどれだけ信頼できますか? 保護されたmainブランチの定型タスクは自前で検討できます。信頼できないPRは、まず隔離実行環境に渡します。

最初の2つは手間をかける価値があるかを決め、3つ目は数分を節約する代わりに本番環境ごと犠牲にしないかを決めます。

Self-hostedが無料なのは計算分だけ、請求全体ではない

GitHub Actionsの課金ドキュメントによれば、現行ルールは次の通りです。Self-hosted Runnerのジョブは無料。プライベートリポジトリの標準的なGitHub-hosted Runnerは、まずプランに含まれる分を消費し、超過分は有料です。現在の公式枠では、GitHub Freeは月2,000分、ProとTeamは3,000分が含まれますが、具体値はリポジトリ所有者のアカウントに依存します。標準的なLinux x64・2 vCPUを例にすると、公式価格は $0.006/分 で、ホスト型ジョブは1分未満の部分を切り上げます。

これは3つの勘定に分けて考える必要があります。

コストVPSへ移行するとどうなるか
GitHub-hostedの計算分移行したSelf-hostedジョブはこのホスト分を消費しなくなる
VPSの計算と保守CPU、メモリ、ディスク、帯域、運用コストは自己負担
GitHubのその他SKU超過分のArtifact・Cacheストレージやその他のホスト型ジョブは別途計算が必要

古いニュースに引きずられがちな時点もあります。GitHubは当初、Self-hosted Runnerに$0.002/分のプラットフォーム料金を課すと発表しましたが、その後の更新で実施を延期しました。古い記事の「2026年3月から課金」は現行ルールとして扱えません。とはいえ、永遠に変わらない保証もありません。

私の3つの過去ジョブは、よい仮定の材料になります。標準2 vCPUのLinuxホスト型Runnerでまったく同じ時間がかかったとすると、切り上げで9分、13分、2分の計24分です。これらがすべて超過の有料分だった場合に限り、参考価格は$0.144になります。 これは定価との照合計算であって、実際の節約額ではありません。同じ期間の請求とプラン内使用量は、アカウントのUsageで確認する必要があります。

1台のVPSと2つのRunner:runs-onを変えるだけでは済まない

Self-hosted Runnerは自機で動作し、GitHubに接続してジョブを受け取るプロセスです。GitHubがSSHでVPSにログインする仕組みではありません。公式のRunnerドキュメントに仕組みの説明があります。

既存のGitHub Actionsジョブ記録から、成功した3つの実行を取り出します。

実際のタスクRunnerの役割結果ジョブのログ先頭末尾の時間差
ブログのビルドと本番デプロイ本番RunnerSuccess8分32秒
ブログPRのコンテンツ検証(3回目の試行)同じ本番RunnerSuccess12分12秒
エンジニアリングツールのセキュリティと契約チェック独立したCI RunnerSuccess1分27秒

2つのRunnerはGitHub上で異なるアイデンティティを持ち、ジョブのログは同じVPSを指しています。これだけでも、2つのリポジトリが実際に自前の計算資源を使っていることが分かります。うまく書けたYAMLだけ、というわけではありません。表の時間は過去ジョブのログの時間差で、実行記録の照合に適しています。キャパシティの問題は、リソース制限と運用ポリシーで解決するしかありません。

それほど大きくないVPSで一番起きやすい問題は、むしろリソースの奪い合いです。CIが大きなビルドを始めると、常駐の本番サービスが急に鈍くなります。そこでRunnerのアイデンティティ、ホストレベルの制限、Workflowの受入を別々に扱っています。

Runnerのラベルだけでは足りない:リソース制限はsystemdの仕事

2つのリポジトリは、それぞれ Settings → Actions → Runners で独自のRepo-level Runnerを作れます。GitHubの現行ページからダウンロードURL、チェックサム、短期の登録トークンを取得します。本番とCIは、rootでない異なるLinuxアカウント、独立した作業ディレクトリ、最小権限で動作し、別々のラベルを付けます。例えば次の通りです。

# 已审查的生产部署
runs-on: [self-hosted, linux, x64, deploy-prod]

# 受信任的工程检查
runs-on: [self-hosted, linux, x64, ci-lab]

ラベルは誰を選ぶかだけを決め、分離はしません。 2つのRunnerがどちらも本番の秘密を読めたり、どちらも制限のない sudo やDockerソケット権限を持っていたりすれば、名前が違っても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コアのCPU時間の75% を意味し、デュアルコアVPS全体の75%ではありません。MemoryHigh=700M はメモリ回収の圧力が高まり始めるソフトなしきい値、MemoryMax=1050M はハードな境界で、超えるとOOMが発生してCIジョブが失敗する可能性があります。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が実行中のジョブを中断します。まずアイドルであることを確認し、本番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の受入しきい値です。スクリプトは残量と失敗理由を出力するので、メモリ不足とディスク不足を区別しやすくなります。

このステップは確認だけで、予約はできません。 2つのリポジトリの concurrency グループは、リポジトリをまたぐグローバルロックではありません。私の選択は、エンジニアリングCIを独立したsystemd cgroupのCPU/メモリ上限の下に置き、リソース不足のときは無限に膨らむのではなく失敗させること。本番サービスは独自の権限とリソース境界を保ちます。大きなビルドや、2つのリポジトリの同時実行を厳密に避けなければならない場面では、最も確実なのは単一の管理されたタスクキューか独立したVPSへの切り替えで、単一のスケジューラが仕事を直列に分配します。リポジトリをまたぐ同名の concurrency をグローバルな相互排他と誤解しないでください。本当のホストレベルの直列化が欲しければ、別途審査した flock ラッパー実行器を設計できますが、すべてのリソース集約タスクの入口をロックしなければならず、1つのジョブの前だけにロックをかけても効果はありません。この方式は私の環境では導入していません。代償はタスクがキューに並ぶことです。2つのリポジトリの同時スループットは維持できません。

同じホストで2つのリポジトリの重いステップを直列化したい場合は、別途審査した共有ファイルロック方式を選べます。まず管理者がロック専用のシステムグループを作り、信頼できる2つの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サービスを再起動し、新しい補助グループを取得させます。実行中のジョブを安易に中断してはいけません。次に、両リポジトリとも、各自のリソース集約コマンドを同じロックの範囲内に入れる必要があります。

flock -w 900 /var/lock/ci-queue/build.lock \
  bash -e -c 'npm ci && npm test'

こうすれば2番目の参加者は最大900秒待ってから失敗します。無限にぶら下がることはありません。これは同じロックで包んだコマンドだけを守るもので、GitHubジョブの全ステップを自動で直列化するものでも、信頼できないワークフローがロックを意図的に回避するのを防げるものでもありません。そういった理由もあり、私の既存の双Runnerはこのロック方式を採用していません。これは独立した評価が必要な再利用可能なレシピであって、既に動いている実測結果として語ってはいけません。

一度のCIを緑にするために MemoryMax を際限なく上げてはいけません。余裕を恒常的に超えるタスクは、別のマシンに移すべきです。

PRと本番が同じRunnerを共有するのは、別のリスク勘定

ブログの既存Workflowを見直すと、コンテンツPRの検証と本番デプロイが依然として同じ本番Runnerラベルを使っています。信頼できる保守者だけのプライベートリポジトリでは、しばらくは使えるかもしれません。しかし、外部とのオープンな協業の安全な手本としては教えられません。

GitHubのセキュリティガイドは警告しています。永続的なSelf-hosted Runnerで信頼できないコードを動かすと、ジョブをまたぐ永続的な汚染が残ったり、スクリプトがホストのファイルや権限に触れたりする可能性があります。将来、外部のコラボレーター、ForkからのPR、その他の信頼できないコードが検証パイプラインに入るなら、本番機とは分けるべきです。

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

これは、一部のPR検証が引き続きGitHub-hosted分を消費することを意味します。でも、数分を節約するために見知らぬスクリプトをデプロイの認証情報に近づけるのは、割に合わない交換です。プライベートリポジトリであっても、実際のコラボレーター権限、ブランチ保護、Workflowを変更できる人をきちんと見る必要があります。

Runnerが落ちる・VPSが壊れる場合のデプロイ復旧

自前のRunnerのコストは月額賃料だけではなく、ダウンタイム、更新、復旧時間も含まれます。特に実務的な違いは、Self-hosted Runnerがオフラインになると、そのラベルに合致するジョブはキューに積まれ、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はジョブが完了したことしか証明しません。月次請求の精算まではしてくれません。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の分をどう節約するか、そして1台のVPS上の複数のRunnerが互いに足を引っ張らないようにするにはどうするか。

時刻に関する注記:課金と公式発表は2026年10月19日時点で確認。ジョブの時間は既存の実行記録から、systemdの数値は既存のインストールスクリプトからで、新たなホスト負荷試験の結果ではありません。