VPC: CIDR設計と2 AZ×3層ネットワークのコスト判断
前章までで、Terraform を実行する専用の IAM ユーザーができました。ここから実際に AWS のリソースを積み上げていきます。最初に作るのは VPC です。ALB も ECS も RDS も、自分がどのサブネットに属するかを作成時に指定する必要があり、そのサブネットを持つ VPC が先になければ作れません。1
VPC そのものは CIDR ブロック 1 つを持つだけのリソースで、実質的な設計判断はサブネットの区切り方に現れます。この章では、Tasuku が使う10.0.0.0/16という CIDR の根拠を扱います。続けて、2 つのアベイラビリティゾーンに public・private-app・private-data の 3 層を配置する理由と、プライベートサブネットの出口となる NAT Gateway を 2 つ用意する根拠へ進みます。
4.1. VPC と CIDR の基礎
VPC (Virtual Private Cloud) は、AWS アカウント内に論理的に切り出された仮想ネットワークです。作成時に指定するのは IPv4 の CIDR ブロック 1 つだけで、そのアドレス空間の中にサブネットを切り出していきます。
CIDR ブロックのサイズには制約があります。AWS が許可するのは/16(65,536 個の IP アドレス)から/28(16 個)までの範囲で、これより大きくも小さくもできません。2 プライベート IP アドレスの範囲 (RFC 1918) を使うのが通例で、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16のいずれかから選びます。
サブネットにも同じ/16〜/28の制約があります。ただしサブネットでは、CIDR ブロックの先頭 4 個と末尾 1 個、計 5 個の IP アドレスを AWS が予約するため、実際に使える IP 数はブロックサイズより 5 少なくなります。/24なら 256 個から 5 個引いて 251 個です。3 4 予約される 5 個の内訳は、ネットワークアドレス、VPC ルータ用、DNS サーバー用、AWS の将来利用向け、そしてブロードキャストアドレスです。3
VPC 全体の CIDR 設計では、使い切らずに余裕を持たせることが AWS Well-Architected Framework でも推奨されています。5 小さすぎる VPC やサブネットを作ってしまうと、後から追加の CIDR ブロックを足すか、サブネットを作り直す羽目になります。
4.2. Tasuku の CIDR 設計
Tasuku のネットワークには10.0.0.0/16を割り当てます。RFC 1918 の範囲内で、AWS が提供する VPC の例でもそのまま使われる値です。2 65,536 個の IP アドレス空間のうち、この章で実際に使うのは 6 個の/24サブネットだけです。
| 層 | AZ1 | AZ2 | 用途 |
|---|---|---|---|
| public | 10.0.0.0/24 | 10.0.1.0/24 | ALB (第6章) |
| private-app | 10.0.10.0/24 | 10.0.11.0/24 | ECS Fargate (第8・10章) |
| private-data | 10.0.20.0/24 | 10.0.21.0/24 | RDS (第9章) |
採番は詰めていません。層ごとに 10 刻みで空番を残し、10.0.2.0/24から10.0.9.0/24のような間を空けています。第 11 章で環境を分離するときや、将来サブネットを追加するときに、既存の番号を採番し直さずに済むようにするためです。
/24は 251 個の IP アドレスを持ちます。3 Fargate タスクは 1 つにつき 1 つの ENI (Elastic Network Interface) を専有するため、6 実行するタスク数がそのままこの 251 個を消費します。ただし 6 つのサブネット全体で 256 個の/24のうち 6 個しか使っておらず、CIDR 空間そのものには十分な余裕があります。
4.3. 2 AZ × 3 層のサブネット構成
Tasuku のネットワークは、2 つのアベイラビリティゾーンに public・private-app・private-data の 3 層を配置します。
publicはインターネットゲートウェイへの経路を持つサブネットで、ALB を置きます。private-appはインターネットゲートウェイへの経路を持たず、NAT Gateway 経由でのみ外向き通信ができるサブネットで、ECS Fargate のタスクを置きます。private-dataは private-app と同じくインターネットゲートウェイへの経路を持ちませんが、NAT Gateway への経路も持たせず、RDS を置きます。
3 層に分ける理由は、層ごとに必要な通信の向きが違うためです。ALB は不特定多数からの着信を受ける必要があり、public サブネット以外の選択肢がありません。ECS Fargate のタスクは、着信は不要な一方、コンテナイメージの取得やログ送信のために外向きの通信が必要です。7 RDS は管理サービスでありインターネットからアクセスされるべきではなく8、かつこの構成では RDS からインターネットへの発信も必要としません。
アベイラビリティゾーンの指定には、ap-northeast-1aのような文字列を直接書き込みません。aws_availability_zonesデータソースで実行時に取得した一覧の先頭 2 つを使います。AWS はアベイラビリティゾーンの物理的な場所をアカウントごとにランダムな名前へ割り当てており、あるアカウントのus-east-1aが別のアカウントのus-east-1aと同じ場所を指すとは限りません。9 コードに具体的な名前を固定してしまうと、別のアカウントで動かしたときに意図と違うゾーンに展開されかねません。
なお、この一覧に 2 つ以上のアベイラビリティゾーンが含まれることはコード上でチェックしていません。Tasuku が使うap-northeast-1は 4 つのアベイラビリティゾーンを持つため実害はありませんが、10 アベイラビリティゾーンが 1 つしかないリージョンに同じコードを持ち込む場合は、先に一覧の件数を確認してください。
4.4. インターネットゲートウェイとパブリックサブネットの経路
インターネットゲートウェイ (IGW) は、VPC とインターネットの間の出入り口です。VPC に 1 つだけアタッチし、vpc_idを指定するだけで済みます。11
public サブネット 2 つの経路は、どちらも同じ IGW に向かうだけです。IGW はアベイラビリティゾーンに紐づかないため、2 つのサブネットで別々のルートテーブルを用意する意味がなく、1 枚を共有します。ルート内容は次の 1 行だけです。
0.0.0.0/0 → インターネットゲートウェイ
4.5. NAT Gateway: プライベートサブネットの出口とコスト判断
private-app サブネットに置く ECS Fargate のタスクは、コンテナイメージの取得やログ送信のために外向きの通信が必要です。7 private-app サブネットは IGW への経路を持たないため、この外向き通信は NAT Gateway を経由させます。
NAT Gateway の数には 2 つの選択肢があります。
1 つ目は、2 つのアベイラビリティゾーンで 1 個の NAT Gateway を共有する構成です。時間課金を半分に抑えられますが、その NAT Gateway が乗っているアベイラビリティゾーンが利用不可になると、もう一方の健全なアベイラビリティゾーンにある private-app サブネットもインターネットへの出口を失います。
2 つ目は、アベイラビリティゾーンごとに 1 個ずつ、計 2 個の NAT Gateway を用意する構成です。NAT Gateway の高可用性は単一のアベイラビリティゾーン内に閉じており、アベイラビリティゾーンを跨いだ耐障害性を得るにはアベイラビリティゾーンごとに 1 個が必要だというのが、AWS の複数の公式ドキュメントで一致している見解です。13 14 15 16
Tasuku では 2 個の構成を選びます。第 1 章で、1 つのアベイラビリティゾーンが利用不可になってもサービスを継続する構成にすると決めているためです。17 NAT Gateway だけアベイラビリティゾーンをまたぐ単一障害点にしてしまっては、この前提と矛盾します。
コストの増分は、時間課金の分だけです。NAT Gateway が処理するデータの量に対する課金は、同じアベイラビリティゾーン内で見る限り NAT Gateway の個数に依存せず、そのアベイラビリティゾーンを通過するトラフィックの総量だけで決まります。18 2 個にして増えるのは、稼働時間分の固定費だけです。
なお、AWS は「Regional NAT Gateway」という可用性モードも提供しています。1 つの論理的な NAT Gateway が、ワークロードの存在するアベイラビリティゾーンへ自動的に展開されるしくみで、Terraform 上もaws_nat_gateway1 個で書けます。19 ただし、この機能はアベイラビリティゾーンごとに帯域を確保する動作をしており、20 コードの見た目が 1 個になることと、実際の課金がアベイラビリティゾーンごとに 2 個用意する構成より安くなることは別の話です。後者は執筆時点で確認できていません。コスト構造が確定していないこの選択肢を主教材にはせず、Tasuku では採用せず、選択肢の 1 つとしてここで触れるにとどめます。
4.6. ルートテーブルの設計
Tasuku のネットワークには 4 枚のルートテーブルを用意します。
| ルートテーブル | 対象サブネット | ルート |
|---|---|---|
| public | public-az1, public-az2 (共有) | 0.0.0.0/0 → IGW |
| private-app-az1 | private-app-az1 | 0.0.0.0/0 → NAT Gateway (az1) |
| private-app-az2 | private-app-az2 | 0.0.0.0/0 → NAT Gateway (az2) |
| private-data | private-data-az1, private-data-az2 (共有) | なし |
public が 1 枚で足りる理由は前節で述べた通りです。private-app は、自分のアベイラビリティゾーンにある NAT Gateway にしか向けられないため、アベイラビリティゾーンごとに 1 枚必要です。
private-data には、VPC 内部向けの暗黙の local ルート(全ルートテーブルに自動的に入る、CIDR 内向けの削除不能なルート)以外、意図的にルートを追加しません。RDS はインターネットからアクセスされるべきではなく8、かつこの構成ではインターネットへの能動的な通信も必要としないためです。21 public・private-app・private-data という 3 層に分けた意味は、このルートテーブルの中身の違いにそのまま表れています。public はどこへでも出ていける、private-app は NAT Gateway 越しにだけ出ていける、private-data は VPC 内部以外どこへも出ていけない(ECS タスクからの接続は VPC 内部通信のため到達可能)、という 3 段階です。
いずれのサブネットも、VPC 作成時に暗黙で使われる「メインルートテーブル」には乗せません。6 つのサブネットすべてに、どのルートテーブルに属するかを明示的に関連付けます。
4.7. セキュリティグループチェーンの方針 (実装は後続章で)
ここまでのルートテーブルの設計は、通信が「どこまで届きうるか」を決めるものでした。実際にどの通信を許可するかは、セキュリティグループが担います。
Tasuku で最終的に必要になるセキュリティグループは 3 つで、許可の連鎖は次の図の通りです。
| セキュリティグループ | 許可する送信元 | 作成する章 |
|---|---|---|
| ALB用 | インターネット (80番→443番ポート) | 第6章→第7章 |
| ECSタスク用 | ALB用SGのみ | 第8章 |
| RDS用 | ECSタスク用SGのみ | 第9章 |
この章の時点では、ALB・ECS・RDS のいずれもまだ存在しません。存在しないリソースへの通信を許可するルールは書けないため、実体の作成はそれぞれのリソースを作る章に委ねます。第3章で IAM ロールを予告表だけにとどめたのと同じ考え方です。
この章で実際に手を入れるのは、VPC 作成時に自動的にできる既定のセキュリティグループだけです。既定のセキュリティグループは、何も設定しなければ同一セキュリティグループ内からの通信をすべて許可し、外向きの通信もすべて許可した状態のままになります。使う予定のないリソースにこの既定のセキュリティグループが誤って付いてしまったときの影響を抑えるため、ingress と egress のルールを両方とも空にして閉じます。22 23
4.8. Terraform 実装
ここまでの設計をvpc.tfに書いていきます。既存のvariables.tfには VPC 全体の CIDR を変数として追加し、VPC・サブネット・ルーティングといったこの章の主要なリソースはvpc.tf1 ファイルに収めます。第 2 章のbudget.tf、第 3 章のiam.tfと同じく、1 つの章が主に足すリソースを 1 つのファイルにまとめる方針です。あわせて、後続の章が参照する値をまとめるoutputs.tfをこの章から新設します。
まず、6 つのサブネットの定義をローカル値としてまとめます。
locals {
# 2 AZ × 3 層 (public / private-app / private-data) の6サブネット定義
# az_key ("az1"/"az2") を全リソースの for_each キーに揃え、AZ の対応関係を一貫させる
subnets = {
"public-az1" = { tier = "public", az_key = "az1", cidr = "10.0.0.0/24", public_ip_launch = true }
"public-az2" = { tier = "public", az_key = "az2", cidr = "10.0.1.0/24", public_ip_launch = true }
"private-app-az1" = { tier = "private-app", az_key = "az1", cidr = "10.0.10.0/24", public_ip_launch = false }
"private-app-az2" = { tier = "private-app", az_key = "az2", cidr = "10.0.11.0/24", public_ip_launch = false }
"private-data-az1" = { tier = "private-data", az_key = "az1", cidr = "10.0.20.0/24", public_ip_launch = false }
"private-data-az2" = { tier = "private-data", az_key = "az2", cidr = "10.0.21.0/24", public_ip_launch = false }
}
# az1 → names[0]、az2 → names[1] (data.aws_availability_zones.available の取得順)
az_key_to_index = { az1 = 0, az2 = 1 }
# 層ごとに az_key をキーにしたサブネットキーの逆引き。NAT Gateway とルートテーブルの
# for_each をこの az_key で揃えることで、AZ の取り違えを構造的に防ぐ
public_subnets_by_az = { for k, v in local.subnets : v.az_key => k if v.tier == "public" }
private_app_subnets_by_az = { for k, v in local.subnets : v.az_key => k if v.tier == "private-app" }
private_data_subnet_keys = { for k, v in local.subnets : k => k if v.tier == "private-data" }
}
for_eachはこの連載ではじめて使うメタ引数です。6 つの似た形のサブネットを個別のブロックとして書く代わりに、1 つのresourceブロックに定義のマップを渡し、Terraform にブロック分の展開をまかせます。ポイントは、public・private-app それぞれのfor_eachのキーを"az1"/"az2"という共通の文字列に揃えていることです。あるアベイラビリティゾーンの NAT Gateway が、別のアベイラビリティゾーンのルートテーブルに紐づいてしまうような取り違えを、この共通キーによって防ぎます。
アベイラビリティゾーンの一覧は、実行時にデータソースとして取得します。
data "aws_availability_zones" "available" {
state = "available"
}
VPC 本体とサブネットを作ります。
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
# DNS ホスト名は既定 false のため明示する
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${local.name_prefix}-vpc"
}
}
resource "aws_subnet" "this" {
for_each = local.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = data.aws_availability_zones.available.names[local.az_key_to_index[each.value.az_key]]
map_public_ip_on_launch = each.value.public_ip_launch
tags = {
Name = "${local.name_prefix}-${each.key}"
}
}
enable_dns_hostnamesは既定でfalseのため、明示的にtrueを指定しています。24 aws_subnetのmap_public_ip_on_launchも既定はfalseで、25 public サブネットの 2 つだけeach.value.public_ip_launch経由でtrueを渡しています。
インターネットゲートウェイと、public サブネット用のルートテーブルです。
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-igw"
}
}
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
}
tags = {
Name = "${local.name_prefix}-public-rt"
}
}
resource "aws_route_table_association" "public" {
for_each = local.public_subnets_by_az
subnet_id = aws_subnet.this[each.value].id
route_table_id = aws_route_table.public.id
}
続いて、NAT Gateway と private-app 用のルートテーブルです。
resource "aws_eip" "nat" {
for_each = local.public_subnets_by_az
domain = "vpc"
tags = {
Name = "${local.name_prefix}-nat-eip-${each.key}"
}
depends_on = [aws_internet_gateway.main]
}
resource "aws_nat_gateway" "this" {
for_each = local.public_subnets_by_az
allocation_id = aws_eip.nat[each.key].id
subnet_id = aws_subnet.this[each.value].id
tags = {
Name = "${local.name_prefix}-nat-${each.key}"
}
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "private_app" {
for_each = local.private_app_subnets_by_az
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[each.key].id
}
tags = {
Name = "${local.name_prefix}-private-app-rt-${each.key}"
}
}
resource "aws_route_table_association" "private_app" {
for_each = local.private_app_subnets_by_az
subnet_id = aws_subnet.this[each.value].id
route_table_id = aws_route_table.private_app[each.key].id
}
aws_eipとaws_nat_gatewayのどちらにも、インターネットゲートウェイへのdepends_onを明示しています。EIP や NAT Gateway は内部的にインターネットゲートウェイの存在を前提にしており、暗黙の依存関係の解決順に任せるとエラーになることがあるため、明示するのが公式の推奨です。26 27
private-data 用のルートテーブルと、既定のセキュリティグループです。
resource "aws_route_table" "private_data" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-private-data-rt"
}
}
resource "aws_route_table_association" "private_data" {
for_each = local.private_data_subnet_keys
subnet_id = aws_subnet.this[each.value].id
route_table_id = aws_route_table.private_data.id
}
resource "aws_default_security_group" "main" {
vpc_id = aws_vpc.main.id
}
aws_default_security_groupは、他のリソースと性質が異なります。新しく何かを作るのではなく、VPC 作成時に自動でできる既定のセキュリティグループを Terraform の管理下に引き取り、ingress・egress のルールを何も書かないことで、既存のルールをすべて削除します。28
最後に、後続の章が参照する値をoutputs.tfにまとめます。
output "vpc_id" {
description = "第4章で作成したVPCのID (第6/8/9章で参照)"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "publicサブネット (ALB用、第6章で参照) のIDリスト"
value = [for k, v in aws_subnet.this : v.id if local.subnets[k].tier == "public"]
}
output "private_app_subnet_ids" {
description = "private-appサブネット (ECS Fargate用、第8/10章で参照) のIDリスト"
value = [for k, v in aws_subnet.this : v.id if local.subnets[k].tier == "private-app"]
}
output "private_data_subnet_ids" {
description = "private-dataサブネット (RDS用、第9章で参照) のIDリスト"
value = [for k, v in aws_subnet.this : v.id if local.subnets[k].tier == "private-data"]
}
output "nat_gateway_public_ips" {
description = "NAT GatewayのパブリックIP (動作確認・将来の外部API許可リスト用)"
value = [for k, v in aws_nat_gateway.this : v.public_ip]
}
この連載は第 11 章までモジュール化を行わない単一構成のため、後続の章はoutputs.tfを経由せずaws_subnet.this["public-az1"].idのように直接参照できます。それでもoutputs.tfを用意するのは、terraform outputで値を確認できるようにするためと、この章が公開する値を 1 か所にまとめておくためです。
4.9. 動作確認
terraform fmt -check -recursive -diffとterraform validateを実行し、構文とプロバイダスキーマに問題がないことを確認します。29 この 2 つのコマンドが保証するのは構文とスキーマの整合までで、実際に AWS へapplyした結果までは保証しません。
terraform planを実行すると、次の 22 件の追加が計画に表示されるはずです。
aws_vpc1 件aws_subnet6 件aws_internet_gateway1 件aws_route_table(public) 1 件 +aws_route_table_association2 件aws_eip2 件aws_nat_gateway2 件aws_route_table(private-app) 2 件 +aws_route_table_association2 件aws_route_table(private-data) 1 件 +aws_route_table_association2 件
aws_default_security_groupは新しくリソースを作るのではなく既存の既定セキュリティグループを引き取る特殊な動作のため、28 上の 22 件とは別枠で、変更として表示される可能性があります。
applyのあとは、terraform outputで VPC ID や NAT Gateway のパブリック IP を確認できます。NAT Gateway のパブリック IP は、外部の API を IP アドレスで許可リスト化するような場面で、後の章から参照することになります。
本章のまとめ
- VPC が持つ実質的な設計判断は、CIDR の割り当てとサブネットの区切り方に現れます。Tasuku は
10.0.0.0/16に 2 AZ × 3 層 (public / private-app / private-data) の 6 サブネットを切り出しました - NAT Gateway はアベイラビリティゾーンごとに 2 個用意します。単一構成よりコストはかかりますが、第 1 章で決めた「1 アベイラビリティゾーン障害でも継続する」構成に必要な選択です
- ルートテーブルは 4 枚で、public・private-app・private-data という 3 層の性質の違いが、そのままルートの中身の違いに表れています
- セキュリティグループは、この章では既定のものを閉じるだけにとどめ、ALB・ECS・RDS 用の実体は使う章でそれぞれ作ります
for_eachをこの連載ではじめて導入し、6 つの同型なサブネットと NAT Gateway をまとめて定義しました
次に読む
- 第 5 章: S3: バケット設計原則と tfstate 管理の定石
- 第 3 章: IAM: ポリシー JSON の読み書きと、Terraform 実行権限の現実解
- 連載の目次: 全 12 章の構成と読み進め方