フューチャー技術ブログ

Terraformでのループ処理と条件分岐

はじめに

はじめまして!TIG DXチームの小林と申します。

Terraformでは似たリソースを複数構築する際に、ループ処理や条件分岐を利用することで、コードの冗長化を防ぎ、可読性や保守性を上げることができます。

私自身まだTerraform歴半年ですが、初心者目線で「Terraformのコードをスマートに書きたい!」というモチベーションのもと本記事を書きました。

サマリ

ループ処理

方法 分類 主な用途(個人的なイメージ)
count メタ引数 ・開発や検証用などで簡単なリソースを複数個作りたい場合
・将来的に数が増減しないようなリソースを作る場合
for_each メタ引数 ・ループ処理で複数リソースを作りたい場合は基本こちら
for ・フィルタリング機能を利用して条件によってリソース構築を制御したい場合
・既存の設定値や構築済リソースから任意のリストやマップを取得したい場合
・その他使ったら幸せになれる場合
dynamic block ・resource block内で同一のブロックを複数定義する場合
・可読性や保守性が落ちないことが明確な場合

条件分岐

方法 分類 主な用途(個人的なイメージ)
三項演算子 ・条件分岐を行いたい場合は基本こちら
for_eachfor を併用 - ・forループ内の要素の特定条件でリソースを作り分ける場合

構築するリソース(ベース)

本記事で構築するリソースはこちらです。

AWS上に10.10.0.0/16 のVPC1つと、10.10.0.0/24 ~ 10.10.3.0/24 でサブネットを計4つ(public2つ、private2個想定)を各パターンで作成していきます。

まずはベースとして、シンプルにresource blockを羅列したものを記載しています。
(以降、サブネット部分の処理がメインのため、VPC部分の記述は省略します。)

resource "aws_vpc" "test-vpc" {
cidr_block = "10.10.0.0/16"
}

resource "aws_subnet" "public-1" {
vpc_id = aws_vpc.test-vpc.id
availability_zone = "ap-northeast-1a"
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, 0)
}

resource "aws_subnet" "public-2" {
vpc_id = aws_vpc.test-vpc.id
availability_zone = "ap-northeast-1a"
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, 1)
}

resource "aws_subnet" "private-1" {
vpc_id = aws_vpc.test-vpc.id
availability_zone = "ap-northeast-1a"
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, 2)
}

resource "aws_subnet" "private-2" {
vpc_id = aws_vpc.test-vpc.id
availability_zone = "ap-northeast-1a"
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, 3)
}

参考:cidrsubnet

ループ処理(count)

countを利用すると、このように書くことができます。

count = xとカウント回数を定義し、count.index で0からx回カウントアップする引数を指定できます。

resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.test-vpc.id
availability_zone = "ap-northeast-1a"
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, count.index)
}

resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.test-vpc.id
availability_zone = "ap-northeast-1a"
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, count.index + 2)
}

publicとprivateの区別が無ければ、count = 4としてresource blockを1つで全サブネットを構築可能ですが、可読性や保守性が落ちるため分けています。

さて、ここでpublicのサブネットを1つ増やしたくなった場合はどうすれば良いでしょうか。

簡単な話ではありますが、以下のようにcountの値を3に修正することで、増やすことができます。

resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.test-vpc.id
availability_zone = "ap-northeast-1a"
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, count.index)
}

resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.test-vpc.id
availability_zone = "ap-northeast-1a"
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, count.index + 3)
}

しれっと修正しましたが、privateの方の count.index + 2count.index + 3としています。

これを忘れると、以下のようなCIDRブロックのコンフリクトエラーが起きます。

Error: creating EC2 Subnet: InvalidSubnet.Conflict: The CIDR ‘10.10.2.0/24’ conflicts with another subnet

countの使いづらいところは主にここだと思っています。数を増減させたいときにcount.indexの値の変動がどこまで影響するか、大規模や複雑なリソースでは把握が難しく、保守性が低下します。

