Terraformのはじめかた3

あまり見ている方もいないだろうと思ってのんびりしていたら、知り合いからはよ続きを書けとせかされてしまいました。

さて、前回まででSecurityGroupが出来上がりました。せっかくだからEC2も立ち上げましょうか。

さて、EC2を作るにあたって、必要な要素はなんでしょうか。
無駄に行数稼いでもしょうがないので、さくっと答えを書きます。

ec2.tf
resource "aws_instance" "web-server" {
  ami = "ami-0053d11f74e9e7f52"
  instance_type = "t2.micro"
  tags = merge(var.default_tags, map("Name", "web server"))
}

これだけです。キーペアは?とかセキュリティグループは?とかどこのサブネットで起動するのこれ?とかEC2を使ったことが少しでもあれば思ってしまうかもしれません。

が、EC2起動にあたって最低限として必要なのはこれだけです。Tagはそもそも不要ですので、amiとインスタンスタイプだけあれば起動します。EBSは勝手にAMIで定義された最小サイズで作成されてアタッチされますし、ネットワークは適当なサブネットが選出されます(妥当、という意味ではなく、本当の意味で適当に、です)。セキュリティグループはVPCのデフォルトセキュリティグループがアタッチされ、キーペアは設定されません。

要するにログインもできないインスタンスが出来上がります。まあ後から直せますけれど。

さあ、当然変えたいものがたくさんありますね!

まず必須感の強いパラメータから。AMIをここでは固定IDで突っ込んでいます。これ、どんどん新しいものになっていくわけですよね。そうすると、固定で入れておくより、起動時点での最新版が使いたいですね。ここで活躍するのがdataリソースと呼ばれるものです。

dataリソースは、terraformの実行に先立ち、awsなどに問い合わせを行い、動的なIDなどを起動前に取得して確定させるリソースです。
例えば皆さんが良く使われるものにAWSのアカウントIDというものがあります。IAMを構成する際に、しょっちゅう参照することになります。
これ、毎回マネジメントコンソール開いて調べるの、面倒ですよね。私は面倒です。

data "aws_caller_identity" "self" {}

そこで、どこかにこんなものを書いておきます。これは、自分自身のアカウント情報をあらかじめ取得してくるデータリソースです。data.aws_caller_identity.self.account_idでアカウントIDを参照できるようになります。このようにして、環境が異なっても同じコードが利用できるよう、再利用性を高めていくわけです。

さて、今回はAMIですから、その時点の最新のAMIのIDが取得できると嬉しいですね。

data "aws_ami" "amazon-linux2" {
  executable_users = [ "self" ]
  most_recent=true
  name_regex="^amzn2-ami-hvm-2.0.*.x86_64-gp2$"
  owners=["amazon"]
}

こんな感じです。AWSさんですとかなり命名規則が厳格なので、これでほぼ固定的に最新のAMIが得られる(派生系のAMIがgp2のあとに-monoとかでつながってくるケースがあるので、name_regexで限定しています)わけですが、これがPrivateイメージや第三者のPublicイメージだったりすると、かなり厳格にフィルタしないと得られにくいかもしれません。そんな時はfilterなどを使ってさらに絞り込みます。

ここの記載内容でマネジメントコンソールでAMIがきれいに絞り込めていれば基本的には問題ありませんが、先にも言った通り命名規則は人によって異なるので、基本的にはamazonイメージか、自己所有イメージでの使用が原則です。

次に、instance_typeは変数化しておきたいですね。いちいちec2.tfの中から該当行を探して、書き換えて、というのはなんとも面倒です。ほかにも変数化したいものは書いておきます。せっかくだから、ハッシュ配列で記録しておきましょう。webサーバもapiサーバもdbサーバもとりあえずvar.instances以下にアクセスすればパラメータが取れるようにしてしまいましょう。
データ構造はなるべくTerraformの使用するリソースの構造に合わせておくと、あとあと拡張が楽です。例えばこんな感じですね。

instances = {
  web = {
    instance_type = "t2.micro"
    root_block_device = {
      volume_size = "10"
      volume_type = "gp2"
    }
  }
}

AWSのAPIでは未指定の状態のEBSはマグネティックボリュームになります。TerraformはAPIを呼び出すので、当然ディスクの指定をしていなければマグネティックボリュームで作成されてしまうので、ボリュームタイプの指定はきちんと行いましょう。

また、VPCのIDを自動取得にしたため、variableでの指定が不要になります。
加えて、いくつかの特殊リソースを用いているので、これらの取り込みをします。具体的には、ローカルファイルを作成するLocalプロバイダ、TLS暗号を作成する、TLSプロバイダ、そして乱数生成をするRandomプロバイダです。

このほか、よく使うのはTemplateプロバイダなどはuser_dataの作成などでよく使います。
以上のようなことを次々と書いていくと、こんな感じになります。

provider.tf
terraform {
  required_version = "~> 0.12.0"
}
provider "aws" {
  version = "~> 3.0"
  region = "ap-northeast-1"
  profile = "Project-Name"
}
provider "local" {}
provider "tls" {}
provider "random" {}

terraform.tfvars
instances = {
  web = {
    instance_type = "t2.micro"
    root_block_device = {
      volume_size = "10"
      volume_type = "gp2"
    }
  }
}

variables.tf
variable ssh_sources {
  default = {
    any_allow = {
      ipv4_source = "0.0.0.0/0"
      description = "Any Allow"
    },
    vpc_allow = {
      ipv4_source = "10.0.0.0/8"
    }
  }
}
variable default_tags {
  default = {
    Created_By = "Terraform"
    Terraform = true
  }
}
variable instances {}

