Terraformを使ってAWSにWebアプリケーションの実行環境を立てる (EC2立てるまで)

Webアプリケーション実行環境をIaCで管理したい.
Terraformでクラウド構成を作ってAnsibleでミドルウェアをインストールしたい.
BeanstalkやLightsailのようなPaaSではなくTerraformを使ってVPCから自前で作ってみる.
この記事はEC2を立てるまでが範囲.
次の記事でAnsibleを使って立てたEC2にミドルウェアをインストールする.

この記事で紹介する範囲

この記事ではTerraformを使ってAWS上に以下の構成を作るまでを書いてみる.

とはいえTerraformの習得が8割くらいのモチベなので実用性はあまり重視しない.
サブネットをプライベートとパブリックに分けてみたい.
プライベートにDB(MySQL), パブリックにWebサーバ(nginx).
ひとまずALBは配置しない.

Terraformの導入

Ansibleもそうだけれども, アプリを保守している期間って割と長いもので、
その間, 構成管理ツール側のバージョンが上がってしまう傾向がある.
そうすぐに古い書き方が使えなくなることはないが, 警告が出まくって気分がよくない.
構成管理ツールの古いバージョンを残しておきたい, どのバージョンを使うか選びたい, という期待がある.
rbenvやpyenvのようにTerraform自体のバージョンを管理するtfenvをインストールしておき,
この記事を書いた日の最新である 1.0.3 をインストールすることにする.


$ brew install tfenv
$ tfenv --version
tfenv 2.2.2

$ tfenv list-remote
.1.0-alpha20210714
1.1.0-alpha20210630
1.1.0-alpha20210616
1.0.3
1.0.2
1
...
$  tfenv install 1.0.3
...

$ tfenv list
1.0.3

$ tfenv use 1.0.3
Switching default version to v1.0.3
Switching completed

$ terraform version
Terraform v1.0.3
on darwin_amd64

git secretsの導入

AWSのcredentialsなどを誤ってcommitしてしまう事故を防ぐためにgit secretsを導入する.
commit時に内容を検証してくれて, もしそれらしきファイルがあればリジェクトしてくれる.
どこまで見てくれるのか未検証だけれども入れておく.
Laravelの.env_staging等に書いたcredentialsがどう扱われるか後で検証する.


$ brew install git-secrets
$ git secrets --install
✓ Installed commit-msg hook to .git/hooks/commit-msg
✓ Installed pre-commit hook to .git/hooks/pre-commit
✓ Installed prepare-commit-msg hook to .git/hooks/prepare-commit-msg
$ git secrets --register-aws
OK

ディレクトリ構成

勉強用の小さな環境を作るのだけれども, 今後の拡張性については考慮しておきたい.
割と規定されている傾向があるAnsibleと比較して,Terraformは自由な印象.
以下の記事を参考にさせて頂きました.
Terraformなにもわからないけどディレクトリ構成の実例を晒して人類に貢献したい


iac
├── dev
│   ├── backend.tf
│   ├── main.tf -> ../shared/main.tf
│   ├── provider.tf -> ../shared/provider.tf
│   ├── versions.tf -> ../shared/versions.tf
│   ├── terraform.tfvars
│   └── variables.tf -> ../shared/variables.tf
└── shared
    ├── main.tf
    ├── provider.tf
    ├── variables.tf
    └── modules
        ├── vpc
        │   ├── eip.tf
        │   ├── internet_gateway.tf
        │   ├── nat_gateway.tf
        │   ├── routetables.tf
        │   ├── subnet.tf
        │   ├── vpc.tf
        │   ├── outputs.tf
        │   └── variables.tf
        └── ec2
            ├── ec2.tf
            ├── keypair.tf
            ├── network_interface.tf
            ├── security_group.tf
            ├── outputs.tf
            └── variables.tf

tfstateの保存先の定義

tfstate は Terraformが管理しているリソースの現在の状態を表すファイル.
terraformは「リソースを記述したファイル」と「現在の状態」の差分を埋めるように処理を行うが,
いちいち「現在の状態」を調べにいくとパフォーマンスが悪化するため, ファイルに保存される.
(確かにAnsibleは毎回「現在の状態」を調べにいっているっぽく,これが結構遅くて毎回イライラする)

デフォルトだとローカルに作られるが, それだとチーム開発で共有できないので,
S3等に作るのが良くあるパターン.
Terraformでは”バックエンド”という概念で扱われる. “バックエンド”を以下のように記述する.

バックエンドの定義はterraformの前段にあり, S3 bucketとDynamoDB tableを手動で作っておく必要がある.
変数を使うことができないのでハードコードしないといけない. 議論があるらしい.
key,secretを書く代わりにprofileを書くことで, 構成管理可能になる.
(同じprofile名をチームで共有しないといけない…)

