作りながら覚えるTerraform入門(3) - VPC編

作りながら覚えるTerraform入門シリーズの第3回です。
第2回の基本編ではとりあえずVPCを作成してみましたが、今回はサブネットやルートテーブルなど含めて改めてVPC関連のリソースを作成してみましょう。

##### 作りながら覚えるTerraform入門シリーズ

1. [インストールと初期設定](https://qiita.com/m-oka-system/items/6103bbb9f103db1fea0e)
1. [基本編](https://qiita.com/m-oka-system/items/983653f611fe305820c8)
1. **VPC編** => 今回はコチラ
1. [EC2編](https://qiita.com/m-oka-system/items/8280f748f88ac2e28ad7)
1. [Route53 + ACM編](https://qiita.com/m-oka-system/items/34f3ac3d98210d06dbc8)
1. [ELB編](https://qiita.com/m-oka-system/items/8882431042a1d01d0b7c)
1. [RDS編](https://qiita.com/m-oka-system/items/a45867495f95461b0a2c)

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

- 変数の使い方
- リソース間の参照方法
- 関数の使い方
- Stateファイルの管理

# VPCの作成

まず、改めてVPCを作成します。
`network.tf`を以下のように書き換えます。

```terraform:network.tf
################################
# VPC
################################
resource "aws_vpc" "vpc" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${var.prefix}-vpc"
  }
}
```

DNS関連の以下の2つのパラメータは基本的にどちらとも有効にしておく場面が多いと思います。割りと重要な設定なので、true/falseいずれの場合であっても明示的に記述しておくのが良いと思います。

- DNS解決： enable_dns_support
- DNSホスト名： enable_dns_hostnames

省略した場合、デフォルトでは`enable_dns_support`は`true`、`enable_dns_hostnames`は`false`になります。パラメータの意味は、公式ドキュメントの [VPC での DNS の使用](https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/vpc-dns.html) を参照しましょう。

`instance_tenancy`は省略していますが`default`が設定されます。
`tags`の中にKeyValueの組み合わせをマップで記述することでタグを指定できます。指定しないとコンソール画面での識別に困るリソースはNameタグを指定します。

# 変数の使い方

プレフィックスのように何度も出現する文字列や、環境ごとに変動する値は変数化しておきましょう。
変数を定義するには`variable`ブロックを使います。既存の`network.tf`ファイルに記述しても動作しますが、別ファイルに切り出して`variables.tf`に記述するのが一般的です。

```terraform:variables.tf
# Common
variable "prefix" {
  description = "Project name given as a prefix"
  type        = string
  default     = "cloud02"
}

# VPC
variable "vpc_cidr" {
  description = "The CIDR block of the VPC"
  type        = string
  default     = "10.0.0.0/16"
}
```

`variable`に続けて変数の名前を定義し、波括弧{}で囲った中に説明(description)、型(type)、デフォルト値などを記述します。説明、型は以降は省きますが、省略せずに記述することが推奨されています。

`network.tf`では、定義した変数を以下のように記述して呼び出しています。

- `cidr_block           = var.vpc_cidr`
- `Name = "${var.prefix}-vpc"`

`var.変数名`で呼び出すことができますが、文字列の中に変数を記述する場合は`"${var.変数名}-vpc"`のように`${}`で括った形で変数を呼び出して、文字列全体はダブルクオーテーションで囲みます。

`variables.tf`でデフォルト値を指定しない場合、planやapply実行時にプロンプトが表示されて入力を求められます。変数が宣言されているのに値がわからない状態、ということですね。

```bash
terraform plan
      
var.prefix
  Enter a value: 
```

`terraform plan -var="prefix=cloud02"`のように`-var`を使って引数で渡す方法もあります。

```bash
terraform plan -var="prefix=cloud02" -var="vpc_cidr=10.0.0.0/16"
```
`terraform.tfvars`というファイル名に変数の値を記述すると、自動的に読み込んでくれます。

```bash:terraform.tfvars
prefix   = "cloud02"
vpc_cidr = "10.0.0.0/16"
```

また、環境変数で指定することもできます。`TF_VAR_<変数名>=<値>`の形式で宣言します。

```bash
export TF_VAR_prefix=cloud02
export TF_VAR_vpc_cidr=10.0.0.0/16
```

整理すると、変数のデフォルト値を書いていない場合、

- コマンド実行時にプロンプトで入力する
- コマンド実行時に`-var`オプションで指定する
- `terraform.tfvars`ファイルで指定する
- 環境変数で指定する

ということになります。同時に指定された場合の [優先順位](https://www.terraform.io/docs/language/values/variables.html#variable-definition-precedence) は、
`-var`オプション -> `terraform.tfvars` -> 環境変数 となり、どれも指定されていなければプロンプトで入力となります。

初めは `variables.tf` と `terraform.tfvars` の使い分けがわかりにくいかもしれませんが、

- `variables.tf`には変数の宣言を書く
- `terraform.tfvars`には変数の値を書く

というように理解しています。
`variables.tf`のデフォルト値で全ての変数値を指定すると`terraform.tfvars`は不要になりますが、開発/検証/本番など環境ごとに変動する値を定義したり、パスワードなど機密情報を書いて`.gitignore`でバージョン管理対象外とする場合に`terraform.tfvars`は役立ちます。

なお、`terraform.tfvars`以外の名前で定義する場合、例えば、`dev.tfvars`というように開発環境向けの変数を記述したファイルを利用する場合は`-var-file`で指定すると読み込むことができます。

```bash
terraform plan -var-file="dev.tfvars"
```

変数の基本的な使い方は以上です。
ローカル変数を宣言するための [locals](https://www.terraform.io/docs/language/values/locals.html) ブロックもありますが、基本的な使い方は同じです。
`variables.tf` と `terraform.tfvars`は以下のようにして続きを進めていきます。

```terraform:variables.tf
# Common
variable "prefix" {}

# VPC
variable "vpc_cidr" {
  default = "10.0.0.0/16"
}
```

```terraform:terraform.tfvars
prefix = "cloud02"
```

この段階でいったんapplyしてVPCの作成を確認しても構いませんし、そのまま続きのコードを記述して最後にapplyしてもOKです。

# インターネットゲートウェイの作成

次はインターネットゲートウェイを作成します。
`network.tf`に以下のコードを追記します。

```terraform:network.tf
################################
# Internet Gateway
################################
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.prefix}-igw"
  }
}
```

`vpc_id = aws_vpc.vpc.id`では、VPCのIDを参照しています。
今回のIGW→VPCのように他のリソースと関連付ける場合には、`<リソースタイプ>.<リソースの識別名>.<属性>` という形で関連リソースを指定することができます。

属性は今回の例では`id`です。
リファレンスの「Attributes Reference」を参照するとどういった属性があるかわかりますが、たいていの場合は`id`や`arn`といったリソースを一意に特定するためのものが利用されます。

# サブネットの作成

続いてサブネットを作成します。
`network.tf`に以下のコードを追記します。

```terraform:network.tf
################################
# Subnet
################################
resource "aws_subnet" "public_subnet_1a" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1a"
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, 11) # 10.0.11.0/24
  map_public_ip_on_launch = true

  tags = {
    "Name" = "${var.prefix}-public-subnet-1a"
  }
}

resource "aws_subnet" "public_subnet_1c" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1c"
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, 12) # 10.0.12.0/24
  map_public_ip_on_launch = true

  tags = {
    "Name" = "${var.prefix}-public-subnet-1c"
  }
}

resource "aws_subnet" "private_subnet_1a" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1a"
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, 21) # 10.0.21.0/24
  map_public_ip_on_launch = false

  tags = {
    "Name" = "${var.prefix}-private-subnet-1a"
  }
}

resource "aws_subnet" "private_subnet_1c" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1c"
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, 22) # 10.0.22.0/24
  map_public_ip_on_launch = false

  tags = {
    "Name" = "${var.prefix}-private-subnet-1c"
  }
}
```

[システム構成](https://qiita.com/m-oka-system/items/6103bbb9f103db1fea0e#%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%E6%A7%8B%E6%88%90) を見て頂くとわかりやすいと思いますが、パブリックサブネットとプライベートサブネットをAZ-1aとAZ-1cにそれぞれ作成しています（計4つ）

パブリックサブネットは`map_public_ip_on_launch`を`true`にしてパブリックIPアドレスの自動割当を有効にしています。また、`cidr_block`は [cidrsubnet](https://www.terraform.io/docs/language/functions/cidrsubnet.html) という関数を使ってVPCのCIDRである「10.0.0.0/16」を元に算出しています。

構文は`cidrsubnet(prefix, newbits, netnum)`です。
一見難しそうに見えるかもしれませんが、それぞれの関数部分を`terraform console`で確認するとわかりやすいです。

```bash
terraform console

> cidrsubnet("10.0.0.0/16", 8, 11)
"10.0.11.0/24"
> cidrsubnet("10.0.0.0/16", 8, 12)
"10.0.12.0/24"
> cidrsubnet("10.0.0.0/16", 8, 21)
"10.0.21.0/24"
> cidrsubnet("10.0.0.0/16", 8, 22)
"10.0.22.0/24"
```

Terraformには他にも様々な組み込み関数がありますが、動作確認をするには`terraform console`が便利です。

# ルートテーブルの作成

最後にルートテーブルを作成してサブネットに関連付けます。
`network.tf`に以下のコードを追記します。

```terraform:network.tf
################################
# Public Route Table
################################
resource "aws_route_table" "public_rt" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.prefix}-public-route"
  }
}

resource "aws_route" "to_internet" {
  route_table_id         = aws_route_table.public_rt.id
  gateway_id             = aws_internet_gateway.igw.id
  destination_cidr_block = "0.0.0.0/0"
}

resource "aws_route_table_association" "public_rt_1a" {
  subnet_id      = aws_subnet.public_subnet_1a.id
  route_table_id = aws_route_table.public_rt.id
}

resource "aws_route_table_association" "public_rt_1c" {
  subnet_id      = aws_subnet.public_subnet_1c.id
  route_table_id = aws_route_table.public_rt.id
}

################################
# Private Route Table
################################
resource "aws_route_table" "private_rt" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.prefix}-private-route"
  }
}

resource "aws_route_table_association" "private_rt_1a" {
  subnet_id      = aws_subnet.private_subnet_1a.id
  route_table_id = aws_route_table.private_rt.id
}

resource "aws_route_table_association" "private_rt_1c" {
  subnet_id      = aws_subnet.private_subnet_1c.id
  route_table_id = aws_route_table.private_rt.id
}
```

`aws_route_table`で空のルートテーブルを作成
`aws_route`でルートを追加、
`aws_route_table_association`でサブネットに関連付け
という構成になっています。

パブリックサブネット向けのルートにはインターネット宛のルートとしてインターネットゲートウェイと関連付けています。プライベートサブネットには今回は特にルートを追加しませんが、NATGWが存在する場合などは同じようにルートを追加すればOKです。

`terraform apply`を実行して、VPC、インターネットゲートウェイ、サブネット、ルートテーブルが作成、関連付けされていることを確認しましょう。

# StateファイルをS3へ移行

さて、[Terraformの実行計画](https://qiita.com/m-oka-system/items/983653f611fe305820c8#terraform%E3%81%AE%E5%AE%9F%E8%A1%8C%E8%A8%88%E7%94%BB) で解説したようにTerraformでは差分を簡単に知ることができて便利ですが、これはこれから実行されるコードと `terraform.tfstate` というファイルを比較することで実現しています。

`terraform.tfstate`はapplyやdestroyするたびに自動的に作成・更新されるファイルで、その名のとおり、Terraformによって管理されるインフラの状態が書かれています。中身はJSON形式です。

例えば`terraform.tfstate`を誤って削除してしまった場合、全てのリソースが新規扱いになるため`apply`を実行するとさきほど作成したVPC関連のリソースが再び一式作成されてしまいます。つまり、インフラの状態を維持・管理していく上で、非常に重要なファイルということです。

チーム開発では複数人で共有しながら進める必要があるため、`terraform.tfstate`をS3などのストレージで管理するのが一般的です。学習の範囲では`terraform.tfstate`がローカルに保存されていても問題ありませんが、この記事では`terraform.tfstate`をS3に移行してみたいと思います。

`terraform.tfstate`が保存される場所などの定義をTerraformでは「バックエンド」と呼び、`backend`ブロックに記述します。`provider.tf`に`backend`ブロックを追加しましょう。S3バケットの名前は読み替えてください。

```terraform:provider.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
  backend "s3" {
    bucket = "cloud02-tf-remote-state-bucket"
    key    = "ap-northeast-1/dev/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

provider "aws" {
  region = "ap-northeast-1"
}
```

`backend "s3"`と記述することで保存先をS3とすることを宣言しています。
他にも`backend "remote"`と書けばTerraform Cloudを利用することになります。

`bucket` `key` `region`にはそれぞれバケット名、tfstateのパス(ファイル名)、リージョンを指定します。
[Backend Configuration](https://www.terraform.io/docs/language/settings/backends/configuration.html) に書かれている通り、`backend`ブロックでは変数が使えないので注意しましょう。

>A backend block cannot refer to named values (like input variables, locals, or data source attributes).

バックエンドで指定するS3バケットは予めコンソール画面から作成しています。
S3バケット自体もTerraformで作成することもできますが、その場合は同じ管理下に含めず、別途作成するのが良さそうです。

書き換えできたら`terraform plan`を実行してみます。

```bash
terraform plan

Error: Backend initialization required, please run "terraform init"
│ 
│ Reason: Initial configuration of the requested backend "s3"
│ 
│ The "backend" is the interface that Terraform uses to store state,
│ perform operations, etc. If this message is showing up, it means that the
│ Terraform configuration you're using is using a custom configuration for
│ the Terraform backend.
│ 
│ Changes to backend configurations require reinitialization. This allows
│ Terraform to set up the new configuration, copy existing state, etc. Please run
│ "terraform init" with either the "-reconfigure" or "-migrate-state" flags to
│ use the current configuration.
│ 
│ If the change reason above is incorrect, please verify your configuration
│ hasn't changed and try again. At this point, no changes to your existing
│ configuration or state have been made.
```

そうすると、バックエンドの構成が変更されたので`terraform init`を実行するよう促されます。

```bash
terraform init

Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes
```

`terraform init`を実行すると、ローカルのtfstateをS3にコピーするか聞かれますので「yes」を入力して進めます。そうすると、ローカルの`terraform.tfstate`はクリアされ、S3バケット上にファイルが作成されます。

![3FFE3306-E4DB-4F2A-8C87-907511FAEF87.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402286/f49b092c-f5e0-d7fb-8f0d-c11bb862ee12.png)

この状態で改めて`terraform plan`を実行してみます。

```bash
terraform plan

─────────────────────────────────────────────────────────────────────────────────────────────────

No changes. Your infrastructure matches the configuration.

Your configuration already matches the changes detected above. If you'd like to update the Terraform state to match, create and apply a refresh-only plan:
  terraform apply -refresh-only
```

`No changes`と表示されているので、S3の`terraform.tfstate`を参照して差分チェックできていることがわかります。これ以降、変更を加えるたびにS3にある`terraform.tfstate`が更新されることになります。

S3に保存することで複数人で共有できるようになりましたが、厳密には同時に変更が加えられないよう更新中にロックしておく仕組みが必要です。[DynamoDB State Locking](https://www.terraform.io/docs/language/settings/backends/s3.html#dynamodb-state-locking) にあるようにDynamoDBを使えば簡単に実装できますが、まだ試せていないので今回は省略させて頂きます。

今回は以上です。次回はEC2編ということで、Webサーバを2台構築してみたいと思います！

# 参考リンク

- [VPC での DNS の使用](https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/vpc-dns.html)
- [Input Variables](https://www.terraform.io/docs/language/values/variables.html)
- [Local Values](https://www.terraform.io/docs/language/values/locals.html) 
- [cidrsubnet Function](https://www.terraform.io/docs/language/functions/cidrsubnet.html)
- [S3 Backend](https://www.terraform.io/docs/language/settings/backends/s3.html)