CTS-KB
インフラ

GitLab Runner を GCE で self-hosted 運用する — ブートストラップ問題と SaaS の 4.6 倍コスト差

⏱ 約 19 分で読めます
#GitLab Runner #GCE #Terraform #CI/CD #GCP #Ubuntu #Docker #Self-hosted #Compute Minutes #Group Runner #コスト最適化 #ブートストラップ問題

🎯 はじめに:なぜ self-hosted Runner なのか

GitLab.com の SaaS Runner は手軽ですが、 CTS-EC のような中規模 SaaS のテストパイプライン では次の壁にぶつかります。

  • dotnet テスト + Testcontainers(DinD) で 1 ジョブ 10 分超
  • Compute Minutes の上限 に毎月触れる
  • 共有 VPC 内の内部リソース(Cloud SQL Private IP・Secret Manager・Artifact Registry)にアクセスしづらい
  • キャッシュ / Docker レイヤ が毎回 cold start で持ち越せない

そこで CTS では GCE 上に self-hosted GitLab Runner を 1 台だけ立てて、 CI 時間を短縮しつつ、共有 VPC 内のリソースを内部 IP のまま叩ける環境 を維持しています。本記事はその 最終構成(Ubuntu 26.04 + e2-standard-8) と、そこに至るまでに踏んだ 「Runner が自分で自分を載せ替えられない」というブートストラップ問題 の処方箋をまとめたノートです。

認証レイヤ(WIF)とパイプライン設計は Terraform IaC 実践ガイド:CI/CD パイプライン編WIF でキーレス認証 を参照してください。本記事はその Runner 実体側 の話に絞ります。

🧬 構成概要 — 旧 → 新の差分

項目旧構成新構成
OSDebian 12(Kernel 6.1)Ubuntu 26.04 LTS(Kernel 7.0)
マシンタイプe2-standard-4(4 vCPU / 16 GB)e2-standard-8(8 vCPU / 32 GB)
ディスクpd-balanced 100 GBpd-balanced 100 GB
RunnerGitLab Runner 18.x(Docker executor)GitLab Runner 18.x(Docker executor)
Docker27.x + containerd 1.x29.x + containerd 2.x
クリーンアップ週次 cron で無条件 prune起動時の 80% 超過時のみ prune

リソース使用率(実測)

指標
CPU 使用率(ピーク)100%50%
ディスクスループット50 MiB/s100 MiB/s
dotnet:test 実行時間10 分超5〜6 分(40〜50% 短縮)

CPU 増強は「並列度を上げる」のではなく「1 ジョブあたりの vCPU を増やす」ため

ここが今回の設計でいちばん誤解されやすいポイントです。 vCPU を 4 → 8 に倍増したのに concurrent = 2 は据え置き にしています。

構成vCPUconcurrent1 ジョブあたりの vCPU
422 vCPU
82(据え置き)4 vCPU(2 倍)

CTS-EC のボトルネックは dotnet テスト 1 ジョブで 10 分超 という点で、 ジョブ単体のスループット を上げないと CI 全体が縮みません。ここで concurrent = 4 に上げてしまうと、

  • 1 ジョブあたりの vCPU は 8 ÷ 4 = 2 vCPU に逆戻り
  • 1 ジョブあたりのメモリも 32 GB ÷ 4 = 8 GB まで縮む
  • 結果、 dotnet テスト 1 本の実行時間が改善せず、待ち時間だけ並列で隠れる 状態になります

並列度を上げるのは 「短いジョブが大量にあるとき」 には有効ですが、 「1 本が重いジョブ」 には逆効果です。今回の vCPU 増強の意図は、 concurrent = 2 を維持したまま 1 ジョブあたりに割ける CPU/メモリを倍にする ことにあり、これが dotnet:test 10 分超 → 5〜6 分 の短縮に直接効いた要因です。

concurrent は「同時に走らせる本数」、vCPU は「1 本あたりに使える馬力」 。dotnet / Java / TypeScript の重いビルド・テストでは、 馬力を増やしてから本数を考える のが原則です。CI が「待ち時間を体感で減らせない」とき、concurrent を上げる前に 「1 ジョブが何 vCPU で走っているか」 を数えてください。

月額コスト(3 時間/日 × 22 日 = 66 時間/月、自動停止前提)

