JAWS-UG東京支部のLT会「AWS新オフィスでやっていき宣言LT会!」で、「Network Firewallやっていき!」というタイトルで登壇してきました。
サービスの概要やコスト、TGWネイティブ統合といった話はスライドを見ていただくとして、この記事では検証部分やTerraformで書きながら詰まったところを残しておきます。触ってみて学びだった「エッジ関連付けを外したら何もできなくなった」話はスライドに入りきりませんでした。
この記事のコードをそのまま流せば同じ環境が作れます。
この記事で分かること
- Network Firewall の肝はルールの書き方ではなくルートテーブル
- 必要なルートは3つで、うち1つは「IGWへのエッジ関連付け」という見慣れないもの
- そのエッジ関連付けを外すと非対称ルーティングになり、ping や curl を試す以前に SSM Session Manager で EC2 に入れなくなる
- 検証環境は2時間で \$0.99(ただし消し忘れると AZ ごとに月 \$284)
検証構成
VPC1個で完結する構成で検証。
EC2にはSSM Session Managerで入ります。
Terraformのコードは記事の最後に置いておきます。ルートテーブルまわり以外はほぼデフォルトのままなので、そこだけ押さえればひととおり動きます。
この環境で試したのは3つです。ステートフルルールのドメインリストで .amazon.com だけ許可してcurlの通り方が変わることを見て、ステートレスルールでpingを止めて、Suricataルールでアウトバウンドのsshを止めました。(詳細はスライド参照)
ここから先は、その過程で分かったことを書いていきます。
ルートテーブルが肝
触る前は「ルールをどう書くか」がこのサービスの肝だと思っていました。実際に組んでみるとルーティングのほうでした。
必要なルートは3つです。
①ワークロードサブネットのデフォルトルートをNFWエンドポイントへ

②Firewallサブネットのデフォルトルートを IGW へ

③ IGWにIngressルートテーブルをエッジ関連付けして、戻りをNFWへ
・ルート

①と②は直感的です。EC2から出た通信をまずファイアウォールに送り、検査を通ったらインターネットへ出す。検証構成図のとおりです。
肝は③でした。ルートテーブルはサブネットに関連付けるものだと思い込んでいたのですが、Internet Gatewayにも紐付けられます。コンソールのルートテーブル画面に「Edgeの関連付け」というタブがあって、そこでIGWを指定します。
こうすると「IGWからVPCに入ってくるパケット」がこのルートテーブルで評価されるようになり、ワークロードサブネット宛の戻り通信をNFWへ向かわせることができます。行きも帰りも同じ検問所を通す、という状態がこれで完成します。
エッジ関連付けを外すとどうなるか試した
③を忘れると非対称ルーティングになるようです。非対称ルーティングとは行きと帰りで検問所が変わってしまい、ステートフルな仕組みが機能しなくなるような状態です。
実際その状態になるとどういったことが起きるか気になったので、構築後にコンソールからエッジ関連付けを外してみました。

外す前に予想を立てました。
ping: OK
curl: NG
pingは行きの時点で判定が終わるので通るはず、curlは戻りを見られないとレスポンスが返らず怪しいと考えました。
実際に起きたのは、SSMセッションが繋がらなくなって、pingもcurlも試せないという状態でした。(まさかのそこ!?)
行きは変わらず NFW を通りますが、戻りだけが IGW から EC2 へ直行するようになります。
片側しか見えなくなった NFW は以降のパケットを知らない通信として落とすため、SSM エージェントの接続もそこで切れた、のかなと考えました。