また、indexとあるように、countを利用して構築したリソースは配列として管理されます。

tfstateを覗いてみると、index_keyというキーの値が0や1などの数値で存在します。

 {
"mode": "managed",
"type": "aws_subnet",
"name": "public",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"index_key": 0,
(後略)

これは、indexの途中(↑のpublicサブネットで言うとcount.indexが1のサブネット)が削除された場合に、その後のリソースが全て作り直しになることを意味します。

ここもcountの不便なところで、将来的に数が増減するようなリソースを構築する際は向いていません。

ループ処理(for_each)

countの不便なところを解決したのがfor_eachだと思います。

for_eachを使うと以下のように書くことができます。

setmapを定義して、その要素の数だけリソースを構築することができます。

setやmapの値はeach.key(setの値やmapのkey)やeach.value(setの値やmapのvalue)を使って各変数に定義できます。(=setを使う場合はeach.keyeach.valueは同じになります。)

mapが多重構造になっている場合は、以下のようにeach.value.xxxと書くことで変数に定義できます。

resource "aws_subnet" "subnet" {
for_each = tomap({
public-1a = {
az = "ap-northeast-1a"
netnum = 0
},
public-1c = {
az = "ap-northeast-1c"
netnum = 1
},
private-1a = {
az = "ap-northeast-1a"
netnum = 2
},
private-1c = {
az = "ap-northeast-1c"
netnum = 3
},
})
vpc_id = aws_vpc.test-vpc.id
availability_zone = each.value.az
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, each.value.netnum)
}

可読性も比較的保たれたまま、resource blockを1つで書くことができました!

もちろん、publicとprivateでresource blockを分けても良いです。countと違い、リソースを増減させたい場合はmapに要素を追加するだけで良く、かつkeyで管理されているので既存のリソースに影響が及びません。

またfor_eachは複数の属性をループで回せて便利なので、azもループに含めてマルチAZ構成も実現しています。

ループ処理(for)

後述するdynamic blockも同様ですが、countfor_eachと違ってforは「式」です。誤解を恐れず簡単に言うと、そもそもリソースを複数作るためのものではないということです。

具体的には、forlist, set, tuple, map, objectを入力として、tupleもしくはobjectを出力するものです。そのため使い方は多様ですが、個人的に嬉しい使い方を2つ記載します。

使い方① 特定条件でフィルタリングしてリソースを構築する

構築するリソースが増えてくると、tfファイルの数やコードの行数が多くなって管理が大変でしょう。

そんな時はlocal valuesに各設定値を一元的に記載しておくと管理しやすくなるかもしれません。

以下の例は無理やりforを使いに行ってるので良い例ではありませんが、local valuesに条件となる値を設定しておき、resource blockではその条件によって構築や設定をするかを振り分ける、ということが可能です。

locals {
subnet = {
public-1a = {
public = true
az = "ap-northeast-1a"
netnum = 0
},
public-1c = {
public = true
az = "ap-northeast-1c"
netnum = 1
},
private-1a = {
public = false
az = "ap-northeast-1a"
netnum = 3
},
private-1c = {
public = false
az = "ap-northeast-1c"
netnum = 4
},
}
}

resource "aws_subnet" "public" {
for_each = { for key, value in local.subnet : key => value if value.public == true }
vpc_id = aws_vpc.test-vpc.id
availability_zone = each.value.az
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, each.value.netnum)
}

resource "aws_subnet" "private" {
for_each = { for key, value in local.subnet : key => value if value.public == false }
vpc_id = aws_vpc.test-vpc.id
availability_zone = each.value.az
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, each.value.netnum)
}

参考:Local Values

使い方② あるリソースの特定の設定値一覧を取得する

例えばprivateサブネットからのみアクセス可能としたいリソース(EC2など)を構築し、そのセキュリティグループを構築するような場合を考えます。

サブネットは将来的に増減する可能性があり、それらのCIDRブロックを反映させて適切なインバウンドルールを設定する必要があります。

