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 章の構成と読み進め方