いつの間にやらEC2のSSMセッションマネージャーに「セッションマネージャーの接続ステータス」が生えていました。以前は接続できるかどうかは「接続」しないとわからなかったような気もするので、ステータス表示されてて地味に便利になっていました。
復旧はエッジ関連付けを戻せば直るはずですが、タイミングによっては戻すだけでは繋がらず、EC2の再起動が必要でした。何回かつけ外しをしたのですが、再起動なしで直ったケースもあるので、毎回必要というわけではなさそうです。SSMエージェントが持っていたコネクションが死んだまま残ってしまい、EC2を再起動しないといけないのかもしれないです。
ついでにドメインリストで .amazonaws.com を許可しないとSSMエージェントの通信が落ちてEC2に入れなくなります。ファイアウォールの内側に管理系の通信も入っているという当たり前の事実をこちらでも体感しました。検証用のEC2に入れなくなるだけならまだしも、本番で同じことをやると洒落になりません。
見慣れないVPCエンドポイント
TerraformでNetwork Firewallを作ると、VPCエンドポイントが勝手に1つ増えます。作った覚えがないので何だろうと思ってコンソールで見に行ったら、エンドポイントタイプが GatewayLoadBalancer になっていました。
どうやらNetwork Firewallを作成してサブネットに紐づけると自動で作成されるようでした。
ファイアウォールエンドポイントは、AWSコンソール上のVPCルートテーブルのターゲット選択画面においてvpce-idで表示されます。ファイアウォールエンドポイントはAWS Gateway Load Balancerを利用しているため、エンドポイントのElastic Network Interface(ENI)は「gateway_load_balancer_endpoint」タイプとなります。
参考
そのためこのエンドポイントはNetwork Firewall本体に付随するものなので、手動で消すとよろしくなさそうです。 terraform destroy でNetwork Firewallが消えれば一緒に消えました。
Terraformを使っていて気になったこと
削除保護に注意
Network Firewall には削除保護があります。
コンソールで作るとデフォルトで有効になっており、有効なままだと terraform destroy が失敗します。
Terraform プロバイダー側の既定値は false ですが、検証環境では明示的に書いて意図を残しておくと楽です。
delete_protection = false
subnet_change_protection = false
作成に時間がかかる
ファイアウォール本体の作成に5分から10分ほどかかります。 terraform apply を叩いてから待つことになるので、その間はモンエナを飲んで備えておくと良いです。destroyも同じくらいかかります。
お片付け
terraform destroy を叩きます。
消し忘れるとAZごとに月\$284が延々と請求されます。(マルチAZ構成なら倍)
これはエンドポイントの時間課金だけの金額で、実際はデータ処理料が上乗せされるので、最低でも日本円で約45,000円と思っておくと安全です。
翌日にBillingコンソールでNetwork Firewallの課金が止まっているのを確認しておくとさらに安心です。
Terraformのコード
CloudShellにこのファイルを置き、Terraform準備して terraform init から terraform apply で構築できます。
main.tf(クリックで展開)
# =============================================================
# AWS Network Firewall 最小構成検証 (Single-AZ / VPC 1個で完結)
#
# Internet
# |
# IGW <- Ingress RT (エッジ関連付け: 戻りをNFWへ)
# |
# [Firewall Subnet /28] <- NFWエンドポイント
# |
# [Workload Subnet] <- EC2 (t4g.nano, SSM接続)
#
# ドメイン制御: .amazon.com / .amazonaws.com を許可
# =============================================================
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
variable "region" {
default = "ap-northeast-1"
}
variable "prefix" {
default = "nfw-demo"
}
provider "aws" {
region = var.region
}
data "aws_availability_zones" "available" {
state = "available"
}
locals {
az = data.aws_availability_zones.available.names[0]
vpc_cidr = "10.0.0.0/16"
fw_subnet = "10.0.0.0/28"
work_subnet = "10.0.1.0/24"
allow_domains = [
".amazon.com",
".amazonaws.com", # SSM接続用
]
}
# -------------------------------------------------------------
# VPC / Subnet / IGW
# -------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = local.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.prefix}-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.prefix}-igw" }
}
resource "aws_subnet" "firewall" {
vpc_id = aws_vpc.main.id
cidr_block = local.fw_subnet
availability_zone = local.az
tags = { Name = "${var.prefix}-firewall-subnet" }
}
resource "aws_subnet" "workload" {
vpc_id = aws_vpc.main.id
cidr_block = local.work_subnet
availability_zone = local.az
map_public_ip_on_launch = true
tags = { Name = "${var.prefix}-workload-subnet" }
}
# -------------------------------------------------------------
# Network Firewall (ルールグループ / ポリシー / 本体)
# -------------------------------------------------------------
resource "aws_networkfirewall_rule_group" "domain_allow" {
name = "${var.prefix}-domain-allowlist"
type = "STATEFUL"
capacity = 100
rule_group {
rule_variables {
ip_sets {
key = "HOME_NET"
ip_set {
definition = [local.vpc_cidr]
}
}
}
rules_source {
rules_source_list {
generated_rules_type = "ALLOWLIST"
target_types = ["HTTP_HOST", "TLS_SNI"]
targets = local.allow_domains
}
}
}
}
# Suricata互換ルール: アウトバウンドSSHを遮断 (msgがALERTログに出る)
resource "aws_networkfirewall_rule_group" "suricata_ssh_block" {
name = "${var.prefix}-suricata-ssh-block"
type = "STATEFUL"
capacity = 50
rule_group {
rule_variables {
ip_sets {
key = "HOME_NET"
ip_set {
definition = [local.vpc_cidr]
}
}
}
rules_source {
rules_string = <<-EOT
drop tcp $HOME_NET any -> $EXTERNAL_NET 22 (msg:"Outbound SSH blocked by nfw-demo"; sid:100001; rev:1;)
EOT
}
}
}
resource "aws_networkfirewall_firewall_policy" "main" {
name = "${var.prefix}-policy"
firewall_policy {
stateless_default_actions = ["aws:forward_to_sfe"]
stateless_fragment_default_actions = ["aws:forward_to_sfe"]
stateless_rule_group_reference {
priority = 10
resource_arn = aws_networkfirewall_rule_group.stateless_icmp.arn
}
stateful_rule_group_reference {
resource_arn = aws_networkfirewall_rule_group.domain_allow.arn
}
stateful_rule_group_reference {
resource_arn = aws_networkfirewall_rule_group.suricata_ssh_block.arn
}
}
}
# ステートレスルール: ICMP制御 (8.8.8.8宛のみ許可、他はdrop)
# ステートレスはパケット単位評価なので、行き/戻りを自分で書く必要がある点に注目
resource "aws_networkfirewall_rule_group" "stateless_icmp" {
name = "${var.prefix}-stateless-icmp"
type = "STATELESS"
capacity = 10
rule_group {
rules_source {
stateless_rules_and_custom_actions {
# (1) 8.8.8.8宛のICMPは許可 (行き)
stateless_rule {
priority = 10
rule_definition {
actions = ["aws:pass"]
match_attributes {
protocols = [1] # ICMP
source {
address_definition = "10.0.1.0/24"
}
destination {
address_definition = "8.8.8.8/32"
}
}
}
}
# (2) 8.8.8.8からのICMPも許可 (戻り: ステートレスは戻りを自動追跡しない!)
stateless_rule {
priority = 11
rule_definition {
actions = ["aws:pass"]
match_attributes {
protocols = [1]
source {
address_definition = "8.8.8.8/32"
}
destination {
address_definition = "10.0.1.0/24"
}
}
}
}
# (3) それ以外のICMPは全部drop
stateless_rule {
priority = 20
rule_definition {
actions = ["aws:drop"]
match_attributes {
protocols = [1]
source {
address_definition = "10.0.1.0/24"
}
destination {
address_definition = "0.0.0.0/0"
}
}
}
}
}
}
}
}
resource "aws_networkfirewall_firewall" "main" {
name = var.prefix
firewall_policy_arn = aws_networkfirewall_firewall_policy.main.arn
vpc_id = aws_vpc.main.id
delete_protection = false # 検証用: destroyしやすいようOFF
subnet_change_protection = false
subnet_mapping {
subnet_id = aws_subnet.firewall.id
}
tags = { Name = var.prefix }
}
# NFWエンドポイントID (Single-AZなので先頭を取得)
locals {
fw_endpoint_id = tolist(aws_networkfirewall_firewall.main.firewall_status[0].sync_states)[0].attachment[0].endpoint_id
}
# -------------------------------------------------------------
# ルートテーブル3点セット (ここが勘所)
# -------------------------------------------------------------
# (1) Workload -> デフォルトルートをNFWエンドポイントへ
resource "aws_route_table" "workload" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.prefix}-workload-rt" }
}
resource "aws_route" "workload_to_fw" {
route_table_id = aws_route_table.workload.id
destination_cidr_block = "0.0.0.0/0"
vpc_endpoint_id = local.fw_endpoint_id
}
resource "aws_route_table_association" "workload" {
subnet_id = aws_subnet.workload.id
route_table_id = aws_route_table.workload.id
}
# (2) Firewall Subnet -> デフォルトルートをIGWへ
resource "aws_route_table" "firewall" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.prefix}-firewall-rt" }
}
resource "aws_route" "fw_to_igw" {
route_table_id = aws_route_table.firewall.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
resource "aws_route_table_association" "firewall" {
subnet_id = aws_subnet.firewall.id
route_table_id = aws_route_table.firewall.id
}
# (3) IGWエッジ関連付け -> 戻り通信をNFWエンドポイントへ (忘れると非対称で死ぬ)
resource "aws_route_table" "igw_ingress" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.prefix}-igw-ingress-rt" }
}
resource "aws_route" "ingress_to_fw" {
route_table_id = aws_route_table.igw_ingress.id
destination_cidr_block = local.work_subnet
vpc_endpoint_id = local.fw_endpoint_id
}
resource "aws_route_table_association" "igw_edge" {
gateway_id = aws_internet_gateway.main.id
route_table_id = aws_route_table.igw_ingress.id
}
# -------------------------------------------------------------
# アラートログ (ブロックされた通信を可視化)
# -------------------------------------------------------------
resource "aws_cloudwatch_log_group" "nfw_alert" {
name = "/${var.prefix}/alert"
retention_in_days = 7
}
resource "aws_networkfirewall_logging_configuration" "main" {
firewall_arn = aws_networkfirewall_firewall.main.arn
logging_configuration {
log_destination_config {
log_destination = { logGroup = aws_cloudwatch_log_group.nfw_alert.name }
log_destination_type = "CloudWatchLogs"
log_type = "ALERT"
}
}
}
# -------------------------------------------------------------
# 検証用EC2 (t4g.nano, SSM Session Managerで接続)
# -------------------------------------------------------------
data "aws_ssm_parameter" "al2023_arm" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64"
}
resource "aws_iam_role" "ec2_ssm" {
name = "${var.prefix}-ec2-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.ec2_ssm.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ec2_ssm" {
name = "${var.prefix}-ec2-ssm-profile"
role = aws_iam_role.ec2_ssm.name
}
resource "aws_security_group" "workload" {
name = "${var.prefix}-workload-sg"
vpc_id = aws_vpc.main.id
# インバウンドなし (SSM接続なのでSSH不要)
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "test" {
ami = data.aws_ssm_parameter.al2023_arm.value
instance_type = "t4g.nano"
subnet_id = aws_subnet.workload.id
vpc_security_group_ids = [aws_security_group.workload.id]
iam_instance_profile = aws_iam_instance_profile.ec2_ssm.name
tags = { Name = "${var.prefix}-test" }
# ルート設定が完了してからSSM登録させたいので依存を明示
depends_on = [
aws_route.workload_to_fw,
aws_route_table_association.igw_edge,
]
}
# -------------------------------------------------------------
# Outputs
# -------------------------------------------------------------
output "instance_id" {
value = aws_instance.test.id
}
output "fw_endpoint_id" {
value = local.fw_endpoint_id
}
output "alert_log_group" {
value = aws_cloudwatch_log_group.nfw_alert.name
}
output "next_step" {
value = "aws ssm start-session --target ${aws_instance.test.id}"
}
おわりに
Network Firewallは奥が深いです。コストは高いですがドメイン制御できるのはかなり魅力的です。また、Suricataを使うことでNetwork Firewallの真価が発揮されるようなので奥地へと向かって修行してきます。
その真価がわかる記事はこちらです。



