Terraformをしばらく書いて覚えた個人的なTipsについて

2020.05.20

IaCするにあたりツールの選定は1つ重要な要素になります. Terraformは様々なツールの中の1つであり仕事で触っていく中で柔軟にかつ非常に容易にインフラをかけるツールと気付きました.
書いていく中でいろいろ便利だと思ったことがたくさんあったのでまとめてみました.

Terraformのバージョンを固定する

新規開発なので特に問題はなかったですが0.11と0.12で大きな違いがあります.
今後バージョンアップが発生した場合に問題を減らす意味で早めに設定した方が良いでしょう.
Terraform自体の設定をしている部分に追加しましょう.

config.tf

terraform {
  required_version = ">= 0.12"
}

複数バージョンを使用する場合はtfenvを利用して対応できるようにすると良いかと思います.

tfstateをS3バケットで管理する

tfstateをローカルで保持していると複数人での開発が難しくなりまた, 誤った削除が起こりえます.
なのでtfstateについてはS3バケットに保存しましょう.
バックエンドとして利用するS3バケットはTerraformで管理しない方が良いため, AWS CLI経由で作成します.
またtfstateレベルでいつでも巻き戻せるようにバージョニングも有効にしておきます.

$ BUCKET_NAME=<BACKEND S3 BUCKET>
$ REGION=<S3 BUCKET REGION>
$ aws --region $REGION s3api create-bucket \
  --bucket $BUCKET_NAME
$ aws s3api put-bucket-versioning \
  --bucket $BUCKET_NAME \
  --versioning-configuration Status=Enabled

あとは設定に内容を入れましょう.

config.tf

terraform {
  backend "s3" {
    bucket = "BUCKET_NAME"
    key    = "path/to/key"
    region = "REGION"
  }
}

また規模が大きくなってきたりCIツールで自動実行する場合にtfstateを同時編集する可能性があります.
なのでそうなってきたら, tfstateのロックのためにDynamoDB Tableの導入を検討するのが良いかと思います.

変数を利用してタグを一括でつける

AWS環境でワークロードの識別や, リソースの判別のためにタグを活用するのは一般的かと思います.
システム名やシステムごとに請求タグをつけてコストを確認したり, 様々なユースケースで活用できます.
Terraformでももちろんタグの登録はできますが1つ1つのリソースにタグを付与していったらDRYでなくかつ, 冗長です.
なので変数を利用してタグをつけつつ, 各々のリソースに対して良い感じのNameタグを付与していく方法を考えます.
私は主にタグにシステム名と環境を渡したいので, tags変数に2つを記載しています. よりたくさん付与したい場合はジャンジャン書いて大丈夫です.

variables.tf

variable "tags" {
  description = "A map of tags to add to all resources"
  type        = map(string)

  default = {
    environment = "prod"
    system      = "devio"
  }
}

変数ができたところで上手にEC2インスタンスに対してタグ付けをしましょう.
例としては踏み台サーバにタグをつけていきます.
指定した2つのタグに追加して, 「devio-prod-bastion」という名前タグがつけられます.
またEC2, VPC, サブネットに似たような名前をつけたい場合は末尾の部分を変えるだけでうまくできるのでコーディングも非常に楽になります.

ec2.tf

resource "aws_instance" "bastion" {
	tags = merge(
    map("Name", "${var.tags["system"]}-${var.tags["environment"]}-bastion"
  ), var.tags)

	subnet_id              = aws_subnet.public["ap-northeast-1a"].id
  ami                    = "ami-0123456789012"
  instance_type          = "t3.small"
  key_name               = "key"  
  vpc_security_group_ids = [ "sg-0123456789012" ]
}

for_eachでVPCをきれいに書く

VPCとサブネットにそれぞれCIDRを書いていくと少し冗長になり変更時に大変です.
そこで変数に「cidr_block」と「subnet_numbers」を利用することで非常にきれいに書くことができます.
コードをみてもらえばわかると思うのですが2つの変数で下記のようなことを指定します.

  • vpc_cidrでVPCのCIDRを決定する
  • subnet_numbersでサブネットのリージョンと第3オクテットの値を決定できるようにする

vpc.tf

locals {
  vpc_cidr = "192.168.0.0/16"
  subnet_numbers = {
    "ap-northeast-1a" = 0
    "ap-northeast-1c" = 1
  }
}

esource "aws_vpc" "main" {
  cidr_block           = local.vpc_cidr
}

resource "aws_subnet" "private" {
  for_each          = local.subnet_numbers
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 8, each.value)
  availability_zone = each.key
}

またサブネットを参照したい場合は下記のようにコードを書きます.
「resource.resource_name["key"]」のような形で簡単に指定することが可能です.
簡単でかつ設定変更があまりない, VPCやサブネットだからこそだいぶ生きる書き方だと思っています.

