作りながら覚える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接続するための準備について書いてみたいと思います!