作りながら覚えるTerraform入門(4) - EC2編

作りながら覚えるTerraform入門シリーズの第4回です。
今回はEC2関連のリソースを作成してみましょう。EC2にはnginxをインストールして、ブラウザでHTTP接続できるところまでを確認します。

##### 作りながら覚えるTerraform入門シリーズ

1. [インストールと初期設定](https://qiita.com/m-oka-system/items/6103bbb9f103db1fea0e)
1. [基本編](https://qiita.com/m-oka-system/items/983653f611fe305820c8)
1. [VPC編](https://qiita.com/m-oka-system/items/bba8d32d57142a870ce5)
1. **EC2編** => 今回はコチラ
1. [Route53 + ACM編](https://qiita.com/m-oka-system/items/34f3ac3d98210d06dbc8)
1. [ELB編](https://qiita.com/m-oka-system/items/8882431042a1d01d0b7c)
1. [RDS編](https://qiita.com/m-oka-system/items/a45867495f95461b0a2c)

今回の学習ポイントは以下です。

- データソースの使い方
- httpプロバイダーの使い方
- ローカル変数の使い方
- outputの使い方


# キーペアの作成

Terraformには現時点ではキーペアを新規作成する方法はないようです。
`aws_key_pair`というResourceは存在しますが、既存のキー(公開鍵)をインポートするためのものなので、

1. ssh-keygenでキーペアを作成
1. `aws_key_pair`で公開鍵をインポート

という流れになるようです。
今回はコンソール画面から作成します。

![A4760948-E8D1-482A-AFD2-F6F3A61A47ED.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402286/95852cc9-77e8-2f35-e488-a55f9ec7077c.png)

# IAMロールの作成

EC2に割り当てるIAMロールを作成します。
アタッチするポリシーは管理ポリシーの`AdministratorAccess` を使います。
`iam.tf`を作成し、以下のコードを貼り付けます。

```terraform:iam.tf
################################
# IAM
################################
data "aws_iam_policy" "administrator" {
  arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

data "aws_iam_policy_document" "ec2_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_instance_profile" "ec2" {
  name = aws_iam_role.ec2.name
  role = aws_iam_role.ec2.name
}

resource "aws_iam_role" "ec2" {
  name               = "${var.prefix}-ec2-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
}

resource "aws_iam_role_policy_attachment" "ec2" {
  role       = aws_iam_role.ec2.name
  policy_arn = data.aws_iam_policy.administrator.arn
}
```

まず、`aws_iam_role`でIAMロールを作成しています。
この時、`assume_role_policy`で信頼されたエンティティ(AssumeRole)を指定します。
コンソール画面で作成する場合は、EC2を選択するだけでOKなのですが、

![CAEF9B9C-F3A3-4D5B-AFB8-7C1A806875A6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402286/f1df2145-4f1b-f1ff-7d75-83f115a93451.png)

Terraformで指定する場合はJSON形式で指定します。
[Resource: aws_iam_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) にあるように直接JSONで記述する方法や、リファレンスにはサンプルがありませんが`file`関数で指定する方法もあります。

```terraform
# 直接記述
resource "aws_iam_role" "test_role" {
  name = "test_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      },
    ]
  })
}
```
```terraform
# file関数で読み込み
resource "aws_iam_role" "test_role" {
  name = "test_role"
  assume_role_policy = file("./param/AssumeRole.json")
}
```

今回のコードでは[Data Sources](https://www.terraform.io/docs/language/data-sources/index.html) (以下、データソース)を利用します。
データソースはあるリソースを読み込むために利用されます。
今回のように初めからコンソール画面で選択できるもの（AssumeRole、IAMポリシー、EC2のAMIなど）や、他のTerraformで管理されているリソースを参照する場合に利用します。書き方はResourceブロックと同じ感じですね。

```terraform
data "aws_iam_policy_document" "ec2_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}
```

このデータソースを参照するには
`data.aws_iam_policy_document.ec2_assume_role.json`
という形で記述します。これもResourceブロックを参照する時と同じ形式です。

ぱっとみ末尾の`json`が拡張子のように見えるかもしれませんが（私だけ？）、これは`id`や`arn`を参照する場合と同じで`json`という「属性」を指定しています。

そして、`aws_iam_role_policy_attachment`でIAMロールにポリシーをアタッチしています。
`policy_arn`で指定する「AdministratorAccess」の管理ポリシーもデータソースを使って読み込んだ`arn`を`data.aws_iam_policy.administrator.arn`という形で参照しています。

```terraform
data "aws_iam_policy" "administrator" {
  arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
：
resource "aws_iam_role_policy_attachment" "ec2" {
  role       = aws_iam_role.ec2.name
  policy_arn = data.aws_iam_policy.administrator.arn
}
```

データソースを使わず、以下のように直接「AdministratorAccess」のARNを指定しても構いません。ここでデータソースを使うメリットは正直よくわかっていませんが、おそらく、同じポリシーを複数のIAMロールにアタッチする場合は`policy_arn`にハードコーディングせず、データソースを参照させるほうが保守性が向上するのだと思います。

```terraform
resource "aws_iam_role_policy_attachment" "ec2" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
  # policy_arn = data.aws_iam_policy.administrator.arn
}
```

最後に、インスタンスプロファイルの作成です。

```terraform
resource "aws_iam_instance_profile" "ec2" {
  name = aws_iam_role.ec2.name
  role = aws_iam_role.ec2.name
}
```

インスタンスプロファイルは、コンソール画面からIAMロールの作成を行った場合には自動的に作成されてIAMロールと関連付けられますのであまり意識することはありませんが、IAMロールをEC2にアタッチする時のコネクタの役割として必要になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402286/e115bd07-f49d-c6b2-010c-9c8c49aa825c.png)

# セキュリティグループの作成

続いて、セキュリティグループを作成します。
`security.tf`を作成し、以下のコードを貼り付けます。
ここでは、EC2のセキュリティグループと合わせて、ELB、RDS向けのものも作成しておきます。

```terraform:security.tf
################################
# Security group
################################
# ELB
resource "aws_security_group" "elb_sg" {
  name   = "${var.prefix}-elb-sg"
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.prefix}-elb-sg"
  }
}

resource "aws_security_group_rule" "in_http_from_all" {
  type              = "ingress"
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.elb_sg.id
}

resource "aws_security_group_rule" "in_https_from_all" {
  type              = "ingress"
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.elb_sg.id
}

resource "aws_security_group_rule" "out_all_from_elb" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.elb_sg.id
}

# EC2
resource "aws_security_group" "web_sg" {
  name   = "${var.prefix}-ec2-sg"
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.prefix}-ec2-sg"
  }
}

data "http" "ipify" {
  url = "http://api.ipify.org"
}

locals {
  myip         = chomp(data.http.ipify.body)
  allowed_cidr = (var.allowed_cidr == null) ? "${local.myip}/32" : var.allowed_cidr
}

resource "aws_security_group_rule" "in_ssh_from_myip" {
  type              = "ingress"
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = [local.allowed_cidr]
  security_group_id = aws_security_group.web_sg.id
}

resource "aws_security_group_rule" "in_http_from_myip" {
  type              = "ingress"
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  cidr_blocks       = [local.allowed_cidr]
  security_group_id = aws_security_group.web_sg.id
}

resource "aws_security_group_rule" "in_http_from_elb" {
  type                     = "ingress"
  from_port                = 80
  to_port                  = 80
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.elb_sg.id
  security_group_id        = aws_security_group.web_sg.id
}

resource "aws_security_group_rule" "out_all_from_ec2" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.web_sg.id
}

# RDS
resource "aws_security_group" "rds_sg" {
  name   = "${var.prefix}-rds-sg"
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.prefix}-rds-sg"
  }
}

resource "aws_security_group_rule" "in_mysql_from_ec2" {
  type                     = "ingress"
  from_port                = 3306
  to_port                  = 3306
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.web_sg.id
  security_group_id        = aws_security_group.rds_sg.id
}

resource "aws_security_group_rule" "in_memcached_from_ec2" {
  type                     = "ingress"
  from_port                = 11211
  to_port                  = 11211
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.web_sg.id
  security_group_id        = aws_security_group.rds_sg.id
}

```

コードが縦長ですが、上から順にELB、EC2、RDS向けのセキュリティグループを作成しています。
`aws_security_group`で空のセキュリティグループを作成し、
`aws_security_group_rule`で`ingress` または `egress` のルールを追加しています。

ELB、EC2のアウトバウンドルールはすべて許可しています。RDSはなし。
インバウンドルールは次の通りです。※MemcachedはRDSのプラグイン追加に合わせて許可しています。

| 対象サービス | インバウンドルール                       |
| ---- | :-------------------------------------|
| ELB  | 外部からのHTTP、HTTPSを許可              |
| EC2  | ELBからのHTTP、MyIPからのSSH、HTTPを許可   |
| RDS  | EC2からのMySQL、Memcached接続を許可      |

`aws_security_group_rule`で接続元にIPレンジを指定する場合は`cidr_blocks`を使い、セキュリティグループを指定する場合は`source_security_group_id`を使います。

EC2のインバウンドルールでは、MyIP、つまり自分のPCのグローバルIPアドレスからのSSH、HTTPを許可するようにしています。グローバルIPアドレスを取得するには、IPアドレスを応答してくれるAPIを実行することで実現できます。今回は、ipifyというサービスを利用しています。

TerraformでHTTPのリクエストを送信するには、[HTTP Provider](https://registry.terraform.io/providers/hashicorp/http/latest/docs) のデータソースを使います。
HTTP Providerを利用するために`provider.tf`の末尾に`provider "http" {}`の1行を追加します。

```terraform:provider.tf
:
provider "http" {}
```

`http`のデータソースでipifyのAPIを実行し、その結果からIPアドレスを取り出して`myip`というローカル変数に格納しています。外部から変更されることのないような値は [locals](https://www.terraform.io/docs/language/values/locals.html) ブロックを使ってローカル変数として宣言すると便利です。`local.変数名`の形で参照できます。

許可するグローバルIPアドレスを個別に変数でも指定できるよう、`allowed_cidr`では三項演算子(IF文のようなもの)を使って判定しています。指定されていなければAPI実行結果のMyIPを採用し、変数が指定されていればそのIPを採用します。これは、以下の記事を参考にしています。

[Terraformで自分のパブリックIPを使う方法
](https://dev.classmethod.jp/articles/reference-my-pubic-ip-in-terraform/)

```terraform
data "http" "ipify" {
  url = "http://api.ipify.org"
}

locals {
  myip         = chomp(data.http.ipify.body)
  allowed_cidr = (var.allowed_cidr == null) ? "${local.myip}/32" : var.allowed_cidr
}
```

グローバルIPアドレスを指定するための変数を`variables.tf`に追加します。

```terraform:variables.tf
# EC2
variable "allowed_cidr" {
  default = null
}
```

なお、新しいプロバイダーを追加した場合は`terraform init`が必要になります。HTTP Providerのプラグインが取得されます。

```bash
terraform init

Initializing the backend...

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Finding latest version of hashicorp/http...
- Using previously-installed hashicorp/aws v3.44.0
- Installing hashicorp/http v2.1.0...
- Installed hashicorp/http v2.1.0 (signed by HashiCorp)
:
```

`terraform apply`を実行して、セキュリティグループが3つ作成されていることを確認しましょう。

# EC2の作成

最後に、EC2を2台作成します。
`ec2.tf`を作成し、以下のコードを貼り付けます。

```terraform:ec2.tf
################################
# ENI
################################
resource "aws_network_interface" "web_01" {
  subnet_id       = aws_subnet.public_subnet_1a.id
  private_ips     = ["10.0.11.11"]
  security_groups = [aws_security_group.web_sg.id]

  tags = {
    Name = "${var.prefix}-web-01"
  }
}

resource "aws_network_interface" "web_02" {
  subnet_id       = aws_subnet.public_subnet_1c.id
  private_ips     = ["10.0.12.11"]
  security_groups = [aws_security_group.web_sg.id]

  tags = {
    Name = "${var.prefix}-web-02"
  }
}
```

まず、EC2に接続するネットワークインターフェイスを作成します。
後述の`aws_instance`の中でプライベートIPアドレスやセキュリティグループを指定することもできますが、ENIにNameタグを付ける方法がわからなかったので、先にENIを作成して関連付ける方法をとりました。`aws_ec2_tag`を使えばあとからタグ付けはできるようです。

AZ-1a、1cそれぞれのサブネットIDを指定し、サブネットの範囲内のIPアドレスを`private_ips`に指定しています。なお、プライベートIPアドレスは複数持つことができるので`["10.0.11.11"]`のように角かっこで囲った配列の形式になっています。セキュリティグループはEC2向けのものを指定します。

続いて、EC2インスタンスを作成します。`ec2.tf`に以下のコードを追加します。

```terraform:ec2.tf
################################
# EC2
################################
data "aws_ssm_parameter" "amzn2_latest_ami" {
  name = "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
}

resource "aws_instance" "web_01" {
  ami                     = data.aws_ssm_parameter.amzn2_latest_ami.value
  instance_type           = "t2.micro"
  iam_instance_profile    = aws_iam_instance_profile.ec2.name
  disable_api_termination = false
  monitoring              = false
  user_data               = file("./param/userdata.sh")
  key_name                = "${var.prefix}-key"

  network_interface {
    network_interface_id = aws_network_interface.web_01.id
    device_index         = 0
  }

  root_block_device {
    volume_size           = 8
    volume_type           = "gp2"
    delete_on_termination = true
    encrypted             = false
  }

  ebs_block_device {
    device_name           = "/dev/sdf"
    volume_size           = 10
    volume_type           = "gp2"
    delete_on_termination = true
    encrypted             = false
  }

  tags = {
    Name = "${var.prefix}-web-01"
  }

  volume_tags = {
    Name = "${var.prefix}-web-01"
  }
}

resource "aws_instance" "web_02" {
  ami                     = data.aws_ssm_parameter.amzn2_latest_ami.value
  instance_type           = "t2.micro"
  iam_instance_profile    = aws_iam_instance_profile.ec2.name
  disable_api_termination = false
  monitoring              = false
  user_data               = file("./param/userdata.sh")
  key_name                = "${var.prefix}-key"

  network_interface {
    network_interface_id = aws_network_interface.web_02.id
    device_index         = 0
  }

  root_block_device {
    volume_size           = 8
    volume_type           = "gp2"
    delete_on_termination = true
    encrypted             = false
  }

  ebs_block_device {
    device_name           = "/dev/sdf"
    volume_size           = 10
    volume_type           = "gp2"
    delete_on_termination = true
    encrypted             = false
  }

  tags = {
    Name = "${var.prefix}-web-02"
  }

  volume_tags = {
    Name = "${var.prefix}-web-02"
  }
}

output "web01_public_ip" {
  description = "The public IP address assigned to the instanceue"
  value       = aws_instance.web_01.public_ip
}

output "web02_public_ip" {
  description = "The public IP address assigned to the instanceue"
  value       = aws_instance.web_02.public_ip
}

```

このあたりでそろそろ「コードが冗長なんでループで回す方法ないの？」と思われるかもしれませんが、その方法はまた別途まとめたいと思います。。

データソースの`aws_ssm_parameter`ではAmazonLinux2の最新のAIを取得しています。[AMI パブリックパラメータの呼び出し](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/parameter-store-public-parameters-ami.html) にあるように、AWSのパブリックパラメータを利用するとAmazonLinuxやWindowsServerの最新AMIを取得することができます。

```terraform
data "aws_ssm_parameter" "amzn2_latest_ami" {
  name = "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
}
```

`aws_instance`でEC2インスタンスを作成できますが、この時、予め作成しているキーペア、IAMのインスタンスプロファイル、ネットワークインターフェイスを指定します。

個人的には、EBSボリュームにタグを付与できる`volume_tags`が使える点は驚きました。ENIにも付与できたらいいのに。。

`user_data`ではEC2起動時に実行するシェルスクリプトを`file`で指定しています。paramフォルダの中に`userdata.sh`を作成します。

```bash
mkdir param/
touch param/userdata.sh
```

作成した`userdata.sh`に以下のコードを貼り付けます。

```bash:userdata.sh
#!/bin/bash

# Variables
AWS_AVAIL_ZONE=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone) && echo $AWS_AVAIL_ZONE
AWS_REGION=$(echo "$AWS_AVAIL_ZONE" | sed 's/[a-z]$//') && echo $AWS_REGION
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id) && echo $INSTANCE_ID
EC2_NAME=$(aws ec2 describe-instances --region $AWS_REGION --instance-id $INSTANCE_ID \
  --query 'Reservations[*].Instances[*].Tags[?Key==`Name`].Value' --output text) && echo $EC2_NAME

# Install nginx if not installed
nginx -v
if [ "$?" -ne 0 ]; then
  sudo amazon-linux-extras install -y nginx1
  sudo systemctl enable nginx
  sudo systemctl start nginx
fi

# Install mysql client if not installed
mysql --version
if [ "$?" -ne 0 ]; then
  # delete mariadb
  yum list installed | grep mariadb
  sudo yum remove mariadb-libs -y

  # add repo
  sudo yum install -y https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm

  # disable mysql5.7 repo and enable mysql8.0 repo
  sudo yum-config-manager --disable mysql57-community
  sudo yum-config-manager --enable mysql80-community

  # install mysql client
  sudo yum install -y mysql-community-client
fi

# Create index.html
echo "<h1>${EC2_NAME}</h1>" > index.html
sudo mv ./index.html  /usr/share/nginx/html/
```

`userdata.sh`では以下の処理を順番に行っています。

- 変数の宣言
- nginxのインストール
- MySQLクライアントのインストール
- index.htmlの作成

変数の宣言では、EC2のメタデータから自身のAZ(リージョン)、インスタンスIDを取得して、AWSCLIの`describe-instances`コマンドで自身のNameタグの値を`EC2_NAME`の変数に格納しています。

取得した`EC2_NAME`はindex.htmlのH1タグに書き込んでいるので、ブラウザでHTTPアクセスした際に表示されるHTMLから、接続しているEC2の名前が判別できるようになります。

nginxのインストールは、`amazon-linux-extras install`で行い、自動起動を有効にしてサービスを開始しています。MySQLクライアントのインストールは、初めから入っているmariadbを削除してからインストールしています。以下の記事などを参考にさせて頂きました。

[【AWS EC2】Amazon Linux2にMySQLのclientだけをインストールしてRDSに接続する方法](https://qiita.com/tamorieeeen/items/d9b2af588f1dfd43120d)

`ec2.tf`の最後に登場する [output](https://www.terraform.io/docs/language/values/outputs.html) ブロックは、出力結果を表示させるためのものです。`output`に続けて出力の識別名を与えて、波括弧の中に`description`や`value`を書きます。ここでは、2台のEC2のパブリックIPアドレスを表示させるようにしています。`value`にはResourceブロックを参照する場合と同じように`<リソースタイプ>.<リソースの識別名>.<属性>`という形で出力したい属性を記述します。

```terraform:ec2.tf
output "web01_public_ip" {
  description = "The public IP address assigned to the instanceue"
  value       = aws_instance.web_01.public_ip
}

output "web02_public_ip" {
  description = "The public IP address assigned to the instanceue"
  value       = aws_instance.web_02.public_ip
}
```

`terraform apply`を実行して、EC2が2台作成されていることを確認しましょう。
また、コマンドの最後に`output`で指定した属性が以下のように出力されるはずです。

```bash
Outputs:

web01_public_ip = "54.250.9.170"
web02_public_ip = "18.183.88.247"
```

Outputsの内容は`terraform.tfstate`にも書き込まれます。

```json:terraform.tfstate
"outputs": {
    "web01_public_ip": {
      "value": "54.250.9.170",
      "type": "string"
    },
    "web02_public_ip": {
      "value": "18.183.88.247",
      "type": "string"
    }
  },
```

[terraform output](https://www.terraform.io/docs/cli/commands/output.html) を実行すると、あとからでもOutputsの内容を確認できます。識別名を指定すれば絞り込むこともできます。

```bash
# Outputsの内容をすべて表示
terraform output
web01_public_ip = "54.250.9.170"
web02_public_ip = "18.183.88.247"

# 識別名を指定して表示
terraform output web01_public_ip
"54.250.9.170"
```

ブラウザでEC2に接続すると、Nameタグの値が表示されるはずです。
userdataによりnginxのインストール、index.htmlの作成が行われていて、セキュリティグループによりMyIPからのHTTP接続が許可されていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402286/0b38c5ca-ecfb-6b7d-90b4-a5286916291d.png)

今回は以上です。次回はRoute53 + ACM編ということで、独自ドメインでHTTPS接続するための準備について書いてみたいと思います！

# 参考リンク
- [Data Sources](https://www.terraform.io/docs/language/data-sources/index.html)
- [HTTP Provider](https://registry.terraform.io/providers/hashicorp/http/latest/docs)
- [Local Values](https://www.terraform.io/docs/language/values/locals.html)
- [Terraformで自分のパブリックIPを使う方法](https://dev.classmethod.jp/articles/reference-my-pubic-ip-in-terraform/)
- [AMI パブリックパラメータの呼び出し](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/parameter-store-public-parameters-ami.html)
- [【AWS EC2】Amazon Linux2にMySQLのclientだけをインストールしてRDSに接続する方法](https://qiita.com/tamorieeeen/items/d9b2af588f1dfd43120d)
- [Output Values](https://www.terraform.io/docs/language/values/outputs.html)
- [Command: output](https://www.terraform.io/docs/cli/commands/output.html)