ec2.tf
# アカウント情報を取得
data "aws_caller_identity" "self" {}
# デフォルトVPC情報を取得
data "aws_vpc" "default_vpc" { default = true }
# デフォルトVPC内のサブネットIDをリストで取得
data "aws_subnet_ids" "subnets" { vpc_id = data.aws_vpc.default_vpc.id }
# サブネットIDのリスト長-1を最大値として、ランダムな数字を作成
# keepersはこの対象が変更されるまでは一度作成した乱数値をキープする
# ここではinstance_typeの変更まで、変更は生じません。
# ※よくやる手としては環境変化に対応する変数を定義して利用したりします

resource "random_integer" "select_subnet" {
  min = 0
  max = length(data.aws_subnet_ids.subnets.ids) - 1
  keepers = {
    instance_type = var.instances.web.instance_type
  }

}
# AMIからamazon linux2の最新版AMIの情報を取得
data "aws_ami" "amazon-linux2" {
  most_recent = true
  name_regex = "^amzn2-ami-hvm-2.0.*.x86_64-gp2$"
  owners = ["amazon"]
}
# 公開鍵/秘密鍵の自動作成

# このリソースは論理リソースなので、再作成は明示的に指示しない限り発生しません。
resource "tls_private_key" "key-pair" {
  algorithm = "RSA"
  rsa_bits = "4096"
}
# 作った秘密鍵の保存。必ずVCSの管理外の場所に置くようにしましょう。
resource "local_file" "private_key" {
  sensitive_content = tls_private_key.key-pair.private_key_pem
  filename = "../private_key"
  file_permission = "0600"
}
# 公開鍵の保存。こちらはどこでもよいですが、秘密鍵とセットにしておきます。
# openssh形式が欲しければ、public_ssh_opensshも出力しておけばOKです。
resource "local_file" "public_key" {
  content = tls_private_key.key-pair.public_key_pem
  filename = "../public_key"
  file_permission = "0744"
}
# 公開鍵のAWSへのアップロード
resource "aws_key_pair" "key_pair" {
  key_name = "Terraform-key"
  public_key = tls_private_key.key-pair.public_key_openssh
}
# インスタンスの作成
resource "aws_instance" "web-server" {
  ami = data.aws_ami.amazon-linux2.id
  instance_type = var.instances.web.instance_type
  root_block_device {
    volume_type = var.instances.web.root_block_device.volume_type
    volume_size = var.instances.web.root_block_device.volume_size
  }
# セキュリティグループを指定
  vpc_security_group_ids = [aws_security_group.ssh-allow.id]
# キーペアを指定
  key_name = aws_key_pair.key_pair.key_name
# public ipの割り付けを有効化
  associate_public_ip_address = true
# サブネットの指定
  subnet_id = elements(tolist(data.aws_subnet_ids.subnets.ids),random_integer.select_subnet.result)

  tags = merge(var.default_tags, map("Name", "web server"))
# AMIの新しいものがリリースされるとリソースパラメータが変動します。
# 下記指定がなければ既存のEC2を破棄して新しく作ってしまいます。
# ignore_changesは、指定したパラメータが変更されてもそれを無視します。
  lifecycle {
    ignore_changes = [ami]
  }
}

security_group.tf
resource "aws_security_group" "ssh-allow" {
  name = "SSHAllowed"
  vpc_id = data.aws_vpc.default_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"))
}

とりあえずささやかですが、これでどこのAWSアカウント上でもSSH許可するセキュリティグループを作成し、EC2インスタンスをデフォルトVPC上に作成するコードということになります。
※セキュリティグループのコード中のvar.vpc_idも置き換えています。

variables.tfを含めて中を見ても、どこにもリテラル値がなく、すべて参照とデータ取得で構成されていることになります。こうなった時点で、コードはポータビリティを有し、別環境でも同じようにサーバを作るコード、ということになりました。

もちろん、terraform.tfvarsには固有値を埋め込む、というスタイルなので、こちらの内容は環境ごとで変えていって構わない、ということになります。
providerの追加を行っているので、まずはterraform initを実行し、その後plan -> applyと実行してみてください。

実際にきちんとEC2が起動しているか、確認してみましょう。

さて、TerraformをはじめとするIaCの本領発揮は実はここから先、です。ここまではあくまで序盤。もちろん、この段階でもまだまだ考慮すべきこと、やるべきこと、そしてコードの洗練などあるんですが、細かくやっていったら時間がいくらあっても足りません。

まずは習うより慣れろ、で使い始めることが肝要です。

では、おもむろに起動しているEC2インスタンスを終了させ、削除してしまいましょう。ついでにセキュリティグループの接続元IPも適当に書き換えてしまってください。

その後、再度、terraform planを実行してみましょう。
どうでしょうか。皆さんが破棄したり、書き換えてしまった内容を再度Terraformが破棄し、コード記載内容に書き換える、あるいは記載内容に従って作成するぞ、と言われたと思います。

これが、コード管理によるインフラストラクチャの真骨頂です。TerraformではTerraform実行時の最後の状態がterraform.tfstateというファイルに記録されています。このファイルはとても重要なので、決して触らないでください(いや、触ったりもするんですが)。

Terraform実行時に、TerraformはターゲットのAWSアカウントのリソースの状態と、自分の知っているリソース作成時の状態の記録をDiffし、その差分に基づいて実行計画を立てます。
このあたりはTerraformもAWSのCloudFormationも同様です。InfrabbitがCloudFormationではなくTerraformを利用しているのはいくつかの理由があったりはしますが、いまはそれは横に置いておきます。

さて、次回はTerraformを用いた運用ステージでの作業を見ていきましょう。
tfstateの内容を、どのように更新し、どのように適切に変更するのか、というところとなります。

では本日はこのあたりで。