はじめに
TerraformはIaC(Infrastructure as Code)ツールの一つです。
私自身、AWSを勉強する傍ら、AWSへの理解を深めるため、Terraformについても学んでいます。
今回はELBを用いたベーシックな構成について、Terraformで構築してみたので記事として投稿しました。今後似たような感じで投稿していこうと思います。
アーキテクチャー図
非常にシンプルな構成です。
以下のように要件を設定してます。
- Route53に登録したドメインでアクセスできる
- 一つのVPCにパブリックサブネットとプライベートサブネットが複数存在
- AZを跨いだ構成にしておく
- サーバーインスタンスはプライベートサブネット内に配置する
- インスタンスはALBを通してしかアクセスできないようにする
- ALBはHTTPSとHTTP通信の両方を受け付けるが、HTTP通信でアクセスされた場合にはHTTPSにリダイレクトする
済ませておいてほしいこと(説明しないこと)
- terraformの初期セットアップ
- 認証周りの設定を済ませている(AWS CLIが使えれば多分大丈夫)
- webサーバ用のamiを登録
- SSL/TLS証明書の準備
- ドメインの準備
- 色々あります
コードの概要
ディレクトリ構成
関係するリソース毎に分けてtfファイルを配置しています。
全般的なインフラ構成の記述はenv>devは以下のmain.tfで、
各種リソースについてはmodulesの中で記述しています。
test
├─env
│ └─dev
│ ├─ main.tf
│ ├─ outputs.tf
│ ├─ provider.tf
│ └─ variables.tf
│
└─modules
├─alb
│ ├─ main.tf
│ ├─ outputs.tf
│ └─ variables.tf
│
├─ec2_server
│ ├─ main.tf
│ ├─ outputs.tf
│ └─ variables.tf
│
└─network
├─ main.tf
├─ outputs.tf
└─ variables.tf
ルートプロジェクト
ポイントをいくつかまとめると、こんな感じになります。
- 設定の詳細はmodules内のブロックに記述して、main.tfはあくまで司令塔の役割に留めること
- amiのid等、固有のものについてはmain.tf内に記述すること(variables.tfの中に書いてもよかった気もしますが、記述が2ファイルに散らかるのが嫌だったのでmain.tfに書きました)
- 必要最小限の変数しか記述せず、変数の加工などはmodule内のブロックに任せる。
コードの詳細は以下のとおりです。
- provider.tf
provider "aws" {
shared_credentials_files = ["~/.aws/credentials"]
profile = "default"
region = "ap-northeast-1"
}
- main.tf
module "network" {
source = "../../modules/network"
vpc-cidr = "10.1.0.0/16"
vpc-name = "elb-terr"
env-name = "dev"
private-subnets = ["private-1a","private-1c","private-1d"]
public-subnets = ["public-1a","public-1c","public-1d"]
az-list = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
}
module "ec2_server" {
source = "../../modules/ec2_server"
ec2-count = 3
ami-id = "ami-XXXXXXXXXX"
instance-type = "t2.nano"
key-name = "XXXXX"
# networkのoutputsを読み取って代入
private-subnet-ids = module.network.private-subnet-ids
vpc-id = module.network.vpc-id
alb-sg-id = module.alb.alb-sg-id
}
module "alb" {
source = "../../modules/alb"
public-subnet-ids = module.network.public-subnet-ids
private-subnet-ids = module.network.private-subnet-ids
vpc-id = module.network.vpc-id
server-instance-ids = module.ec2_server.server-ids-map
certificate-arn = "arn:aws:acm:ap-northeast-1:XXXXXXXXXXXX:certificate/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
myzoneid = "ZXXXXXXXXXXXXXXXXXXX"
mydomain = "www.hoge.com"
}
- outputs.tf
# 特になし
- variables.tf
# 特になし
ネットワーク関係
- variables.tf
rootのmain.tfから受け取るものをここで指定しています。
variable "vpc-cidr" {}
variable "vpc-name" {}
variable "env-name" {}
variable "private-subnets" {}
variable "public-subnets" {}
variable "az-list" {}
- main.tf
VPCやサブネット等の構成を記述しています。
長ったらしいですが、特に中身はありません。
しいて言えば、あった方が助かるような変数をlocal変数として定義しています。
また、サブネットの記述については、Taishi Oikawa氏の記事を参考にしております。
resource "aws_vpc" "main" {
cidr_block = var.vpc-cidr
tags = {
Name = var.vpc-name
}
}
resource "aws_subnet" "public" {
for_each = local.subnets.public-subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = {
Name = "${var.env-name}-${each.value.name}"
}
}
resource "aws_subnet" "private" {
for_each = local.subnets.private-subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = {
Name = "${var.env-name}-${each.value.name}"
}
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.env-name}-igw"
}
}
resource "aws_route_table" "rtb-public" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.env-name}-rtb-public"
}
}
resource "aws_route_table_association" "rtb-assoc-pub" {
for_each = aws_subnet.public
route_table_id = aws_route_table.rtb-public.id
subnet_id = each.value.id
}
resource "aws_route" "route-igw" {
route_table_id = aws_route_table.rtb-public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
resource "aws_eip" "nat-gateway-ip-address" {
domain = "vpc"
}
resource "aws_nat_gateway" "example-nat-gateway" {
allocation_id = aws_eip.nat-gateway-ip-address.id
subnet_id = local.public-subnet-ids[0]
tags = {
Name = "gw-NAT-1st"
}
}
resource "aws_route_table" "rtb-private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.env-name}-rtb-private"
}
}
resource "aws_route_table_association" "rtb_assoc_prv" {
for_each = aws_subnet.private
route_table_id = aws_route_table.rtb-private.id
subnet_id = each.value.id
}
resource "aws_route" "route-natgw" {
route_table_id = aws_route_table.rtb-private.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_nat_gateway.example-nat-gateway.id
}
# https://qiita.com/TaishiOikawa/items/8b9db3df76102096d159
locals {
subnets = {
private-subnets = {
for key in var.private-subnets :
key => {
name = key,
cidr = cidrsubnet(var.vpc-cidr, 8, index(var.private-subnets, key))
az = var.az-list[index(var.private-subnets, key) % length(var.az-list)]
}
},
public-subnets = {
for key in var.public-subnets :
key => {
name = key,
cidr = cidrsubnet(var.vpc-cidr, 8, index(var.public-subnets, key) + length(var.private-subnets))
az = var.az-list[index(var.public-subnets, key) % length(var.az-list)]
}
}
}
public-subnet-ids = [for s in aws_subnet.public : s.id]
private-subnet-ids = [for s in aws_subnet.private : s.id]
}
- outputs.tf
他のモジュールで必要になりそうなものを、ピックアップして定義しています。
output "private-subnet-ids" {
value = local.private-subnet-ids
}
output "vpc-id" {
value = aws_vpc.main.id
}
output "public-subnet-1st-id" {
value = local.public-subnet-ids[0]
}
output "public-subnet-ids" {
value = local.public-subnet-ids
}
webサーバー用インスタンス
こちらについても同様に、
サーバーを設定するにあたって必要な変数をvariables.tfに、
ほかのモジュールが必要とする変数をoutputs.tfに書いていきます。
- variables.tf
variable "ec2-count" {}
variable "ami-id" {}
variable "instance-type" {}
variable "key-name" {}
variable "private-subnet-ids" {}
variable "vpc-id" {}
variable "alb-sg-id" {}
- main.tf
正直セキュリティグループの記述の方が長いです。もっといい書き方があるような気がする。
# define instance configure
# prerequired:
# 1. you have to prepare your original ami of web server
# 2. you have to prepare the key pair in case access other instance like bastion
resource "aws_instance" "web" {
count = var.ec2-count
ami = var.ami-id
instance_type = var.instance-type
key_name = var.key-name
vpc_security_group_ids = [
"${aws_security_group.MyWebServerGroup-terr.id}"
]
tags = {
Name = "${format("web%02d", count.index + 1)}"
}
subnet_id = "${var.private-subnet-ids[(count.index % length(var.private-subnet-ids))]}"
}
# define security group
resource "aws_security_group" "MyWebServerGroup-terr" {
name = "MyWebServerGroup-terr"
description = "MyWebServerGroup-terr"
vpc_id = var.vpc-id
ingress {
description = "http from VPC"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = ["${var.alb-sg-id}"]
}
ingress {
description = "TLS from anywhere"
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = ["${var.alb-sg-id}"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = "allow_tls_http"
}
}
locals {
server-ids-map = {
for instance in aws_instance.web :
instance.tags.Name => instance.id
}
# server-ids-map-deprecated = {
# "id0" = aws_instance.web[0].id
# "id1" = aws_instance.web[1].id
# "id2" = aws_instance.web[2].id
# }
}
- outputs.tf
output server-ids-map{
value = local.server-ids-map
}
ロードバランサー
ロードバランサーについても同様です。
- variables.tf
variable "public-subnet-ids" {}
variable "private-subnet-ids" {}
variable "vpc-id" {}
variable "server-instance-ids" {}
variable "certificate-arn" {}
variable "myzoneid" {}
variable "mydomain" {}
- main.tf
特記事項として、ターゲットグループへの登録については、インスタンスの数ごとに繰り返し呼ばざるをえないためfor_eachを使っていることと、
Route53へのCNAMEレコードの追加もこちらでやっていることが挙げられます。
新たにRoute53みたいなmoduleを作っても良かった気がしますが、そのためだけに分割するのもアレだったので今回はalb側の記述に統合してしまいました。
resource "aws_lb" "alb" {
name = "sample-alb-terr"
security_groups = ["${aws_security_group.MyALB-terr.id}"]
subnets = [for id in var.public-subnet-ids : id]
internal = false
enable_deletion_protection = false
}
resource "aws_lb_target_group" "alb" {
name = "alb-tg-terr"
port = 80
protocol = "HTTP"
vpc_id = var.vpc-id
}
# a target group attachment is called per instance
resource "aws_lb_target_group_attachment" "alb" {
for_each = var.server-instance-ids
target_group_arn = aws_lb_target_group.alb.arn
target_id = each.value
port = 80
}
# HTTP To HTTPS
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.alb.arn
port = "80"
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
# HTTPS
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.alb.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08"
certificate_arn = var.certificate-arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.alb.arn
}
}
resource "aws_route53_record" "www" {
zone_id = var.myzoneid
name = var.mydomain
type = "CNAME"
ttl = 300
records = [aws_lb.alb.dns_name]
}
resource "aws_security_group" "MyALB-terr" {
name = "MyALB-terr"
description = "MyALB-terr"
vpc_id = var.vpc-id
ingress {
description = "http from VPC"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
ingress {
description = "TLS from anywhere"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = "allow_http_https"
}
}
実行方法
- 構築したいときは以下のコマンドを打ってください。
$ terraform apply
※ terraform initをしていなければ忘れずに実行しておくこと
- 構築したインフラを消したいときには、以下のコマンドを打ってください
$ terraform destroy
気を付けたことやその他メモ
- Terraformに限ったことではないですが、命名規則とかがよくわからなくなる。
- たまにlistを渡すと(特にid系)、"まだidがわからないから処理できないよ~"みたいなエラーが出てくるので、一部mapを使ってごり押ししているところがあります。
- 設計上はインスタンスを大量に立てられるようにしていますが、それによる責任については負いかねます。用が済んだら都度消してください。
まとめ
IaC化できるころには、そのサービスについて代替熟知したも同然だと思って、楽しく書いています。
また、一度IaC化してしまえば、消したいときに消す、環境を作りたいときにコマンド一つで復活できる、みたいなことができるのは非常に便利なのではないかと思います。
また、Terraformについては、CDKと違い、言語依存ではないので、開発者の得意な言語で変な差別が生まれることがなくて良いですね(この部分については私個人は好意的にとらえています)。
今度はコンテナやログを絡めた設計もしていきたいですね。
コメントや指摘事項がありましたら、遠慮なくご連絡ください。