項目
VM$8.84$17.69
Disk 100 GB$12$12
合計約 $21/月約 $30/月

差額は +$9/月(約 ¥1,400) 。テスト時間が半分になるので、開発者 1 人の待ち時間削減で容易にペイします。

💸 GitLab.com SaaS Runner の Compute Minutes は何倍するのか

self-hosted Runner を選ぶ最大の経済的根拠は、 GitLab.com の SaaS Runner(hosted runners on Linux)の Compute Minutes 課金が、CI 実行時間に比例してリニアに膨らむ ことです。

SaaS Runner の課金構造

GitLab.com の SaaS Runner は 「マシンサイズ × 実行時間」 で Compute Minutes を消費します。

インスタンスタイプvCPU / RAM倍率(min あたり)
saas-linux-small-amd641 / 4 GB
saas-linux-medium-amd642 / 8 GB
saas-linux-large-amd644 / 16 GB
saas-linux-xlarge-amd648 / 32 GB
saas-linux-2xlarge-amd6416 / 64 GB12×

つまり e2-standard-8 相当のスペックを SaaS で再現すると、実時間 1 分が 6 Compute Minutes に化ける ことになります。

Premium プランでの試算(CTS-EC の実ワークロード)

CTS-EC の CI 実行時間を 3 時間/日 × 22 日 = 3,960 実分/月 と置きます。Runner は concurrent = 2 で並列実行する想定です。

シナリオ倍率実分Compute Minutes
medium(2 vCPU/8 GB)相当3,9607,920
large(4 vCPU/16 GB)相当3,96011,880
xlarge(8 vCPU/32 GB)相当3,96023,760

GitLab Premium プランの 月次無料枠は 10,000 Compute Minutes 。超過分は 1,000 Compute Minutes パック = $10 で買い増す課金体系です(Linux 1× 換算)。

シナリオ超過 Compute Minutes追加課金($10/1,000 min)
medium 相当0(枠内)$0
large 相当1,880約 $19/月
xlarge 相当(新構成と同等)13,760約 $138/月

self-hosted vs SaaS の実コスト比較

構成月額固定費追加 Compute Minutes 課金月額合計
self-hosted GCE(新構成)$30(VM + Disk)なし約 $30/月
SaaS medium(性能は半分以下)$0$0(10,000 枠内)$0/月
SaaS large(旧構成相当)$0約 $19/月約 $19/月
SaaS xlarge(新構成相当)$0約 $138/月約 $138/月

並べると 「同じ 8 vCPU / 32 GB 構成」を SaaS で組むと月額 4.6 倍 になる計算です($30 → $138)。 年換算で約 ¥17 万円の差額 で、新人エンジニアの自己研鑽予算が 1 年分軽く吹き飛ぶ規模です。

なぜここまで差が出るのか

Compute Minutes 課金は 「Runner が CI 実行に使った時間」だけ カウントされる従量課金で、 アイドル時間はかかりません 。一見お得ですが、実際に詰まる落とし穴は次の 3 つです。

  1. 倍率が指数的に効くxlarge の 6× は素朴に「VM が 6 倍高い」のではなく、 GitLab の SaaS インフラ運用費・margin が乗る ため、生 GCE と比べると割高
  2. Testcontainers / DinD が遅い — SaaS Runner は dind 起動オーバーヘッド が毎回発生し、self-hosted の Docker レイヤキャッシュ温存が効かない。結果、 同じテストでも実行時間が 1.3〜1.5 倍 になり、Compute Minutes 消費もそのぶん膨らむ
  3. 並列度が線形に効く — SaaS Runner は 並列ジョブごとに別ホストが立ち上がる ため、concurrent = 2 で 2 並列にすれば Compute Minutes も単純に 2 倍消費する。self-hosted は 同じ VM を使い回す ので並列度を上げても VM コストは一定(ただし vCPU は分割されるため、CTS は 1 ジョブあたりの馬力を確保する目的で concurrent = 2 を据え置き——前述の「CPU 増強は並列度を上げるためではない」を参照)

Self-hosted のもう 1 つの強み:「アイドル従量制」 → 「固定費」への切替

