[AWS] Terraform で EFS 作って、EC2 起動時にマウントさせておく

More than 1 year has passed since last update.

Terraform を使うと、EFS を作成して EC2 にマウントさせておくなんてことが簡単にできます。

Autoscaling 環境で Web ドキュメントルートを共有したい時とかに便利なんで、みんな使えばいいと思うよ。

なお、この記事の想定読者は AWS はダッシュボードからポチポチしてインスタンス立てたりしてるけど、そろそろインフラをコードで管理したいな。Terraform とか便利そうだねー使ってみたいねーって人です。

てわけで、単純に EC2 立ち上げても面白くないので EFS をマウントさせてみました。

そもそも、Terraform ってなんだ?って人は、以下のページとか参考になると思います。

Terraform簡易チュートリアル on AWS


実際の設定

Terraform は、特定のディレクトリ下にある拡張子が .tf なファイルを全部読み込んでいい感じにリソースを起動してくれます。なので、機能別に .tf 作成していってみましょう。


メイン設定

まず、メインの設定を作成。

プロバイダーとか、設定ファイル内で使用する変数とか設定していってみましょうか。


main.tf

# 今回のプロジェクト名

variable "project" {}
variable "domain" {}

# AWS リソースを起動するリージョンとかの情報
variable "region" { default = "us-west-2" }
variable "azs" {
default {
"a" = "us-west-2a"
"b" = "us-west-2b"
"c" = "us-west-2c"
}
}

# AMI ID (Amazon Linux)
variable "ami" {
default {
"us-west-2" = "ami-8ca83fec"
}
}

# EC2 接続用の SSH 鍵の公開鍵
variable "ssh_public_key" {}

provider "aws" {
region = "${var.region}"
}


variable で設定した値は tf ファイル内で ${var.region} のようにして参照可能です。

また、terraform の各種コマンドを実行する際に以下のようにパラメータとして変数を渡して上書きすることもできます。

$ terraform plan \

-var 'project=example' \
-var 'domain=example.com'

同じディレクトリ内に terraform.tfvars というファイルがあれば、それを読み込んで値が上書きされたりします。この辺の詳細は以下を参照してください。

Input Variables - Terraform by HashiCorp

provider "aws" は、aws を使いますよって宣言です。

以下のように アクセスキーを書いておくこともできますが、それやるとうっかり github とかに公開した時とかに切ない目にあうのでやめたほうが吉でしょう。

provider "aws" {

access_key = "__ACCESS_KEY__"
secret_key = "__SECRET_KEY__"
region = "us-west-2"
}

環境変数 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY を読み込んでいい感じでやってくれるので、僕は direnv 使って作業ディレクトリ内で環境変数を変更することで対応してます。

(もちろん、この場合でも .gitignore.envrc を含めておいて間違って公開しないようにしないと切ない目にあうので注意)


VPC の作成

こんな感じの .tf ファイルで VPC と subnet が作成できます。


vpc.tf

## VPC

resource "aws_vpc" "app" {
cidr_block = "172.31.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
instance_tenancy = "default"

tags {
"Name" = "${var.project}"
}
}

## Subnet
resource "aws_subnet" "a" {
vpc_id = "${aws_vpc.app.id}"
cidr_block = "172.31.0.0/20"
availability_zone = "${lookup(var.azs,"a")}"
map_public_ip_on_launch = true

tags {
"Name" = "${var.project}-subnet-a"
}
}

resource "aws_subnet" "b" {
vpc_id = "${aws_vpc.app.id}"
cidr_block = "172.31.16.0/20"
availability_zone = "${lookup(var.azs,"b")}"
map_public_ip_on_launch = true

tags {
"Name" = "${var.project}-subnet-b"
}
}

resource "aws_subnet" "c" {
vpc_id = "${aws_vpc.app.id}"
cidr_block = "172.31.32.0/20"
availability_zone = "${lookup(var.azs,"c")}"
map_public_ip_on_launch = true

tags {
"Name" = "${var.project}-subnet-c"
}
}


resource "aws_subnet" の中に ${aws_vpc.app.id} ってのが出てきましたね。

Terraform の中では、管理下にあるリソースの情報を他のリソースの設定でも参照することが可能です。

各リソースで使用できる値が異なってくるので、その辺は公式ドキュメント読みましょう。

例えば aws_vpc で使用できる値は aws_vpc を参照すればわかります。

また、${lookup(var.azs,"a")} ってのも出てきましたね。