backendをS3にする際にS3のbucketをどう作るか問題はいろいろ議論があるようで,
いずれ以下の記事を参考にしてよしなにbucketを作れるようにしたい.

dynamodb_tableを設定すると、そこにロックファイルを作ってくれるようになる.
多人数で同じ構成管理を触るときに便利.
Backend の S3 や DynamoDB 自体を terraform で管理するセットアップ方法


terraform {
  backend "s3" {
    region = "ap-northeast-1"
    profile = "ikuty"
    bucket = "terraform-state-dev"
    key    = "terraform-state-dev.tfstate"
    dynamodb_table = "terraform-state-lock-dev"
  }
}

credentialsの書き方

ルートにある terraform.tfvarsというファイルを置いておくと、
そこに記述した内容を変数に注入することができる.
“注入”という言葉で良いのか不明だが、定義した変数の初期値を設定してくれる.

credentialsを構成管理に登録するのはご法度.
terraform.tfvarsを構成管理外として何らかの方法で環境にコピーする.
多くのツールで採用されている「よくあるパターン」.

他に,applyコマンドに直接渡したり, 環境変数で指定したりできるが,
Terraform公式は.tfvarsを推奨している.


aws_access_key_id = "AKI*****************"
aws_secret_access_key = "9wc*************************************"
aws_region = "ap-northeast-1"

providerの定義

プロバイダとは, 要は”AWS”,”Azure”,”GCP”.. のような粒度の何か.
Terraformは結構な種類のプロバイダに対応していて「どのプロバイダを使うか」を定義する.
今回はAWSを使う. dev.tfvarsに記述しておいたCredentialsを変数で受けて設定する.

以下,変数の定義方法, デフォルト値の設定方法を示している.
.tfvarsに記述した同名の変数について,terraformが値を設定してくれる.


variable "aws_access_key_id" {}
variable "aws_secret_access_key" {}
variable "aws_region" {
    default = "ap-northeast-1"
}

provider "aws" {
    access_key = "${var.aws_access_key_id}"
    secret_key = "${var.aws_secret_access_key}"
    region = "${var.aws_region}"
}

エントリポイント

Terraformのエントリポイントはルートに置いた”main.tf”.
ディレクトリ構成を凝らないのであれば、main.tf に全てをベタ書きすることもできる.
今回、devやstg, prod のような環境ごとにルートを分ける構成を作りたいのだが、
main.tf 自体は環境ごとに差異が無いことを前提にしている.

./shared/main.tf というファイルを作成し、
各環境ごとの main.tf を ./shared/main.tf の Symbolic Link とする.

main.tf でリソースの定義はおこなわない. 同階層の./modules にモジュール定義があるが,
main.tf は ./modules以下の各モジュールに変数を渡すだけ.

VPCの作成とEC2の作成を各モジュールに分割した.
各モジュールのOutputのスコープはモジュールまでなので、
例えばEC2モジュールからVPCモジュールのVPC IDを直接受け取れない.
main.tf はモジュールの上に位置するため、このようにモジュール間で変数を共有できる.


module "vpc" {
        source = "../shared/modules/vpc"
}
module "ec2" {
        source = "../shared/modules/ec2"
        vpc_id = module.vpc.myVPC.id
        private_subnet_id = module.vpc.private_subnet.id
        public_subnet_id = module.vpc.public_subnet.id
}

VPCモジュール

./shared/modules/vpc以下にVPCモジュールを構成するファイルを配置する.

スコープがVPCモジュールに閉じたローカル変数を定義する.
以下のようにしておくと、モジュール内から local.vpc_cidr.dev のように値を取得できる.


locals {
        vpc_cidr = {
                dev = "10.1.0.0/16"
        }
        subnet_cidr = {
                private = "10.1.2.0/24"
                public = "10.1.1.0/24"
        }
}

VPCを1個作る. VPCのCIDRは10.1.0.0/16.


resource "aws_vpc" "myVPC" {
        cidr_block = local.vpc_cidr.dev
        instance_tenancy = "default"
        enable_dns_support = "true"
        enable_dns_hostnames = "false"
        tags = {
                Name = "myVPC"
        }
}

作ったVPC内にサブネットを2個作る. 1つはPrivate用. もう1つはPublic用.
PrivateサブネットのCIDRは10.1.2.0/24. PublicサブネットのCIDRは10.1.1.0/24.
AZは両方同じで “ap-northeast-1a”.
map_public_ip_on_launchをtrueとしておくと,
そこで立ち上げたEC2に自動的にpublic ipが振られる.