SaaS Runner は 「使った分だけ払う」 が魅力に見えますが、 CI が止められなくなったプロダクトでは、月のスパイクが読めない 不安定要因になります。リファクタ作業期や負荷試験期には CI が突然 2〜3 倍走り、 月末に「先月のパイプライン代が $400 でした」と請求書が刺さる 事故もあります。

self-hosted は VM の固定費 $30/月で青天井に CI を回せる ため、 「CI が高いから走らせない」という心理的ブレーキが消える のが大きな副次効果です。これは数字には出にくいですが、 TDD / DDD を徹底するチームほど効いてくる無形の利得 です。

試算は GitLab Premium プラン・Linux Compute Minutes・1,000 min パック $10 を前提としています。Ultimate プランや Dedicated 環境では別建ての料金体系になります。最新の料金は GitLab Pricing で確認してください。

🔁 ブートストラップ問題:「Runner は自分で自分を載せ替えられない」

本記事の 核心 はここです。Terraform のコードと CI/CD パイプラインだけ眺めていると見落としますが、 self-hosted Runner を IaC で運用する瞬間に、必ずこの矛盾を踏みます

何が起きるのか

通常、CTS のインフラは Terraform IaC 実践ガイド:CI/CD パイプライン編 のとおり、 GitLab CI に登録された self-hosted Runner(タグ self-hostedterraform apply を実行します。

ところが今回、 「Debian 12 → Ubuntu 26.04」「e2-standard-4 → e2-standard-8」 の変更は GCE インスタンスの destroy → create を伴います。

GitLab CI/CD(Runner 上で実行中)
  └→ terraform apply
      └→ GCE: gitlab-runner-1 を destroy
          └→ Runner 本体が落ちる
              └→ apply ジョブが宙吊りで失敗
                  └→ 新しい GCE が作られない
                      └→ Runner が永遠に戻らない

これは 「自分が乗っている枝を、自分のチェーンソーで切る」 構造で、CI/CD 内で完結させることが原理的に不可能です。

採った判断:destroy を伴う変更だけ local apply に倒す

通常変更(startup-script 修正・タグ追加・IAM 追加 など)
  → GitLab CI/CD で apply(Runner 上で完結)

destroy/recreate を伴う変更(OS イメージ・マシンタイプ・disk 種別 など)
  → 開発者の端末から local apply
      (Runner が落ちている間は plan/apply できないため致し方なく)

「キレイにフルマネージド CI/CD」 ではない ことを受け入れ、 稀にしか発生しない destroy 系の変更だけ局所的に手元で打つ 割り切りです。Runner 本体のような 「CI/CD の足場」自体 をいじる作業は、年に 1〜2 回しかありません。常時 CI/CD で動かす必要はないと判断しました。

ブートストラップ問題を踏まないための 3 つの装置

装置効果
prevent_destroy = true誤って Runner を CI 経由で消す事故を物理的に止める
ignore_changes = [desired_status, metadata["ssh-keys"]]OS Login の SSH キー更新で毎回差分が出る無駄を抑止
マーカーファイル(/opt/gitlab-runner/.installedstartup-script 再実行時の二重登録を防止
resource "google_compute_instance" "runner" {
  # ...
  lifecycle {
    prevent_destroy = true
    ignore_changes  = [desired_status, metadata["ssh-keys"]]
  }
}

prevent_destroy = true のおかげで、 CI 経由で誤って Runner を消そうとすると plan 段階でエラーになります 。OS アップグレード時はこの行を一時的に外して local apply し、終わったら戻す——という二段階の運用です。「面倒」ではあるものの、 「面倒さ」が事故防止のセーフティネット になります。

🛠️ Terraform モジュール設計

terraform/modules/infra/gcp/gitlab-runner/ に切り出した実装の要点だけ抜粋します。

サービスアカウントと最小権限

locals {
  runner_roles = [
    "roles/secretmanager.secretAccessor",   # Runner トークン取得
    "roles/logging.logWriter",              # Cloud Logging 書き込み
    "roles/monitoring.metricWriter",        # Cloud Monitoring メトリクス
    "roles/artifactregistry.reader",        # AR イメージ pull
  ]
}

resource "google_project_iam_member" "runner_roles" {
  for_each = toset(local.runner_roles)
  project  = var.project_id
  role     = each.value
  member   = "serviceAccount:${google_service_account.runner.email}"
}

ポイント:

  • roles/compute.admin は付けない — Runner SA が GCE を直接いじる必要はない(Terraform が WIF で別 SA を使う)
  • roles/storage.admin も付けない — Artifact Registry の reader だけで十分

ネットワーク:内部 IP + Cloud NAT

resource "google_compute_address" "internal" {
  count        = var.internal_ip != null ? 1 : 0
  name         = "${var.instance_name}-internal-ip"
  address_type = "INTERNAL"
  address      = var.internal_ip   # "10.x.y.z" を固定
  subnetwork   = var.subnetwork
  purpose      = "GCE_ENDPOINT"
}

resource "google_compute_instance" "runner" {
  network_interface {
    network    = var.network        # 共有 VPC(host project 側)
    subnetwork = var.subnetwork
    network_ip = var.internal_ip

    # 外部 IP は付けない。egress は Cloud NAT 経由。
    dynamic "access_config" {
      for_each = var.enable_external_ip ? [1] : []
      content {}
    }
  }
}

外部 IP を付けない理由:

  • 攻撃面の削減 — SSH ブルートフォースの的を消す
  • 運用統一 — 全ての egress を Cloud NAT のログで一元監査
  • VPN 経由 SSH — 開発者は社内 VPN 越しに ssh 10.x.y.z で繋ぐ運用

lifecycle で「Runner を CI から守る」

resource "google_compute_instance" "runner" {
  # ...

  desired_status            = null
  allow_stopping_for_update = true

  lifecycle {
    prevent_destroy = true
    ignore_changes  = [desired_status, metadata["ssh-keys"]]
  }
}
  • desired_status = null + ignore_changes = [desired_status] — ユーザが手動で gcloud compute instances stop した後 terraform apply しても、 「停止状態」を Terraform が勝手に起動状態に戻さない 。コスト最適化のための停止運用と Terraform を共存させるための定石。
  • allow_stopping_for_update = true — マシンタイプ変更などで GCE の停止が必要になったとき、Terraform が勝手に停止 → 変更 → 起動を行えるようにする(ただし destroy/recreate を伴う変更は別問題)。

🚀 startup-script の冪等性設計

GCE のインスタンスは何かの拍子(メンテナンス・再作成)で再起動されます。startup-script は 何度走っても安全 でなければなりません。

マーカーファイルで二重登録を防ぐ

MARKER_FILE="/opt/gitlab-runner/.installed"

if [ ! -f "$MARKER_FILE" ]; then
  log "Runner を登録中..."

  RUNNER_TOKEN=$(gcloud secrets versions access latest \
    --secret="${runner_token_secret_id}" \
    --project="${project_id}")

  timeout 120 gitlab-runner register \
    --non-interactive \
    --url "${gitlab_url}" \
    --token "$RUNNER_TOKEN" \
    --executor docker \
    --docker-image "${docker_default_image}" \
    --docker-privileged \
    --name "${runner_name}"

  sed -i "s/^concurrent = .*/concurrent = ${runner_concurrent}/" \
    /etc/gitlab-runner/config.toml

  mkdir -p /opt/gitlab-runner
  touch "$MARKER_FILE"
else
  log "Runner は既に登録済み"
fi

これがないと、再起動のたびに config.toml[[runners]] ブロックが追記され、 同一トークンで Runner が複数登録された見た目になり、CI のジョブがランダムに重複した別インスタンスへ振られる 事故になります。

Ubuntu 26.04(resolute)の代替インストール

Ubuntu 26.04(コードネーム resolute)は packages.gitlab.com 側のリポジトリがまだ未対応 で、script.deb.sh をそのまま走らせると 404 で詰まります。回避策として、 glibc / kernel 後方互換が効く Ubuntu 24.04 LTS(noble)パッケージ で代替インストールしています。

if ! command -v gitlab-runner &>/dev/null; then
  curl -fsSL "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" \
    -o /tmp/gitlab-runner-script.deb.sh

  if [ "$OS_ID" = "ubuntu" ] && [ "$(. /etc/os-release && echo "$VERSION_CODENAME")" = "resolute" ]; then
    os=ubuntu dist=noble bash /tmp/gitlab-runner-script.deb.sh
  else
    bash /tmp/gitlab-runner-script.deb.sh
  fi

  apt-get install -y -qq gitlab-runner
fi

将来 resolute のリポジトリが正式公開されたら、この if ブロックは削除予定です。 「いま動かすため」の一時的な互換コード であることをコメントに残しておくと、後任者の混乱を減らせます。

Docker リポジトリの動的切り替え

Debian 12 と Ubuntu 26.04 で同じ startup-script を使い回すために、 /etc/os-releaseID で Docker 公式リポジトリを切り替えます。

OS_ID=$(. /etc/os-release && echo "$ID")
case "$OS_ID" in
  debian|ubuntu) ;;
  *) log "ERROR: 未対応の OS_ID=$OS_ID"; exit 1 ;;