これは Terraform の組み込み関数です、lookup は配列の中からキーをもとに値を探す関数です。

詳しくは Built-in Functions を読んでください。

ついでに Internet Gateway と Route Table も設定しておきましょう。


route-table.tf

## Internet Gateway

resource "aws_internet_gateway" "igw" {
vpc_id = "${aws_vpc.app.id}"
}

## Route Table
resource "aws_route_table" "rtb" {
vpc_id = "${aws_vpc.app.id}"
route {
cidr_block = "0.0.0.0/0"
gateway_id = "${aws_internet_gateway.igw.id}"
}
}

resource "aws_route_table_association" "route_a" {
subnet_id = "${aws_subnet.a.id}"
route_table_id = "${aws_route_table.rtb.id}"
}

resource "aws_route_table_association" "route_b" {
subnet_id = "${aws_subnet.b.id}"
route_table_id = "${aws_route_table.rtb.id}"
}

resource "aws_route_table_association" "route_c" {
subnet_id = "${aws_subnet.c.id}"
route_table_id = "${aws_route_table.rtb.id}"
}



IAM ロールの作成

次に EC2 に割り当てるための IAM ロールを作ってみましょう。

ポリシーは、AWS が用意している AmazonEC2RoleforDataPipelineRole と、EC2 から CloudwatchLogs にログを送信するためのカスタムポリシーを作ってアタッチしてみます。


iam-role.tf

## For EC2 instance Role

resource "aws_iam_role" "instance_role" {
name = "instance_role"
path = "/"
assume_role_policy = <<POLICY
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
POLICY
}

## AmazonEC2RoleforDataPipelineRole
resource "aws_iam_role_policy_attachment" "data-pipeline" {
role = "${aws_iam_role.instance_role.name}"
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforDataPipelineRole"
}

## PutCloudwatchLogs
resource "aws_iam_policy" "put-cloudwatch-logs" {
name = "AmazonEC2PutCloudwatchLogs"
description = ""
policy = <<POLICY
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Effect": "Allow",
"Resource": "*"
}
]
}
POLICY
}

resource "aws_iam_role_policy_attachment" "put-cloudwatch-logs" {
role = "${aws_iam_role.instance_role.name}"
policy_arn = "${aws_iam_policy.put-cloudwatch-logs.arn}"
}


aws_iam_roleassume_role_policy のところと、aws_iam_policypolicy のところでヒアドキュメントが出てきましたね。

こんな風に複数行にわたるインラインポリシーはヒアドキュメントで記述することが可能です。

また、以下のように別ファイルにしておいて読み込ませることも可能です。

管理しやすい方でやってください。


iam-role.tf

resource "aws_iam_role" "instance_role" {

name = "instance_role"
path = "/"
assume_role_policy = "${file("data/instance_role_assume_policy.json")}"
}


data/instance_role_assume_policy.json