ec2.tf

resource "aws_instance" "main" {
  subnet_id  = aws_subnet.public["ap-northeast-1a"].id

  ami                    = "ami-0123456789012"
  instance_type          = "t3.small"
  key_name               = "key"  
  vpc_security_group_ids = [ "sg-0123456789012" ]
}

組み込み関数を活用する

Terraformでは便利な組み込み間数がかなりあります.
タグの付与で利用したmergeもそうですが, 例えばスネークケースをケバブケースに変換したい場合などに非常に役立ちます.
例えば変数の中ではスネークケースを利用したいけど, AWSリソース名はケバブケースにしたい... といったユースケースにも簡単に対応できます.
流石にここまで書いてくると若干可読性は落ちてくるのでユースケースと要相談しましょう.

vpc.tf

resource "aws_security_group" "main" {
  for_each    = toset(local.security_groups)
  vpc_id      = aws_vpc.main.id
  name        = "${var.tags["system"]}-${var.tags["environment"]}-${replace(each.value, "_", "-")}"
  description = "${var.tags["system"]}-${var.tags["environment"]}-${replace(each.value, "_", "-")}"

  tags = merge(
    map("Name", "${var.tags["system"]}-${var.tags["environment"]}-${replace(each.value, "_", "-")}"
  ), var.tags)
}

variable "tags" {
  description = "A map of tags to add to all resources"
  type        = map(string)

  default = {
    environment = "prod"
    system      = "devio"
  }
}

locals {
  security_groups = [
    "aaa_sg",
    "bbb_sg",
  ]
}

また組み込み間数の出力をデバッグしたい場合は「terraform console」が役に立ちます.
下記のようなIDEでデバッグが簡単にできます.

$ terraform console
> cidrsubnet("192.168.0.0/16", 8, 0)
192.168.0.0/24

循環参照を解消する

セキュリティグループを作る際に「aws_security_group」の中でIngressとEgressルールを定義しました.
こうした場合にterraform planの結果で「Error: Cycle: aws_security_group.a, aws_security_group.b」といったエラーがおきました. つまり下記のような状況になります.

  • セキュリティグループAのIngress RuleでセキュリティグループBのIDを利用している
  • セキュリティグループBのIngress RuleでセキュリティグループAのIDを利用している

そのためセキュリティグループAをIngress Rule含めて先に作成すべきか, セキュリティグループBをIngress Rule含めて先に作成すべきか循環参照が起きているために判断できなくなっています.

問題に陥った際には依存関係をグラフ化して可視化するのが原因究明に役に立つと思います.
この時役に立つのがgraphvizを利用して問題を可視化しましょう.

$ terraform graph | dot -Tsvg > graph.svg

TerraformでもCloudFormationでもセキュリティグループとルールを同時に定義しようとすると大体循環参照が発生して作成に失敗します.
なので冗長にはなりますがセキュリティグループとルールを分離して定義するのが回避策になります.

ここでちょっとしたテクニックですがfor_eachと変数を利用することでセキュリティグループの作成だけは見通しよく作成することができます.
下記のように指定するとsecurity_groupsに入れた値の分だけセキュリティグループを作成してくれます.

sg.tf

locals {
  security_groups = [
    "aaa_sg",
    "bbb_sg",
  ]
}

resource "aws_security_group" "main" {
  for_each    = toset(local.security_groups)
  vpc_id      = aws_vpc.main.id
  name        = each.value
  description = each.value
}

ルール自体は別途定義する必要があるのでEgressルールも忘れずに, 下記のように定義をしていきましょう.

sg.tf

resource "aws_security_group_rule" "aaa_sg_ingress1" {
  security_group_id = aws_security_group.main["aaa_sg"].id
  type              = "ingress"
  protocol          = "tcp"
  from_port         = 443
  to_port           = 443
  cidr_blocks       = [ "0.0.0.0/0" ]
}
resource "aws_security_group_rule" "aaa_sg_egress" {
  security_group_id = aws_security_group.main["aaa_sg"].id
  type              = "egress"
  protocol          = "-1"
  from_port         = 0
  to_port           = 0
  cidr_blocks       = [ "0.0.0.0/0" ]
}

ELB のアクセスログの設定について

ELBのアクセスログを収集する場合にはS3バケットにポリシーを適用する必要があります.
具体的にはドキュメントに指定のあるAWSアカウントからのアクションを許可する必要があるのですが, 都度都度調べるのは結構な労力になります.
Terraformだと下記のような指定で簡単にS3バケットポリシーを付与することができます. リージョンについてはconfigで指定したものを元に自動で調べてくれます.

