TerraformでAWSルートテーブルを書くときの設計判断
この記事では、AWSのルートテーブルについてTerraformで書いて分かったこと、その過程で決めた設計判断をまとめます。
- メインルートテーブルには何も書かない
関連付けを忘れても、意図せずインターネットにつながらないようにする - 関連付けはサブネットから自動で作る
サブネットを増やしたときに、関連付けを忘れないようにする - AZごとに宛先が変わりうる経路は、ルートテーブルを分けておく
今は同じ宛先でも、あとから値の変更だけでAZ単位の経路に切り替えられるようにする - ルートの書き方は、module化するかどうかで選ぶ
インラインは統制と読みやすさ、独立resourceは条件分岐やmoduleの外からのルート追加に強い
構成
構成は次の図のとおりです。
2つのAZに、publicとprivateのサブネットを1つずつ置いています。

環境:Terraform v1.16 / AWS provider v6 / 東京リージョン
Terraformで見えてくるもの
コンソールでインターネット抜けのルートテーブルを作る流れは、次のようになります。
- 「ルートテーブルを作成」で、名前とVPCを指定する
- 詳細画面の「ルート」タブで、0.0.0.0/0 → IGW を追加する
- 「サブネットの関連付け」タブで、publicサブネットにする予定のサブネットを選ぶ
操作はすべて、1つのルートテーブルの詳細画面の中で完結します。画面の上では、「ルートテーブル」という1つのリソースに設定を足していく感覚です。
ところが、同じものをTerraformで書こうとすると、ルートテーブルは1つのresourceに収まらず3種類に分かれます。
| resource | 役割 | 対応するAPI |
|---|---|---|
| aws_route_table | 入れ物(ルートテーブル本体) | CreateRouteTable |
| aws_route | 1つのルート | CreateRoute |
| aws_route_table_association | サブネットとの紐付け | AssociateRouteTable |
一見すると、Terraformがわざわざ細かく分けているように見えます。しかし表の右列のとおり、AWSのAPIがもともと3つに分かれています。コンソールは、3回のAPI呼び出しを1つの画面にまとめて見せていただけでした。Terraformのresourceは、そのAPIの単位をほぼそのまま写しています。
コードにすると次のとおりです。3つのresourceが、route_table_id と subnet_id でつながっています。
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
}
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
Terraform (HCL)部品が分かれているぶん、コードは長くなります。その代わり、ルートテーブル本体・ルート・関連付けのそれぞれについて、「どう書くか」「そもそも書くか」を部品ごとに決められます。
次の章から扱う4つの設計判断は、どれもこの「部品ごとに決められる」ことの上に成り立っています。
1. メインルートテーブルには何も書かない
VPCを作ると、AWSが「メインルートテーブル」を自動で作ります。どのルートテーブルにも関連付けていないサブネットは、暗黙的にメインルートテーブルを使います。 そして、関連付けを書き忘れてもエラーにはなりません。
ここで、メインルートテーブルに 0.0.0.0/0 → IGW が入っていたとします。関連付けを書き忘れたサブネットは、黙ってインターネットにつながってしまいます。逆に、メインがVPC内のlocalルートだけなら、書き忘れの結果は「つながらない」で済みます。
つながらないことにはすぐ気づけますが、意図せずつながっていることには気づきづらいです。
なので、メインルートテーブルには手を触れず、localルートだけの状態とします。
これがコードにどう現れるかというと、何も現れません。このコードには、メインルートテーブルが一度も登場しないからです。「触らない」という判断は、「書かない」という形でしか表せません。
つまり、コードを読んだだけではこの判断は伝わりません。必要であれば、なぜ書かなかったのかは、コメントかドキュメントに残す必要があります。
なお、AWSのサブネットには「public」「private」という属性がそもそもありません。サブネットがpublicになるかprivateになるかは、インターネットに抜けられるルートテーブルを関連付けたかだけで決まります。関連付けが設計の要になるのはそのためです。
2. 関連付けの書き漏れを、構造で防ぐ
メインルートテーブルを空にしておけば、作成したルートテーブルのサブネットへの関連付けを書き漏れても安全側に倒れます。とはいえ、書き漏れないに越したことはありません。
下のコードでは、関連付けの for_each にサブネットのresourceそのものを渡しています。
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}Terraform (HCL)こうしておくと、サブネットが1つ増えれば関連付けも自動で1つ増えます。サブネットを足したのに関連付けを足し忘れる、ということが構造的に起きません。
コンソールでは、関連付けは「チェックボックスを入れる操作」でした。コードでは「サブネットの集合から導かれる関係」として書けます。関連付けが独立したresourceだからこそできることです。
3. privateのルートテーブルをAZごとに分ける
現在は、NAT Gatewayの冗長化手段として、リージョナルを選択すると思います。ですが今回はコストの面から、NAT Gatewayをゾーナルで1台だけ置く構成としています。
NATが1台なら、privateのルートテーブルは1つで足ります。2つのprivateサブネットは、どちらも同じNATに向ければよいからです。
それでもコードでは、ルートテーブルをAZごとに1つずつ作りました(冒頭の図の rtb-private-1a と rtb-private-1c)。今は2つの中身がまったく同じなので、一見すると無駄に見えます。
なぜ分けたのか:AZごとに向き先を変えたくなる場面
分けておいたのは、「AZごとに、別の宛先へ向けたい」場面が実際にあるからです。
- プライベートNATをAZごとに置く
IPアドレスが重複するVPC同士や、オンプレミスとの間をつなぐときに使う「プライベートNAT」は、リージョナルでは作れず、ゾーナルでしか使えません。冗長化するならAZごとに1台ずつ置き、各AZのサブネットを自分のAZのNATへ向ける必要があります。 - AWS Network Firewallなどで通信を検査する
ファイアウォールのエンドポイントはAZごとに作られます。各AZのサブネットは、同じAZのエンドポイントへ向ける必要があります。
ルートテーブルが1つだけだと、0.0.0.0/0 の向き先も1つしか書けません。AZごとに別の宛先を書くには、ルートテーブル自体がAZごとに分かれている必要があります。
インターネットへ出るためのNATを冗長化するだけなら、リージョナルNAT Gatewayでルートは全AZ共通の1つで済みます。AZごとに分けておくのは、上のように「AZごとの宛先」が必要になる構成への備えです。
コードでは「変数1つ」の切り替えになる
Terraform版では、NATの台数を変数 nat_gateway_count で持ち、各AZがどのNATを使うかを locals で計算しています。
locals {
nat_index_by_az = {
for i, az in var.azs : az => i % var.nat_gateway_count
}
}
resource "aws_route_table" "private" {
for_each = local.private_subnet_cidrs # AZ名がキー
vpc_id = aws_vpc.main.id
}
resource "aws_route" "private_nat" {
for_each = local.private_subnet_cidrs
route_table_id = aws_route_table.private[each.key].id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[local.nat_index_by_az[each.key]].id
}Terraform (HCL)% は割り算の余りです。
| nat_gateway_count | 1a が使うNAT | 1c が使うNAT |
|---|---|---|
| 1 | 0番 | 0番(1 % 1 = 0) |
| 2 | 0番 | 1番(1 % 2 = 1) |
AZごとにNATを置く形にする場合は、nat_gateway_count を 2 に変えるだけです。
もしルートテーブルを1つで作っていたら、後から分けるには関連付けを含めた構造の変更が必要になります。先に分けておけば、同じことが値の変更で済みます。
分けるコストはほぼゼロ
ルートテーブル自体は無料です。そして for_each で書いているので、AZが増えてもコードの量は増えません。
コンソールでは、ルートテーブルを1つ増やすごとに作成・ルート追加・関連付けの手間がかかるので、1つで済ませたくなりますが、コードではその手間がなくなります。
今は必要がないが、変更に対応できる構造だけは先に用意しておく。ということが簡単に行えます。
4. ルートはインラインで書くか、独立resourceで書くか
ここまでのコードでは、ルートを aws_route という独立したresourceで書いてきました。実は、ルートテーブルの中に直接書く方法もあります。
# A:インライン
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
# B:独立resource(採用)
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}Terraform (HCL)AWS providerはどちらの書き方も提供しています。ドキュメントに書かれているのは、同じルートテーブルに対して両方を混ぜてはいけない(設定が衝突して、差分が出続ける)ということだけです。どちらを推奨するかは書かれていません。
インラインの強み
- 統制が強い
インラインのrouteは、そのルートテーブルの中身を丸ごと管理します。誰かがコンソールで手動でルートを追加すると、次のplanでそれが差分として検出され、applyで消されます。独立resourceでは、コードに書いていないルートは検出すらできません。 - 読みやすい
ルートテーブルとその中身が1か所にまとまっています。独立resourceはroute_table_idでつながっているので、全体を知るには複数のresourceを追う必要があります。
ただし、統制の強さは plan を定期的に実行していることが前提です。実行していなければ、インラインでも手動変更には気づけません。
独立resourceの強み
独立resourceの強みは、構成をmoduleにして使い回すときに出てきます。
- moduleの利用
moduleの中でルートテーブルを作り、呼び出し側からルートを足したい場面があります(たとえば、後からTransit Gatewayへのルートを足すなど)。インラインでは、moduleの外からrouteブロックを差し込む方法がありません。つまりインラインでは原理的に書けないということです。
それに対して、独立resourceなら呼び出し側でaws_routeを書くだけです。
- 条件分岐がある場合の読みやすさ
module は、たとえば「NATを作る/作らない」を変数で切り替えます。独立resourceなら、下のコードのようにルートごとにcountを付けるだけです。
インラインで同じことをするには、dynamic "route"ブロックでrouteを生成する必要があります。条件が1つならまだしも、NAT・Transit Gateway・VPCエンドポイント…と増えると、ルートテーブル1つの定義がかなり読みにくくなります。
つまり、インラインの読みやすさは「ルートが静的に決まっている場合」に限った話です。
resource "aws_route" "private_nat" {
count = var.enable_nat ? 1 : 0
...
}Terraform (HCL)実際にはどちらが多いか
VPCを作るmoduleとして広く使われている terraform-aws-modules/vpc/aws は、独立resourceで書かれています。一方、moduleにしない小規模な構成では、インラインも使われています。
どちらを選択するかは、構成の規模よりも「moduleにするかどうか」で決める、と私は整理しています。
今回、最終的に独立resourceを選んだ理由
- 今後VPCを増やすときにmoduleにする予定がある。
- 広く使われているVPC moduleと同じ形なので、他の人が読んでも見慣れている
- インラインの主な利点である手動変更の検出は、今後CI/CDでapplyの経路を一本化し、コンソールからの変更を権限で塞ぐことで代わりが利くと考えた
なお、この判断は取り返しのつかない類のものではありません。
そこについては次で解説します。
「インラインで始めて、あとから独立resourceへ移す」という選択肢
小さいうちはインラインで書き、moduleにするタイミングで独立resourceへ移す、という進め方も十分合理的です。移すときに、ルートテーブルやサブネットを作り直す必要はありません。
ただし、移行は「route ブロックを消して aws_route を書く」だけでは済みません。
routeブロックを1つも書かないと、Terraformはそのルートテーブルのルートを管理しなくなるだけです。AWS上のルートは消えずに残ります。- その状態で
aws_routeを追加してapplyすると、同じルートがすでに存在するため、作成に失敗します(RouteAlreadyExists)。
既存のルートは、import ブロックで aws_route に取り込むのが確実です。IDは「ルートテーブルID_宛先CIDR」の形です。
import {
to = aws_route.public_internet
id = "rtb-xxxxxxxxxxxxxxxxx_0.0.0.0/0"
}Terraform (HCL)plan の最終行が Plan: 1 to import, 0 to add, ... のようになっていれば、既存のルートを作り直さずに引き継げています。
おわりに
コンソールで何気なく作っていたルートテーブルも、Terraformで書くと判断する場面がいくつもあるのが面白いところ。
書いたコードは、そのまま設計の説明になりますが、「メインルートテーブルには何も書かない」のように、書かないことを選んだ判断はコードに残りません。
コードとあわせて、そういった見えない部分についてもコメントなど書き残しておくとより、分かりやすいものになるのかな、とか思ったりしました。