Terraformのはじめかた2

すこし間が開いてしまいました。

TerraformでSecurityGroupを作成したところですが、実際にこのように利用していくと、SecurityGroupだけでも膨大なコードになることに気が付くと思います。

最も、可読性という意味ではコードが膨れ上がりますが、実はこのほうが読み取り自体はしやすい、というなんともな話になってきます。
しかしながら、SecurityGroupなどでは特に、同じIPアドレスからの許可ルールをたくさん書いたりするわけです。接続元IPアドレスが変わったら、これを一斉に変更する必要があります。

sedで一括で書き換えてしまってもよいのですが、VCSに入れている場合、diffの量が膨大になってしまいます。

要するに、変数化してしまえればいい、というお話ですね。この時に用いるのが、variableリソースとlocalsリソースです。

それぞれ、ほぼ似たような内容になるので、使い分けについては利用者の感覚的なところに大きく依存します。私の場合、variableは直接利用者が書き換える値、localsはコード内で実際に用いる値、として利用しています。
variableとして投入された値は、そのまま利用されることはほぼなく、いったんlocalsでlocal変数として渡し、実際のリソース定義ではこのlocalsの値のみを参照します。

これは何のためにやっているかというと、variableとして作る構造体が、必ずしもTerraform内部で利用しやすい形ではないことがある、と思っているためですね。このことはまあ後程ということで。まずは利用方法と行きましょう。

variableはいわゆる変数に相当するもので、variableブロックによって定義していくことができます。

variable allowd_ip {
  default = "10.0.0.0/12"
}

といった感じで定義できます。

ちなみに、デフォルト定義を記述しない場合、何らかの方法で値のセットを求められます。
さて、内容的には見たまんまではあるのですが、この場合、allowed_ipは文字列の値をとるデフォルト値を持っており、特に指定がされなければこの値を初期値として有します。

デフォルト値は極力セットしておくことが望ましいプロパティで、このデフォルト値は同時に変数の型宣言としても機能している側面があります。つまり、default = “10.0.0.0/12″を宣言した上記のallowed_ipに対して、ハッシュやリストを上書きすることができなくなります(上記の場合、自動的にtype=”string”が認識される)。

もちろん、typeプロパティだけを記述して、明示的に型だけ宣言することもできます。

さて、一般的に変数、といえばこのあと何やかやと計算したりしたものを再格納して…なんてことを思うかもしれませんが、Terraformにおける変数、とは極めてStaticなものです。つまり、原則一度与えた値がTerraform実行終了までの間で変化することはありません。

どちらかといえば、変数というよりも定数に近しいものとなります。
ただ、実行都度与える初期値を変えることができるので、定数かといわれると困りますが…。

このvariableに対して、初期値ではない値を与える方法としては、いくつか方法があります。

・コマンドライン実行時に直接Terraformに値を与える
・環境変数からの読み取り
・tfファイルに直接記述
・tfvarsファイルまたは、設定ファイルによる記述
といった方法があります。

そして、デフォルト値が存在しない変数に対しては、Terraform実行時に上記のいずれの方法からも値の注入がなされない場合、CLI上での入力プロンプトで値の注入が求められます。

・コマンドライン実行時に直接Terraformに値を与える
とりあえず最もお手軽ですが、面倒なのでデバッグフラグの変数をセットする、とか、デバッグ用に値を入れるとか、そういうケースでしか私は使っていません。
terraform plan -var ‘allowed_ip=10.0.0.0/24’
などの形で変数のデフォルト値を変更できます。

これは、環境変数からの読み取り時も形式は同様で、TF_VAR_をPrefixとして持っている環境変数は自動的に読み込まれます。
つまり、下記は上記と同等です。
TF_VAR_allowed_ip=10.0.0.0/24 terraform plan

・tfvarsファイルまたは、設定ファイルによる記述
terraform.tfvarsというファイルを作っておくと、Terraformは自動的にこのファイルの中身を変数の定義ファイルとして利用します。

書式としては単に-varやTF_VAR_以下への記述の羅列で良く、

terraform.tfvars
allowed_ip="10.0.0.0/24"
hogehoge="foobar"

この場合、variableリソースの記述のあるallowed_ipは取り込まれ、当該変数にオーバーライトされますが、hogehogeはどこにも定義がないのでエラーを起こします。

・tfファイルやtfvarsを用いる
こちらはどちらかというとよりStaticな定義となります。IaCによる生成においては、これらの変数値は極めてリテラルに近しいものであり、極端に動くことはあまりありませんから、IaC運用においてもこちらの方法を用いることのほうが多くなります。

ここから先はファイルをどう定義するか、という極めて好みレベルの問題になるので、何とも言えませんが、とりあえず記述だけ。

まず、一般的によく使われるのはvariables.tfファイルです。別にこの名前に深い意味はなく、Terraformは*.tfのファイルをすべて読むのですが、慣例的にこの名前が使われることが多い、というだけです。

