VPCピアリングで失敗した話。8個のVPC管理が地獄になるまで
VPCピアリングで始めたネットワーク設計が、VPC8個で28本の接続に。管理が地獄化してTransit Gateway移行を決めた実体験と、判断を失敗させない選択基準を解説。
VPCピアリングで始まった僕たちの失敗
先日、チームのスラックで「あれ、これ誰のVPCと誰のVPC繋いでたっけ?」って会話が出た。冗談じゃなくて、本気で管理できなくなってた状態だった。
うちの会社は2022年当初、AWSのVPC戦略を立てるとき「VPCピアリングで十分でしょ」という判断をしていた。その当時は開発環境、ステージング環境、本番環境、そしてデータ分析用のVPCで計4つ。ピアリングなら十分に見えてたんだ。
ところが2024年から事業が急速に拡大して、VPCが増殖していった。気づいたら8個のVPCがある状態に:
- 本番VPC(東京リージョン)
- 本番VPC(大阪リージョン)
- 開発用VPC(東京)
- ステージング用VPC(東京)
- データレイク用VPC(東京)
- 機械学習用VPC(東京)
- パートナー企業との連携VPC(東京)
- レガシーシステムVPC(東京)
これをすべてピアリングで繋ぐとなると…もう管理が地獄だった。ピアリング接続だけで28本の接続が必要になるんだ。その上、セキュリティグループの設定、ルートテーブルの追加、VPCフローログの分析…複雑さが指数関数的に増えていく。
2025年の春、「このままだと本番障害の時に原因特定ができない」という理由で、ようやくTransit Gatewayの導入を決めた。その時に得た教訓を、今から始める人向けにまとめておくことにした。
VPCピアリング vs Transit Gatewayの実装比較
実際に両方を運用してみてわかったのが、「単なるコスト比較じゃなくて、複雑さのコストが圧倒的に高い」ってこと。数字だけ見たら見誤る。
スペック比較表
| 項目 | VPCピアリング | Transit Gateway |
|---|---|---|
| セットアップ時間 | 5分(VPC2つ) | 30分(初回)+ 各VPC5分 |
| 同時接続数 | 実質制限なし | リージョンあたり5000接続 |
| 推移的ルーティング | 非対応(A-B, B-C なら A-C は直接通信不可) | 対応(自動でハブ化) |
| 管理の複雑さ | VPCペアごとに設定 | 一元管理 |
| トラブルシューティング | 複雑(経路追跡が大変) | シンプル(フロー図で可視化) |
| 月額基本費用(VPC 8個)* | 約2万円 | 約4.5万円 |
| 通信コスト(1TB/月) | 約1.5万円 | 0円 |
| 合計月額(1TB/月通信時) | 約3.5万円 | 約4.5万円 |
*2025年5月時点の東京リージョン料金
「Transit Gatewayの方が高い」ってイメージ、正直多いと思う。でも実際のところは通信量が多いほどTransit Gatewayが有利になる。うちの場合、VPC間通信が月30TB近くあるので、ピアリングだと月30万円以上のデータ転送費がかかる。Transit Gatewayなら通信コストゼロだから、実は月25万円近く削減できたんだ。
初期導入時は「なんで余計な費用をかけるんだ」って言われた。でも3ヶ月後には「あ、これで通信コスト消えてる」って気づいて、採算が取れたと判明する。結局、見方によって全然違うんだ。
Transit Gateway導入の実装パターン
うちが実装した構成がこれ。
graph TB
subgraph TGW["Transit Gateway (ap-northeast-1)"]
TGW_Core["TGW Core<br/>as-xxx"]
end
subgraph Prod_VPC["Production VPC (10.1.0.0/16)"]
Prod_AZ_A["AZ-a<br/>Subnet: 10.1.1.0/24"]
Prod_AZ_C["AZ-c<br/>Subnet: 10.1.2.0/24"]
Prod_TGW_ENI["TGW Attachment"]
end
subgraph Dev_VPC["Development VPC (10.2.0.0/16)"]
Dev_AZ_A["AZ-a<br/>Subnet: 10.2.1.0/24"]
Dev_TGW_ENI["TGW Attachment"]
end
subgraph DataLake_VPC["DataLake VPC (10.3.0.0/16)"]
DataLake_AZ_A["AZ-a<br/>Subnet: 10.3.1.0/24"]
DataLake_TGW_ENI["TGW Attachment"]
end
subgraph Partner_VPC["Partner VPC (10.4.0.0/16)"]
Partner_AZ_A["AZ-a<br/>Subnet: 10.4.1.0/24"]
Partner_TGW_ENI["TGW Attachment"]
end
Prod_TGW_ENI -->|"アタッチ"| TGW_Core
Dev_TGW_ENI -->|"アタッチ"| TGW_Core
DataLake_TGW_ENI -->|"アタッチ"| TGW_Core
Partner_TGW_ENI -->|"アタッチ"| TGW_Core
TGW_Core -->|"Transit Gateway Route Table<br/>10.0.0.0/8"| Prod_VPC
TGW_Core -->|"Transit Gateway Route Table"| Dev_VPC
TGW_Core -->|"Transit Gateway Route Table"| DataLake_VPC
TGW_Core -->|"セキュリティ分離<br/>Network ACL"| Partner_VPC
style TGW fill:#FF9900
style Prod_VPC fill:#4DD0E1
style Dev_VPC fill:#81C784
style DataLake_VPC fill:#FFB74D
style Partner_VPC fill:#EF5350
ポイントは3つある。
1. Transit Gateway Route Table(TGW-RT)の設計
これが一番ハマったやつ。Transit Gatewayにも「ルートテーブル」があるってことを最初理解できてなかった。
VPC側のルートテーブルと、TGW側のルートテーブルは別物なんだ。本番VPCのサブネットから 10.2.0.0/16 (開発VPC)への通信をしたい場合、以下の2段階が必要になる:
- VPC側のルートテーブル設定
Destination: 10.2.0.0/16
Target: TGW attachment
- TGW側のルートテーブル設定
Destination: 10.2.0.0/16
Target: dev-vpc-attachment
この2段階で初めて通信が成立する。最初、VPC側だけ設定して「あ、通信できない」ってハマった。
正直、TerraformでIaC化しておくと後々楽になる。以下みたいなコードで自動化できるんだ:
# Transit Gateway
resource "aws_ec2_transit_gateway" "main" {
description = "Main TGW for VPC connectivity"
default_route_table_association = "enable"
default_route_table_propagation = "enable"
tags = {
Name = "prod-tgw"
}
}
# VPCアタッチメント
resource "aws_ec2_transit_gateway_attachment" "prod" {
subnet_ids = aws_subnet.prod_private[*].id
transit_gateway_id = aws_ec2_transit_gateway.main.id
vpc_id = aws_vpc.prod.id
transit_gateway_default_route_table_association = true
tags = {
Name = "prod-vpc-attachment"
}
}
# VPC側ルートテーブル
resource "aws_route" "prod_to_other_vpcs" {
route_table_id = aws_route_table.prod_private.id
destination_cidr_block = "10.0.0.0/8" # すべてのVPCのCIDR
transit_gateway_id = aws_ec2_transit_gateway.main.id
}
# TGW側ルートテーブル(自動伝播)
resource "aws_ec2_transit_gateway_route_table" "prod" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
tags = {
Name = "prod-route-table"
}
}
2. パートナーVPCのセキュリティ分離
これ、重要なのに落とし穴になりやすい。すべてのVPCをTransit Gatewayに繋いだからって、すべてが通信できるようにしちゃダメなんだ。
パートナー企業のVPCとは、セキュリティグループレベルではなく、Transit Gateway Route Table レベルで分離する。別のRoute Tableを作って、意図的に限定的なルートだけ登録する。
# パートナーVPC用の独立したRoute Table
resource "aws_ec2_transit_gateway_route_table" "partner" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
tags = {
Name = "partner-route-table"
}
}
# パートナーから本番データレイクへのみ通信許可
resource "aws_ec2_transit_gateway_route" "partner_to_datalake" {
destination_cidr_block = "10.3.0.0/16" # DataLake VPC
transit_gateway_attachment_id = aws_ec2_transit_gateway_attachment.partner.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.partner.id
}
こうしておくと、パートナーVPCは意図した通信路(この場合DataLake VPCへの10.3.0.0/16)だけに限定される。本番VPCへのアクセスはRoute Table上に存在しないから、実装上ブロックされるんだ。セキュリティグループを頼るより、はるかに安全。
3. マルチリージョン対応
うちが後で困ったやつ。大阪にも本番環境を立てることになったとき、「あ、リージョンごとにTransit Gatewayが必要だ」って気づいた。
Transit Gatewayはリージョンスコープなので、東京のTGWと大阪のTGWを繋ぐには、Transit Gateway Peering Connection を使う。
flowchart LR
subgraph Tokyo["Region: ap-northeast-1"]
TGW_Tokyo["Transit Gateway<br/>Tokyo"]
VPC_Prod_Tokyo["Production VPC<br/>10.1.0.0/16"]
VPC_Dev_Tokyo["Dev VPC<br/>10.2.0.0/16"]
VPC_Prod_Tokyo -->|attach| TGW_Tokyo
VPC_Dev_Tokyo -->|attach| TGW_Tokyo
end
subgraph Osaka["Region: ap-northeast-3"]
TGW_Osaka["Transit Gateway<br/>Osaka"]
VPC_Prod_Osaka["Production VPC<br/>10.11.0.0/16"]
VPC_Prod_Osaka -->|attach| TGW_Osaka
end
TGW_Tokyo <-->|"Peering Connection<br/>通信料: 約1.5万円/月<br/>(1TB時)"| TGW_Osaka
style TGW_Tokyo fill:#FF9900
style TGW_Osaka fill:#FF9900
style VPC_Prod_Tokyo fill:#4DD0E1
style VPC_Dev_Tokyo fill:#81C784
style VPC_Prod_Osaka fill:#FF6B6B
このマルチリージョンピアリング、実装は簡単なんだけど、デメリットがある。
リージョン間通信は依然として課金対象になる。つまり、東京のVPCから大阪のVPCへデータを送ると、従来通りデータ転送費を取られる(1GB単位で約20円)。Transit Gateway内での通信は無料だが、リージョン間を越えると別なんだ。
うちの場合、リージョン間の月通信量が5TBくらいあるので、月7万5000円の追加費用が発生してる。これは我慢するしかない。でも、ピアリングで同じ5TBやり取りしてれば月18万円かかるから、Transit Gatewayなら7.5万円で済む…ということで、やっぱり得をしてるんだ。
実装でハマった4つの落とし穴
1. DNS解決がTransit Gatewayを経由しない
これ、地味に困る。prod-db.internal みたいなRoute 53プライベートホストゾーンで名前解決している場合、Transit Gateway経由で他のVPCから接続したいと思ったら、Route 53 Resolver Endpointを別途構築しなきゃいけない。
Transit Gateway Attachmentしただけでは、DNSリクエストはTransit Gatewayを経由しない。各VPCのRoute 53 Resolverが独立して動くから、クロスVPC通信するときは名前解決が失敗する。
対策はRoute 53 Resolver Sharing Rulesを有効化することだ。
resource "aws_route53_resolver_query_logging_config" "cross_vpc" {
name = "cross-vpc-resolution"
cloudwatch_log_group_name = aws_cloudwatch_log_group.route53.name
resource_id = aws_vpc.prod.id
}
resource "aws_route53_resolver_endpoint" "inbound" {
direction = "INBOUND"
name = "prod-resolver"
security_group_ids = [aws_security_group.resolver.id]
dynamic "ip_address" {
for_each = aws_subnet.prod_resolver
content {
subnet_id = ip_address.value.id
}
}
}
これで他のVPCから prod-db.internal で名前解決できるようになる。
2. セキュリティグループが「最初は」制限的になる
Transit Gatewayでネットワーク的に繋がったからって、セキュリティグループを忘れちゃダメ。本番VPC内のRDSインスタンスのセキュリティグループに、開発VPCのCIDR(10.2.0.0/16)からのインバウンドを許可しないと、結局接続できない。
うちが最初やったミスが、「Transit Gatewayに接続したら、セキュリティグループはVPC内通信の設定だけでいいだろう」という思い込み。実際には、クロスVPC通信でも、インバウンドルールに明示的に許可を書かなきゃいけない。
resource "aws_security_group_rule" "dev_to_prod_db" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["10.2.0.0/16"] # 開発VPCのCIDR
security_group_id = aws_security_group.prod_rds.id
}
これを忘れると、ネットワークレベルは繋がってるのに、「あれ、タイムアウトするぞ」ってハマる。
3. VPCフローログの分析が複雑になる
VPCフローログを見る際、Transit Gateway経由の通信がどのアタッチメントを通ったかを追跡するのが最初は難しい。VPCフローログには srcaddr, dstaddr は記録されるけど、「どのTransit Gateway アタッチメント経由か」は明示的には出ない。
対策は、Transit Gateway FlowLogsを別途有効化することだ。
resource "aws_ec2_transit_gateway_logging_options" "main" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
log_options {
log_destination = aws_cloudwatch_log_group.tgw.arn
log_format = "${version} ${account-id} ${tgw-id} ${tgw-attachment-id} ${flow-logs-format}"
}
}
これでTransit Gateway側のフローログが CloudWatch Logs に出力される。VPCフローログと組み合わせると、通信経路がはっきり可視化できるんだ。
4. 移行期間中は「ピアリング」と「Transit Gateway」の両方が共存する地獄
これ、一番の落とし穴だ。既存の8つのVPCを全部Transit Gatewayに移行するには時間がかかる。その間、「一部はピアリング、一部はTransit Gateway」みたいな状態になる。
うちの場合、3ヶ月かけて段階的に移行したんだけど、その間の管理が地獄だった。
段階的な切り替えが必要な場合は、こんな感じで管理する:
# ピアリング接続を削除(本番への影響を最小化)
resource "aws_vpc_peering_connection" "dev_to_datalake_deprecated" {
lifecycle {
ignore_changes = [all]
}
}
正直なところ、このハイブリッド状態が想像以上にストレスだった。新しい通信経路はTransit Gateway経由なのに、レガシーな接続はピアリング経由…困ったときにどちらを確認すればいいのか、毎回迷う。
移行を決めたら、極力短期間で一気に切り替える方が、心理的な負荷が低い。段階的が安全に見えるけど、実際には混乱が増すんだ。
VPCピアリングを選ぶべきケース
ここまで読むと「Transit Gateway一択でしょ」って思われそうだけど、ピアリングが正解のケースもある。正直に言うと、判断は規模次第だ。
VPCピアリングがまだ適切な場合:
- 2〜3個のVPCだけ:管理の複雑さがほぼゼロ。Transit Gatewayの固定費が無駄
- VPC間通信量が少ない:月1TB未満なら、ピアリングの通信料が1.5万円以下。Transit Gatewayの月4.5万円の固定費が勝る
- 一度だけ繋ぐ:例えば、本番環境 ↔ 分析用VPC という単一の接続。ピアリングで十分
- リージョンが異なる:リージョン間はそもそも推移的ルーティングがないので、ピアリングとTransit Gatewayの優位性が下がる
逆に、以下の場合はTransit Gateway一択だ:
- 4個以上のVPC:管理の複雑さがアウト
- VPC間通信が月5TB以上:データ転送費でTransit Gatewayが圧倒的に安い
- 将来的にVPCが増える可能性:最初はピアリングでも、後々Transit Gatewayに移行する手間が膨大
実際の判断フロー
flowchart TD
A["VPC数を数える"] -->|"2個以下"| B{"VPC間の月通信量?"}
A -->|"3個以上"| C["Transit Gateway<br/>一択"]
B -->|"1TB未満"| D["VPCピアリング<br/>で良い"]
B -->|"1TB以上"| E{"将来VPCが<br/>増える?"}
E -->|"NO"| F["ピアリングでOK<br/>ただし3年で見直し"]
E -->|"YES"| G["Transit Gateway<br/>先制導入推奨"]
style C fill:#FF9900
style G fill:#FF9900
style D fill:#81C784
style F fill:#FFB74D
まとめ
VPCピアリング vs Transit Gateway、結局のところ「今は何個で、将来何個になるか」で決まる。
うちの失敗は、「3年後に8個になるなんて想像してなかった」ってこと。もし2023年の時点で「5年で10個になるでしょ」と予測できてれば、当時からTransit Gateway導入してた。今では「新しいVPC立てるぞ」ってなったら、自動的にTransit Gateway Attachmentする流れができてる。
Key Takeaway:
- VPC 4個以上 + 月通信5TB以上 = Transit Gateway確定 —— これはもう迷う余地がない
- ハイブリッド状態は短期間で解決 —— ピアリングと共存させると管理負荷が爆増する
- 通信コストの可視化が意思決定を変える —— 最初は固定費で損してると思っても、半年で元取れるケースが多い
- Route Table、DNS、セキュリティグループは独立して設定 —— ネットワーク的に繋がったからって、全部が通信できるわけじゃない
- リージョン間はピアリングとTransit Gatewayの優位性が変わる —— 別途検討が必要
正直なところ、2025年現在ならTransit Gatewayは十分成熟してるし、AWSのマネージドサービスだから運用負荷も少ない。「規模が小さいなら…」って躊躇する必要もそこまでない。むしろ、「将来VPCが増える可能性があるなら、早めに導入」がベストプラクティスになってきた気がする。