以下のlocal.allow_cidr_blockのように記載することで、publicサブネットとprivateサブネットのCIDRブロック一覧が簡単に取得できます。

locals {
subnet = {
public = {
public-1a = {
az = "ap-northeast-1a"
netnum = 0
},
public-1c = {
az = "ap-northeast-1c"
netnum = 1
},
},
private = {
private-1a = {
az = "ap-northeast-1a"
netnum = 2
},
private-1c = {
az = "ap-northeast-1c"
netnum = 3
},
},
}
allow_cidr_block = {
public = [
for k, v in local.subnet.public :
aws_subnet.public[k].cidr_block
]
private = [
for k, v in local.subnet.private :
aws_subnet.private[k].cidr_block
]
}
}

resource "aws_subnet" "public" {
for_each = local.subnet.public
vpc_id = aws_vpc.test-vpc.id
availability_zone = each.value.az
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, each.value.netnum)
}

resource "aws_subnet" "private" {
for_each = local.subnet.private
vpc_id = aws_vpc.test-vpc.id
availability_zone = each.value.az
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, each.value.netnum)
}

resource "aws_security_group" "private_resource" {
vpc_id = aws_vpc.test-vpc.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = local.allow_cidr_block.private
}
}

ループ処理(dynamic block)

countfor_eachがresource blockを複数作成するときに利用したのに対し、dynamic blockはresource block内のブロックを複製するときに利用できます。

例えば、forの使い方②で述べたようなセキュリティグループを構築する場合で、publicとprivate両方のサブネットからアクセス可能なセキュリティグループを作りたいとします。

この場合は、ingressのブロックを複製すると簡単に構築できるため、以下のようにdynamic blockが利用して書くことができます。

locals {
subnet = {
public = {
public-1a = {
az = "ap-northeast-1a"
netnum = 0
},
public-1c = {
az = "ap-northeast-1c"
netnum = 1
},
},
private = {
private-1a = {
az = "ap-northeast-1a"
netnum = 2
},
private-1c = {
az = "ap-northeast-1c"
netnum = 3
},
},
}
allow_cidr_block = {
public = [
for k, v in local.subnet.public :
aws_subnet.public[k].cidr_block
]
private = [
for k, v in local.subnet.private :
aws_subnet.private[k].cidr_block
]
}
}

resource "aws_subnet" "public" {
for_each = local.subnet.public
vpc_id = aws_vpc.test-vpc.id
availability_zone = each.value.az
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, each.value.netnum)
}

resource "aws_subnet" "private" {
for_each = local.subnet.private
vpc_id = aws_vpc.test-vpc.id
availability_zone = each.value.az
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, each.value.netnum)
}

resource "aws_security_group" "public_resource" {
vpc_id = aws_vpc.test-vpc.id
dynamic "ingress" {
for_each = local.allow_cidr_block
content {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ingress.value
}
}
}

ただし、dynamic blockは冒頭の説明でも述べた通り「resource block内のブロック」を複製するもので、単純なkey : value の形で定義する変数では利用できなかったりと、使い方が限定的です。(本記事では主にサブネットを複製してきましたが、サブネットの複製にdynamic blockは使えません。)

もう少しだけdynamic blockの使い道を考えます。

実践ではセキュリティグループは1個ということは基本ありえず、様々なリソース用に色々なセキュリティグループを構築することになるでしょう。

また、それぞれのセキュリティグループにはルールはいくつか存在し、CIDRブロックでなくセキュリティグループがソースになったり、ポートやプロトコルが異なっていたりもするでしょう。そうなると、以下のように全ての設定値をlocal valuesにmapとしてまとめておくのが良いでしょう。(長くなるのでサブネット部分の記述も省略しました。)