esac
DOCKER_REPO_URL="https://download.docker.com/linux/$OS_ID"

distro を増やす予定がなくても、 OS 移行期に「両 OS で動く startup-script」 が手元にあると、ロールバック判断の自由度が大きく上がります。

🏷️ Runner スコープ:Group / Project / Instance の使い分け

GitLab の Runner は、 「どこから見えるか」 で 3 つのスコープに分かれます。

スコープ見える範囲キュー設定者
Instance RunnerGitLab インスタンス全体フェアユースインスタンス管理者
Group Runnerグループ配下の全プロジェクト・サブグループFIFOグループ Owner
Project Runner特定プロジェクト(複数共有可)FIFOプロジェクト Owner

CTS の選択:Group Runner(cts-g グループ)

CTS では cts-g グループ配下の全プロジェクトから見える Group Runner を採用しています(タグ self-hosted, docker, linux)。判断の根拠は次の 3 点です。

  1. 新規プロジェクトに対するゼロ設定の到達性 グループ配下に新しいリポジトリを切るたびに Project Runner を毎回登録する運用は 作業漏れ が発生しやすく、新しいリポジトリの CI が突然 SaaS Runner に流れて Compute Minutes が課金される事故 が起こります。Group Runner なら 新リポジトリは何もしなくても self-hosted Runner にディスパッチ されます。
  2. Runner 1 台の運用を「致し方なく」許容するため Project Runner だと「プロジェクトごとに 1 台ずつ」という発想になりがちですが、 本記事冒頭のブートストラップ問題 のとおり、Runner 自身を載せ替える作業はプロジェクト数だけ重複させたくありません。 「グループ全体に 1 台」 の集約運用が、運用負荷とコストの最適点です。
  3. タグでジョブの誤配信を防げる Group Runner はグループ全プロジェクトから見えますが、 tags: [self-hosted, docker, linux] を指定したジョブだけが流れ込む 設計にすれば、想定外のジョブで埋まる事故は防げます(後述)。

