はじめに
エンタープライズ環境でAWSを利用していると、VPCが増えるにつれて「アウトバウンド通信をどう制御するか」が悩みどころになってきます。
各VPCにNAT GatewayやFirewallを配置する構成もありますが、セキュリティポリシーや運用を考えると、出口を集約したくなるケースは少なくありません。
本記事では、AWS Transit Gateway と Network Firewall を組み合わせた Centralized Egress アーキテクチャについて、Terraform / Terragrunt での実装例を交えながら整理していきます。
以前公開した
「異なるVPCのNetwork Firewallでドメインベースのアクセス制御をする際はHOME_NETの上書きが必要」
では、HOME_NET 設定の落とし穴にフォーカスしましたが、本記事ではその前提となるネットワーク全体の設計まで掘り下げます。
アーキテクチャ概要
Hub-and-Spoke構成
今回の構成は、Transit Gateway を中心に据えた Hub-and-Spoke 型です。
-
Hub(Egress VPC)
インターネットへの出口を担うVPCです。
Network Firewall、NAT Gateway、Internet Gateway を配置し、すべてのアウトバウンド通信がここを通過します。 -
Spoke(Workload / Tooling VPC)
実際のワークロードが動くVPCです。
直接インターネットには出ず、Transit Gateway 経由で Hub に集約されます。
この構成を取ることで、以下のメリットがあります。
-
アウトバウンド制御の集中管理
Firewall ルールやログを1か所で管理できる -
コストの抑制
NAT Gateway や Network Firewall をVPCごとに持たなくてよい -
運用の単純化
セキュリティポリシー変更時の影響範囲が限定される
VPC構成
本アーキテクチャでは3つのVPCを使用します。
| VPC名 | CIDR(例) | 役割 | Hub/Spoke |
|---|---|---|---|
| Workload VPC | 192.0.2.0/16 | アプリケーション実行基盤 | Spoke |
| Tooling VPC | 198.51.100.0/16 | CI/CD・ビルド基盤 | Spoke |
| Egress VPC | 203.0.113.0/16 | 集約Egress | Hub |
Terragruntを使う理由
本構成では Terraform のラッパーとして Terragrunt を利用しています。
Terragrunt採用の理由は以下です。
DRYを保ちやすい
環境差分(stg / prd など)を terragrunt.hcl 側に閉じ込められるため、モジュール側のコードをかなり素直に保てます。
依存関係をコードで表現できる
dependency ブロックで「このモジュールは、あのモジュールの出力を使う」という関係が明示できるのは、ネットワーク構成では特に助かります。
# envs/stg/network-egress/terragrunt.hcl の例
dependency "network" {
config_path = "../network"
mock_outputs = {
vpc_id = "vpc-mock"
subnet_ids = { "app-1a" = "subnet-mock" }
}
}
inputs = {
workload_vpc_id = dependency.network.outputs.vpc_id
spoke_cidrs = [
dependency.network.outputs.subnet_cidrs["app-1a"],
dependency.network.outputs.subnet_cidrs["app-1c"],
]
}
Terraformプロジェクト構成
ディレクトリ構成は以下の通りです。環境別の設定はTerragruntで管理し、再利用可能なモジュールを分離しています。
infrastructure/
├── envs/
│ ├── root.hcl
│ ├── stg/
│ │ ├── network/
│ │ │ └── terragrunt.hcl
│ │ └── network-egress/
│ │ ├── terragrunt.hcl
│ │ └── allowed_domains.yaml
│ └── prd/
│ ├── network/
│ │ └── terragrunt.hcl
│ └── network-egress/
│ ├── terragrunt.hcl
│ └── allowed_domains.yaml
└── modules/
├── network/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── vpc/
│ ├── subnet/
│ ├── route_table/
│ └── vpc_endpoint/
└── network-egress/
├── main.tf
├── variables.tf
├── outputs.tf
├── vpc/
├── subnet/
├── route_table/
├── nat_gateway/
├── network_firewall/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── tgw/
├── main.tf
├── variables.tf
└── outputs.tf
root.hclの役割
root.hclは、すべての環境で共通の設定を定義するファイルです。主に以下の内容を含みます。
# envs/root.hcl
# 現在のディレクトリから環境名を自動検出
locals {
env = basename(dirname(get_terragrunt_dir())) # "stg" or "prd"
module = basename(get_terragrunt_dir()) # "network" or "network-egress"
region = "ap-northeast-1"
# 環境ごとのAWSアカウントID
aws_account_ids = {
stg = "123456789012"
prd = "987654321098"
}
}
# リモートステートの設定(S3バックエンド)
remote_state {
backend = "s3"
config = {
bucket = "${local.env}-terraform-state"
key = "${local.module}/terraform.tfstate"
region = local.region
encrypt = true
dynamodb_table = "${local.env}-terraform-lock"
}
}
# プロバイダー設定の自動生成
generate "provider" {
path = "providers.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.region}"
}
EOF
}
この構造により、環境固有の設定(CIDR、許可ドメイン等)と再利用可能なインフラロジックを分離できます。
Transit Gateway設計
ルートテーブルの分離
Transit Gateway では Spoke用 と Hub用 のルートテーブルを分けています。
- Spoke RTB:0.0.0.0/0 を Egress VPC に向ける
- Hub RTB:各 Spoke VPC の CIDR を保持する
この分離をしておくことで、「どこから来た通信をどこへ返すか」が読みやすくなります。
# modules/network-egress/tgw/main.tf
# Transit Gateway本体
resource "aws_ec2_transit_gateway" "this" {
description = var.name
# デフォルトのルートテーブル関連付けを無効化
# これにより、明示的なルーティング制御が可能になる
default_route_table_association = "disable"
default_route_table_propagation = "disable"
tags = merge({ Name = var.name }, var.tags)
}
# Spokeルートテーブル(Workload VPC、Tooling VPC用)
resource "aws_ec2_transit_gateway_route_table" "spoke" {
transit_gateway_id = aws_ec2_transit_gateway.this.id
tags = merge(var.tags, { Name = "${var.name}-spoke" })
}
# Hubルートテーブル(Egress VPC用)
resource "aws_ec2_transit_gateway_route_table" "hub" {
transit_gateway_id = aws_ec2_transit_gateway.this.id
tags = merge(var.tags, { Name = "${var.name}-hub" })
}
VPCアタッチメントとappliance_mode_support
複数VPCをTransit Gatewayに接続する際、appliance_mode_supportの設定が重要です。
# modules/network-egress/tgw/main.tf
# Workload VPCのアタッチメント(Spoke)
resource "aws_ec2_transit_gateway_vpc_attachment" "workload" {
vpc_id = var.workload_vpc_id
subnet_ids = var.workload_attachment_subnet_ids
transit_gateway_id = aws_ec2_transit_gateway.this.id
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
# アプライアンスモードを有効化
# これにより、同一フローのトラフィックが常に同じAZを経由する
appliance_mode_support = "enable"
tags = merge({ Name = "${var.name}-workload" }, var.tags)
}
# Tooling VPCのアタッチメント(Spoke)
resource "aws_ec2_transit_gateway_vpc_attachment" "tooling" {
vpc_id = var.tooling_vpc_id
subnet_ids = var.tooling_attachment_subnet_ids
transit_gateway_id = aws_ec2_transit_gateway.this.id
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
appliance_mode_support = "enable"
tags = merge({ Name = "${var.name}-tooling" }, var.tags)
}
# Egress VPCのアタッチメント(Hub)
resource "aws_ec2_transit_gateway_vpc_attachment" "egress" {
vpc_id = var.egress_vpc_id
subnet_ids = var.egress_attachment_subnet_ids
transit_gateway_id = aws_ec2_transit_gateway.this.id
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
appliance_mode_support = "enable"
tags = merge({ Name = "${var.name}-egress" }, var.tags)
}
appliance_mode_supportを有効にする理由は、Network Firewallのようなステートフルアプライアンスを経由する際の非対称ルーティング問題を防ぐためです。appliance_mode_support = "enable"を設定しておくことで、同一フローは必ず同じAZを通過する挙動を保証できます。
今回のようなCentralized Egress 構成では、必須な設定となります。
ルートテーブル関連付けとルート伝播
Spoke VPCからのトラフィックをEgress VPC(Hub)に集約するためのルーティング設定です。
# modules/network-egress/tgw/main.tf
# Spoke VPCをSpokeルートテーブルに関連付け
resource "aws_ec2_transit_gateway_route_table_association" "assoc_workload" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.workload.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spoke.id
}
resource "aws_ec2_transit_gateway_route_table_association" "assoc_tooling" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.tooling.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spoke.id
}
# Egress VPC(Hub)をHubルートテーブルに関連付け
resource "aws_ec2_transit_gateway_route_table_association" "assoc_egress" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.egress.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.hub.id
}
# Spoke VPCのCIDRをHubルートテーブルに伝播
# これにより、戻りトラフィックが正しくSpoke VPCにルーティングされる
resource "aws_ec2_transit_gateway_route_table_propagation" "prop_workload_to_hub" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.workload.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.hub.id
}
resource "aws_ec2_transit_gateway_route_table_propagation" "prop_tooling_to_hub" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.tooling.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.hub.id
}
# SpokeルートテーブルのデフォルトルートをEgress VPC(Hub)に向ける
resource "aws_ec2_transit_gateway_route" "spoke_default_to_egress" {
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spoke.id
destination_cidr_block = "0.0.0.0/0"
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.egress.id
}
# Hubルートテーブルから各Spoke VPCへのルート
resource "aws_ec2_transit_gateway_route" "hub_routes_to_spokes" {
for_each = toset(var.spoke_cidrs)
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.hub.id
destination_cidr_block = each.value
transit_gateway_attachment_id = contains(var.tooling_cidrs, each.value) ? aws_ec2_transit_gateway_vpc_attachment.tooling.id : aws_ec2_transit_gateway_vpc_attachment.workload.id
}
Spoke VPCのルートテーブル設定
Spoke VPC内のサブネットからTransit Gatewayへのデフォルトルートを設定します。
# modules/network-egress/tgw/main.tf
# Workload VPC(Spoke)の各サブネットのルートテーブルにTGWへのデフォルトルートを追加
resource "aws_route" "workload_spoke_default_to_tgw" {
for_each = toset(var.workload_tgw_default_rtb_ids)
route_table_id = each.value
destination_cidr_block = "0.0.0.0/0"
transit_gateway_id = aws_ec2_transit_gateway.this.id
}
# Tooling VPC(Spoke)の各サブネットのルートテーブルにTGWへのデフォルトルートを追加
resource "aws_route" "tooling_spoke_default_to_tgw" {
for_each = toset(var.tooling_tgw_default_rtb_ids)
route_table_id = each.value
destination_cidr_block = "0.0.0.0/0"
transit_gateway_id = aws_ec2_transit_gateway.this.id
}
Network Firewall設計
ドメインベースフィルタリング
Network Firewall では HTTP Host ヘッダーと TLS SNI を使って、ドメイン単位の制御が可能です。
ALLOWLIST モードを使うと、「許可したドメイン以外はすべて落とす」という分かりやすいポリシーになります。
# modules/network-egress/network_firewall/main.tf
resource "aws_networkfirewall_rule_group" "domain_allowlist" {
count = var.create_network_firewall ? 1 : 0
capacity = var.rule_group_capacity
name = "${var.name}-domain-allowlist"
type = "STATEFUL"
rule_group {
stateful_rule_options {
# STRICT_ORDERにより、ルールの評価順序を明示的に制御
rule_order = "STRICT_ORDER"
}
rules_source {
rules_source_list {
# ALLOWLISTモード:指定したドメインのみ許可
generated_rules_type = "ALLOWLIST"
# HTTP HostヘッダーとTLS SNIの両方を検査
target_types = ["HTTP_HOST", "TLS_SNI"]
targets = var.allowed_domains
}
}
}
tags = merge(var.tags, {
Name = "${var.name}-domain-allowlist"
})
}
許可ドメインの設定例
許可ドメインはYAMLファイルで管理し、カテゴリごとに整理することで変更管理を容易にしています。
# envs/stg/network-egress/allowed_domains.yaml
allowed_domains:
# AWSサービス用
- "*.amazonaws.com"
- "*.amazon.com"
# OS更新・パッケージリポジトリ用
- "*.amazonlinux.com"
- "*.redhat.com"
- "*.debian.org"
# コンテナレジストリ用
- "*.docker.io"
- "*.docker.com"
# ビルドツール・パッケージマネージャー用
- "*.npmjs.org"
- "*.github.com"
※ 実際の運用では、必要最小限のドメインのみを許可し、ワイルドカードの使用は慎重に検討してください。
Firewall Policy とHOME_NET設定
ここが本アーキテクチャの核心部分です。複数VPC(Spoke)からのトラフィックを検査するためには、HOME_NET変数を適切に設定する必要があります。
# modules/network-egress/network_firewall/main.tf
resource "aws_networkfirewall_firewall_policy" "main" {
count = var.create_network_firewall ? 1 : 0
name = "${var.name}-policy"
firewall_policy {
# ステートレスルールはすべてステートフルエンジンに転送
stateless_default_actions = ["aws:forward_to_sfe"]
stateless_fragment_default_actions = ["aws:forward_to_sfe"]
# HOME_NET変数の設定(重要)
policy_variables {
rule_variables {
key = "HOME_NET"
ip_set {
# Egress VPC(Hub)自身のCIDRに加え、
# すべてのSpoke VPCのCIDRを含める
definition = var.home_net_cidrs
}
}
}
stateful_engine_options {
rule_order = "STRICT_ORDER"
}
# デフォルトアクション:確立済みセッションをドロップ
# これにより、許可リストにないドメインへの通信は拒否される
stateful_default_actions = ["aws:drop_established"]
stateful_rule_group_reference {
resource_arn = aws_networkfirewall_rule_group.domain_allowlist[0].arn
priority = 10
}
}
tags = merge(var.tags, {
Name = "${var.name}-policy"
})
}
# Network Firewall本体
resource "aws_networkfirewall_firewall" "main" {
count = var.create_network_firewall ? 1 : 0
name = "${var.name}-firewall"
firewall_policy_arn = aws_networkfirewall_firewall_policy.main[0].arn
vpc_id = var.vpc_id
dynamic "subnet_mapping" {
for_each = var.firewall_subnet_ids
content {
subnet_id = subnet_mapping.value
}
}
tags = merge(var.tags, {
Name = "${var.name}-firewall"
})
}
HOME_NET設定の実際の値
Terragrunt設定ファイルでは、すべてのSpoke VPCサブネットのCIDRをhome_net_cidrsに含めています。
# envs/stg/network-egress/terragrunt.hcl
inputs = {
# ... 他の設定 ...
home_net_cidrs = [
# Egress VPC(Hub)自身のCIDR
local.egress_vpc_cidr, # 例: 203.0.113.0/16
# Tooling VPC(Spoke)のサブネットCIDR
dependency.network.outputs.tooling_subnet_cidrs["build-1a"],
dependency.network.outputs.tooling_subnet_cidrs["build-1c"],
# Workload VPC(Spoke)のプライベートサブネットCIDR
dependency.network.outputs.workload_subnet_cidrs["app-1a"],
dependency.network.outputs.workload_subnet_cidrs["app-1c"],
dependency.network.outputs.workload_subnet_cidrs["batch-1a"],
dependency.network.outputs.workload_subnet_cidrs["batch-1c"],
# ... 他のサブネット ...
]
}
HOME_NETを適切に設定しないと、Network FirewallはSpoke VPCからのトラフィックを「外部からのトラフィック」と誤認識し、ドメインベースのフィルタリングが正しく機能しません。これは前回の記事で詳しく解説した内容です。
Egress VPC(Hub)のルーティング設計
サブネット構成
Egress VPC(Hub)は3つのサブネット層で構成されています。
# envs/stg/network-egress/terragrunt.hcl
inputs = {
subnets = {
# NAT Gatewayを配置するパブリックサブネット
"public-natgw-1a" = {
cidr_block = "203.0.113.0/24" # 例
availability_zone = "ap-northeast-1a"
name = "${local.env}-egress-public-natgw-subnet-1a"
}
# Network Firewallエンドポイントを配置するサブネット
"firewall-endpoint-1a" = {
cidr_block = "203.0.113.16/28" # 例
availability_zone = "ap-northeast-1a"
name = "${local.env}-egress-firewall-subnet-1a"
}
# Transit Gatewayアタッチメント用サブネット
"tgw-attachment-1a" = {
cidr_block = "203.0.113.32/28" # 例
availability_zone = "ap-northeast-1a"
name = "${local.env}-egress-tgw-subnet-1a"
}
}
}
ルートテーブル設計
各サブネットのルートテーブルは、トラフィックフローに応じて異なる設定が必要です。
# modules/network-egress/main.tf
module "route_table" {
source = "./route_table"
vpc_id = local.vpc_id
subnets = var.subnets
subnet_ids = module.subnet.subnet_ids
shared_route_tables = var.shared_route_tables
tags = merge(var.tags, var.route_table_tags)
internet_gateway_id = module.igw[0].internet_gateway_id
# パブリックサブネット → IGW(デフォルトルート)
internet_gateway_routes = var.internet_gateway_routes
# Firewallサブネット → NAT Gateway(デフォルトルート)
nat_gateway_routes = { for k, v in var.nat_gateway_routes : k => {
nat_gateway_id = module.nat_gateway.nat_gateway_ids[0]
} }
# TGWアタッチメントサブネット → Network Firewallエンドポイント(デフォルトルート)
network_firewall_endpoint_routes = {
"tgw-attachment-1a" = {
vpc_endpoint_id = module.network_firewall.firewall_endpoint_ids_by_az["ap-northeast-1a"]
}
}
# Spoke VPCへの戻りルート(TGW経由)
tgw_id = try(module.tgw[0].transit_gateway_id, null)
cidr_routes_to_tgw = {
"tgw-attachment-1a" = var.spoke_cidrs
}
# Firewallサブネットからの戻りルート(TGW経由)
cidr_routes_to_tgw_from_firewall = {
"firewall-endpoint-1a" = var.spoke_cidrs
}
# パブリックサブネットからSpoke VPCへの戻りルート(NFW経由)
cidr_routes_to_nfw_endpoint = {
"public-natgw-1a" = {
vpc_endpoint_id = module.network_firewall.firewall_endpoint_ids_by_az["ap-northeast-1a"]
cidrs = var.spoke_cidrs
}
}
}
トラフィックフローの詳細
アウトバウンドトラフィックの流れを詳しく見てみましょう。
行きのトラフィック(Spoke VPC → Internet)
- Spoke VPC内のEC2等のリソースがサブネットのルートテーブルを参照
- デフォルトルート(0.0.0.0/0)がTransit Gatewayを指している
- Transit GatewayのSpokeルートテーブルがEgress VPC(Hub)アタッチメントにルーティング
- Egress VPCのTGWアタッチメントサブネットに到着
- TGWアタッチメントサブネットのルートテーブルがNetwork Firewallエンドポイントを指している
- Network Firewallがドメインを検査し、許可リストにあれば通過
- FirewallサブネットのルートテーブルがデフォルトルートとしてNAT Gatewayを指している
- NAT Gatewayからパブリックサブネットへ
- パブリックサブネットのルートテーブルがIGWを指している
- インターネットへ
戻りのトラフィック(Internet → Spoke VPC)
- インターネットからの応答がIGWに到着
- NAT Gatewayが宛先をプライベートIPに変換
- パブリックサブネットのルートテーブルで、Spoke VPC CIDRへのルートがNetwork Firewallエンドポイントを指している
- Network Firewallを通過(ステートフルなので、確立済みセッションは許可)
- Firewallサブネットのルートテーブルで、Spoke VPC CIDRへのルートがTransit Gatewayを指している
- Transit GatewayのHubルートテーブルが適切なSpoke VPCアタッチメントにルーティング
- Spoke VPCに到着
Network Firewallロギング
トラブルシューティングとセキュリティ監査のために、Network Firewallのログを有効化することを推奨します。
# modules/network-egress/network_firewall/main.tf
resource "aws_cloudwatch_log_group" "nfw_alert" {
count = var.create_network_firewall && var.enable_logging ? 1 : 0
name = "/aws/networkfirewall/${var.name}/alert"
retention_in_days = var.log_retention_days
tags = merge(var.tags, {
Name = "${var.name}-nfw-alert-logs"
Type = "NetworkFirewall-Alert"
})
}
resource "aws_cloudwatch_log_group" "nfw_flow" {
count = var.create_network_firewall && var.enable_logging ? 1 : 0
name = "/aws/networkfirewall/${var.name}/flow"
retention_in_days = var.log_retention_days
tags = merge(var.tags, {
Name = "${var.name}-nfw-flow-logs"
Type = "NetworkFirewall-Flow"
})
}
resource "aws_networkfirewall_logging_configuration" "main" {
count = var.create_network_firewall && var.enable_logging ? 1 : 0
firewall_arn = aws_networkfirewall_firewall.main[0].arn
logging_configuration {
# アラートログ:ルールにマッチしたトラフィックを記録
log_destination_config {
log_destination = {
logGroup = aws_cloudwatch_log_group.nfw_alert[0].name
}
log_destination_type = "CloudWatchLogs"
log_type = "ALERT"
}
# フローログ:すべてのトラフィックフローを記録
log_destination_config {
log_destination = {
logGroup = aws_cloudwatch_log_group.nfw_flow[0].name
}
log_destination_type = "CloudWatchLogs"
log_type = "FLOW"
}
}
}
コストについて
Network FirewallとTransit Gatewayはデータ処理量に応じた課金があるため、以下の点の考慮が必要です。
- Network Firewall: エンドポイント時間料金 + データ処理料金
- Transit Gateway: アタッチメント時間料金 + データ処理料金
- NAT Gateway: 時間料金 + データ処理料金
まとめ
本記事では、Transit GatewayとNetwork Firewallを組み合わせた複数VPC間のアウトバウンド制御アーキテクチャについて解説しました。
重要なポイントをまとめると以下の通りです。
- Hub-and-Spoke構成により、Egress VPC(Hub)に集約されたNetwork Firewallで複数のSpoke VPCのアウトバウンドトラフィックを一元管理
- Transit Gatewayルートテーブルの分離(Spoke/Hub)により、トラフィックフローを明確に制御
- appliance_mode_supportを有効にして非対称ルーティング問題を防止
- HOME_NET設定にすべてのSpoke VPC CIDRを含めることで、ドメインベースフィルタリングを正しく機能させる
- ルートテーブル設計では、行きと戻りの両方のトラフィックパスを考慮
- ロギングを有効にしてトラブルシューティングとセキュリティ監査に備える
このアーキテクチャにより、セキュリティ要件を満たしながら、複数VPCのアウトバウンドトラフィックを効率的に管理できます。