resource "aws_subnet" "public_1a" {
        vpc_id = aws_vpc.myVPC.id
        depends_on = [aws_vpc.myVPC]
        availability_zone = "ap-northeast-1a"
        cidr_block = local.subnet_cidr.public
        map_public_ip_on_launch = true
        tags = {
                Name = "public-1a"
        }
}
resource "aws_subnet" "private_1a" {
        vpc_id = aws_vpc.myVPC.id
        depends_on = [aws_vpc.myVPC]
        availability_zone = "ap-northeast-1a"
        cidr_block = local.subnet_cidr.private
        tags = {
                Name = "private-1a"
        }
}

VPCに紐づくInternet Gatewayを作る.


resource "aws_internet_gateway" "myGW" {
        vpc_id = "${aws_vpc.myVPC.id}"
        depends_on = [aws_vpc.myVPC]
        tags = {
                Name = "my Internet Gateway"
        }
}

Privateサブネットからインターネットに繋ぐために、
PublicサブネットにNAT Gatewayを作りたい.
NAT Gateway用のEIPを作る.


resource "aws_eip" "nat_gateway" {
        vpc = true
        depends_on = [aws_internet_gateway.myGW]
        tags = {
                Name = "Eip for Nat gateway"
        }
}

PublicサブネットにNAT Gatewayを作る.
EIPは上で作成したものを使う.


resource "aws_nat_gateway" "myNatGW" {
        allocation_id = aws_eip.nat_gateway.id
        subnet_id = aws_subnet.public_1a.id
        depends_on = [aws_internet_gateway.myGW]
        tags = {
                Name = "my Nat Gateway"
        }
}

ルートテーブル. いろいろなところで書かれていた内容を試してようやく動くものができた.
VPCにはデフォルトで「メインルートテーブル」が作られる.
メインルートテーブルはいじっていない.

以下、Private, Publicサブネットそれぞれのためのルートテーブルを定義している.
PublicサブネットからInternet Gatewayに繋ぐ. PrivateサブネットからNAT Gatewayに繋ぐ.


# Route table for public
# public
resource "aws_route_table" "public" {
        vpc_id = aws_vpc.myVPC.id
        depends_on = [aws_internet_gateway.myGW]
        tags = {
                Name = "my Route Table for public"
        }
}
# private
resource "aws_route_table" "private" {
        vpc_id = aws_vpc.myVPC.id
        depends_on = [aws_internet_gateway.myGW]
        tags = {
                Name = "my Route Table for private"
        }
}

# Route table association
# public
resource "aws_route_table_association" "public" {
        subnet_id = aws_subnet.public_1a.id
        route_table_id = aws_route_table.public.id
}
# private
resource "aws_route_table_association" "private" {
        subnet_id = aws_subnet.private_1a.id
        route_table_id = aws_route_table.private.id
}

# Routing for public
resource "aws_route" "public" {
        route_table_id = aws_route_table.public.id
        gateway_id = aws_internet_gateway.myGW.id
        destination_cidr_block = "0.0.0.0/0"
}

# Routing for private
resource "aws_route" "private" {
        route_table_id = aws_route_table.private.id
        gateway_id = aws_nat_gateway.myNatGW.id
        destination_cidr_block = "0.0.0.0/0"
}

EC2モジュール

./shared/modules/ec2以下にEC2モジュールを構成するファイルを配置する.

スコープがEC2モジュールに閉じたローカル変数を定義する.
main.tfからVPCモジュールのOutputをEC2モジュールに渡す必要があるが、
渡すデータを受けるためにEC2モジュール側で変数を定義しておく必要がある.


locals {
        private = {
                ip = "10.1.2.5"
                ami = "ami-0df99b3a8349462c6"
                instance_type = "t2.micro"
        }
        public = {
                ip = "10.1.1.5"
                ami = "ami-0df99b3a8349462c6"
                instance_type = "t2.micro"
        }
}
variable "vpc_id" {
        type = string
}
variable "private_subnet_id" {
        type = string
}
variable "public_subnet_id" {
        type = string
}

EC2にアクセスするための鍵ペア.
既に鍵ペアを持っているものとし、その公開鍵を渡す.
以下のようにすると、HostからSSHの-iオプションで秘密鍵を指定して接続できるようになる.


resource "aws_key_pair" "deployer" {
        key_name = "deployer"
        public_key = "{公開鍵}"
}

EC2に設定するセキュリティグループを作る.
この記事では, Private, Publicともに、インバウンドをSSHのみとした.
次の記事でPublicにHTTPを通す.
アウトバウンドとして全て通すようにしないとインスタンスから外にアクセスできなくなる(ハマった).


# Security group
resource "aws_security_group" "web_server_sg" {
        name = "web_server"
        description = "Allow http and https traffic."
        vpc_id = var.vpc_id
}

