🎯 はじめに:なぜ 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 実体側 の話に絞ります。
🧬 構成概要 — 旧 → 新の差分
| 項目 | 旧構成 | 新構成 |
|---|---|---|
| OS | Debian 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 GB | pd-balanced 100 GB |
| Runner | GitLab Runner 18.x(Docker executor) | GitLab Runner 18.x(Docker executor) |
| Docker | 27.x + containerd 1.x | 29.x + containerd 2.x |
| クリーンアップ | 週次 cron で無条件 prune | 起動時の 80% 超過時のみ prune |
リソース使用率(実測)
| 指標 | 旧 | 新 |
|---|---|---|
| CPU 使用率(ピーク) | 100% | 50% |
| ディスクスループット | 50 MiB/s | 100 MiB/s |
| dotnet:test 実行時間 | 10 分超 | 5〜6 分(40〜50% 短縮) |
CPU 増強は「並列度を上げる」のではなく「1 ジョブあたりの vCPU を増やす」ため
ここが今回の設計でいちばん誤解されやすいポイントです。 vCPU を 4 → 8 に倍増したのに concurrent = 2 は据え置き にしています。
| 構成 | vCPU | concurrent | 1 ジョブあたりの vCPU |
|---|---|---|---|
| 旧 | 4 | 2 | 2 vCPU |
| 新 | 8 | 2(据え置き) | 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-amd64 | 1 / 4 GB | 1× |
saas-linux-medium-amd64 | 2 / 8 GB | 2× |
saas-linux-large-amd64 | 4 / 16 GB | 3× |
saas-linux-xlarge-amd64 | 8 / 32 GB | 6× |
saas-linux-2xlarge-amd64 | 16 / 64 GB | 12× |
つまり 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)相当 | 2× | 3,960 | 7,920 |
large(4 vCPU/16 GB)相当 | 3× | 3,960 | 11,880 |
xlarge(8 vCPU/32 GB)相当 | 6× | 3,960 | 23,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 つです。
- 倍率が指数的に効く —
xlargeの 6× は素朴に「VM が 6 倍高い」のではなく、 GitLab の SaaS インフラ運用費・margin が乗る ため、生 GCE と比べると割高 - Testcontainers / DinD が遅い — SaaS Runner は
dind起動オーバーヘッド が毎回発生し、self-hosted の Docker レイヤキャッシュ温存が効かない。結果、 同じテストでも実行時間が 1.3〜1.5 倍 になり、Compute Minutes 消費もそのぶん膨らむ - 並列度が線形に効く — 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-hosted) が terraform 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/.installed) | startup-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-release の ID で 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 Runner | GitLab インスタンス全体 | フェアユース | インスタンス管理者 |
| Group Runner | グループ配下の全プロジェクト・サブグループ | FIFO | グループ Owner |
| Project Runner | 特定プロジェクト(複数共有可) | FIFO | プロジェクト Owner |
CTS の選択:Group Runner(cts-g グループ)
CTS では cts-g グループ配下の全プロジェクトから見える Group Runner を採用しています(タグ self-hosted, docker, linux)。判断の根拠は次の 3 点です。
- 新規プロジェクトに対するゼロ設定の到達性 グループ配下に新しいリポジトリを切るたびに Project Runner を毎回登録する運用は 作業漏れ が発生しやすく、新しいリポジトリの CI が突然 SaaS Runner に流れて Compute Minutes が課金される事故 が起こります。Group Runner なら 新リポジトリは何もしなくても self-hosted Runner にディスパッチ されます。
- Runner 1 台の運用を「致し方なく」許容するため Project Runner だと「プロジェクトごとに 1 台ずつ」という発想になりがちですが、 本記事冒頭のブートストラップ問題 のとおり、Runner 自身を載せ替える作業はプロジェクト数だけ重複させたくありません。 「グループ全体に 1 台」 の集約運用が、運用負荷とコストの最適点です。
- タグでジョブの誤配信を防げる
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 jobs | OFF | タグを書き忘れたジョブが self-hosted に流れ込むのを防ぐ。 concurrent = 2 の貴重な枠(dotnet テストに 4 vCPU を割り当てる前提)を、無関係なドラフトパイプラインに食われない。 |
| Tags | self-hosted, docker, linux | ジョブ側の tags: と完全一致したものだけを引き受ける。 タグは強い名詞 にする(runner-1 のような番号ではなく self-hosted のような意味で書く)。 |
| Protected | 状況による | ON にすると Protected ブランチ / タグのジョブだけ に制限される。本番デプロイ用 Runner は必ず ON。本記事の Runner はテスト中心で、main 以外のブランチでも CI を回したいので OFF。 |
| Maximum job timeout | 3600 秒(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 と接続 |
| offline | 2 時間以上接続なし |
| stale | 7 日以上接続なし |
| 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_scriptでenv | 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_statusをnull+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 が 404 | dist=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 と比べた構築コストを十分に上回るので、この構成を選び続けています。
関連記事
- Terraform IaC 実践ガイド:CI/CD パイプライン編 — 本 Runner が実行する側の CI/CD パイプライン設計
- WIF でキーレス認証 — Runner SA とは別の、Terraform 認証用 WIF の設計
- GitLab → GCP 認証方式の 3 世代と CTS の選択 — 本 Runner の前段にある認証方式の歴史
- Terraform IaC 実践ガイド:準備編 — State 管理・アカウント設計
関連用語
- Workload Identity Federation — 本 Runner と Terraform の認証基盤
- GitLab id_tokens — Runner 上のジョブが取得する OIDC トークン
- NAT — 本 Runner の egress を担う Cloud NAT の前提概念
外部資料