「Ansible×TerraformをGitOpsで回す」〜Terraformプロバイダーを問わない構成管理の自動化〜 第4回:ローカルGitea vs GitHub Actionsの選択基準
🗺️ 初めての方、シリーズの全体像を知りたい方はこちら
シリーズ全体については、以下のまとめブログで整理しています。
→ 「Ansible×TerraformをGitOpsで回す」〜Terraformプロバイダーを問わない構成管理の自動化〜 第1部まとめブログ:「Gitが唯一の真実」を成り立たせる前提の設計 ※近日公開予定
📋 目次
- はじめに
- パイプラインはどこで実行されるのか
- 到達性を確保する3つの構成
- 状態の永続性とドリフト観測
- セルフホスト型Git基盤でも同じワークフローは動くのか
- 4つの基準による比較と採用構成
- クラウドでも同じ判断が必要になる理由
- まとめ
- 次回予告
- 連載一覧:「Ansible×TerraformをGitOpsで回す」
1. はじめに
AnsibleとTerraformのコードをGitHubのリポジトリで管理していて、
- GitHub Actionsで、
terraform applyとansible-playbookを実行するワークフローを書いた - ワークフローを起動すれば、管理しているサーバーに変更が反映される
- リポジトリのホスティング先やパイプラインの実行場所は、使い慣れたサービスを選べばよい
と考えていないでしょうか。
GitHub Actionsを扱う記事の多くは、GitHubが用意する実行環境(ホステッドランナー)の上でのビルドやテスト、あるいはクラウドの公開APIへのデプロイを題材にしています。プライベートなネットワークの中にあるサーバーをAnsibleで操作する場面は、あまり扱われていません。一方、AnsibleとTerraformのパイプラインを実際に組もうとすると、次のような場面にぶつかります。
- ワークフローは起動するのに、サーバーへのSSHの接続がタイムアウトする
terraform planは通るのに、Ansibleだけが失敗する- ランナーの中で作ったコンテナでは検証が通るのに、実際のサーバーには届かない
- 定期実行でドリフトを検知しようとしたが、実行のたびに環境が作り直され、前回の実行からの変化を観測できない
- 社内の規定で、コードやジョブのログを外部のサービスに置けない
これらは、ネットワークの設定や社内の規定の問題に見えます。共通しているのは、パイプラインが動く場所から操作対象に届くか、そして何をどこに預けることになるかが、ホスティング先と実行場所を選ぶ時点で決まっていない点です。
パイプラインの実行場所は、「AnsibleとTerraformの連携が壊れる理由はライフサイクルにあった」(以下、Ansible×Terraformシリーズ)でも扱ってきました。Ansible×Terraformシリーズの 第32回 と 第37回 では、GitHub Actionsのワークフローで、Terraformによるコンテナの起動からAnsibleの適用、定期的なドリフトの検知までを実行しました。ただし、実行のたびに使い捨てられるホステッドランナーでは、常時起動しているインフラを外から監視できません。そのため、第37回 の定期検知は、コンテナの起動から改ざんの注入、検知までを、1回のワークフロー実行の中で完結させる構成でした。
本シリーズの 第1回 のセクション5では、この構成を振り返ったうえで、管理対象と同じネットワーク内に置くself-hostedランナーを採用し、稼働し続けるインフラに対する定期検知と自動収束を第4部で作ると整理しました。第3回 では、変更の分類をパスで表現するリポジトリの構造と、mainを唯一の真実とするブランチ運用を設計しました。同じ回で、リポジトリを非公開で運用することを前提にしましたが、その理由はまだ示していません。
この回では、第3回 で設計したリポジトリを、どこでホストし、パイプラインをどこで動かすかを決めます。比較するのは、GitHubのホステッドランナー、GitHubのself-hostedランナー、そしてセルフホスト型のGit基盤とそのCIの3つの構成です。セルフホスト型のGit基盤は、Giteaを代表例として検証します。これらを、到達性、状態の永続性、機密情報の置き場所、運用負荷の4つの基準で比較し、本シリーズの採用構成と、リポジトリを非公開で運用する理由を示します。第1部の最終回として、第2部(第5回 〜 第12回)でパイプラインを組むための実行基盤を、この回で確定させます。
正確に言うと、パイプラインをどこに置くかは、機能の比較では決まらず、ランナーから操作対象に届くかと、何を外部に預けるかという信頼の境界で決まります。この回で扱う問いは、「パイプラインをどこで動かせば操作対象に届き、何をどこまで外部に預けることになるのか」です。
次のセクションでは、パイプラインが実際にどこで実行されているのかを確認します。
2. パイプラインはどこで実行されるのか
GitHub Actionsのジョブが実際にどこで実行され、そこから操作対象に届くかを確認します。
パイプラインが成立する条件
パイプラインでAnsibleとTerraformを実行するには、ジョブを実行する環境(ランナー)から、それぞれのツールの接続先に届く必要があります。
- Terraform:プロバイダーのAPIに接続する。DockerプロバイダーであればDockerデーモン、AWSやGCPであればクラウドのAPIが接続先になる
- Ansible:インベントリに書かれた接続先へ、SSHで接続する
どちらか一方でも届かなければ、パイプラインは成立しません。ここでは、Ansibleの接続先であるSSHのポートに届くかを確認します。Terraformの接続先に届くかはプロバイダーによって異なるため、セクション7 で扱います。
GitHubの公式ドキュメント(GitHub-hosted runners)では、GitHubが用意するランナー(ホステッドランナー)は、一部を除き、ジョブごとに新しく用意される仮想マシンであり、LinuxのランナーはMicrosoft Azureの仮想マシン上で動くとされています。一方、操作対象は、オンプレミスのサーバーやクラウドのプライベートサブネット内のインスタンスのように、プライベートなネットワークの中にあることが一般的です。
【GitHubが用意するネットワーク】
ホステッドランナー(ジョブごとに用意される仮想マシン)
│
│ SSH(Ansible)
↓
【プライベートなネットワーク】
操作対象(オンプレミスのサーバー、プライベートサブネット内のインスタンスなど)
この検証環境では、操作対象のコンテナ(target-node1〜3)は、1台のLinuxホストの上で、Dockerのプライベートなネットワークに接続しています。
■ 検証内容:ホステッドランナーから操作対象への接続
接続を確認するためのワークフローを、リポジトリに追加します。
- ファイル名:
.github/workflows/reachability-check.yml
name: reachability-check
on:
workflow_dispatch:
inputs:
runner:
description: 'runs-onに渡すランナーのラベル'
required: true
default: 'ubuntu-latest'
type: choice
options:
- ubuntu-latest
- self-hosted
target_host:
description: '接続先のIPアドレス'
required: true
type: string
jobs:
check:
runs-on: ${{ inputs.runner }}
steps:
- name: 実行環境を表示する
run: |
echo "hostname=$(hostname)"
echo "runner_environment=${RUNNER_ENVIRONMENT}"
ip -4 -o addr show scope global
- name: 接続先のSSHポートに接続する
env:
TARGET_HOST: ${{ inputs.target_host }}
run: |
nc -zv -w 5 "${TARGET_HOST}" 22 && rc=0 || rc=$?
echo "exit_code=${rc}"
このワークフローは、手動で起動したときだけ実行されます(workflow_dispatch)。起動するときに、どのランナーで実行するか(runner)と、接続先のIPアドレス(target_host)を入力します。接続先のIPアドレスをファイルに書かず、起動時に渡すのは、第2回 のセクション4で確認したとおり、IPアドレスはGitの外にある値だからです。
ジョブの1つ目のステップでは、ランナーのホスト名、ランナーの種別、IPアドレスを表示します。ランナーの種別を示すRUNNER_ENVIRONMENTは、GitHubが設定する環境変数で、ホステッドランナーではgithub-hosted、self-hostedランナーではself-hostedになります(Variables reference)。2つ目のステップでは、接続先のSSHのポート(22番)にncで接続し、終了コードを表示します。
接続先には、target-node1のIPアドレスを使います。
実行コマンド
terraform output target_nodes_ips
▼ 実行結果
{
"target-node1" = "172.19.0.4"
"target-node2" = "172.19.0.2"
"target-node3" = "172.19.0.3"
}
GitHubのリポジトリの「Actions」タブからこのワークフローを手動で起動し、runnerにubuntu-latest、target_hostに172.19.0.4を入力して実行しました。ジョブのログは次のとおりです。ログの各行の先頭にある時刻は省略しています。
まず、ジョブの準備(Set up job)のログには、ランナーを用意した基盤が表示されています。
▼ 実行結果(Set up jobから抜粋)
##[group]Runner Image Provisioner
Hosted Compute Agent
(途中省略:バージョン、コミット、ビルド日時、ワーカーIDの表示)
Azure Region: eastus
##[endgroup]
▼ 実行結果(ステップ「実行環境を表示する」)
##[group]Run echo "hostname=$(hostname)"
(途中省略:実行したスクリプトとシェルの表示)
##[endgroup]
hostname=runnervm8df0l
runner_environment=github-hosted
2: eth0 inet 10.1.0.5/20 metric 100 brd 10.1.15.255 scope global eth0\ valid_lft forever preferred_lft forever
4: docker0 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0\ valid_lft forever preferred_lft forever
▼ 実行結果(ステップ「接続先のSSHポートに接続する」)
##[group]Run nc -zv -w 5 "${TARGET_HOST}" 22 && rc=0 || rc=$?
(途中省略:実行したスクリプト、シェル、環境変数の表示)
##[endgroup]
nc: connect to 172.19.0.4 port 22 (tcp) timed out: Operation now in progress
exit_code=1
接続は5秒でタイムアウトしました。このタイムアウトが、target-node1が停止していたためではないことを確かめるため、操作対象と同じホストから、同じ接続先に同じコマンドで接続します。
実行コマンド
docker ps --format 'table {{.Names}}\t{{.Status}}'
▼ 実行結果
NAMES STATUS
target-node1 Up About an hour
target-node2 Up About an hour
target-node3 Up About an hour
実行コマンド
nc -zv -w 5 172.19.0.4 22; echo "exit_code=$?"
▼ 実行結果
Connection to 172.19.0.4 22 port [tcp/ssh] succeeded!
exit_code=0
■ 結果
同じ接続先(172.19.0.4のポート22)に対する2つの結果を並べると、次のとおりです。
| 接続元 | 接続元のネットワーク | 結果 |
|---|---|---|
| ホステッドランナー | 10.1.0.5/20(eth0) |
タイムアウト(exit_code=1) |
| 操作対象と同じホスト | 操作対象と同じDockerのネットワークにつながるホスト | 接続成功(exit_code=0) |
target-node1は起動していて、SSHのポートで待ち受けています。結果を分けたのは、接続先の状態ではなく、接続元がどこにあるかです。ホステッドランナーは、Azureの仮想マシン(Azure Region: eastus)として、10.1.0.5/20という、GitHubが用意したネットワークの中で動いていて、操作対象のネットワークへの経路を持っていません。
ホステッドランナーには、ランナー自身のDockerのネットワーク(docker0、172.17.0.1/16)もありました。プライベートなIPアドレスは、ネットワークごとに同じ範囲が繰り返し使われます。172.19.0.4という値は、この検証環境のホストの中のネットワークでだけ意味を持つアドレスであり、別のネットワークにあるランナーからは、同じ形のアドレスでも同じ相手を指しません。
この構造は、操作対象がオンプレミスのサーバーでも、クラウドのプライベートサブネット内のインスタンスでも同じです。プライベートなネットワークの中にある操作対象には、ホステッドランナーから直接SSHで届きません。セクション1 で挙げた「ワークフローは起動するのに、サーバーへのSSHの接続がタイムアウトする」という場面は、この状態です。
次のセクションでは、ランナーから操作対象に届くようにするための3つの構成を整理します。
3. 到達性を確保する3つの構成
ランナーから操作対象に届くようにする構成を3つに整理し、そのうち、操作対象と同じネットワークにself-hostedランナーを置く構成を実機で確認します。
3つの構成
セクション2 で確認したとおり、ホステッドランナーからは、プライベートなネットワークの中にある操作対象に届きません。ランナーから操作対象に届くようにするには、ランナーと操作対象を同じネットワークに置く必要があります。その置き方には、次の3つがあります。
【構成1:ランナーの中で環境ごと作る】
GitHub → ホステッドランナー
└─ 操作対象(実行のたびに、ランナーの中に作る)
【構成2:操作対象と同じネットワークに、self-hostedランナーを置く】
GitHub ← (外向きのHTTPSでジョブを取りに行く) self-hostedランナー
│ SSH
↓
操作対象
(ここまでがプライベートなネットワーク)
【構成3:操作対象と同じネットワークに、Git基盤とCIのランナーを置く】
Git基盤(Gitea、GitLabなど) → CIのランナー →(SSH)→ 操作対象
(すべてプライベートなネットワーク)
構成1は、操作対象をランナーの中に作ることで、ランナーと操作対象を同じ場所に置く構成です。Ansible×Terraformシリーズの 第32回 と 第37回 で、コンテナの起動からAnsibleの適用までを1回のワークフロー実行の中で行ったのが、この構成です。届くことは保証されますが、操作対象はランナーとともに実行のたびに作り直されます。この点は、セクション4 で扱います。
構成2は、GitHubはそのまま使い、ジョブを実行するランナーだけを、操作対象と同じネットワークに置く構成です。GitHubの公式ドキュメント(Self-hosted runners reference)では、self-hostedランナーは、GitHubに接続してジョブの割り当てを受け取るとされています。通信の要件として挙げられているのは、ランナーを置くホストから外向きにHTTPS(443番)で接続できることで、外からランナーに接続するためにポートを開ける要件は挙げられていません。
構成3は、Gitのリポジトリそのものと、CIのランナーの両方を、操作対象と同じネットワークに置く構成です。この構成は、セクション5 で、Giteaを代表例として確認します。
ここでは、構成2を実機で確認します。
■ 検証内容:self-hostedランナーの登録
この検証環境では、操作対象のコンテナが動いているLinuxホストに、self-hostedランナーを置きます。GitHubのリポジトリの「Settings」→「Actions」→「Runners」で「New self-hosted runner」を開くと、OSとアーキテクチャ(ここではLinux、x64)に応じた、ダウンロードと登録のコマンドが表示されます。表示されたコマンドを、ホームディレクトリの下で実行しました。登録の対話形式の質問には、すべて既定値で答えています。ランナーは、サービスとして登録して起動しました。shasumの行は、ダウンロードしたファイルのハッシュ値を、画面に表示された値と照合する確認です。
実行コマンド
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.337.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz
echo "70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613 actions-runner-linux-x64-2.337.0.tar.gz" | shasum -a 256 -c
tar xzf ./actions-runner-linux-x64-2.337.0.tar.gz
./config.sh --url https://github.com/juehara-crypto/ansible-terraform-ci-lab --token (画面に表示された登録用のトークン)
sudo ./svc.sh install control
sudo ./svc.sh start
サービスの状態を確認します。
実行コマンド
systemctl is-enabled actions.runner.juehara-crypto-ansible-terraform-ci-lab.ubuntu-controller.service
systemctl is-active actions.runner.juehara-crypto-ansible-terraform-ci-lab.ubuntu-controller.service
▼ 実行結果
enabled
active
ランナーはサービスとして起動していて(active)、ホストを再起動しても自動で起動する設定(enabled)になっています。
GitHubのリポジトリのランナーの一覧には、登録したランナーubuntu-controllerが、ラベルself-hosted、Linux、X64とともに表示され、状態はジョブを待っている「Idle」になりました。