locals {
subnet = {
public = {
public-1a = {
az = "ap-northeast-1a"
netnum = 0
},
public-1c = {
az = "ap-northeast-1c"
netnum = 1
},
},
private = {
private-1a = {
az = "ap-northeast-1a"
netnum = 2
},
private-1c = {
az = "ap-northeast-1c"
netnum = 3
},
},
}
allow_cidr_block = {
public = [
for k, v in local.subnet.public :
aws_subnet.public[k].cidr_block
]
private = [
for k, v in local.subnet.private :
aws_subnet.private[k].cidr_block
]
}
security_group = {
ec2_a = {
ingress_1 = {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = local.allow_cidr_block.private
security_groups = null
},
ingress_2 = {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = null
security_groups = [aws_security_group.alb.id]
}
}
ec2_b = {
ingress_1 = {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = local.allow_cidr_block.private
security_groups = null
}
}
}
}

resource "aws_security_group" "ec2_a" {
vpc_id = aws_vpc.test-vpc.id
dynamic "ingress" {
for_each = local.security_group.ec2_a
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks != null ? ingress.value.cidr_blocks : null
security_groups = ingress.value.security_groups != null ? ingress.value.security_groups : null
}
}
}

resource "aws_security_group" "ec2_b" {
vpc_id = aws_vpc.test-vpc.id
dynamic "ingress" {
for_each = local.security_group.ec2_b
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks != null ? ingress.value.cidr_blocks : null
security_groups = ingress.value.security_groups != null ? ingress.value.security_groups : null
}
}
}

…なんとか書けました。

ご覧の通りcidr_blockssecurity_groupsはどちらかのみ設定するため、それを実現させるために後述する三項演算子を用いたり、local valuesにもわざわざnullとして定義しています。

さて、ループ処理の目的であるコードの冗長化を防ぎ、可読性や保守性を上げることはできたでしょうか。dynamic blockを使わずにシンプルにingressのブロックを羅列しても行数はむしろ減りますし、ループや条件分岐がなくなる分、可読性や保守性も上がりそうです。

ちなみに公式のベストプラクティスでもdynamic blockの使い過ぎは推奨されておらず、モジュールの再利用を目的としてシンプルな構成にしたいような場合に、利用することを推奨しています。

このため、dynamic blockは可読性や保守性を考えて慎重に利用するのが良いと思われます。

条件分岐(三項演算子)

Terraformでは条件分岐を行いたい場合は基本1通りで、この三項演算子を利用します。

構文は以下の通りで、conditionに記載した条件がtrueならtrue_valが、falseならfalse_valが採用されます。

condition ? true_val : false_val

参考:Conditional Expressions

簡単な例では、環境ごとにリソースの数を変えるような場合があります。

例えば本番環境は冗長化したいのでマルチAZで構築するが、開発/検証環境はシングルAZで良い場合などに、環境名ごとにcountの値を変えるような操作が可能です。
以下のように書くことで、環境名(local.env)の値を変えるだけで本番環境と開発/検証環境で、リソース数の切り替えができます。

locals {
env = "prod"
az = [
"ap-northeast-1a",
"ap-northeast-1c",
"ap-northeast-1d"
]
}

resource "aws_subnet" "public" {
count = local.env == "prod" ? 2 : 1
vpc_id = aws_vpc.test-vpc.id
availability_zone = local.az[count.index]
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, count.index)
}

resource "aws_subnet" "private" {
count = local.env == "prod" ? 2 : 1
vpc_id = aws_vpc.test-vpc.id
availability_zone = local.az[count.index]
cidr_block = cidrsubnet(aws_vpc.test-vpc.cidr_block, 8, count.index + 2)
}

条件分岐(for_each と for を併用)

こちらは、forの部分で記載したものの再掲となります。

Terraformに一般的なプログラミング言語でいうif文はありませんが、for文の中のifによってループ処理の中で条件分岐を行うことができます。

使い方はfor(使い方① 特定条件でフィルタリングしてリソースを構築する)をご参照ください。

最後に

Terraformにおけるループ処理と条件分岐をまとめました。

自分も例外ではなく、初心者はまずTerraformの構文に慣れるところが難しいかと思います。

本記事が同じようなTerraform初心者の一助となれば幸いです。