{

"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}


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

EC2 から EFS へのアクセスは 2049 番ポートを介して行われるので、EFS が所属するセキュリティグループに穴を開けないといけません。

EC2 は 80, 443, 22 を解放してみます。


security-group.tf

## For EC2

resource "aws_security_group" "ec2" {
name = "${var.project}-EC2"
description = "for ${var.project} EC2"
vpc_id = "${aws_vpc.app.id}"

ingress = [
{
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
]

egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}

## For EFS
resource "aws_security_group" "efs" {
name = "${var.project}-EFS"
description = "for ${var.project} EFS"
vpc_id = "${aws_vpc.app.id}"

ingress {
from_port = 2049
to_port = 2049
protocol = "tcp"
security_groups = ["${aws_security_group.ec2.id}"]
}

egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}



EFS の作成

こんな感じで EFS が作成できます。

各サブネットごとにマウントターゲットを作成して、そいつをセキュリティグループに所属させる形ですね。


efs.tf

resource "aws_efs_file_system" "app" {

tags {
"Name" = "${var.domain}"
}
}

resource "aws_efs_mount_target" "app-a" {
file_system_id = "${aws_efs_file_system.app.id}"
subnet_id = "${aws_subnet.a.id}"
security_groups = ["${aws_security_group.efs.id}"]
}

resource "aws_efs_mount_target" "app-b" {
file_system_id = "${aws_efs_file_system.app.id}"
subnet_id = "${aws_subnet.b.id}"
security_groups = ["${aws_security_group.efs.id}"]
}

resource "aws_efs_mount_target" "app-c" {
file_system_id = "${aws_efs_file_system.app.id}"
subnet_id = "${aws_subnet.c.id}"
security_groups = ["${aws_security_group.efs.id}"]
}



EC2 の作成

さて、いよいよ EC2 です。

ここでは、user-data を使って、初回ローンチ時に EFS をマウントさせてしまいます。

さらにマウントした EFS 内に html/ ってディレクトリを作成して、そいつを /var/www/html にシンボリックリンクしてみましょうか。

と言っても、こんな感じで大丈夫です。


ec2.tf

## IAM Instance Profile

resource "aws_iam_instance_profile" "instance_role" {
name = "instance_role"
role = "${aws_iam_role.instance_role.name}"
}

## SSH Key
resource "aws_key_pair" "deployer" {
key_name = "${var.project}"
public_key = "${var.ssh_public_key}"
}

## EC2
resource "aws_instance" "app" {
ami = "${lookup(var.ami,var.region)}"
availability_zone = "${aws_subnet.a.availability_zone}"
ebs_optimized = false
instance_type = "t2.micro"
monitoring = true
key_name = "${aws_key_pair.deployer.key_name}"
subnet_id = "${aws_subnet.a.id}"
vpc_security_group_ids = ["${aws_security_group.ec2.id}"]
associate_public_ip_address = true
source_dest_check = true
iam_instance_profile = "${aws_iam_instance_profile.instance_role.id}"
disable_api_termination = false

user_data = <<USERDATA
#!/bin/bash
az="${aws_subnet.a.availability_zone}"
efs_region="${var.region}"
efs_id="${aws_efs_file_system.app.id}"
efs_mount_target="${aws_efs_mount_target.app-a.dns_name}:/"
efs_mount_point="/mnt/efs/$${efs_id}/$${az}"
web_doc_root="/var/www/html"

# EFS Mount
/usr/bin/yum -y install nfs-utils || /usr/bin/yum -y update nfs-utils
if [ ! -d $${efs_mount_point} ]; then
mkdir -p $${efs_mount_point}
fi
cp -pi /etc/fstab /etc/fstab.$(date "+%Y%m%d")
echo "$${efs_mount_target} $${efs_mount_point} nfs4 defaults" | tee -a /etc/fstab
mount $${efs_mount_point}

# create Web document root
if [ -d $${web_doc_root} ]; then
rm -rf $${web_doc_root}
fi
if [ ! -d $${efs_mount_point}/html ]; then
mkdir $${efs_mount_point}/html
chown ec2-user:ec2-user $${efs_mount_point}/html
fi
ln -s $${efs_mount_point}/html $${web_doc_root}
chown -h ec2-user:ec2-user $${web_doc_root}
USERDATA

root_block_device {
volume_type = "gp2"
volume_size = 10
delete_on_termination = true
}

tags {
"Name" = "${var.domain}"
}
}


user_data は長めのシェルスクリプトなので、可読性が悪いから ${file("data/user_data.sh")} とかってやって別ファイルで管理したいですよね。

でも待ってください、ヒアドキュメントでやってるのは理由があるのです。

ヒアドキュメントで書くと、user_data 用のシェルスクリプトの中で Terraform の変数が使えます。

マウントするには EFS の ID とか、マウントターゲットの dns_name とか必要になってきますが、それを作成前に知らなくてもこのように書いておけるのです。便利ですね。

その代わり、user_data 用のシェルスクリプト内でローカルな環境変数を使いたい場合は $${efs_mount_point} のように書いてあげてくださいね。

ざっと、こんな感じです。

慣れちゃえば、tf ファイルを使い回しできるので便利ですよ。

また、すでに作成済みの AWS リソースを Terraform 管理下に置きたい場合は

$ terraform import aws_instance.app ${instance_id}

のようにして管理下に置くことができます。

管理されているリソースは terraform.tfstate というファイルに書き込まれます。

さらに別プロダクトになるのですが Terraforming と言うツールを使用すると、既存の AWS リソースから Terraform 用の tf ファイルを作成したり、terraform.tfstate を作成したりもできるので便利です。

Terraforming については、Terraforming で既存のインフラを Terraform 管理下におく を参考にしてください。


実際にリソース作成してみる

tf ファイル書いたら

$ terraform plan

で、設定ファイルに誤りがないか?既存のリソースへの影響はどの程度あるのかが確認できます。

実際に反映させたい時は

$ terraform apply

で、おっけ。

では、良い Terraform を!