variableリソースを集中的に記述するファイルとして用いられることがほとんどです。

variables.tf
variable domain {
  type=string
  default="example.jp"
}

variable allowed_ips {
  type = list
  default = [ "x.x.x.x/32", "y.y.y.y/24" ]
}

variable identifier {
  type = strings
}

variable debug {
  type = bool
  default = false
}

そして、ここからは個人の好みの問題で、環境によってどういう値を変数として持たせるか、はIaCコード記述におけるコーディングルールということになります。
※一応ベストプラクティス的なものはありますが、IaCという性質上、それが汎用的に多くのの環境で適正といえるか、というとそうでもないように思います。

まずは動的に変更する可能性のある要素を変数化して独立化し、コード本体にリテラル値を埋めこまない、というのが汎用化を目指すうえでは必要になってきます。逆に、特定の固定環境を構成するためだけのコードである、という定義であれば、実はこれは逆効果になることもあります。
もっとも、それは他の言語含めてコードに触れるのは初めて、といったレベルの初学者において顕著に現れますが、ある程度コード慣れしているならば変数とロジックの分離にそれほど違和感はないかと思います。

さて、それでは先のSecurityGroupのコードを分離していってみましょう。

resource "aws_security_group" "ssh-allow" {
  name = "SSHAllowed"
  vpc_id = "vpc-xxxxxxxxxxx"  <- VPCのVPC ID
  description = "SSH Allowed from anywhere"
  ingress {
    from_port = 22
    to_port = 22
    protocol = "tcp"
    cidr_blocks = [ "0.0.0.0/0" ]
    ipv6_cidr_blocks = [ "::/0" ]
   description = "SSH Allowed anywhere"
  }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = [ "0.0.0.0/0" ]
    ipv6_cidr_blocks = [ "::/0" ]
    description = "Egress Anywhere"
  }
  tags = {
    Created_By = "Terraform"
    Terraform = true
    Name = "SSH Allowed"
  }
}

元となるコードはこれですね。変数化したい部分はどこか、というのをまず考慮する必要があります。
例えば、
SSHを許可するSecurityGroup、HTTPを許可するSecurityGroup、SMTPを許可するSecurityGroupでそれぞれ別のコードを記述するなら、ソースIPアドレスなどが分離できれば良いでしょう。
一方で、それらのコードも大体同じなのだから、単一のコードでSecurityGroup全体を管理できるようにしていきたいなら、ポートなども制御できなくてはいけません。

厄介なことに、SecurityGroupは接続元としてIPだけを指定するわけではありません。セキュリティグループIDもソースとして利用できますし、一つのSexurityGroupの定義コードで不定数のIngressやEgressを記述することができてしまいます。
※もちろん後からルールだけの追加もできますが。

このように、どのレイヤでどのようにコード分離を進めていくか、というのを計画するのが抽象化レイヤということになります。
どこまでを制御範囲とし、どこからをコードにリテラルに埋め込むべきものとするか、という感じですね。

ただ、この中でまず確実に変数化しておくべきものははっきりしています。
それは、VPC IDです。現時点のコードの時点で、すでに不定であることが明白です。これは環境によって定まるものであり、リテラルではありえない、ということが明白ですね。

variables.tf
variable vpc_id {
  type=string
}

terraform.tfvars
vpc_id = "vpc-yyyyyyy"

security_group.tf
resource "aws_security_group" "ssh-allow" {
  name = "SSHAllowed"
  vpc_id = var.vpc_id
  description = "SSH Allowed from anywhere"
  ingress {
    from_port = 22
    to_port = 22
    protocol = "tcp"
    cidr_blocks = [ "0.0.0.0/0" ]
    ipv6_cidr_blocks = [ "::/0" ]
   description = "SSH Allowed anywhere"
  }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = [ "0.0.0.0/0" ]
    ipv6_cidr_blocks = [ "::/0" ]
    description = "Egress Anywhere"
  }
  tags = {
    Created_By = "Terraform"
    Terraform = true
    Name = "SSH Allowed"
  }
}

はい、これでコードロジックであるsecurity_group.tfは汎用化されたといえます。
IngressやEgressを汎用化して、ループで不定数を一気に定義してしまうことも実はできるのですが、それはちょっといろいろとテクニックが必要になってきます。いきなりやると確実に目が滑るので、今回は含んでいません。

やり方だけは書きます。Security Group全体を抽象化してしまうのであれば、Security Groupを定義する辞書配列を形成し、それをループで回し、さらにその辞書配列内の要素を用いてdynamicブロックによる動的ブロック生成でループさせます。
そのうえで、接続元ソースによるDynamicブロック内の記述可変に対応させるため、try文で値のセットの成功失敗で記述されるプロパティを動的変更する、というかなり変態チックな書き方になります。