Instance Runner を選ばない理由

GitLab.com の Instance Runner は 「フェアユースキュー」 で、他組織のジョブと同じインフラを共有します。CTS のような 共有 VPC 内のリソース(Cloud SQL Private IP・Secret Manager・Artifact Registry)にアクセスする CI には不向きで、 そもそも他テナントのジョブと同じ Runner ホストに乗ること自体がセキュリティ要件を満たしません

Project Runner を選ばない理由

プロジェクト単位で Runner を分ければ最も分離性は高くなりますが、

  • Runner 台数 × プロジェクト数のコスト爆発
  • ブートストラップ問題が「Runner 台数」回発生
  • 「Lock to current projects」を切り忘れて他プロジェクトに漏れる事故

の 3 点で、 「分離性の便益」より「運用負荷の増大」が上回る と判断しました。Project Runner が真に効くのは 「特定プロジェクトだけが触る本番デプロイ用シークレットを Runner 環境変数に持たせる」 ような明確な分離要件があるときに限ります。

Group Runner 運用での 5 つの推奨設定

GitLab 公式ドキュメント(Configure runners)から、Group Runner で押さえるべきフラグを抽出します。

設定推奨値理由
Run untagged jobsOFFタグを書き忘れたジョブが self-hosted に流れ込むのを防ぐ。 concurrent = 2 の貴重な枠(dotnet テストに 4 vCPU を割り当てる前提)を、無関係なドラフトパイプラインに食われない。
Tagsself-hosted, docker, linuxジョブ側の tags: と完全一致したものだけを引き受ける。 タグは強い名詞 にする(runner-1 のような番号ではなく self-hosted のような意味で書く)。
Protected状況によるON にすると Protected ブランチ / タグのジョブだけ に制限される。本番デプロイ用 Runner は必ず ON。本記事の Runner はテスト中心で、main 以外のブランチでも CI を回したいので OFF。
Maximum job timeout3600 秒(1 時間)ハングしたジョブが Runner を占有し続けないようにする。プロジェクト側の値より短いと、こちらが優先される。
Lock to current projects(Group Runner では設定不可)この項目は Project Runner 専用。Group Runner では出てこないが、混同しないこと。

