この記事はTerraform Advent Calendarの4日目の記事です。
前回は2日の @keitakei777 さんによる「TerraformでEC2のインスタンスを立ててみる」でした。
次回は6日に @ymasaoka さんによる記事が予定されています。こちらも楽しみです。
はじめに
Terraformはインフラ管理の定番ツールですが、いざ複数環境(dev/prodなど)を扱おうとすると、環境ごとの差分管理が意外と難しいものです。
その解決方法として、共通モジュールを作成し、複数のテナントでこれを利用するといったケースはよくありますが、一部の環境でのみ特殊なオプションを有効化したいといった場合、モジュール側を直接編集するほかないという問題点があります。
kubernetes + kustomizeのように、環境ごとにパッチを当てる仕組みがあれば便利ですが、Terraformにはそのような仕組みはありません。
そこで、本記事では、Terraformの設定をyamlで記述し、環境ごとにパッチを当ててみるといった実験を行った結果を紹介します。
この方法により、環境ごとの差分管理が容易になり、コードの再利用性も向上します。
実用的かどうかはさておき、Terraformの新しい使い方の一例として参考になれば幸いです。
Terraformのjson記法についておさらい
Terraformの入力形式といえば .tf ファイル(HCL)ですが、実はJSONでも書けるのをご存知でしょうか。
例えば、以下のような.tfファイル
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "2.4.0"
}
}
}
provider "local" {}
resource "local_file" "hello1" {
content = "1st Hello, World!"
filename = "hello1.txt"
}
resource "local_file" "hello2" {
content = "2nd Hello, World!"
filename = "hello2.txt"
}
は、つぎのようなjsonで記述できます
{
"provider": {
"local": {}
},
"resource": {
"local_file": {
"hello1": {
"content": "1st Hello, World!",
"filename": "hello1.txt"
},
"hello2": {
"content": "2nd Hello, World!",
"filename": "hello2.txt"
}
}
},
"terraform": {
"required_providers": {
"local": {
"source": "hashicorp/local",
"version": "2.4.0"
}
}
}
}
これを<filename>.tf.jsonというファイル名で保存しておけば、普段と同じようにterraform applyできます。
しかし、このjsonを1から手で書くのは少々大変です。yamlで記述して、jsonに変換できれば便利そうです。
yisp
yispはyaml中に式を埋め込み、評価することのできるツールです。
例えば次のようなyaml
mynumber: !yisp
- add
- 5
- 3
をyisp build .ですると、
mynumber: 8
が出力されます。
他にも複数のYAMLを取り込んでまとめたり、オブジェクト同士をマージしたり、出力をJSON形式に変換したりといった機能も備わっています。
今回はこのyispを使って、Terraform JSON構造をYAMLで記述しやすくしたり、環境ごとの差分を扱ってみます。
とりあえず使ってみる
サクっとEC2を立てるだけのTerraform定義をyamlで書いてみましょう。
terraform:
required_providers:
aws:
source: hashicorp/aws
version: ">= 3.0.0"
provider:
aws:
region: ap-northeast-1
data:
aws_ami:
amazon_linux:
most_recent: true
owners:
- "137112412989" # Amazon
filter:
- name: name
values:
- "amzn2-ami-hvm-*-x86_64-gp2"
resource:
aws_instance:
example:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t2.micro
tags:
Name: example-instance
これをjsonに変換します。何でもいいんですが、ここではyispを使ってみます
yisp build tf.yaml -o json
すると、次のようなjsonが出力されます。
{
"data": {
"aws_ami": {
"amazon_linux": {
"filter": [
{
"name": "name",
"values": [
"amzn2-ami-hvm-*-x86_64-gp2"
]
}
],
"most_recent": true,
"owners": [
"137112412989"
]
}
}
},
"provider": {
"aws": {
"region": "ap-northeast-1"
}
},
"resource": {
"aws_instance": {
"example": {
"ami": "${data.aws_ami.amazon_linux.id}",
"instance_type": "t2.micro",
"tags": {
"Name": "example-instance"
}
}
}
},
"terraform": {
"required_providers": {
"aws": {
"source": "hashicorp/aws",
"version": "\u003e= 3.0.0"
}
}
}
}
これをrendered.tf.jsonとして保存しておけば、あとは通常通りterraform init、terraform applyで適用できます。
makefileを作る
本当はterraformコマンドが標準入力からマニフェストを受け取ってくれたら楽だったのですが、適用するためにはファイルとして実体がある必要がある為、makeファイルにまとめておくと楽です。
つぎのようにシンプルなmakefileを作成しておくことで、terraform planをした際にまだjsonが生成されていなければyamlから自動的にjsonが生成されたのち、terraformコマンドが実行されるようになります。
SOURCES := $(wildcard *.yaml)
rendered.tf.json: $(SOURCES)
yisp build . -o json > rendered.tf.json
init: rendered.tf.json
terraform init
plan: rendered.tf.json
terraform plan
apply: rendered.tf.json
terraform apply
モジュール化する
次にモジュールを作成し、dev環境とprod環境から、それぞれ異なるパラメーターで呼び出せるようにしてみます。
この構成はkustomizeを利用したkubernetesマニフェスト管理の構成方法に強く影響を受けています。
次のようなディレクトリ構造を作ります。
📁 .
├── 📁 base
│ └── 📄 ec2.yaml
└── 📁 env
├── 📁 dev
│ ├── 📄 index.yaml
│ └── 📄 main.yaml
│ ├── 📄 ec2.yaml
└── 📁 prodなど...
base/ec2.yaml
例えば、今回の例では、同じAMIのEC2インスタンスを複数建てたいので、これをモジュール化するとしましょう。
base/ec2.yamlを次のように作成します。
!yisp &default
- lambda
- [props]
- !quote
# こうするとモジュールを呼び出すたびにこのdataが定義されるが、
# 最終的にマージされると1つにまとまるので問題ない
data:
aws_ami:
amazon_linux:
most_recent: true
owners:
- "137112412989" # Amazon
filter:
- name: name
values:
- "amzn2-ami-hvm-*-x86_64-gp2"
resource:
aws_instance: !yisp
- maps.make # オブジェクトのキーを変数から作る為に、maps.makeを使う
- *props.name
- !quote
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: !yisp [default, *props.instance_type, "t2.micro"]
tags:
Name: *props.name
yispに慣れてない人には少々分かりづらいかもしれませんが、ここではlambda式を使って、propsという引数を受け取り、その中のnameとinstance_typeを使ってEC2インスタンスを定義しています。
env/dev/ec2.yaml
次に、baseで作ったモジュールをdev環境から呼び出してみましょう。
2つのEC2インスタンスを立てる例を示します。
!yisp
- import
- [ec2, ../base/ec2.yaml] # baseのec2モジュールを名前付きでimportする
---
!yisp
- *ec2.default # baseのec2モジュールの関数defaultを呼び出す
- name: dev-server
instance_type: t3.medium
---
!yisp
- *ec2.default
- name: dev-database
instance_type: t3.medium
ここでyisp build ec2.yamlを実行すると、たしかに2つのEC2インスタンスが定義されたyamlが出力されます。
$ yisp build ec2.yaml
data:
aws_ami:
amazon_linux:
most_recent: true
owners:
- "137112412989" # Amazon
filter:
- name: name
values:
- "amzn2-ami-hvm-*-x86_64-gp2"
resource:
aws_instance:
dev-server:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t3.medium
tags:
Name: dev-server
---
data:
aws_ami:
amazon_linux:
most_recent: true
owners:
- "137112412989" # Amazon
filter:
- name: name
values:
- "amzn2-ami-hvm-*-x86_64-gp2"
resource:
aws_instance:
dev-database:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t3.medium
tags:
Name: dev-database
しかし、このyamlは2つのドキュメントに分かれてしまっているので、後でこれをマージして1つのオブジェクトにまとめる必要があります。
env/dev/main.yaml
dev環境用にterraformの基本設定を書いておきます
terraform:
required_providers:
aws:
source: hashicorp/aws
version: ">= 3.0.0"
provider:
aws:
region: ap-northeast-1
env/dev/index.yaml
env/dev/index.yamlでは、main.yamlとec2.yamlを読み込み、1つのオブジェクトにまとめます。
!yisp
- lists.reduce
- - include
- main.yaml
- ec2.yaml
- maps.merge
index.yamlでは、yispのlists.reduce関数を使って、includeした複数のyamlドキュメントをmaps.mergeでマージしています。
ここで、yisp build .を実行すると、次のような出力が得られます。
terraform:
required_providers:
aws:
source: hashicorp/aws
version: ">= 3.0.0"
provider:
aws:
region: ap-northeast-1
data:
aws_ami:
amazon_linux:
most_recent: true
owners:
- "137112412989" # Amazon
filter:
- name: name
values:
- "amzn2-ami-hvm-*-x86_64-gp2"
resource:
aws_instance:
dev-server:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t3.medium
tags:
Name: dev-server
dev-database:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t3.medium
tags:
Name: dev-database
複数に分かれていたオブジェクトが、1つにマージされて出力されていますね。
途中で重複して存在していたdataモジュールも、1つに集約されていることが確認できます。
特定リソースにパッチを当てる
例えば、dev-databaseインスタンスでmonitoring設定を有効にしたいとします。
通常であれば、baseモジュールを直接編集する必要がありましたが、今回はyispを使っているためこの環境のみにパッチを当てることができます。
次のようにec2.patch.yamlを作成します。
yispでは、maps.patch関数を使うことで、RFC 6902で定義されているJSON Patchと近い形式でオブジェクトにパッチを当てることができます。 (※maps.patchはRFC6902に加えて独自拡張が含まれております(後述))
op: add
path: "/resource/aws_instance/dev-database/monitoring"
value: true
そして、index.yamlでこのパッチを取り込みます。
!yisp
- maps.patch
- - lists.reduce
- - include
- main.yaml
- ec2.yaml
- maps.merge
- - include
- ec2.patch.yaml
ちなみに、よりオススメな書き方
今回はパッチを当てるのみですが、次々に様々な操作を加えたい場合は、pipeline式を使うとネストを避けられて見通しが良くなります。
!yisp
- pipeline
# 使用リソースを集める
- - lists.reduce
- - include
- main.yaml
- ec2.yaml
- maps.merge
# パッチを適用する
- - lambda
- [x]
- - maps.patch
- *x
- - include
- ec2.patch.yaml
すると、yisp build .の出力は次のようになります。
resource:
aws_instance:
dev-server:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t3.medium
tags:
Name: dev-server
dev-database:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t3.medium
tags:
Name: dev-database
monitoring: true
たしかに、baseモジュールを直接編集することなく、dev環境のみにmonitoring設定を追加できました。
同種のリソースに一括でパッチを当てる
今回は特定のリソースにのみパッチを当てましたが、例えば、dev環境の全てのEC2インスタンスに対して一括でパッチを当てたい場合もあるでしょう。
これも、maps.patchを使うことで実現可能です。
ec2.patch.yamlに次のように追記します。
op: add
path: "/resource/aws_instance/dev-database/monitoring"
value: true
---
op: add
path: "/resource/aws_instance/*/tags"
value:
Environment: Production
Owner: TeamA
Project: ProjectX
RFC6902ではワイルドカードはサポートされていませんが、yispのmaps.patchではpath中に*を使うことで、該当する全てのキーに対してパッチを適用できます。
これを適用すると、dev-serverとdev-databaseの両方に対してtagsが追加されます。
resource:
aws_instance:
dev-server:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t3.medium
tags:
Name: dev-server
Environment: Production
Owner: TeamA
Project: ProjectX
dev-database:
ami: "${data.aws_ami.amazon_linux.id}"
instance_type: t3.medium
tags:
Name: dev-database
Environment: Production
Owner: TeamA
Project: ProjectX
monitoring: true
このように、yispを活用することでモジュールをある程度共通化しつつ、環境ごとの差分を柔軟に管理できるようになりました。
おわりに
今回はyispを利用してTerraformの定義をYAMLで記述することで、環境ごとの差分をパッチで管理する方法を紹介しました。
正直、実用的かどうかはまだ未知数ですが、Kubernetesのkustomizeのように「環境差分を後から吸収できる」仕組みをTerraformにも持ち込めるのは面白い発見でした。
興味があればぜひ手元で遊んでみてください。