作りながら覚えるTerraform入門シリーズの第4回です。
今回はEC2関連のリソースを作成してみましょう。EC2にはnginxをインストールして、ブラウザでHTTP接続できるところまでを確認します。
作りながら覚えるTerraform入門シリーズ
- インストールと初期設定
- 基本編
- VPC編
- EC2編 => 今回はコチラ
- Route53 + ACM編
- ELB編
- RDS編
今回の学習ポイントは以下です。
- データソースの使い方
- httpプロバイダーの使い方
- ローカル変数の使い方
- outputの使い方
キーペアの作成
Terraformには現時点ではキーペアを新規作成する方法はないようです。
aws_key_pairというResourceは存在しますが、既存のキー(公開鍵)をインポートするためのものなので、
- ssh-keygenでキーペアを作成
- 
aws_key_pairで公開鍵をインポート
という流れになるようです。
今回はコンソール画面から作成します。
IAMロールの作成
EC2に割り当てるIAMロールを作成します。
アタッチするポリシーは管理ポリシーのAdministratorAccess を使います。
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なのですが、
Terraformで指定する場合はJSON形式で指定します。
Resource: aws_iam_role にあるように直接JSONで記述する方法や、リファレンスにはサンプルがありませんがfile関数で指定する方法もあります。
# 直接記述
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"
        }
      },
    ]
  })
}
# file関数で読み込み
resource "aws_iam_role" "test_role" {
  name = "test_role"
  assume_role_policy = file("./param/AssumeRole.json")
}
今回のコードではData Sources (以下、データソース)を利用します。
データソースはあるリソースを読み込むために利用されます。
今回のように初めからコンソール画面で選択できるもの(AssumeRole、IAMポリシー、EC2のAMIなど)や、他のTerraformで管理されているリソースを参照する場合に利用します。書き方はResourceブロックと同じ感じですね。
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という形で参照しています。
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にハードコーディングせず、データソースを参照させるほうが保守性が向上するのだと思います。
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
}
最後に、インスタンスプロファイルの作成です。
resource "aws_iam_instance_profile" "ec2" {
  name = aws_iam_role.ec2.name
  role = aws_iam_role.ec2.name
}
インスタンスプロファイルは、コンソール画面からIAMロールの作成を行った場合には自動的に作成されてIAMロールと関連付けられますのであまり意識することはありませんが、IAMロールをEC2にアタッチする時のコネクタの役割として必要になります。
セキュリティグループの作成
続いて、セキュリティグループを作成します。
security.tfを作成し、以下のコードを貼り付けます。
ここでは、EC2のセキュリティグループと合わせて、ELB、RDS向けのものも作成しておきます。
################################
# 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 のデータソースを使います。
HTTP Providerを利用するためにprovider.tfの末尾にprovider "http" {}の1行を追加します。
:
provider "http" {}
httpのデータソースでipifyのAPIを実行し、その結果からIPアドレスを取り出してmyipというローカル変数に格納しています。外部から変更されることのないような値は locals ブロックを使ってローカル変数として宣言すると便利です。local.変数名の形で参照できます。
許可するグローバルIPアドレスを個別に変数でも指定できるよう、allowed_cidrでは三項演算子(IF文のようなもの)を使って判定しています。指定されていなければAPI実行結果のMyIPを採用し、変数が指定されていればそのIPを採用します。これは、以下の記事を参考にしています。
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に追加します。
# EC2
variable "allowed_cidr" {
  default = null
}
なお、新しいプロバイダーを追加した場合はterraform initが必要になります。HTTP Providerのプラグインが取得されます。
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を作成し、以下のコードを貼り付けます。
################################
# 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に以下のコードを追加します。
################################
# 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 パブリックパラメータの呼び出し にあるように、AWSのパブリックパラメータを利用するとAmazonLinuxやWindowsServerの最新AMIを取得することができます。
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を作成します。
mkdir param/
touch param/userdata.sh
作成した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に接続する方法
ec2.tfの最後に登場する output ブロックは、出力結果を表示させるためのものです。outputに続けて出力の識別名を与えて、波括弧の中にdescriptionやvalueを書きます。ここでは、2台のEC2のパブリックIPアドレスを表示させるようにしています。valueにはResourceブロックを参照する場合と同じように<リソースタイプ>.<リソースの識別名>.<属性>という形で出力したい属性を記述します。
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で指定した属性が以下のように出力されるはずです。
Outputs:
web01_public_ip = "54.250.9.170"
web02_public_ip = "18.183.88.247"
Outputsの内容は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 を実行すると、あとからでもOutputsの内容を確認できます。識別名を指定すれば絞り込むこともできます。
# 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接続が許可されていることがわかります。
今回は以上です。次回はRoute53 + ACM編ということで、独自ドメインでHTTPS接続するための準備について書いてみたいと思います!



