LoginSignup
1
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-06-08

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

作りながら覚えるTerraform入門シリーズ
  1. インストールと初期設定
  2. 基本編
  3. VPC編
  4. EC2編 => 今回はコチラ
  5. Route53 + ACM編
  6. ELB編
  7. RDS編

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

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

キーペアの作成

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

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

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

A4760948-E8D1-482A-AFD2-F6F3A61A47ED.png

IAMロールの作成

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

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

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が拡張子のように見えるかもしれませんが(私だけ?)、これはidarnを参照する場合と同じでjsonという「属性」を指定しています。

そして、aws_iam_role_policy_attachmentでIAMロールにポリシーをアタッチしています。
policy_arnで指定する「AdministratorAccess」の管理ポリシーもデータソースを使って読み込んだarndata.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にアタッチする時のコネクタの役割として必要になります。

image.png

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

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

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_ruleingress または 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.tf
:
provider "http" {}

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

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

Terraformで自分のパブリック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に追加します。

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を作成し、以下のコードを貼り付けます。

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.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に以下のコードを貼り付けます。

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に続けて出力の識別名を与えて、波括弧の中にdescriptionvalueを書きます。ここでは、2台のEC2のパブリックIPアドレスを表示させるようにしています。valueにはResourceブロックを参照する場合と同じように<リソースタイプ>.<リソースの識別名>.<属性>という形で出力したい属性を記述します。

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で指定した属性が以下のように出力されるはずです。

Outputs:

web01_public_ip = "54.250.9.170"
web02_public_ip = "18.183.88.247"

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

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接続が許可されていることがわかります。

image.png

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

参考リンク

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1