■ 検証内容:self-hostedランナーから操作対象への接続
セクション2 と同じワークフローを、runnerにself-hosted、target_hostに172.19.0.4を入力して起動しました。変えたのは、ジョブを実行するランナーの指定だけです。ジョブのログは次のとおりです。ログの各行の先頭にある時刻は省略しています。
▼ 実行結果(Set up jobから抜粋)
Current runner version: '2.337.0'
Runner name: 'ubuntu-controller'
Runner group name: 'Default'
Machine name: 'ubuntu-controller'
▼ 実行結果(ステップ「実行環境を表示する」)
##[group]Run echo "hostname=$(hostname)"
(途中省略:実行したスクリプトとシェルの表示)
##[endgroup]
hostname=ubuntu-controller
runner_environment=self-hosted
2: enp0s8 inet 192.168.56.30/24 brd 192.168.56.255 scope global enp0s8\ valid_lft forever preferred_lft forever
3: enp0s9 inet 10.0.4.15/24 metric 100 brd 10.0.4.255 scope global dynamic enp0s9\ valid_lft 78854sec preferred_lft 78854sec
4: br-241cceaae174 inet 172.18.0.1/16 brd 172.18.255.255 scope global br-241cceaae174\ valid_lft forever preferred_lft forever
5: br-3ab348052bbc inet 172.19.0.1/16 brd 172.19.255.255 scope global br-3ab348052bbc\ valid_lft forever preferred_lft forever
6: docker0 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0\ valid_lft forever preferred_lft forever
▼ 実行結果(ステップ「接続先のSSHポートに接続する」)
##[group]Run nc -zv -w 5 "${TARGET_HOST}" 22 && rc=0 || rc=$?
(途中省略:実行したスクリプト、シェル、環境変数の表示)
##[endgroup]
exit_code=0
Connection to 172.19.0.4 22 port [tcp/ssh] succeeded!
br-3ab348052bbcがどのネットワークかを確認します。
実行コマンド
docker network ls --filter name=ansible-lab-net
▼ 実行結果
NETWORK ID NAME DRIVER SCOPE
3ab348052bbc ansible-lab-net bridge local
■ 結果
同じワークフローを、ランナーの指定だけを変えて実行した結果を並べると、次のとおりです。
| ランナー | runner_environment |
操作対象のネットワークとのつながり | 172.19.0.4のポート22への接続 |
|---|---|---|---|
| ホステッドランナー(セクション2) | github-hosted |
なし(10.1.0.5/20) |
タイムアウト(exit_code=1) |
| self-hostedランナー | self-hosted |
あり(br-3ab348052bbc、172.19.0.1/16) |
接続成功(exit_code=0) |
self-hostedランナーは、操作対象と同じホストで動き、操作対象がつながっているansible-lab-net(3ab348052bbc)に、ランナー自身もつながっています。ジョブは、GitHubから受け取った後、このネットワークの中で実行されるため、操作対象に届きました。
この検証で、ランナーのためにホストの受信のポートは開けていません。ランナーの側からGitHubに接続してジョブを受け取る形なので、操作対象のネットワークの外から、ネットワークの中に入ってくる接続は発生しません。ワークフローの記述も、runs-onに渡すラベルを変えただけで、ジョブの中身は同じです。
このself-hostedランナーは、第2部以降のパイプラインでもそのまま使います。
次のセクションでは、構成1と構成2の違いを、操作対象の状態が実行をまたいで残るかという観点で整理します。
4. 状態の永続性とドリフト観測
セクション3 の構成1と構成2の違いを、操作対象の状態が実行をまたいで残るかという観点で整理し、構成2で実際に観測できることを確認します。
構成1では、前回の実行以降のずれを観測できない
構成1では、操作対象をランナーの中に作ります。セクション2 で引用した公式ドキュメントのとおり、ホステッドランナーはジョブごとに新しく用意される仮想マシンなので、その中に作った操作対象も、実行が終わるとランナーとともになくなります。次の実行では、コードから操作対象を作り直すところから始まります。
このため、構成1では、「前回の実行の後に、操作対象に何が起きたか」を観測できません。前回の実行の後に手動変更があったとしても、その変更が加えられた操作対象は、次の実行の時点ではもう存在しないからです。Ansible×Terraformシリーズの 第37回 の定期検知が、コンテナの起動から改ざんの注入、検知までを1回のワークフロー実行の中で完結させる構成だったのは、このためです。
また、構成1で検証しているのは、ランナーの中に作った操作対象であり、実際に運用している操作対象ではありません。セクション1 で挙げた「ランナーの中で作ったコンテナでは検証が通るのに、実際のサーバーには届かない」という場面は、この違いから生まれます。
一方、構成2では、操作対象はランナーとは別に存在し続け、ランナーは実行のたびに、同じ操作対象に接続します。この違いを、実機で確認します。
■ 検証内容:実行をまたいだ操作対象の観測
操作対象の状態を読むだけのワークフローを、リポジトリに追加します。
- ファイル名:
.github/workflows/state-check.yml
name: state-check
on:
workflow_dispatch:
jobs:
check:
runs-on: self-hosted
steps:
- name: 操作対象のコンテナを表示する
run: |
docker inspect --format '{{.Name}} id={{.Id}} created={{.Created}}' target-node1
- name: 操作対象の設定ファイルを表示する
run: |
docker exec target-node1 cat /etc/drift-check-target.conf
1つ目のステップでは、target-node1のコンテナのIDと作成日時を表示します。2回の実行で同じコンテナを見ているかを、IDで確かめるためです。2つ目のステップでは、Playbookが配置している/etc/drift-check-target.confの内容を表示します。ジョブは、セクション3 で登録したself-hostedランナーで実行します。
このワークフローを手動で起動しました(1回目)。ジョブのログは次のとおりです。ログの各行の先頭にある時刻は省略しています。
▼ 実行結果(1回目、ステップ「操作対象のコンテナを表示する」)
##[group]Run docker inspect --format '{{.Name}} id={{.Id}} created={{.Created}}' target-node1
(途中省略:実行したスクリプトとシェルの表示)
##[endgroup]
/target-node1 id=661068c035d5f431a2752a43545c6afeaf648efbde79933925a34c1bf531325d created=2026-10-04T00:55:00.51973054Z
▼ 実行結果(1回目、ステップ「操作対象の設定ファイルを表示する」)
##[group]Run docker exec target-node1 cat /etc/drift-check-target.conf
(途中省略:実行したスクリプトとシェルの表示)
##[endgroup]
monitored_by=ansible-drift-check
1回目の実行の後に、パイプラインを通さずに、target-node1のファイルを書き換えます。第1回 のセクション6と同じ方法です。
実行コマンド
docker exec -it target-node1 bash -c "sed -i 's/ansible-drift-check/manual-edit/' /etc/drift-check-target.conf"
▼ 実行結果
(出力なし)
実行コマンド
docker exec -it target-node1 bash -c "cat /etc/drift-check-target.conf"
▼ 実行結果
monitored_by=manual-edit
この状態で、同じワークフローをもう一度起動しました(2回目)。
▼ 実行結果(2回目、ステップ「操作対象のコンテナを表示する」)
##[group]Run docker inspect --format '{{.Name}} id={{.Id}} created={{.Created}}' target-node1
(途中省略:実行したスクリプトとシェルの表示)
##[endgroup]
/target-node1 id=661068c035d5f431a2752a43545c6afeaf648efbde79933925a34c1bf531325d created=2026-10-04T00:55:00.51973054Z
▼ 実行結果(2回目、ステップ「操作対象の設定ファイルを表示する」)
##[group]Run docker exec target-node1 cat /etc/drift-check-target.conf
(途中省略:実行したスクリプトとシェルの表示)
##[endgroup]
monitored_by=manual-edit
■ 結果
2回の実行の結果を並べると、次のとおりです。
| 実行 | コンテナのID(先頭12文字) | 作成日時 | /etc/drift-check-target.conf |
|---|---|---|---|
| 1回目 | 661068c035d5 |
2026-10-04T00:55:00.51973054Z |
monitored_by=ansible-drift-check |
| 2回目 | 661068c035d5 |
2026-10-04T00:55:00.51973054Z |
monitored_by=manual-edit |
2回の実行で、コンテナのIDと作成日時は同じでした。ランナーは2回とも同じ操作対象に接続していて、操作対象は実行と実行の間も存在し続けています。そのため、1回目の実行の後にパイプラインの外で加えた書き換えを、2回目の実行で観測できました。
これが、構成1との違いです。構成1では、2回目の実行の時点で、1回目に作った操作対象はもう存在しないため、その間に起きた書き換えを観測する対象がありません。
本シリーズの第4部(第17回 〜 第21回)で作る定期的なドリフトの検知と自動収束は、「前回の実行以降に、操作対象がGitの内容からずれたか」を見る仕組みです。この仕組みが成り立つには、操作対象が実行をまたいで存在し続け、ランナーが毎回その操作対象に届く必要があります。この条件を満たすのは、構成2と構成3です。ここでは状態を表示しただけですが、ずれを検知する手段(ansible-playbook --checkの定期実行など)は、第18回 で扱います。
最後に、書き換えたファイルを、Playbookの本実行で元に戻します。
実行コマンド
ansible-playbook -i inventory.ini site.yml
▼ 実行結果
(途中省略:PLAYとTASKの表示、Pythonのインタープリターに関する警告)
PLAY RECAP ***********************************************************************************************************************
target-node1 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
target-node2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
target-node3 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
実行コマンド
docker exec -it target-node1 bash -c "cat /etc/drift-check-target.conf"
▼ 実行結果
monitored_by=ansible-drift-check
target-node1だけがchanged=1となり、ファイルの内容がmonitored_by=ansible-drift-checkに戻りました。
次のセクションでは、構成3として、セルフホスト型のGit基盤とそのCIでも、同じワークフローが動くかを確認します。
5. セルフホスト型Git基盤でも同じワークフローは動くのか
セクション3 の構成3として、Gitのリポジトリとそのランナーを、操作対象と同じネットワークに置く構成を確認します。セルフホスト型のGit基盤には、GitLabなどいくつかの選択肢がありますが、ここではGiteaとそのCIの機能(Gitea Actions)を代表例として取り上げます。確認したいのは、セクション2 と セクション3 で使ったワークフローが、そのまま動くか、そして操作対象に届くかです。
GiteaとGitea Actionsのランナーは、どちらもDockerコンテナとして起動します。そのため、Dockerが動くLinuxホストであれば、物理サーバーでも、仮想マシンでも、クラウドのインスタンスでも、同じ手順で再現できます。ここでは、操作対象が動いているのと同じLinuxホストで起動します。構築は最小限にとどめ、インストール手順の詳しい解説はしません。
■ 検証内容:GiteaとGitea Actionsのランナーの起動
Giteaを起動します。初期設定の画面を省く設定(INSTALL_LOCK)と、プッシュでリポジトリを作れる設定を付けています。プッシュで作ったリポジトリは非公開になります。
実行コマンド
docker volume create gitops04-gitea-data
docker run -d --name gitops04-gitea \
-p 3000:3000 \
-e USER_UID=1000 \
-e USER_GID=1000 \
-e GITEA__security__INSTALL_LOCK=true \
-e GITEA__server__ROOT_URL=http://192.168.56.30:3000/ \
-e GITEA__repository__ENABLE_PUSH_CREATE_USER=true \
-e GITEA__repository__DEFAULT_PUSH_CREATE_PRIVATE=true \
-v gitops04-gitea-data:/data \
docker.gitea.com/gitea:28.0.0
sleep 15
docker ps --filter name=gitops04-gitea --format '{{.Names}}\t{{.Status}}\t{{.Ports}}'
▼ 実行結果
gitops04-gitea-data
(途中省略:イメージのダウンロードの表示)
1955fcff422156de5d0d7e8226053a6f0c0b4b9614d965f10b828c6dc3b4acd8
gitops04-gitea Up 16 seconds 22/tcp, 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp
実行コマンド
curl -sI http://localhost:3000 | head -1
▼ 実行結果
HTTP/1.1 200 OK
管理者のユーザーを作成します。パスワードは、--random-passwordで生成しました。
実行コマンド
docker exec -u git gitops04-gitea gitea admin user create \
--username gitops \
--email gitops@example.com \
--admin \
--random-password \
--must-change-password=false
▼ 実行結果
(途中省略:生成されたパスワードの表示)
New user 'gitops' has been successfully created!
次に、セクション2 で作ったreachability-check.ymlを、中身を変えずにコピーしたリポジトリを作ります。
実行コマンド
mkdir /tmp/gitops04-gitea && cd /tmp/gitops04-gitea
git init -q -b main
mkdir -p .github/workflows
cp ~/iac/docker-lab-ci/.github/workflows/reachability-check.yml .github/workflows/
git add .github/workflows/reachability-check.yml
git -c user.name=gitops -c user.email=gitops@example.com commit -q -m "接続確認用のワークフロー"
コピー元とコピー先のファイルが同じであることを、ハッシュ値で確認します。
実行コマンド
sha256sum ~/iac/docker-lab-ci/.github/workflows/reachability-check.yml /tmp/gitops04-gitea/.github/workflows/reachability-check.yml
▼ 実行結果
d3febcde79ab5a8d01383ac093a6a2188d7a87451e15e391a9be6f468eb5c869 /home/control/iac/docker-lab-ci/.github/workflows/reachability-check.yml
d3febcde79ab5a8d01383ac093a6a2188d7a87451e15e391a9be6f468eb5c869 /tmp/gitops04-gitea/.github/workflows/reachability-check.yml
Giteaにプッシュします。-c credential.helper=は、このプッシュでだけ、認証情報を保存しないための指定です。
実行コマンド
git -c credential.helper= push http://192.168.56.30:3000/gitops/gitops04-reachability.git main; echo "exit_code=$?"
▼ 実行結果
Username for 'http://192.168.56.30:3000': gitops
Password for 'http://gitops@192.168.56.30:3000':
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 2 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (5/5), 829 bytes | 207.00 KiB/s, done.
Total 5 (delta 0), reused 0 (delta 0), pack-reused 0
To http://192.168.56.30:3000/gitops/gitops04-reachability.git
* [new branch] main -> main
exit_code=0
最後に、ランナーを起動します。登録用のトークンはGiteaのコマンドで発行し、画面に表示せずにランナーに渡しています。ランナーは、ホストのDockerを使ってジョブのコンテナを作るため、/var/run/docker.sockを渡します(Install with Docker)。ラベルは指定せず、既定のままにしました。
実行コマンド
GITEA_RUNNER_TOKEN=$(docker exec -u git gitops04-gitea gitea actions generate-runner-token)
docker volume create gitops04-runner-data
docker run -d --name gitops04-runner \
-e GITEA_INSTANCE_URL=http://192.168.56.30:3000/ \
-e GITEA_RUNNER_REGISTRATION_TOKEN="$GITEA_RUNNER_TOKEN" \
-e GITEA_RUNNER_NAME=gitops04-runner \
-v gitops04-runner-data:/data \
-v /var/run/docker.sock:/var/run/docker.sock \
docker.io/gitea/runner:3
unset GITEA_RUNNER_TOKEN
sleep 15
docker ps --filter name=gitops04 --format '{{.Names}}\t{{.Status}}'
docker logs gitops04-runner 2>&1 | tail -20
▼ 実行結果
gitops04-runner-data
(途中省略:イメージのダウンロードの表示)
9093e59f8eca11ccafb27d37aebf8c35196b4f89a70bbfc485be3664341dfecd
gitops04-runner Up 15 seconds
gitops04-gitea Up 23 minutes
.runner is missing or not a regular file
level=info msg="Registering runner, arch=amd64, os=linux, version=v3.5.0."
level=debug msg="Successfully pinged the Gitea instance server"
level=info msg="Runner registered successfully."
SUCCESS
time="2026-10-04T05:14:39Z" level=info msg="Starting runner daemon"
time="2026-10-04T05:14:39Z" level=info msg="runner: gitops04-runner, with version: v3.5.0, with labels: [ubuntu-latest ubuntu-24.04 ubuntu-22.04], declare successfully"
ランナーは登録され、ubuntu-latest、ubuntu-24.04、ubuntu-22.04の3つのラベルでジョブを待つ状態になりました。
■ 検証内容:同じワークフローの実行
Giteaの画面でリポジトリの「Actions」を開くと、プッシュしたワークフローreachability-checkが一覧に表示され、手動で実行できることを示す「このワークフローには workflow_dispatch イベントトリガーがあります。」という表示が出ました。「ワークフローを実行」を押すと、ブランチの選択と、ワークフローに書いた2つの入力(ランナーのラベルの選択肢と、接続先のIPアドレスの入力欄)が、GitHubと同じ形で表示されました。

