terraformのcountを有効活用して開発環境だけNAT Gatewayを減らす

terraformでAWSのネットワークを集中管理していて、3つのAZに分散させる構成にしているのですが、
同じ構成で開発環境を構築するとNAT Gatewayのコストが目立つようになってきました。
環境によってprivate subnetの数を制御しようというのが今回のゴールになります。

そもそも元のterraformの定義があまりよくないよくなかったのですが、
コロナ対応がひと段落して少し時間に余裕があったので気がすむまでリファクタリングを行いました。


# TL;DR;

- 環境の判定は[terraformのworkspace](https://www.terraform.io/docs/state/workspaces.html)を活用しましょう
- 環境ごとのリソース制御は[terraformのcount](https://www.terraform.io/intro/examples/count.html) つかって制御しましょう

というお話です。

# 元のテンプレの構成 (反面教師)


### 環境の判定

- 環境の切り替えは terraformのworkspaceを利用し、 local で定義しております。
    - これについては巷にはいろいろ好みがあるようです。
- 決めの問題だと思うのですが、開発と本番しかなかったのでdefaultをdevに向けてます。

```locals.tf
locals {
  # ぶっちゃけ定義はしてたけどほとんど使ってなかったです。
  env   = "${terraform.workspace != "default" ? terraform.workspace : "dev"}"
}
```

workspace の切り替えは `terraform workspace select {env}` で切り替えでき、参照しているリソースのstateも切り替えてくれます。

- 参考 https://www.terraform.io/docs/state/workspaces.html


### ネットワークアドレス

アプリや環境ごとにアカウントを分けて運用してまして、流用できるようにネットワークアドレスはvariableで定義しております。

```variable.tf
variable "vpc_cidr" {
  default = "10.x.x.0/21"
}

variable "private_subnet1_cidr" {
  default = "10.x.x.0/24"
}

variable "private_subnet2_cidr" {
  default = "10.x.y.0/24"
}

variable "private_subnet3_cidr" {
  default = "10.x.z.0/24"
}
```

### subnetの定義

アベイラビリティーゾーンごとにsubnetを個別で定義しておりまして、これが単調で気に入らなかったのです。


```vpc.tf

resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr
  tags = {
    Name = "${local.env}-vpc"
    Env  = local.env
  }
}

resource "aws_subnet" "private_subnet1" {
  vpc_id               = aws_vpc.main.id
  cidr_block           = var.private_subnet1_cidr
  availability_zone_id = "apne1-az1"

  tags = {
    Name = "private-subnet1"
    Env  = local.env
  }
}

resource "aws_subnet" "private_subnet2" {
  vpc_id               = aws_vpc.main.id
  cidr_block           = var.private_subnet2_cidr
  availability_zone_id = "apne1-az2"

  tags = {
    Name = "private-subnet2"
    Env  = local.env
  }
}

resource "aws_subnet" "private_subnet3" {
  vpc_id               = aws_vpc.main.id
  cidr_block           = var.private_subnet3_cidr
  availability_zone_id = "apne1-az4"

  tags = {
    Name = "private-subnet3"
    Env  = local.env
  }
}

# <実際にはpublicも定義してますが割愛>

resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "main-gateway"
    Env  = local.env
  }
}

resource "aws_eip" "nat_eip_1" {
  tags = {
    Name = "${local.env}-nat-eip-1"
    Env  = local.env
  }
}

resource "aws_nat_gateway" "nat_gateway_1" {
  allocation_id = aws_eip.nat_eip_1.id
  subnet_id     = aws_subnet.public_subnet1.id
  depends_on    = [aws_internet_gateway.gw]
  tags = {
    Name = "nat-gateway-1"
    Env  = local.env
  }
}

resource "aws_route_table" "private_route1" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat_gateway_1.id
  }

  tags = {
    Name = "private-route1"
    Env  = local.env
  }
}


#
# 2 ~ 3 も同様、コピペ
#
```

# 最初にやったこと

環境ごとにリソースを作成するか、しないのかという要件なので、`CloudFormation`の`Conditions`属性のような機能を探しておりました。

- https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html

そしたら`terraform`に`count`なる項目があることに気づきまして、これで代用している記事を参考に実装してみました。

- https://www.terraform.io/intro/examples/count.html
- https://qiita.com/mia_0032/items/978449a06699ed1abe15

私の場合、環境はworkspaceをlocalで参照して定義していたのでこんな感じにアレンジしました。

```locals.tf
locals {
  env     = "${terraform.workspace != "default" ? terraform.workspace : "dev"}"
  is_prod = "${local.env == "prod" ? 1 : 0}"
}
```

そして、これを利用して２つめ、3つめのNAT Gatewayにcountを定義してみました。

```vpc.tf
#
# <省略>
#
resource "aws_nat_gateway" "nat_gateway_2" {
  count = local.is_prod
  allocation_id = aws_eip.nat_eip_2.id
  subnet_id     = aws_subnet.public_subnet2.id

  tags = {
    Name = "nat-gateway-2"
    Env  = local.env
  }
}
```

するとここで問題が発生します。
countを定義すると、そのリソースが配列になってしまうので、参照しているルーティングなどのリソースの参照を変更する必要があります。

```vpc.tf
# 省略

resource "aws_route_table" "private_route2" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat_gateway_2[0].id
  }

  tags = {
    Name = "private-route2"
    Env  = local.env
  }
}
```

ということは、`state`も変わるということで、stateの移行を行わないといけません。
今回対象はrouteテーブルだけなのでささっと`terraform state mv`で付け替えちゃいました。


```bash

$ terraform state list
: 省略
aws_nat_gateway.nat_gateway_1
aws_nat_gateway.nat_gateway_2
: 省略

$ terraform state mv aws_nat_gateway.nat_gateway_2 aws_nat_gateway.nat_gateway_2[0]
Move "aws_nat_gateway.nat_gateway_2" to "aws_route_table.nat_gateway_2[0]"
Successfully moved 1 object(s).

$ terraform state list
: 省略
aws_nat_gateway.nat_gateway_1
aws_nat_gateway.nat_gateway_2[0]
: 省略
```

とりあえずこれで`terraform plan`を実行すると、ちゃんと消したい開発環境のnat_gateway 2と3が削除され、
本番環境の方も同様に`terraform state mv`コマンドで整理すればciが回って勝手に消されるという事態も防げます。
めでたしめでたし。

# リファクタリング

やりたいのは開発環境で無駄にNAT Gatewayを作らないことなので目的は達成できているのですが何か気持ち悪い。
要素が一つだけの配列を作ってそれをオンコーディング`[0]`で参照するのが気持ち悪い。
NAT Gatewayが1から3まであるので、素直に、countで本番は3個、開発は1個って定義したくなりました。

ってことでsubnet定義をまるっとまるまるリファクタリングを行いました。

### VPCの定義

開発環境でNAT Gatewayだけ削除しちゃうと、NATGatewayがあるsubnetとないsubnetが混在して無用な問題を引き起こすので、subnetの数も一緒に制御するようにしました。

countを定義したリソース内では、`count.index`という属性でそのリソースの配列のindexが参照できるようです。
そしてついでにpublic subnetとかeipとかroutingとかもろもろ1,2,3と名前がつくもの全部countで配列化しました。

```vpc.tf
#
# 省略
#

resource "aws_subnet" "private_subnet" {
  count                = local.private_subnet_count
  vpc_id               = aws_vpc.main.id
  cidr_block           = var.private_subnet_cidr[count.index]
  availability_zone_id = local.az_ids[count.index]

  tags = {
    Name = "private-subnet1"
    Env  = local.env
  }
}
# public subnet も同様なので割愛

resource "aws_eip" "nat_eip" {
  count = local.private_subnet_count
  tags = {
    Name = "${local.env}-nat-eip-${count.index}"
    Env  = local.env
  }
}

resource "aws_nat_gateway" "nat_gateway" {
  count         = local.private_subnet_count
  allocation_id = aws_eip.nat_eip[count.index].id
  subnet_id     = aws_subnet.public_subnet[count.index].id

  tags = {
    Name = "nat-gateway-${count.index}"
    Env  = local.env
  }
}

resource "aws_route_table" "private_route" {
  count  = local.private_subnet_count
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat_gateway[count.index].id
  }

  tags = {
    Name = "private-route-${count.index}"
    Env  = local.env
  }
}
resource "aws_route_table_association" "private_routing" {
  count          = local.private_subnet_count
  subnet_id      = aws_subnet.private_subnet[count.index].id
  route_table_id = aws_route_table.private_route[count.index].id
}

```

### アベイラビリティゾーン、ネットワークアドレス

そして、subnetの数を素直に定義しました。
本番環境では、azの数だけsubnetを作成するので、azの配列を定義しました。

```locals.tf
locals {
  env                   = "${terraform.workspace != "default" ? terraform.workspace : "dev"}"
  private_subnet_count  = "${local.env == "prod" ? 3 : 1}"
  az_ids = [
    "apne1-az1",
    "apne1-az2",
    "apne1-az4"
  ]
}
```


ネットワークアドレスはパラメーター化していたのでvariablesで定義しました。

```variables.tf
variable "private_subnet_cidr" {
  default = [
    "10.x.x.0/24",
    "10.x.y.0/24",
    "10.x.z.0/24"
  ]
}
```

### `terraform state` の更新

ここまでやると結構なスコープでstateの矛盾が発生します。
`terraform state mv`をひたすら実行しました。
しくじると、NAT Gatewayのelastic ip が解放されて取り返しがつかない事態になります。

~~作業中、何度か「何やってんだ俺」って若干後悔しました~~


### 補足

他リソースの配列参照を`[count.index]`の形式で書きましたが、未検証ですが、どうも resource.name.*.property のような形式で参照できるようです。

`aws_subnet.public_subnet[count.index].id`
これが
`aws_subnet.public_subnet.*.id`
こう書けるのだとか。
https://github.com/terraform-providers/terraform-provider-aws/blob/master/examples/count/main.tf


# まとめ / 感想 / 反省

オートスケーリングが当たり前になり、こういう同じリソースを複数定義することがほとんどないのであまり熟考せず単調な定義をしてしまっていましたが、今回とてもスッキリさせることができました。
AWSアカウントをアプリや環境ごとに作るようになってきて、その都度ネットワークを構築するのでよくコピーされるのだけどほとんどメンテナンスされないので、今回とりあえず単調なコードを排除できてよかったなと自己満足しております。
こういう大きめなリファクタリングしてもterraformのstateの機能で補正できるのすごく便利だなと思いました。
とはいえ、`terraform state list, terraform state mv, terr`....と、結構萎えるので傷口が広がる前に改善できてよかったなと思います。
最初にしっかり調査、設計しましょう笑