# Security group rule SSH(22)
resource "aws_security_group_rule" "web_inbound_ssh" {
        type = "ingress"
        from_port = 22
        to_port = 22
        protocol = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
        security_group_id = aws_security_group.web_server_sg.id
}
resource "aws_security_group_rule" "web_outbound" {
        type = "egress"
        from_port = 0
        to_port = 0
        protocol = "-1"
        cidr_blocks = ["0.0.0.0/0"]
        ipv6_cidr_blocks = ["::/0"]
        security_group_id = aws_security_group.web_server_sg.id
}

# Security group
resource "aws_security_group" "db_server_sg" {
        name = "db_server"
        description = "Allow MySQL traffic."
        vpc_id = var.vpc_id
}

# Security group rule SSH(22)
resource "aws_security_group_rule" "db_inbound_ssh" {
        type = "ingress"
        from_port = 22
        to_port = 22
        protocol = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
        security_group_id = aws_security_group.db_server_sg.id
}
resource "aws_security_group_rule" "db_outbound" {
        type = "egress"
        from_port = 0
        to_port = 0
        protocol = "-1"
        cidr_blocks = ["0.0.0.0/0"]
        ipv6_cidr_blocks = ["::/0"]
        security_group_id = aws_security_group.db_server_sg.id
}

ネットワークインターフェース.
セキュリティグループはEC2インスタンスではなくネットワークインターフェースに紐づく.
EC2(aws_instance)のsecurity_groupsに書けなくてハマった.


# public
resource "aws_network_interface" "public_1a" {
        subnet_id   = var.public_subnet_id
        private_ips = [local.public.ip]
        security_groups = [
                aws_security_group.web_server_sg.id
        ]
        tags = {
                Name = "public_subnet_network_interface"
        }
}
# private
resource "aws_network_interface" "private_1a" {
        subnet_id   = var.private_subnet_id
        private_ips = [local.private.ip]
        security_groups = [
                aws_security_group.db_server_sg.id
        ]
        tags = {
                Name = "private_subnet_network_interface"
        }
}

最後にEC2.


# Web Server
resource "aws_instance" "public" {
        ami = local.public.ami
        instance_type = local.public.instance_type
        key_name = aws_key_pair.deployer.id
        network_interface {
                network_interface_id = aws_network_interface.public_1a.id
                device_index = 0
        }
        credit_specification {
                cpu_credits = "unlimited"
        }
        root_block_device {
                volume_size = 20
                volume_type = "gp2"
                delete_on_termination = true
                tags = {
                        Name = "web-ebs"
                }
        }
        tags = {
                Name = "Web"
        }
}

# DB Server
resource "aws_instance" "private" {
        ami = local.private.ami
        instance_type = local.private.instance_type
        key_name = aws_key_pair.deployer.id
        network_interface {
                network_interface_id = aws_network_interface.private_1a.id
                device_index = 0
        }
        credit_specification {
                cpu_credits = "unlimited"
        }
        root_block_device {
                volume_size = 20
                volume_type = "gp2"
                delete_on_termination = true
                tags = {
                        Name = "db-ebs"
                }
        }
        tags = {
                Name = "DB"
        }
}

実行

作った.tfファイルを再生して環境を構築する.
validateでデバッグして、大体できたらplan(DryRun)で変更が正しそうか確認してみた.
が、評価しなければわからないものについてはDryRunではわからず、
結局applyが途中で止まって解決しないといけない.

ansibleと異なり冪等性が言われていなくて、applyで間違った構成を作ってしまうと、
その先、その構成を修正したとしても上手くいかないことがある.


$ cd "/path/to/dev"
$ terraform validate
Success! The configuration is valid.
$ terraform plan
...
$ terraform apply
...

出来たとして、Publicに立ったEC2のパブリックIPv4をメモる.

疎通確認

Host ->(SSH)-> Web ->(SSH)-> DB を試す. DBから外に繋がるか試す.
SSH Agent Forwardを使うと、Web EC2に秘密鍵を置かないで済む.
Web側のssh configにForwardAgent yesを指定しておく.


Host db
        HostName 10.1.2.5
        User ubuntu
        ForwardAgent yes

いざ.


$ ssh-add "{秘密鍵のパス}"
$ ssh -A ubuntu@{WebのパブリックIPv4}
ubuntu@ip-10-1-1-5$ ssh db
ubuntu@ip-10-1-2-5$ ping yahoo.co.jp
64 bytes from f1.top.vip.kks.yahoo.co.jp (183.79.135.206): icmp_seq=1 ttl=33 time=14.9 ms
64 bytes from f1.top.vip.kks.yahoo.co.jp (183.79.135.206): icmp_seq=2 ttl=33 time=14.6 ms
..

できた..