「Ansible×TerraformをGitOpsで回す」〜Terraformプロバイダーを問わない構成管理の自動化〜 第2回:AnsibleとTerraformのGitOps上での責務分担
🗺️ 初めての方、シリーズの全体像を知りたい方はこちら
シリーズ全体については、以下のまとめブログで整理しています。
→ 「Ansible×TerraformをGitOpsで回す」〜Terraformプロバイダーを問わない構成管理の自動化〜 第1部まとめブログ:「Gitが唯一の真実」を成り立たせる前提の設計 ※近日公開予定
📋 目次
- はじめに
- コミットされる変更の3分類
- どちらでも書ける設定の境界と判定基準
- AnsibleとTerraformの接続面はGitの外にある
- 変更の種類ごとに必要な実行順序
- クラウドプロバイダーでも同じ境界問題が起きる理由
- まとめ
- 次回予告
- 連載一覧:「Ansible×TerraformをGitOpsで回す」
1. はじめに
Terraformでコンテナや仮想マシンを作成し、その内部の設定をAnsibleで投入する構成をGitで管理していて、
- Terraformはインフラのリソースを、AnsibleはOSの中の設定を担当している
- それぞれのコードをGitに置き、変更はコミットを通して反映している
- 役割分担は決まっているので、変更のたびにどちらを動かすかで迷うことはない
と考えていないでしょうか。
AnsibleとTerraformの役割分担は、多くの記事で「Terraformで作って、Ansibleで設定する」という一文で説明されています。この説明は、リソースを初めて作るときの流れとしては正しいものです。しかし、作った後に変更を加える場面で、どちらのツールをどう動かせばよいかまでは示していません。GitOpsで運用すると、変更はすべてコミットとして届くため、次のような場面にぶつかります。
- コンテナに渡す環境変数を、Terraformのリソース定義に書くか、Ansibleで設定ファイルに書くかで迷う
- Terraform側の小さな修正をコミットして適用したら、Ansibleで投入した設定が消えた
- ノードを1台追加するコミットで、Terraformのリソース定義のほかにAnsible側のどこを直し、どの順序で実行すればよいかが決まっていない
これらは、どちらのツールが何を担当するかは分かっているのに起きています。共通しているのは、変更がコミットされたときに、どちらのツールを、どの順序で動かす必要があるかが決まっていない点です。
この役割分担は、「AnsibleとTerraformの連携が壊れる理由はライフサイクルにあった」(以下、Ansible×Terraformシリーズ)でも扱ってきました。Ansible×Terraformシリーズの 第1回 では、Terraformはインフラのリソースを宣言的に、AnsibleはOSの中を手続き的に管理するという役割分担を整理しました。第11回 では、terraform planが検知できるのはtfstateに記録されたリソースの属性に限られ、ansible-playbook --check --diffが検知できるのはPlaybookが管理している範囲に限られることを確認しました。そのうえで、インフラ層とOS層の確認と同期は、二軸として両方行う必要があると整理しました。役割分担と確認の二軸そのものは、この回では再解説しません。
本シリーズの 第1回 では、「Gitが唯一の真実」が崩れる3つの経路を整理しました。そのうち、Gitを経由しない手動変更に対しては、変更の経路をGitに限定する仕組みを第2部(第5回 〜 第12回)で作ります。ただし、経路をGitに限定するには、その前に、どの変更をGitのどこで行い、どのツールで反映するかが決まっている必要があります。この回では、その前提を整理します。
正確に言うと、GitOpsで問われるのは「どのツールが何を担当するか」ではなく、「ある変更がコミットされたとき、どちらのツールを、どの順序で動かす必要があるか」です。この回で扱う問いは、「この変更はTerraformの担当か、Ansibleの担当か、両方か。それをコミット単位でどう判定するのか」です。
次のセクションでは、コミットされる変更を3つに分類します。
2. コミットされる変更の3分類
コミットされる変更を、反映するためにどちらのツールの実行が必要になるかで3つに分類し、そのうち2つを実機で確認します。
分類の基準は、変更したファイルの種類ではなく、その変更をインフラに反映するために、どちらのツールを実行する必要があるかです。
- Terraformだけで完結する変更:Terraformの実行だけで反映が終わり、Ansibleを実行する必要がない変更。例として、既存のコンテナに接続しない新しいリソース(ボリュームなど)の追加
- Ansibleだけで完結する変更:Ansibleの実行だけで反映が終わり、Terraformを実行する必要がない変更。例として、Playbookで配置している設定ファイルの内容の変更
- 両方にまたがる変更:Terraformの実行に加えて、Ansibleの実行も必要になる変更。例として、ノードの追加
Ansibleだけで完結する変更は、Playbookやロールだけを変更するもので、TerraformのコードであるHCLには触れません。Terraformが比較するのは、HCLの定義、tfstate、実際のリソースの属性であり、Playbookは比較の対象に入りません。この検証環境のmain.tfも、PlaybookやロールのファイルをTerraformから参照していません。そのため、ここでは残りの2つをterraform planで確認します。確認は計画の表示までとし、terraform applyは実行しません。
検証環境では、Ansibleのインベントリ(inventory.ini)を、Terraformのlocal_fileリソースで、コンテナのIPアドレスから生成しています。
- ファイル名:
main.tf(該当箇所)
resource "local_file" "ansible_inventory" {
filename = "${path.module}/inventory.ini"
content = templatefile("${path.module}/inventory.tftpl", {
ips = {
for name, container in docker_container.targets :
name => container.network_data[0].ip_address
}
})
}
- ファイル名:
inventory.tftpl
[target_nodes]
%{ for name, ip in ips ~}
${name} ansible_host=${ip} ansible_user=ansible
%{ endfor ~}
ipsは、docker_container.targetsのすべてのコンテナから作られ、テンプレートはその全要素を[target_nodes]グループの下に1行ずつ書き出します。このため、terraform planの結果にlocal_file.ansible_inventoryが含まれるかどうかで、変更がAnsibleの接続先に影響するかを確認できます。
■ 検証内容:既存のコンテナに接続しない新規リソースの追加
どのコンテナにもマウントしないボリュームを、main.tfに追加します。
- ファイル名:
main.tf(追加箇所)
resource "docker_volume" "gitops02_unattached" {
name = "gitops02-unattached-vol"
}
この状態で、terraform planを実行します。
実行コマンド
terraform plan
▼ 実行結果
(途中省略:Refreshing state...によるリソース一覧の出力)
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# docker_volume.gitops02_unattached will be created
+ resource "docker_volume" "gitops02_unattached" {
+ driver = (known after apply)
+ id = (known after apply)
+ mountpoint = (known after apply)
+ name = "gitops02-unattached-vol"
}
Plan: 1 to add, 0 to change, 0 to destroy.
(途中省略:区切り線と-outオプションに関する注意書き)
■ 結果
計画に含まれたのは、docker_volume.gitops02_unattachedの作成だけでした(Plan: 1 to add, 0 to change, 0 to destroy.)。既存のコンテナ(target-node1〜3)の再作成も、インベントリを生成しているlocal_file.ansible_inventoryの差分も含まれていません。
既存のコンテナにもインベントリにも影響しないため、この変更はTerraformの実行だけで反映でき、Ansibleを実行する必要はありません。
■ 検証内容:ノードの追加
次に、ノードを1台追加します。まず、Playbookがどのホストを対象にしているかを確認します。
- ファイル名:
site.yml
---
- name: 接続確認用Playbook
hosts: target_nodes
gather_facts: false
tasks:
- name: 疎通確認
ansible.builtin.ping:
roles:
- common_setup
Playbookは、個々のホスト名ではなく、target_nodesグループを対象にしています。グループに属するホストは、インベントリで決まります。
main.tfのlocalsに、target-node4の定義を追加します。
- ファイル名:
main.tf(該当箇所)
locals {
target_nodes = {
"target-node1" = 2231
"target-node2" = 2222
"target-node3" = 2223
"target-node4" = 2224
}
target_node_networks = {
# (途中省略:target-node1〜3の行)
"target-node4" = [docker_network.lab_net.name]
}
}
この状態で、terraform planを実行します。
実行コマンド
terraform plan
▼ 実行結果
(途中省略:Refreshing state...によるリソース一覧の出力)
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
-/+ destroy and then create replacement
Terraform will perform the following actions:
# docker_container.targets["target-node4"] will be created
+ resource "docker_container" "targets" {
(途中省略:属性とブロックの一覧)
}
# local_file.ansible_inventory must be replaced
-/+ resource "local_file" "ansible_inventory" {
~ content = <<-EOT
[target_nodes]
target-node1 ansible_host=172.19.0.2 ansible_user=ansible
target-node2 ansible_host=172.19.0.3 ansible_user=ansible
target-node3 ansible_host=172.19.0.4 ansible_user=ansible
EOT -> (known after apply) # forces replacement
(途中省略:ハッシュ値等の差分)
# (3 unchanged attributes hidden)
}
(途中省略:local_file.group_vars_target_nodesの再作成計画)
Plan: 3 to add, 0 to change, 2 to destroy.
Changes to Outputs:
~ target_nodes = {
+ target-node4 = {
+ host = (known after apply)
+ port = 22
}
# (3 unchanged attributes hidden)
}
~ target_nodes_ips = {
+ target-node4 = (known after apply)
# (3 unchanged attributes hidden)
}
(途中省略:区切り線と-outオプションに関する注意書き)
■ 結果
計画には、target-node4のコンテナの作成に加えて、local_file.ansible_inventoryとlocal_file.group_vars_target_nodesの再作成が含まれました(Plan: 3 to add, 0 to change, 2 to destroy.)。既存の3台のコンテナは含まれていません。
target-node4のIPアドレスはapplyの後に決まるため、インベントリの内容も(known after apply)になっています。テンプレートはすべてのコンテナを[target_nodes]グループに書き出し、site.ymlはそのグループを対象にしているため、Playbookを変更しなくても、target-node4は作り直されたインベントリを通じてAnsibleの対象に入る構成です。
一方、新しく作られるコンテナの中身は、イメージと、main.tfの定義(uploadブロック等)に書かれた内容だけで決まります。Ansible×Terraformシリーズの 第42回 で整理したとおり、Playbookで配置する設定はmain.tfの定義に書かれていないため、作成時のコンテナには入りません。その設定を入れるには、terraform applyの後にAnsibleを実行する必要があります。そのため、ノードの追加は両方にまたがる変更になります。
なお、ロールをホストごとに割り当てる構成では、Playbookやインベントリのグループ定義の変更も必要になります。この検証環境では、グループ単位でロールを割り当てているため、変更はmain.tfだけで済みます。
3分類のまとめ
2つの検証で変更したファイルは、どちらもmain.tfだけでした。それでも、ボリュームの追加はTerraformの実行だけで反映でき、ノードの追加はAnsibleの実行も必要になります。どのツールの実行が必要になるかは、変更したファイルからは決まりません。
| 分類 | 変更の例 | 変更するファイル | 反映に必要な実行 |
|---|---|---|---|
| Terraformだけで完結する変更 | 既存のコンテナに接続しないボリュームの追加 | main.tf |
Terraform |
| Ansibleだけで完結する変更 | Playbookで配置する設定ファイルの内容の変更 | Playbook、ロール | Ansible |
| 両方にまたがる変更 | ノードの追加 | main.tf(ロールをホストごとに割り当てる構成では、Playbookやインベントリのグループ定義も) |
Terraform、Ansible |
実行の順序と、リソースの再生成を伴う変更の扱いは、セクション5 で整理します。次のセクションでは、どちらのツールでも書ける設定を、どちらに置くべきかの判定基準を扱います。
3. どちらでも書ける設定の境界と判定基準
AnsibleとTerraformのどちらでも書ける設定を具体例で整理し、どちらに置くかを決める判定基準を示します。
境界が曖昧になる設定
検証環境のコンテナは、次のDockerfileから作ったイメージを使っています。
- ファイル名:
Dockerfile
FROM ubuntu:22.04
# 環境変数とパッケージのインストール
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
openssh-server \
python3.10 \
sudo \
iproute2 \
curl \
&& rm -rf /var/lib/apt/lists/*
# SSHD の初期設定
RUN mkdir /var/run/sshd
# 検証用ユーザー(ansible)の作成と sudo 権限付与
ARG ANSIBLE_USER_PASSWORD
RUN useradd -m -s /bin/bash ansible && \
echo "ansible:${ANSIBLE_USER_PASSWORD}" | chpasswd && \
echo 'ansible ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# SSH公開鍵格納用のディレクトリ作成
RUN mkdir -p /home/ansible/.ssh && \
chown -R ansible:ansible /home/ansible/.ssh && \
chmod 700 /home/ansible/.ssh
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]
このイメージには、SSHサーバー(openssh-server)、Python、sudoなどのパッケージと、Ansibleが接続に使うansibleユーザーが含まれています。コンテナを作成するときには、main.tfのuploadブロックで、SSHの公開鍵などを配置しています。その後、Ansibleがコンテナに接続し、Playbookに書かれた設定を投入します。
このように、コンテナの中の状態を決める手段が、イメージ、Terraformのリソース定義、Ansibleの3つあるため、同じ設定をどこに書くかの選択が生まれます。代表的なものは次の3つです。
- 環境変数と設定ファイル:アプリケーションに渡す値を、Terraformの
docker_containerのenvで環境変数として渡すか、Ansibleで設定ファイルに書くか - イメージに焼き込むパッケージと、Ansibleで入れるパッケージ:パッケージを
Dockerfileに書いてイメージに含めるか、コンテナの起動後にAnsibleでインストールするか - コンテナ内の待ち受けポートと、外部公開ポート:SSHサーバーがコンテナの中で待ち受けるポートは、SSHサーバーの設定ファイル(
sshd_configのPort)で決まり、Ansibleで変更できる。外部に公開するポートと、その転送先となるコンテナ内のポートは、main.tfのportsブロック(external、internal)で決まり、Terraformでしか設定できない
3つ目は、どちらのツールでも書ける設定ではなく、1つの値が両方のツールにまたがる設定です。検証環境では、Dockerfileで待ち受けポートを変更していないため、SSHサーバーは既定の22番で待ち受け、portsのinternal = 22と一致しています。ここで、Ansibleでsshd_configのPortだけを変えると、外部公開ポートの転送先の22番で待ち受けるサーバーがなくなり、外部から接続できなくなります。片方だけを変えると整合が崩れるという点で、同じ値を両方のツールで定義する二重管理と同じ構造です。二重管理は、Ansible×Terraformシリーズの 第1回 で、連携時の設計アンチパターンの1つとして整理しています。
判定基準
どちらに置くかは、次の2つの基準で判定します。
- その値が変わったとき、リソースを作り直すべきか
- その値のライフサイクルは誰に属するか
1つ目の基準は、値を変えたときに何が起きるかで判定するものです。Terraformのリソースの属性には、変更をその場で反映できる属性と、変更するとリソースの作り直しになる属性があります。作り直しになる属性に値を置くと、値を変えるたびにコンテナが作り直されます。作り直されたコンテナからは、Ansibleで投入した設定が失われます。これは、Ansible×Terraformシリーズの 第42回 で確認した結果です。
環境変数がどちらに当たるかを、実機で確認します。
■ 検証内容:環境変数の追加
docker_container.targetsにenvを追加し、target-node1にだけ環境変数を渡します。
- ファイル名:
main.tf(該当箇所)
【変更前】
resource "docker_container" "targets" {
for_each = local.target_nodes
name = each.key
image = docker_image.ansible_target.image_id
# (途中省略:以降の定義)
}
【変更後】
resource "docker_container" "targets" {
for_each = local.target_nodes
name = each.key
image = docker_image.ansible_target.image_id
env = each.key == "target-node1" ? ["APP_ENV=production"] : []
# (途中省略:以降の定義)
}
この状態で、terraform planを実行します。
実行コマンド
terraform plan
▼ 実行結果
(途中省略:Refreshing state...によるリソース一覧の出力)
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement
Terraform will perform the following actions:
# docker_container.targets["target-node1"] must be replaced
-/+ resource "docker_container" "targets" {
(途中省略:envより前の属性の差分)
~ env = [ # forces replacement
+ "APP_ENV=production",
]
(途中省略:envより後の属性とブロックの差分)
}
(途中省略:local_file.ansible_inventory、local_file.group_vars_target_nodesの再作成計画)
Plan: 3 to add, 0 to change, 3 to destroy.
(途中省略:Changes to Outputs、区切り線と-outオプションに関する注意書き)
■ 結果
target-node1はmust be replacedとなり、envの行に# forces replacementが付きました。環境変数を1つ追加しただけで、コンテナの作り直しが計画されています。target-node1のIPアドレスが作り直しの後に決まるため、インベントリを生成するlocal_fileの2つも再作成の対象になりました。target-node2・3は計画に含まれていません。
# forces replacementはenv属性に付いているため、環境変数の追加だけでなく、値の書き換えも同じ属性の変更として作り直しになります。つまり、環境変数で値を渡す構成では、値を変えるコミットのたびにコンテナが作り直され、Ansibleで投入した設定を入れ直す必要があります。セクション2 の分類に当てはめると、Terraformのコードだけを変える変更でも、両方にまたがる変更になります。
一方、同じ値をAnsibleで設定ファイルに書く場合、値の変更はコンテナの中のファイルの書き換えであり、コンテナの作り直しは伴いません。Ansibleの実行だけで完結する変更になります。
ライフサイクルは誰に属するか
2つ目の基準は、その値がいつ、何と一緒に変わるかで判定するものです。
Dockerfileに書かれているopenssh-server、python3.10、sudoは、Ansibleがコンテナに接続し、タスクを実行するために必要なものです。AnsibleはSSHで接続し、コンテナの中のPythonでモジュールを実行し、becomeで権限を昇格します。実際、検証環境のansible-playbookの実行結果には、target-node1〜3で/usr/bin/python3.10を使った旨の警告が出ています。これらは、Ansibleを実行する前から存在している必要があるため、Ansibleでは入れられません。コンテナを作る時点で決まっている必要があり、ライフサイクルはイメージとコンテナの作成に属します。
これに対し、アプリケーションの設定値や、その設定のために入れるパッケージは、コンテナが動いている間に、アプリケーションの変更に合わせて変わります。このような値はAnsibleに置けば、変更のたびにコンテナを作り直す必要がありません。
待ち受けポートと外部公開ポートのように、1つの値が両方にまたがる設定は、どちらか一方に寄せることができません。この場合は、片方だけを変えるコミットを作らず、同じコミットでmain.tfとPlaybookの両方を変え、両方にまたがる変更として扱います。
3つの設定に当てはめると、次のとおりです。
| 設定 | Terraform・イメージ側に置く場合 | Ansible側に置く場合 | 判定の目安 |
|---|---|---|---|
| 環境変数と設定ファイル | envで渡す。値の変更はコンテナの作り直しになる |
設定ファイルに書く。値の変更は作り直しを伴わない | コンテナが動いている間に値を変えるなら、Ansibleに置く |
| パッケージ | Dockerfileに書く。変更にはイメージとコンテナの作り直しが必要になる |
起動後にインストールする。コンテナが作り直されると失われる | Ansibleの実行に必要なもの(SSHサーバー、Python、sudo)は、イメージに置く |
| 待ち受けポートと外部公開ポート | portsのinternal、external |
sshd_configのPort |
1つの値が両方にまたがる。同じコミットで両方を変える |
次のセクションでは、AnsibleとTerraformの接続面にある値が、Gitの外にあることを確認します。
4. AnsibleとTerraformの接続面はGitの外にある
AnsibleとTerraformをつなぐ値が、Gitの外にあることを実機で確認し、GitOps上での扱いを整理します。
検証環境では、Terraformがコンテナを作成し、Ansibleはインベントリに書かれたIPアドレスでコンテナに接続します。セクション2 で見たとおり、インベントリはTerraformのlocal_fileリソースが、コンテナのIPアドレスとテンプレートから生成しています。つまり、両ツールの接続面は、次のようにつながっています。
main.tf、テンプレート(IPアドレスの取り出し方と、インベントリの書式)
↓ terraform apply
tfstate(作成したコンテナのIPアドレスを記録)
↓ local_fileによる生成
inventory.ini(Ansibleの接続先)
↓ ansible-playbook
コンテナ
この流れの中で、接続面の値であるIPアドレスが、どこにあり、Gitに含まれているかを確認します。
■ 検証内容:接続面の値の置き場所
まず、Terraformのoutputで、接続面の値を取り出します。
実行コマンド
terraform output
▼ 実行結果
target_nodes = {
"target-node1" = {
"host" = "172.19.0.2"
"port" = 22
}
"target-node2" = {
"host" = "172.19.0.3"
"port" = 22
}
"target-node3" = {
"host" = "172.19.0.4"
"port" = 22
}
}
target_nodes_ips = {
"target-node1" = "172.19.0.2"
"target-node2" = "172.19.0.3"
"target-node3" = "172.19.0.4"
}
同じ値が、tfstateに記録されていることを確認します。
実行コマンド
grep -n '"ip_address"' terraform.tfstate
▼ 実行結果
121: "ip_address": "172.19.0.2",
247: "ip_address": "172.19.0.3",
373: "ip_address": "172.19.0.4",
Terraformが生成したインベントリにも、同じ値が書かれています。
実行コマンド
cat inventory.ini
▼ 実行結果
[target_nodes]
target-node1 ansible_host=172.19.0.2 ansible_user=ansible
target-node2 ansible_host=172.19.0.3 ansible_user=ansible
target-node3 ansible_host=172.19.0.4 ansible_user=ansible
次に、Gitで管理しているファイルに、これらのIPアドレスが含まれているかを確認します。
実行コマンド
git grep -n -E '172\.(18|19)\.0\.[0-9]+'; echo "exit_code=$?"
▼ 実行結果
exit_code=1
tfstateと、Terraformが生成したファイルが、Gitの管理対象に含まれているかを確認します。
実行コマンド
git ls-files --error-unmatch terraform.tfstate inventory.ini group_vars/target_nodes.yml; echo "exit_code=$?"
▼ 実行結果
error: pathspec 'terraform.tfstate' did not match any file(s) known to git
error: pathspec 'inventory.ini' did not match any file(s) known to git
error: pathspec 'group_vars/target_nodes.yml' did not match any file(s) known to git
Did you forget to 'git add'?
exit_code=1
最後に、値を生み出す定義が、Gitで管理されているかを確認します。
実行コマンド
git ls-files | grep -E '\.tf$|\.tftpl$'
▼ 実行結果
group_vars_target_nodes.tftpl
inventory.tftpl
main.tf
outputs.tf
■ 結果
確認した内容を、Gitに含まれるかどうかで並べると、次のとおりです。
| 置き場所 | 含まれるもの | Gitの管理対象か |
|---|---|---|
main.tf、outputs.tf、テンプレート |
IPアドレスの取り出し方と、インベントリの書式 | 含まれる |
| tfstate | 作成したコンテナのIPアドレス | 含まれない |
inventory.ini、group_vars/target_nodes.yml |
tfstateの値から生成した接続先 | 含まれない |
git grepは何も出力せずにexit_code=1で終わり、Gitで管理しているファイルに、接続先のIPアドレスは1つも含まれていませんでした。Gitにあるのは「値の作り方」だけで、Ansibleが実際に使う「値」は、tfstateと、そこから生成したファイルにしかありません。
接続面が「唯一の真実」の例外になる理由
IPアドレスは、コンテナを作成したときに、Dockerがネットワークの空いているアドレスから割り当てます。セクション2 のノード追加のterraform planで、インベントリの内容が(known after apply)になっていたのはこのためです。セクション3 の環境変数の追加でも、同じ理由でインベントリが再作成の対象になりました。値はterraform applyの後にしか決まらず、コミットの時点では書けません。
また、この値は、Gitへの変更と関係なく変わります。Ansible×Terraformシリーズの 第11回 では、コンテナの作り直しでIPアドレスが変わると、古いインベントリのままAnsibleを実行した場合に、接続に失敗するか、意図しないホストに接続することを整理しました。仮にinventory.iniをGitにコミットしても、コンテナが作り直された時点で、Gitにある値は実際の接続先と食い違います。
本シリーズの 第1回 では、「Gitが唯一の真実」と言えるのは、Gitに記述されている範囲の中だけだと整理しました。AnsibleとTerraformの接続面は、最初からこの範囲の外にあります。
GitOps上での扱い
接続面の値は、Gitに入れるのではなく、実行のたびにtfstateから生成し、その生成手順をGitで管理します。検証環境のmain.tfとテンプレートは、この考え方に沿った構成です。値そのものはGitの外にあっても、どの値をどう取り出し、どの書式でAnsibleに渡すかはGitに記録されているため、同じtfstateからは同じインベントリが生成されます。
このため、接続面を使う変更では、Terraformを先に実行してtfstateを更新し、インベントリを生成し直してからAnsibleを実行する順序が必要になります。この順序は、セクション5 で整理します。
生成手順をパイプラインの中でどう実装するかは 第5回 で、どのファイルをGitに置き、どのファイルを置かないかの設計は 第3回 で扱います。値を持つtfstate自体の置き場所は、第6回 で扱います。
次のセクションでは、ここまでの分類をもとに、変更の種類ごとに必要な実行順序を整理します。
5. 変更の種類ごとに必要な実行順序
セクション2 から セクション4 の結果をもとに、変更の種類ごとに、どのツールをどの順序で実行する必要があるかを整理します。
実行順序の原則は、Ansible×Terraformシリーズの 第11回 で整理しています。インフラ層とOS層の同期は、通常はどちらを先に実行してもよく、インフラ層の変更がAnsibleの適用対象(接続先、認証情報など)に影響する場合は、Terraformを先に実行する必要があります。判断に迷う場合は、Terraform→Ansibleの順序で統一しておくと、順序に起因する問題を避けやすくなります。
第11回 が扱ったのは、手動変更によるずれを確認し、同期する場面でした。この回で扱うのは、コミットされた変更を反映する場面です。この場面では、第11回 の原則に、次の2点が加わります。
- Ansibleだけで完結する変更では、Terraformを実行する必要がない
- リソースの再生成を伴うTerraformの変更では、順序だけでなく、作り直したリソースに対してPlaybookの全タスクを適用し直す必要がある
再生成を伴う変更を分けて扱う理由
セクション3 で確認したとおり、環境変数の追加は、main.tfだけを変える変更でありながら、コンテナの作り直し(must be replaced)を伴います。Ansible×Terraformシリーズの 第42回 では、作り直したコンテナから、Ansibleで配置したファイルとインストールしたパッケージが、どちらも失われることを確認しました。terraform applyはエラーも警告も出さずに完了するため、Terraformの実行結果だけでは、この消失に気づけません。
作り直したコンテナは、イメージとmain.tfの定義に書かれた内容だけを持った状態で作られます。そのため、Ansibleの実行は、今回の変更に関係するタスクだけでは足りず、Playbookのすべてのタスクで設定を入れ直す必要があります。変更の内容はTerraformの1つの属性でも、Ansible側では、新しいノードを追加したときと同じだけの作業が必要になります。
この点で、再生成を伴う変更は、セクション2 の3分類のうち「両方にまたがる変更」に含まれますが、変更したファイルからはそう見えないため、分けて扱います。
変更の種類と必要な実行
この回で確認したterraform planの結果を、変更の種類を見分ける手がかりとして並べると、次のとおりです。
| 変更の種類 | この回で確認した例 | terraform planに現れるもの |
必要な実行 |
|---|---|---|---|
| Terraformだけで完結する変更 | 既存のコンテナに接続しないボリュームの追加(セクション2) | 追加するリソースだけ。コンテナとインベントリは含まれない | terraform apply |
| Ansibleだけで完結する変更 | Playbookで配置する設定ファイルの内容の変更 | HCLを変更しないため、差分は現れない | ansible-playbook |
| 両方にまたがる変更 | ノードの追加(セクション2) | コンテナのwill be createdと、インベントリの再作成 |
terraform apply→ansible-playbook |
| 再生成を伴うTerraformの変更 | 環境変数の追加(セクション3) | コンテナのmust be replacedと、インベントリの再作成 |
terraform apply→ansible-playbook(作り直したコンテナに、Playbookの全タスクを適用し直す) |
Terraformが先になる変更は、どちらもterraform planにインベントリの再作成が含まれていました。セクション4 で確認したとおり、Ansibleの接続先はtfstateから生成されるため、Terraformの実行が終わってインベントリが生成し直されるまで、Ansibleは正しい接続先を知ることができません。
逆に言えば、コミットの時点でterraform planを確認すれば、そのコミットがどの種類に当たるかを見分けられます。コンテナのmust be replacedが含まれていれば、main.tfだけの変更であっても、Playbookの全タスクの適用が必要な変更です。
第2部のパイプライン設計への入力
この表は、第2部で作るパイプラインの設計に、そのまま入力として使います。どの変更で、どのツールを、どの順序で実行するかは、パイプラインのトリガー条件とジョブの順序になります。プルリクエストで確認し、マージで適用する基本の構成は 第5回 で、terraform planから再生成や削除を検知して、Ansibleの設定の消失を適用前に扱う仕組みは 第9回 で扱います。
次のセクションでは、この回で整理した境界の問題が、クラウドのプロバイダーでも同じように起きる理由を整理します。
6. クラウドプロバイダーでも同じ境界問題が起きる理由
この回で整理した境界の問題が、Dockerプロバイダーに固有のものではなく、クラウドのプロバイダーでも同じ構造で起きることを整理します。
仮想マシンの中身を決める3つの手段
Dockerの構成要素とAWS・GCPのリソースの対応は、本シリーズの 第1回 のセクション8で整理しました。ここでは、その対応のうち、コンテナや仮想マシンの中身を決める手段に絞って見ます。
セクション3 では、コンテナの中の状態を決める手段が、イメージ、Terraformのリソース定義、Ansibleの3つあることを確認しました。クラウドの仮想マシンでも、手段は同じく3つあります。
| 手段 | Docker(検証環境) | AWS | GCP |
|---|---|---|---|
| イメージに焼く | Dockerfileから作ったイメージ |
AMI | マシンイメージ(boot_diskのimage) |
| 起動時に渡す | docker_containerのenv、upload |
aws_instanceのuser_data(cloud-init等) |
google_compute_instanceのmetadataのstartup-script、metadata_startup_script |
| 起動後に入れる | Ansible | Ansible | Ansible |
どの設定をどの手段で入れるかという選択は、プロバイダーが変わってもそのまま残ります。「イメージに焼くか、起動時に渡すか、Ansibleで入れるか」は、AWSでもGCPでも同じ境界の問題です。
判定基準はそのまま使える
セクション3 の2つの判定基準は、クラウドでもそのまま使えます。
1つ目の基準「その値が変わったとき、リソースを作り直すべきか」については、起動時に渡す値の変更が作り直しになるかどうかが、プロバイダーと、使う属性によって異なります。
- Docker:
envの変更は、コンテナの作り直しになる(セクション3 で確認) - AWS:
user_dataの変更は、既定ではインスタンスの停止と起動になる。user_data_replace_on_changeをtrueにすると、インスタンスの破棄と再作成になる(aws_instance) - GCP:
metadataのstartup-scriptの変更は、インスタンスの作り直しにならない。代わりにmetadata_startup_scriptを使うと、変更がインスタンスの作り直しになる(google_compute_instance)
同じ「起動時に渡す」手段でも、作り直しになるかどうかは一律ではありません。そのため、判定は、プロバイダーの種類からの推測ではなく、terraform planに# forces replacementやmust be replacedが現れるかどうかで行います。作り直しになる場合は、セクション5 の「再生成を伴うTerraformの変更」として、Ansibleで全タスクを適用し直す必要があるのも、Dockerの場合と同じです。
2つ目の基準「その値のライフサイクルは誰に属するか」も、そのまま当てはまります。Ansibleが接続して実行するために必要なもの、つまりSSHサーバー、Python、権限昇格の手段、接続に使う公開鍵は、Ansibleを実行する前から仮想マシンにある必要があります。第1回 のセクション8で整理したとおり、公開鍵は、Dockerではupload、AWSではkey_nameまたはuser_data、GCPではmetadataのssh-keysで渡します。これらは、イメージか起動時に渡す値で用意するものであり、Ansibleで入れることはできません。
接続面もGitの外にある
セクション4 で確認した接続面の問題も、クラウドで同じように起きます。クラウドの仮想マシンでも、IPアドレスを固定しない限り、アドレスは作成時に割り当てられます。たとえばGCPのnetwork_interfaceのnetwork_ipは、指定しなければ自動で割り当てられます。割り当てられた値はtfstateに記録され、Gitには含まれません。値の生成手順をGitで管理し、実行のたびにtfstateから接続先を生成するという扱いは、プロバイダーを問わず同じです。
プロバイダーを差し替えて変わるのは、リソースの名前と属性の名前、そしてどの属性の変更が作り直しになるかという個々の挙動です。変わらないのは、仮想マシンの中身を決める手段が3つあり、どれを選ぶかで変更時に必要な実行が変わるという構造と、両ツールの接続面がGitの外にあるという構造です。
次のセクションでは、この回で整理した内容をまとめます。
7. まとめ
この回で整理した内容を確認します。
- GitOpsでの責務分担は、「どのツールが何を担当するか」ではなく、「コミットされた変更を反映するために、どちらのツールを、どの順序で実行する必要があるか」として設計する
- コミットされる変更は、Terraformだけで完結する変更、Ansibleだけで完結する変更、両方にまたがる変更の3つに分類でき、分類は変更したファイルではなく、反映に必要な実行で決まる。既存のコンテナに接続しないボリュームの追加とノードの追加は、どちらも
main.tfだけの変更だが、前者はTerraformだけで反映でき、後者はAnsibleの実行も必要になることをterraform planで確認した - どちらのツールでも書ける設定は、「その値が変わったとき、リソースを作り直すべきか」と「その値のライフサイクルは誰に属するか」の2つの基準で判定する。
docker_containerに環境変数を追加すると、コンテナの作り直しになることを実機で確認した - 待ち受けポートと外部公開ポートのように、1つの値が両方のツールにまたがる設定は、片方だけを変えず、同じコミットで両方を変える
- AnsibleとTerraformの接続面であるIPアドレスは、tfstateと、そこから生成したインベントリにあり、Gitで管理しているファイルには含まれないことを実機で確認した。Gitで管理するのは値ではなく、値の生成手順である
- 再生成を伴うTerraformの変更は、
main.tfだけの変更であっても、Terraform→Ansibleの順に実行し、作り直したリソースにPlaybookの全タスクを適用し直す必要がある。変更の種類は、terraform planの結果から見分けられる - この境界の問題はDockerプロバイダーに固有のものではなく、AWSやGCPでも、イメージに焼くか、起動時に渡すか、Ansibleで入れるかという同じ形で起きる。起動時に渡す値の変更が作り直しになるかはプロバイダーと属性によって異なるため、
terraform planで判定する
8. 次回予告
本シリーズの 第1回 では、「Gitが唯一の真実」が崩れる3つの経路を整理し、Gitを経由しない手動変更に対しては、変更の経路をGitに限定する仕組みを第2部で作ることを示しました。第2回となる今回は、その前提として、AnsibleとTerraformの責務分担を「変更の種類ごとに必要な実行」として捉え直しました。コミットされる変更を3つに分類し、どちらのツールでも書ける設定を判定する2つの基準を示したうえで、両ツールの接続面がGitの外にあることを実機で確認しました。最後に、変更の種類ごとに必要な実行順序を整理し、この境界の問題がクラウドのプロバイダーでも同じ形で起きることを示しました。
次回は、この回で整理した変更の分類を、Gitリポジトリの構造に落とし込みます。変更の分類をパスで表現するディレクトリ構成、Gitに置くものと置かないもの、モノレポとマルチリポの選択基準、mainを唯一の真実とするブランチ運用を設計します。この回で確認した、tfstateや生成したインベントリをGitに置かないという扱いも、次回の「Gitに置かないもの」として整理します。
📑 連載の移動 前の記事:【GitOps編】第1回 | 次の記事:【GitOps編】第3回
🗺️ 初めての方、シリーズの全体像を知りたい方はこちら
シリーズ全体については、以下のまとめブログで整理しています。
→ 「Ansible×TerraformをGitOpsで回す」〜Terraformプロバイダーを問わない構成管理の自動化〜 第1部まとめブログ:「Gitが唯一の真実」を成り立たせる前提の設計 ※近日公開予定
9. 連載一覧:「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部の最終回。 |