1回目:runnerにself-hostedを指定する
セクション3 と同じく、runnerにself-hosted、target_hostに172.19.0.4を入力して実行しました。ジョブは「待機中」のまま進まず、ジョブの画面には「ラベルに一致するオンラインのランナーが見つかりません: self-hosted」と表示されました。Giteaのランナーのラベルにself-hostedがないためです。この実行はキャンセルしました。

2回目:runnerにubuntu-latestを指定する
ランナーが持つラベルubuntu-latestを指定して、もう一度実行しました。ジョブのログは次のとおりです。ログの各行の先頭にある時刻は省略しています。
▼ 実行結果(2回目、ジョブのコンテナの起動)
::group::Starting job container
image: docker.gitea.com/runner-images:ubuntu-latest
name: GITEA-ACTIONS-TASK-1-WORKFLOW-reachability-check-JOB-check-chec-7abdb9b842675d7ba9ab4cf730e8e22f12273422bd70f319e4dd006ea5cd33bf
network: GITEA-ACTIONS-TASK-1-WORKFLOW-reachability-check-JOB-check-chec-7abdb9b842675d7ba9ab4cf730e8e22f12273422bd70f319e4dd006ea5cd33bf-check-network
(途中省略:イメージのダウンロードとコンテナの作成の表示)
::endgroup::
▼ 実行結果(2回目、ステップ「実行環境を表示する」)
::group::Run echo "hostname=$(hostname)"
(途中省略:実行したスクリプトとシェルの表示)
::endgroup::
hostname=e51090574d44
runner_environment=self-hosted
/var/run/act/workflow/0.sh: line 4: ip: command not found
##[error]Process completed with exit code 127.
ジョブは、ランナーが作ったコンテナ(docker.gitea.com/runner-images:ubuntu-latest)の中で、ジョブ専用のネットワークにつながれて実行されました。ところが、このイメージにはipコマンドが含まれていなかったため、1つ目のステップで失敗し、接続を確認する2つ目のステップは実行されませんでした。
3回目:ジョブのコンテナのイメージに、必要なツールを加える
ワークフローは変えずに、ランナーの側で、ジョブを実行するイメージを替えます。Giteaの公式ドキュメント(Labels)では、ラベルは、どのジョブを受け付け、どのイメージで実行するかを決めるもので、ワークフローが使うツールを含むイメージを選ぶよう案内されています。既定のイメージにipとncを加えたイメージを作ります。
- ファイル名:
/tmp/gitops04-image/Dockerfile
FROM docker.gitea.com/runner-images:ubuntu-latest
RUN apt-get update \
&& apt-get install -y --no-install-recommends iproute2 netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
実行コマンド
docker build -t gitops04-runner-image:latest .
docker run --rm gitops04-runner-image:latest sh -c 'command -v ip; command -v nc; echo "exit_code=$?"'
▼ 実行結果
(途中省略:イメージのビルドの表示)
/usr/sbin/ip
/usr/bin/nc
exit_code=0
ランナーのラベルubuntu-latestを、このイメージに向けて、ランナーのコンテナを作り直します。登録の情報はボリュームに残っているため、登録し直しは不要です。
実行コマンド
docker rm -f gitops04-runner
docker run -d --name gitops04-runner \
-e GITEA_INSTANCE_URL=http://192.168.56.30:3000/ \
-e GITEA_RUNNER_NAME=gitops04-runner \
-e GITEA_RUNNER_LABELS=ubuntu-latest:docker://gitops04-runner-image:latest \
-v gitops04-runner-data:/data \
-v /var/run/docker.sock:/var/run/docker.sock \
docker.io/gitea/runner:3
sleep 10
docker logs gitops04-runner 2>&1 | tail -10
▼ 実行結果
gitops04-runner
d1299c5e2191d42cdc83ef72070acf3260283f01b8e355042e61fbbf8c27765f
time="2026-10-04T05:52:15Z" level=info msg="Starting runner daemon"
time="2026-10-04T05:52:15Z" level=info msg="labels updated to: [ubuntu-latest:docker://gitops04-runner-image:latest]"
time="2026-10-04T05:52:15Z" level=info msg="runner: gitops04-runner, with version: v3.5.0, with labels: [ubuntu-latest], declare successfully"
同じ入力(ubuntu-latest、172.19.0.4)で実行しました。
▼ 実行結果(3回目、ジョブのコンテナの起動)
::group::Starting job container
image: gitops04-runner-image:latest
name: GITEA-ACTIONS-TASK-2-WORKFLOW-reachability-check-JOB-check-chec-574e281c446813a5dca754c0a7207a1009e4040245db7c124418267deb20aaff
network: GITEA-ACTIONS-TASK-2-WORKFLOW-reachability-check-JOB-check-chec-574e281c446813a5dca754c0a7207a1009e4040245db7c124418267deb20aaff-check-network
(途中省略:コンテナの作成の表示)
::endgroup::
▼ 実行結果(3回目、ステップ「実行環境を表示する」)
::group::Run echo "hostname=$(hostname)"
(途中省略:実行したスクリプトとシェルの表示)
::endgroup::
hostname=b45b518b2b34
runner_environment=self-hosted
2: eth0 inet 172.20.0.2/16 brd 172.20.255.255 scope global eth0\ valid_lft forever preferred_lft forever
▼ 実行結果(3回目、ステップ「接続先のSSHポートに接続する」)
::group::Run nc -zv -w 5 "${TARGET_HOST}" 22 && rc=0 || rc=$?
(途中省略:実行したスクリプト、シェル、環境変数の表示)
::endgroup::
nc: connect to 172.19.0.4 port 22 (tcp) timed out: Operation now in progress
exit_code=1
今度は両方のステップが実行されましたが、接続はタイムアウトしました。ジョブのコンテナは、ジョブ専用のネットワーク(172.20.0.2/16)にいて、操作対象のネットワーク(172.19.0.0/16)とは別のネットワークでした。
4回目:ジョブのコンテナを、操作対象のネットワークにつなぐ
ランナーの設定ファイルで、ジョブのコンテナをつなぐネットワークを指定します。既定の設定ファイルを生成し、containerのnetworkをansible-lab-netに書き換えます。
実行コマンド
docker run --rm --entrypoint="" docker.io/gitea/runner:3 gitea-runner generate-config > gitops04-runner-config.yaml
sed -i 's/^ #network: ""$/ network: "ansible-lab-net"/' gitops04-runner-config.yaml
grep -n -E '^ #?network:' gitops04-runner-config.yaml
▼ 実行結果
Command "generate-config" is deprecated, use `config generate` instead.
208: network: "ansible-lab-net"
この設定ファイルを渡して、ランナーのコンテナを作り直します。
実行コマンド
docker rm -f gitops04-runner
docker run -d --name gitops04-runner \
-e GITEA_INSTANCE_URL=http://192.168.56.30:3000/ \
-e GITEA_RUNNER_NAME=gitops04-runner \
-e GITEA_RUNNER_LABELS=ubuntu-latest:docker://gitops04-runner-image:latest \
-e CONFIG_FILE=/config.yaml \
-v /tmp/gitops04-image/gitops04-runner-config.yaml:/config.yaml:ro \
-v gitops04-runner-data:/data \
-v /var/run/docker.sock:/var/run/docker.sock \
docker.io/gitea/runner:3
sleep 10
docker logs gitops04-runner 2>&1 | tail -10
▼ 実行結果
gitops04-runner
6b2d1e581d4c95994f329435d6230c95f1bfcd54b8f3493d6f6d10072540f1ab
time="2026-10-04T05:57:51Z" level=info msg="Starting runner daemon"
time="2026-10-04T05:57:51Z" level=info msg="runner: gitops04-runner, with version: v3.5.0, with labels: [ubuntu-latest], declare successfully"
同じ入力で実行しました。
▼ 実行結果(4回目、ジョブのコンテナの起動)
::group::Starting job container
image: gitops04-runner-image:latest
name: GITEA-ACTIONS-TASK-3-WORKFLOW-reachability-check-JOB-check-chec-36e234804255bdb81e42bcc3b94c0aefda5a6b13513e3bbe62afa94fa2074451
network: ansible-lab-net
(途中省略:コンテナの作成の表示)
::endgroup::
▼ 実行結果(4回目、ステップ「実行環境を表示する」)
::group::Run echo "hostname=$(hostname)"
(途中省略:実行したスクリプトとシェルの表示)
::endgroup::
hostname=1a9e0db4a368
runner_environment=self-hosted
2: eth0 inet 172.19.0.5/16 brd 172.19.255.255 scope global eth0\ valid_lft forever preferred_lft forever
▼ 実行結果(4回目、ステップ「接続先のSSHポートに接続する」)
::group::Run nc -zv -w 5 "${TARGET_HOST}" 22 && rc=0 || rc=$?
(途中省略:実行したスクリプト、シェル、環境変数の表示)
::endgroup::
Connection to 172.19.0.4 22 port [tcp/ssh] succeeded!
exit_code=0
■ 結果
同じワークフローの4回の実行を並べると、次のとおりです。
| 実行 | runs-on |
ランナーの側で変えたこと | ジョブを実行した場所 | 結果 |
|---|---|---|---|---|
| 1回目 | self-hosted |
なし | (ランナーに割り当てられない) | 待機したまま(一致するランナーがない) |
| 2回目 | ubuntu-latest |
なし | 既定のイメージのコンテナ、ジョブ専用のネットワーク | ip: command not foundで失敗 |
| 3回目 | ubuntu-latest |
ジョブのイメージにipとncを加えた |
ジョブ専用のネットワーク(172.20.0.2/16) |
タイムアウト(exit_code=1) |
| 4回目 | ubuntu-latest |
ジョブのコンテナをansible-lab-netにつないだ |
操作対象のネットワーク(172.19.0.5/16) |
接続成功(exit_code=0) |
ワークフローのファイルは、4回とも、GitHubで使ったものから1文字も変えていません。手動実行のトリガー(workflow_dispatch)と入力の選択肢、envでの値の受け渡し、RUNNER_ENVIRONMENTの環境変数は、Giteaでもそのまま使えました。
一方、そのままでは動きませんでした。差は、ワークフローの記法ではなく、ランナーの側にありました。
- ラベルの意味:GitHubの
ubuntu-latestはホステッドランナーを指しますが、Giteaのubuntu-latestは、自分で起動したランナーが、指定のイメージで作るコンテナを指します。同じ名前のラベルでも、ジョブがどこで実行されるかは、Git基盤とランナーの設定で決まります。 - ジョブのイメージに含まれるツール:GitHubのホステッドランナーにあった
ipコマンドが、Giteaの既定のイメージにはありませんでした。 - ジョブのネットワーク:Giteaのランナーは、既定ではジョブごとに別のネットワークを作るため、Git基盤もランナーも操作対象と同じホストにあるのに、ジョブからは操作対象に届きませんでした。ジョブのコンテナを操作対象のネットワークにつなぐと、届きました。
3つ目の結果は、セクション2 と同じことを示しています。到達性を決めるのは、Git基盤やランナーをどこに置いたかではなく、ジョブが実際に実行される場所のネットワークです。Git基盤とランナーを操作対象と同じネットワークに置いても、ジョブがその中の別のネットワークで実行されれば、操作対象には届きません。この構造は、GitLabなど他のセルフホスト型のGit基盤でも同じで、ランナーがジョブをどこで実行するか(ホストの上か、コンテナの中か、どのネットワークか)を確認する必要があります。
なお、Giteaは、この回の比較のために用意したもので、以降の回では使いません。
次のセクションでは、ここまでの結果をもとに、4つの基準で3つの構成を比較し、本シリーズの採用構成を決めます。
6. 4つの基準による比較と採用構成
セクション2 から セクション5 の結果をもとに、3つの構成を、到達性、状態の永続性、機密情報の置き場所、運用負荷の4つの基準で比較し、本シリーズの採用構成を決めます。
4つの基準による比較
| 基準 | GitHub(ホステッドランナー) | GitHub(self-hostedランナー) | セルフホスト型Git基盤(Gitea) |
|---|---|---|---|
| 到達性 | プライベートなネットワークの中の操作対象には届かない(セクション2)。届くのは、ランナーの中に作った操作対象だけ | 操作対象と同じネットワークで実行するため届く(セクション3) | ジョブを実行する場所を、操作対象のネットワークにつなげば届く(セクション5) |
| 状態の永続性 | ランナーの中に作った操作対象は、実行のたびに作り直される(セクション4) | 操作対象はランナーの外で存在し続け、実行をまたいだ変化を観測できる(セクション4) | 操作対象がランナーの外で存在し続ける点は、self-hostedランナーと同じ構造 |
| 機密情報の置き場所 | コード、Secrets、ジョブのログを、GitHubに置く | コード、Secrets、ジョブのログを、GitHubに置く。ジョブの実行だけが自分のネットワークの中になる | コード、Secrets、ジョブのログを、すべて自分のネットワークの中に置ける |
| 運用負荷 | ランナーの運用はない | ランナーを置くホストと、ランナーの運用を抱える | Git基盤そのもの(バックアップ、バージョンアップ、認証、可用性)に加え、ランナーと、ジョブを実行するイメージやネットワークの設定を抱える |
差が出るのは、機密情報の置き場所と運用負荷
到達性と状態の永続性は、self-hostedランナーを置けば、GitHubでもセルフホスト型のGit基盤でも満たせます。どちらも、ジョブを操作対象と同じネットワークで実行し、操作対象をランナーの外に置き続ける構成だからです。2つの構成の差は、残りの2つの基準に出ます。
機密情報の置き場所については、self-hostedランナーを使っても、ジョブのログはGitHubに残ります。セクション3 でself-hostedランナーが出力したログには、ランナーを置いたホストのネットワークの構成(IPアドレスやネットワークの名前)がそのまま含まれていて、そのログは、GitHubの画面で表示したものです。パイプラインでAnsibleを実行すれば、Ansibleの出力も同じようにログとしてGitHubに残ります。コードとSecretsも、GitHubの上にあります。
運用負荷については、セルフホスト型のGit基盤では、Git基盤そのものを自分で運用することになります。セクション5 では、Giteaを起動するだけでなく、ジョブを実行するイメージに必要なツールを加え、ジョブのコンテナをつなぐネットワークを設定して、ようやく操作対象に届きました。GitHubのself-hostedランナーでは、ランナーを置くホストの運用は必要ですが、Git基盤の運用はありません。
判断は「外に置くかどうか」ではなく「どこまで預けるか」
クラウドのインフラを使っている時点で、操作対象もデータも、すでに外部の事業者の上にあります。そのため、この判断は、「外部に置くか、置かないか」ではなく、「どこまでを外部の事業者に預けるか」という、信頼の境界をどこに引くかの問題になります。
GitHubのself-hostedランナーの構成は、コード、Secrets、ジョブのログをGitHubに預け、ジョブの実行と操作対象への接続だけを自分のネットワークの中に置く、という線の引き方です。セルフホスト型のGit基盤の構成は、そのすべてを自分のネットワークの中に置く線の引き方です。どちらが正しいかは、構成からは決まりません。要件で決まります。
- コード、Secrets、ジョブのログを外部に置けない要件がある場合:セルフホスト型のGit基盤を選ぶ
- その要件がなく、Git基盤の運用を抱えたくない場合:GitHubと、操作対象と同じネットワーク内のself-hostedランナーを選ぶ
本シリーズの採用構成
本シリーズは、GitHubと、操作対象と同じネットワーク内に置くself-hostedランナーを採用し、第2部以降の実行基盤として固定します。理由は次の3つです。
- 記事に載せるワークフローとコードを使えば、読者が自分のGitHubのアカウントの上で、同じ構成を再現できるため
- 操作対象が実行のたびに作り直されず、状態が継続するため。第4部(第17回 〜 第21回)のドリフトの検知と自動収束は、この前提の上に成り立つ
- GitHub Actionsは、組織の中で使われているCI/CDのツールとして最も多く、読者が実務で触れる可能性が高いため。JetBrainsが公開している調査(Best CI/CD Tools for 2026: What the Data Actually Shows)では、組織での利用はGitHub Actionsが33%、Jenkinsが28%、GitLab CIが19%とされている
リポジトリを非公開で運用する理由
本シリーズの 第3回 では、リポジトリを非公開で運用することを前提にしました。その理由は、self-hostedランナーを使うことにあります。
GitHubの公式ドキュメント(Secure use reference)では、ホステッドランナーは実行のたびに使い捨てられる仮想マシンでコードを実行するのに対し、self-hostedランナーにはその保証がなく、ワークフローの中の信頼できないコードによって、継続的に乗っ取られるおそれがあるとされています。そのうえで、公開リポジトリでは誰でもプルリクエストを作れるため、self-hostedランナーは公開リポジトリではほぼ使うべきではない、としています。非公開のリポジトリでも、リポジトリをフォークしてプルリクエストを作れる人(一般には読み取りの権限を持つ人)は、self-hostedランナーの環境を乗っ取れるとして、注意を促しています。
本シリーズのself-hostedランナーは、操作対象と同じホストで動き、操作対象に接続でき、tfstateやTerraformが生成した秘密鍵も、同じホストにあります。このランナーでコードを実行できる人は、インフラそのものを操作できる人です。そのため、リポジトリは非公開にし、読み取りの権限を持つ人も、インフラを操作してよい人に限ります。リポジトリを非公開にすることは、ランナーで動くコードを書ける人の範囲を、信頼の境界の内側に限るための線の引き方です。
セルフホスト型のGit基盤を選ぶ場合
第2部以降で作るパイプラインの構造は、セルフホスト型のGit基盤とそのCIを選んだ場合でも同じです。セクション5 で確認したとおり、ワークフローの記法はGiteaでもそのまま使え、差はランナーの側(ラベルの意味、ジョブのイメージ、ジョブのネットワーク)にありました。セルフホスト型を選ぶ場合は、この差をランナーの設定で埋めたうえで、同じ構造のパイプラインを組めます。
また、GitHubを選んだ場合でも、外部に預ける範囲を狭める設計はできます。たとえば、長期間有効なクラウドの認証情報をSecretsに置かずに済ませる方法があります。こうした、外部に預ける機密情報を減らす設計は、第3部(第13回 〜 第16回)で扱います。
次のセクションでは、この回で整理した判断が、クラウドのプロバイダーでも同じように必要になる理由を整理します。
7. クラウドでも同じ判断が必要になる理由
この回で整理した到達性と信頼の境界の判断が、Dockerプロバイダーに固有のものではなく、AWSやGCPでも同じように必要になることを整理します。
TerraformとAnsibleで、接続先の場所が違う
セクション2 で整理したとおり、パイプラインが成立するには、TerraformとAnsibleのそれぞれの接続先に、ランナーから届く必要があります。この接続先が、プロバイダーによって次のように変わります。
| ツール | Docker(本シリーズの検証環境) | AWS | GCP |
|---|---|---|---|
| Terraform | 操作対象と同じホストのDocker | AWSのAPI | GCPのAPI |
| Ansible | コンテナのSSHのポート(Dockerのプライベートなネットワーク) | インスタンスのSSHのポート(VPCのプライベートサブネット) | インスタンスのSSHのポート(VPCのプライベートサブネット) |
この検証環境では、Terraformの接続先もAnsibleの接続先も、同じホストの中にあります。そのため、self-hostedランナーを同じホストに置けば、両方に届きました。
クラウドでは、TerraformとAnsibleの接続先の場所が分かれます。Terraformが接続するクラウドのAPIは、通常、インターネットから到達できる場所で提供されています。認証情報さえ渡せば、ホステッドランナーからでもTerraformは動きます。一方、Ansibleが接続するインスタンスは、プライベートサブネットに置かれていれば、インターネットからは届きません。
「Terraformは動くのに、Ansibleだけ失敗する」
その結果、ホステッドランナーでパイプラインを組むと、terraform planもterraform applyも成功し、インスタンスは作られるのに、その後のansible-playbookだけがSSHの接続で失敗する、という状態になります。セクション1 で挙げた「terraform planは通るのに、Ansibleだけが失敗する」という場面は、この構造から生まれます。
Terraformが動いていることは、パイプラインが操作対象に届いていることを意味しません。届いているのはクラウドのAPIまでで、インスタンスの中には届いていないからです。この場合も、セクション3 の構成2のように、ランナーをインスタンスと同じVPCの中に置く必要があります。セルフホスト型のGit基盤を選ぶ場合も、Git基盤とランナーを、インスタンスに届く場所に置くことになります。
判断の基準は、プロバイダーを問わず同じ
この回で使った基準は、プロバイダーを差し替えても、そのまま使えます。
- 到達性:TerraformとAnsibleのそれぞれの接続先に、ジョブが実行される場所から届くか(セクション2、セクション3、セクション5)
- 状態の永続性:操作対象が実行をまたいで存在し続け、その変化を観測できるか(セクション4)
- 機密情報の置き場所と運用負荷:どこまでを外部の事業者に預け、何を自分で運用するか(セクション6)
変わるのは、接続先がDockerかクラウドのAPIか、操作対象がDockerのネットワークにあるかVPCにあるか、という個々の場所です。クラウドでは、Terraformに渡すクラウドの認証情報も、外部に預けるものに加わります。その扱いは、第3部(第13回 〜 第16回)で扱います。
次のセクションでは、この回で整理した内容をまとめます。
8. まとめ
この回で整理した内容を確認します。
- パイプラインをどこに置くかは、機能の比較では決まらず、ジョブが実行される場所から操作対象に届くかと、何を外部に預けるかという信頼の境界で決まる
- GitHubのホステッドランナーは、ジョブごとに用意される仮想マシンで動き、プライベートなネットワークの中にある操作対象のSSHのポートには届かないことを実機で確認した。同じ接続先に、操作対象と同じホストからは届いた
- ランナーから操作対象に届くようにする構成は、ランナーの中で環境ごと作る構成、操作対象と同じネットワークにself-hostedランナーを置く構成、操作対象と同じネットワークにGit基盤とCIのランナーを置く構成の3つがある。self-hostedランナーは外向きのHTTPSでジョブを受け取り、操作対象と同じホストに置いたランナーから届くことを実機で確認した
- ランナーの中で環境ごと作る構成では、操作対象が実行のたびに作り直されるため、実行をまたいだ変化を観測できない。self-hostedランナーでは、2回の実行で同じ操作対象に接続し、その間に加えた手動変更を観測できることを実機で確認した。これが第4部のドリフトの検知と自動収束の前提になる
- セルフホスト型のGit基盤(Gitea)では、ワークフローの記法はそのまま使えたが、ラベルの意味、ジョブのイメージに含まれるツール、ジョブのネットワークの差により、そのままでは動かなかった。ジョブのコンテナを操作対象のネットワークにつなぐと届くことを実機で確認した。到達性を決めるのは、Git基盤やランナーの置き場所ではなく、ジョブが実際に実行される場所である
- 4つの基準で比較すると、到達性と状態の永続性はself-hostedランナーで満たせ、差が出るのは機密情報の置き場所と運用負荷である。判断は「外に置くかどうか」ではなく「どこまでを外部の事業者に預けるか」であり、要件で決まる
- 本シリーズは、GitHubと、操作対象と同じネットワーク内に置くself-hostedランナーを採用し、第2部以降の実行基盤として固定する
- self-hostedランナーは、ワークフローの中の信頼できないコードによって継続的に乗っ取られるおそれがあり、公開リポジトリでは誰でもプルリクエストを作れるため、本シリーズはリポジトリを非公開で運用する
- クラウドでは、Terraformの接続先であるクラウドのAPIには届き、Ansibleの接続先であるプライベートサブネットのインスタンスには届かない、という形で同じ構造が現れる。到達性、状態の永続性、信頼の境界の判断は、プロバイダーを問わず同じである
9. 次回予告
本シリーズの 第3回 では、変更の分類をGitリポジトリの構造に落とし込み、mainを唯一の真実とするブランチ運用を設計しました。第4回となる今回は、そのリポジトリをどこでホストし、パイプラインをどこで動かすかを扱いました。ホステッドランナーからはプライベートなネットワークの中の操作対象に届かないことを実機で確認し、到達性を確保する3つの構成を整理しました。そのうえで、self-hostedランナーでは実行をまたいだ変化を観測できること、セルフホスト型のGit基盤では、同じワークフローでもランナーの側の違いによってそのままでは動かないことを確認しました。最後に、4つの基準で比較して、選択が信頼の境界の要件で決まることを示し、本シリーズの採用構成と、リポジトリを非公開で運用する理由を整理しました。
今回で、第1部は終わりです。第1部では、第1回 でGitOpsの4原則を評価軸にAnsibleとTerraformの充足度を整理し、「唯一の真実」が崩れる3つの経路を示しました。第2回 では責務分担を変更の種類ごとに必要な実行として捉え直し、第3回 ではその分類をパスで判定するリポジトリの構造を設計しました。今回の実行基盤と信頼の境界を加えた4つが、第2部でパイプラインを組むための前提になります。
次回からは、第2部「GitHub Actionsによる自動化パイプライン」に入ります。第5回 では、この回で導入したself-hostedランナーの上で、GitHub ActionsからAnsibleとTerraformを実行する基本の構成を組みます。プルリクエストで確認し、マージで適用する流れを実装し、変更されたパスによるジョブの切り替え、Terraform→Ansibleの実行順序の保証、実行時のインベントリの生成を扱います。第3回 で洗い出した、リポジトリのterraform/とansible/への再配置も、ワークフローの作り直しとあわせて行います。
次回:第5回:GitHub ActionsからAnsibleを実行する基本構成
📑 連載の移動 前の記事:【GitOps編】第3回 | 次の記事:【GitOps編】第5回
🗺️ 初めての方、シリーズの全体像を知りたい方はこちら
シリーズ全体については、以下のまとめブログで整理しています。
→ 「Ansible×TerraformをGitOpsで回す」〜Terraformプロバイダーを問わない構成管理の自動化〜 第1部まとめブログ:「Gitが唯一の真実」を成り立たせる前提の設計 ※近日公開予定
10. 連載一覧:「Ansible×TerraformをGitOpsで回す」
第1部:GitOpsの基本概念とAnsibleとの接続
| 回数 | テーマ、記事タイトル | 概要 |
|---|---|---|
| 第1回 | なぜ「Gitがインフラの唯一の真実」なのか | GitOpsの4原則を評価軸として、AnsibleとTerraformの充足度を整理する。push型構成の立ち位置と、「唯一の真実」が崩れる3つの経路を示す。 |
| 第2回 | AnsibleとTerraformのGitOps上での責務分担 | 責務分担を「変更の種類ごとに必要な実行」として捉え直す。変更の3分類、境界が曖昧な設定の判定基準、Gitの外にある接続面を整理する。 |
| 第3回 | GitリポジトリとAnsibleの構造設計 | 変更の分類をパスで表現するディレクトリ構成、Gitに置くもの・置かないもの、モノレポとマルチリポの選択基準、mainを唯一の真実とするブランチ運用を設計する。無料プランの非公開リポジトリでは直接プッシュを防げないため、強制する場所を適用前に移す方針を示す。 |
| 第4回 | ローカルGitea vs GitHub Actionsの選択基準 | 到達性・状態の永続性・機密情報の置き場所・運用負荷の4基準で比較し、選択が信頼境界の要件で決まることを示す。本シリーズはGitHub+self-hostedランナーを採用し、リポジトリを非公開で運用する。第1部の最終回。 |