ぶっちゃけ素直にソース別で書いたほうが気持ちも手間も楽です。
また、そういう構造を作ると確かにリソースコードは減るんですが、変数側に記載すべき情報量が増えてしまいます。この辺りはバランスを取ってやっていく必要があり、そんなに可変にしないとこまで可変にしなくても…というところは会ったりはします。

そのあたりをうまくやるために登場するのがmoduleリソースなのですが、今回は触れません。

variables.tfでデフォルトを指定していないのは、vpc_idのデフォルト、というものが通常はありえない(環境ごとで必ず変わる)ためです。
デフォルトが指定されていない場合、terraform.tfvarsや環境変数、コマンドラインからその値が与えられていない場合、terraform実行時に対話的に値の入力を求められます。

こうすることで、定義漏れや想定外の定義をしてしまうことを避けています。

つまり別環境にコードコピーするときに、terraform.tfvarsのコピーを避ける、あるいは値の入ってないterraform.tfvars.templateとか用意しておくことで、異なる環境への移植性を高める、ということになります。

このほかに変数化したいのはタグもそうですね。これはだいたい同じタグをつけていきますから。私が良くやるのは、基本となるデフォルトタグのKeyValueセットと、リソースによって変えたい内容を持つカスタムタグのKeyValueセットを用意しておいて、それらをmergeしてセットする手法ですね。

接続元ソース、そしてdescriptionも独自に定義して外部に出したい内容です。
descriptionにはデフォルトが欲しいですが、接続元ソースはデフォルトがあってはいけませんね。
こういう時はtryを使うと便利です。

variable ssh_sources {
  default = {
      anywhere = {
        ipv4_source = "0.0.0.0/0"
        description = "Any Allow"
      },
      vpc_source = {
        ipv4_source = "10.0.0.0/8"
      }
   }
}

こういう配列をループでDynamicブロックに記述すると、単純にdescription要素が取れるインデックスと取れないインデックスが発生します。普通にエラーします。かといって全部の要素にdescription要素を書く、というのもなんだかなぁ。という気になることもあるでしょう。デフォルト要素書けないの、と。

variable側にはそれは書けませんので、値を使うときに工夫するということになります。

この時に便利なのがtryやcanファンクションです。
tryは第一引数に指定した値がアクセス不能である場合、第二引数の値を用います。いわゆるデフォルト値のセットができます。
canは第一引数に指定した値にアクセス不能であればfalseを返す関数ですね。validationブロックを用いて動作を変える場合に用います。

実際に書くとこんな感じになります。

variables.tf
variable vpc_id {
  type=string
}
variable ssh_sources {
  default = {
      anywhere = {
        ipv4_source = "0.0.0.0/0"
        description = "Any Allow"
      },
      vpc_source = {
        ipv4_source = "10.0.0.0/8"
      }
  }
}
variable default_tags {
  default = {
    Created_By = "Terraform"
    Terraform = true
  }
}

terraform.tfvars
vpc_id = "vpc-yyyyyyy"

security_group.tf
resource "aws_security_group" "ssh-allow" {
  name = "SSHAllowed"
  vpc_id = var.vpc_id
  description = "SSH Allowed from anywhere"
  dynamic ingress {
    for_each = var.ssh_sources
    content {
      from_port = 22
      to_port = 22
      protocol = "tcp"
      cidr_blocks = [ ingress.value.ipv4_source ]
      description = try(ingress.value.description, ingress.key)
    }
   }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = [ "0.0.0.0/0" ]
    ipv6_cidr_blocks = [ "::/0" ]
    description = "Egress Anywhere"
  }
  tags = merge(var.default_tags , map ( "Name", "SSH Allowed" ) )
}

さらに推し進めると、ipv4_sourceがあるかないかをvalidationで検査してcontentに記述する、しないなどを判別したり、あるいはsourceとtypeとして分離して、ingress.typeの内容によって記述する内容を変える、といった動きも書くことができますが、そこまではやりません。

egressはデフォルトとして全許可を出すのがよくあるパターンなので、これは分離してもいいんですが、その場合にはegressを制御したい、という(非常に限定的な)リクエストが発生したとき、どうするかを決めておく必要があります。
つまり、できませんよ、としてしまうか、こんなこともあろうかと。と言いながら対応できるようにあらかじめ書いてしまうか。ということですね。

私でしたら、egressのデフォルトセットを定義しておいて、カスタム値が存在した場合だけオーバーライドさせる感じですね。デフォルト値が残ってたらそもそも全許可が出てしまうので、もし改変要求が出るのであれば、これを打ち消さないといけないでしょう。

ループにポートを含めることで、ポート改変しながらの定義もできますが、今度はポート指定必須のvariableになってきますので、そのあたりはどう考えるか、というところもありますね。

では、本日はこの辺りで。