# 信頼したいAWSアカウント
data "aws_elb_service_account" "main" {}

# S3 Bucket Policy
data "aws_iam_policy_document" "lb_logs" {
  statement {
    effect    = "Allow"
    actions   = [ "s3:PutObject" ]
    resources = [ "${aws_s3_bucket.lb_logs.arn}/PREFIX/*" ]
    principals {
      type = "AWS"
      identifiers = [ data.aws_elb_service_account.main.arn ]
    }
  }
}

/**
	* S3 Bucket
 */
resource "aws_s3_bucket" "lb_logs" {
  acl    = "private"
}

resource "aws_s3_bucket_policy" "alb_logs" {
  bucket = aws_s3_bucket.lb_logs.id
  policy = data.aws_iam_policy_document.lb_logs.json
}

/**
	* ALB
 */
resource "aws_lb" "front_ext_alb" {
  name                       = "lb"
  internal                   = false
  load_balancer_type         = "application"
  security_groups            = [ "sg-0123456789012" ]
  subnets                    = [ "subnet-0123456789012", "subnet-1234567890123"]

  access_logs {
    bucket  = aws_s3_bucket.lb_logs.bucket
    prefix  = "PREFIX"
    enabled = true
  }
}

IAM Roleの作成について

AWSが管理しているマネージドポリシーの場合は非常に綺麗にかけます.
逆に自前でIAM Policyを定義する場合はdataを利用して書いていくか, jsonファイルを用意して作成する形になります.
どちらの手法もとる必要がでてくるので理解していくと良いでしょう.

iam.tf

data "aws_iam_policy_document" "main {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type = "Service"
      identifiers = [ "ec2.amazonaws.com" ]
    }
  }
}

resource "aws_iam_role" "main" {
  name               = "main"
  assume_role_policy = data.aws_iam_policy_document.main.json
}

resource "aws_iam_role_policy_attachment" "main" {
  role       = aws_iam_role.main.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
}

RDSの設定にlifecycleを追加する

RDSインスタンスやクラスタをTerraformで作成した後に, RDSの設定以外の更新をしたい場合や, 再作成が不要な変更をしてもインスタンスやクラスタを再作成しようとします.
回避するにはlfiecycleでignore_changesを追加して, master_passwordとavailability_zonesの更新をTerraformで管理しないようにします.
これについては既知の問題でこの設定以外で回避方法がないので必ず設定しましょう.

rds.tf

resource "aws_rds_cluster" "main" {
  cluster_identifier                  = "aurora-cluster"
  db_subnet_group_name                = aws_db_subnet_group.main.name
  engine                              = "aurora-mysql"
  engine_version                      = "x.x.mysql_aurora.x.xx.x"
  availability_zones                  = [ "ap-northeast-1a", "ap-northeast-1c"]
  database_name                       = "db"
  master_username                     = "masteruser"
  master_password                     = "password"
  backup_retention_period             = 7
  lifecycle {
    ignore_changes = [
      "master_password",
      "availability_zones"
    ]
  }
}

Templateを活用する

EFSをEC2インスタンスにマウントするuserdataが欲しい場合でかつ, EC2とEFSをTerraformで作成する場合にはリソースが出来上がるまでEFSのIDがわからないためuserdataが定まりません. またuserdata自体はシェルスクリプトのためHCLの中にあると少し可読性が落ちます.
なのでtemplateを活用してuserdataを外出しした上で, EFSのIDなど必要なパラメータを渡せるようにします.

ec2.tf

data "template_file" "userdata" {
  template = "${file("${path.root}/assets/userdata.tpl")}"
  vars     = {
    efs_id = aws_efs_file_system.main.id
  }
}

resource "aws_instance" "bastion" {
  user_data = data.template_file.userdata.rendered

  subnet_id              = aws_subnet.public["ap-northeast-1a"].id
  ami                    = "ami-0123456789012"
  instance_type          = "t3.small"
  key_name               = "key"  
  vpc_security_group_ids = [ "sg-0123456789012" ]
}


userdataの中身は下記のようになっています.
シェルが分離した分どちらのファイルもだいぶ可読性があがりましたね.

/assets/userdata.tpl

#!/bin/bash
cloud-init-per once yum_update yum update -y
cloud-init-per once install_amazon-efs-utils yum install -y amazon-efs-utils
cloud-init-per once mkdir_efs mkdir /efs
cloud-init-per once mount_efs echo -e '${efs_id}:/ /efs efs defaults,_netdev 0 0' >> /etc/fstab

さいごに

Terraformはまだまだ便利な機能があり, 書くたびに驚きと楽しさをもたらしてくれます. もしこの記事が参考になりましたら幸いです.