認証トークン方式へ全面移行する

GitLab 17.0 で 登録トークン(Registration Token)方式が全インスタンスで無効化 されています。本記事の構成は 認証トークン(Runner Authentication Token、glrt- で始まる文字列) を Secret Manager に保管し、startup-script の gitlab-runner register --token "$RUNNER_TOKEN" で渡しています。

gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com" \
  --token "$RUNNER_TOKEN"   # glrt-... で始まる Runner Authentication Token

旧来の --registration-token フラグは 17.0 以降エラーになる ので、移行が済んでいない場合は Group Settings → CI/CD → Runners → New group runner から先にトークンを発行し直してください。

Runner ステータスの判定基準

GitLab UI で表示される Runner のオンライン/オフライン判定は、 「最後に GitLab に接続した時刻」 で決まります。

表示判定基準
online過去 2 時間以内に GitLab と接続
offline2 時間以上接続なし
stale7 日以上接続なし
never_contacted一度も接続していない

CTS の Runner は 業務時間外に自動停止 する運用なので、 平時から「オフライン → 朝起動でオンライン」のサイクル で動きます。GitLab UI でオフライン表示になっていても 正常運用の一部 であり、慌てる必要はありません(後述の 「コスト最適化:自動停止 + 手動起動」 節を参照)。

Group Runner の落とし穴:シークレットのスコープを広く取りすぎない

Group Runner は 同一グループ内の全プロジェクトのジョブが同じ Runner ホストで動く という性質上、 CI 変数(特にマスクされたシークレット)の配布範囲 に注意が要ります。

  • Group CI/CD Variables を使うなら必ず Protected ON にして、main / Protected タグのジョブだけが値を読めるようにする
  • 本番デプロイ用のシークレットは Group Runner ではなく、専用の Project Runner(または OIDC + WIF で都度発行) を使う
  • pre_clone_script / pre_build_scriptenv | grep -i secret のようにダンプするコードを マージレビューで止める ルールにする

CTS では本番デプロイの認証情報を一切「変数」として CI に置いていません。 全て WIF + Service Account Impersonation で都度発行し、Runner ホストには静的シークレットを残さない構成です。

🧹 自動メンテナンス:80% 超過時のみクリーンアップ

旧構成は 週次で無条件に docker system prune -af --volumes を実行していました。 Docker レイヤキャッシュが毎週吹き飛ぶ ため、CI が cold start に戻り、せっかくの self-hosted の意味が薄れます。

新構成では 「ディスク使用率 80% 超過時のみ・起動時に 1 回」 に変更しました。

/usr/local/bin/docker-smart-cleanup.sh

#!/bin/bash
USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$USAGE" -gt 80 ]; then
  echo "Disk usage ${USAGE}% > 80%, cleaning up..."
  docker volume prune -f --filter "until=72h"
  docker image prune -f --filter "until=168h"
else
  echo "Disk usage ${USAGE}% <= 80%, skipping cleanup"
fi

ポイント:

  • docker system prune ではなく volume prune + image prune の二段構成system prune だと使用中の network まで巻き込まれることがあるため、影響範囲を限定する
  • --filter "until=72h" / until=168h — 直近で使ったキャッシュは残す(CI 直近実行のキャッシュを温存)
  • 80% 閾値 — 100 GB ディスクの場合、20 GB 残しておけば暴走しない

systemd で起動時に 1 回だけ走らせる

# /etc/systemd/system/docker-cleanup.service
[Unit]
Description=Docker Smart Cleanup on Boot
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/docker-smart-cleanup.sh

[Install]
WantedBy=multi-user.target

CTS の Runner は 業務時間外に自動停止 → 朝に手動起動 の運用なので、 「毎朝 1 回、必要なときだけ掃除する」 タイミングと噛み合います。常時稼働環境では cron + 閾値判定の方が向きます。

💰 コスト最適化:自動停止 + 手動起動

self-hosted Runner の月額コストを抑える肝は 「使っていない時間は止める」 です。

alias runner-on='gcloud compute instances start gitlab-runner-1 \
  --zone=asia-northeast1-b --project=<your-project-id>'

alias runner-off='gcloud compute instances stop gitlab-runner-1 \
  --zone=asia-northeast1-b --project=<your-project-id>'

alias runner-ssh='ssh 10.x.y.z'
alias runner-status='ssh 10.x.y.z sudo gitlab-runner status'

開発者は朝 runner-on、夕方 runner-off を打つだけ。これだけで 月 22 日 × 約 18 時間停止 = 約 400 時間 ぶんの料金がカットできます。

desired_statusnull + ignore_changes にしておかないとrunner-off した直後に CI/CD が terraform apply を打つと Terraform が「あれ起動してないぞ」と勝手に起動し直してしまいます 。ここは Terraform と運用の境界を明示的に切り分けるための重要な設定です。

🚨 ありがちなトラブル

症状原因対処
Runner がオフライン表示gitlab-runner プロセスが落ちているsudo systemctl restart gitlab-runner
gitlab-runner restart がハングgitlab-runner stop がフォアグラウンドジョブを待つCtrl+C で中断 → sudo systemctl restart gitlab-runner
ディスク 100%クリーンアップが効いていないdf -h / 確認後、 sudo docker volume rm $(sudo docker volume ls -q) --force で全消し
config.toml[[runners]] が二重startup-script のマーカー判定をすり抜けたエディタで重複ブロックを削除 → sudo systemctl restart gitlab-runner
startup-script が「Runner を登録中…」で止まるSecret Manager のトークンが期限切れ新トークンを発行 → gcloud secrets versions add gitlab-runner-token --data-file=-
terraform apply が prevent_destroy で失敗OS / マシンタイプ変更を CI から打とうとしたlocal apply に切り替え(本記事のブートストラップ問題)

Secret Manager のトークン更新

echo -n 'glrt-新しいトークン' | gcloud secrets versions add gitlab-runner-token \
  --project=<your-project-id> \
  --data-file=-

トークンは GitLab UI で 「Group → Settings → CI/CD → Runners → New group runner」 から発行します。発行直後の glrt- 始まりの文字列を、即座に Secret Manager へ書き込んで端末からは消す運用です。

🎯 まとめ — 自分で自分を載せ替えられない構造を受け入れる

落とし穴採った設計
Runner が自分で自分を消せないdestroy/recreate 系のみ local apply に倒す 割り切り
誤って CI から Runner を消すprevent_destroy = true + ignore_changes で物理ガード
再起動ごとに Runner が二重登録される/opt/gitlab-runner/.installed マーカーで冪等化
週次クリーンアップで Docker キャッシュが毎週吹き飛ぶ80% 超過時のみ起動時 1 回 に変更
Ubuntu 26.04 で script.deb.sh が 404dist=noble で 24.04 パッケージを代替インストール
停止運用を Terraform が勝手に起動状態に戻すdesired_status = null + ignore_changes で共存
dotnet テスト 1 本が 10 分超で重いvCPU を 2 倍にして concurrent = 2 を据え置き(並列度ではなく 1 ジョブの馬力を倍に)

完全に CI/CD で完結する self-hosted Runner」は、 その Runner 自身を載せ替える瞬間だけは絶対に成立しません 。年に 1〜2 回の OS / マシンタイプ変更を 手元から打つ ことを正面から受け入れた上で、 それ以外の 99% の変更は CI/CD で完結させる 設計に倒すのが、現実解です。

self-hosted の運用コストは 「面倒くささ」と「制御の細かさ」のトレードオフ です。さらに今回の試算で見たとおり、 同等スペックを SaaS Runner で組むと月額が 4.6 倍($30 → $138) に跳ね上がります。CTS では 「dotnet テストを毎回 5 分早く回せる」「CI 代を気にせず TDD を回せる」 という 2 つの価値が、年 1〜2 回の local apply の手間と SaaS と比べた構築コストを十分に上回るので、この構成を選び続けています。


関連記事

関連用語

外部資料