Transit Gatewayでインスペクション用VPCに通信を集約する場合はTransit Gatewayのアプライアンスモードを有効にしよう

Transit Gatewayを完全に理解したい人は是非読んでください。
2021.08.20

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Transit GatewayのVPC間でAZを跨いだ通信は注意しよう

こんにちは、のんピ(@non____97)です。

皆さんはTransit Gatewayを完全に理解していますか? 私は以下のTweetの通り、完全に理解しています。

Transit Gatewayを完全に理解するにあたって、個人的に考える一番の落とし穴は、VPC間でAZを跨いだ通信です。

Transit GatewayのBlack Beltでも紹介されている通り、「VPC間の通信は同一AZのENI(Transit Gateway attachment)を経由して通信が行われる」と紹介されています。

下の画像では、行きの通信と、戻りの通信のAZが異なっていることが図示されています。

抜粋: [AWS Black Belt Online Seminar] AWS Transit Gateway

このように、行きの通信と、戻りの通信のAZが異なっていると困る場面は、間にさらに別のVPCを経由させている場合です。

こちらのAWS公式ドキュメントで紹介している通り、以下の構成図では、後述するアプライアンスモードを有効にしていない場合、VPC AのEC2インスタンスから、VPC BのEC2インスタンスに通信ができません。


抜粋: 例: 共有サービス VPC のアプライアンス - ステートフルアプライアンスおよびアプライアンスモード

通信できない理由を把握するために、図中の破線を辿る形で、「VPC AのEC2インスタンスから、VPC BのEC2インスタンスへの通信フロー」を以下に整理します。

  1. VPC AのEC2インスタンスから、Availability Zone 1のENI(※ 図には記載されていない)及び、Transit Gateway、VPC CのAvailability Zone 1にあるENIを経由して、Availability Zone 1のApplianceに通信が到達する
  2. Availability Zone 1のApplianceは通信内容が問題なければ、本来の送信先であるVPC BのEC2インスタンス宛にリクエストを投げる。
  3. Availability Zone 1のApplianceの通信は、VPC CのAvailability Zone 1にあるENI及び、Transit Gateway、VPC BのAvailability Zone 1のENI(※ 図には記載されていない)を経由して、VPC BのEC2インスタンスに到達する。
  4. VPC BのEC2インスタンスはVPC AのEC2インスタンスにレスポンスを送信する。
  5. VPC BのEC2インスタンスから、Availability Zone 2のENI(※ 図には記載されていない)及び、Transit Gateway、VPC CのAvailability Zone 2にあるENIを経由して、Availability Zone 2のApplianceに通信が到達する
  6. Availability Zone 2のApplianceは、VPC AのEC2インスタンスからのリクエストを受け取っていないため、この通信がVPC BのEC2インスタンスからのレスポンスとして認識できず、ドロップする。

このような場合に活躍するのが、Transit Gatewayのアプライアンスモードです。

Transit Gatewayのアプライアンスモードとは

Transit Gatewayのアプライアンスモードは一言で言うと、「ルーティングが対照的になるようにする機能」です。

AWS公式ドキュメントでは、Transit Gatewayのアプライアンスモードは以下のように説明されています。

アプライアンスモードが有効な場合、トランジットゲートウェイは、フローハッシュアルゴリズムを使用して、アプライアンス VPC 内の 1 つのネットワークインターフェイスを選択し、フローの有効期間中トラフィックを送信します。トランジットゲートウェイは、リターントラフィックに同じネットワークインターフェイスを使用します。これにより、双方向トラフィックは対称的にルーティングされます。つまり、フローの有効期間中、VPC アタッチメント内の同じアベイラビリティーゾーンを経由してルーティングされます。アーキテクチャ内に複数のトランジットゲートウェイがある場合、各トランジットゲートウェイは独自のセッションアフィニティを維持し、各トランジットゲートウェイは異なるネットワークインターフェイスを選択できます。

VPC アタッチメントが複数のアベイラビリティーゾーンにまたがっており、ステートフルな検査のために送信元ホストと送信先ホスト間のトラフィックを同じアプライアンスを介してルーティングする必要がある場合は、アプライアンスが配置されている VPC アタッチメントのアプライアンスモードサポートを有効にします。

詳細については、AWSブログの一元化された検査アーキテクチャを参照してください。

例: 共有サービス VPC のアプライアンス - ステートフルアプライアンスおよびアプライアンスモード

アプライアンスモードを有効にすることで、以下のように、通信先が異なるAZであっても、戻りの通信は行きの通信と同じ経路を辿るようになります。 抜粋: 例: 共有サービス VPC のアプライアンス - Overview

検証の構成

今回の検証の構成は以下の通りです。

2つのAZを使って、以下4つのVPCを作成し、Transit Gatewayに接続しています。

  • Spoke VPC A: 通信元のEC2インスタンスがいるVPC
  • Spoke VPC B: 通信先のEC2インスタンスがいるVPC
  • Inspection VPC: Network FirewallがいるVPC
  • Egress VPC: インターネットに出ていくためのVPC

検証では、以下2つの通信パターンを試してみます。

  1. Spoke VPC AのEC2インスタンスAから、Spoke VPC BのEC2インスタンスAへの通信

  2. Spoke VPC AのEC2インスタンスAから、Spoke VPC BのEC2インスタンスBへの通信

やってみた

各種リソースのデプロイ

私は大量のVPCを真心を込めて構築するタイプではないので、AWS CDKで各種リソースをデプロイします。

ただし、Transit Gatewayのアプライアンスモードの有効化は、AWS CDK及び、CloudFormationでサポートされていなかったため、デプロイ後にAWS CLIを使って有効化します。

なお、AWS CDKのコードはかなり長くなってしまったので、以下に折りたたみます。

AWS CDK関連の情報

AWS CDKのディレクトリ構成

> tree                                                                                                                   金  8/20 13:27:14 2021
.
├── .gitignore
├── .npmignore
├── .vscode
│   └── settings.json
├── README.md
├── bin
│   └── tgw-appliance-mode-network-firewall.ts
├── cdk.json
├── jest.config.js
├── lib
│   └── tgw-appliance-mode-network-firewall-stack.ts
├── package-lock.json
├── package.json
├── test
│   └── tgw-appliance-mode-network-firewall.test.ts
└── tsconfig.json

./lib/tgw-appliance-mode-network-firewall-stack.ts

import * as cdk from "@aws-cdk/core";
import * as ec2 from "@aws-cdk/aws-ec2";
import * as logs from "@aws-cdk/aws-logs";
import * as iam from "@aws-cdk/aws-iam";
import * as networkfirewall from "@aws-cdk/aws-networkfirewall";

export class TgwApplianceModeNetworkFirewallStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Get the string after the stack name in the stack id to append to the end of the Log Group name to make it unique.
    const stackId = new cdk.ScopedAws(this).stackId;
    const stackIdAfterStackName = cdk.Fn.select(2, cdk.Fn.split("/", stackId));

    // Create CloudWatch Logs for VPC Flow Logs
    const flowLogsLogGroup = new logs.LogGroup(this, "FlowLogsLogGroup", {
      logGroupName: `/aws/vendedlogs/vpcFlowLogs-${stackIdAfterStackName}`,
      retention: logs.RetentionDays.ONE_WEEK,
    });

    // Create CloudWatch Logs for Network Firewall Flow Logs
    const networkFirewallFlowLogsLogGroup = new logs.LogGroup(
      this,
      "NetworkFirewallFlowLogsLogGroup",
      {
        logGroupName: `/aws/vendedlogs/networkFirewallFlowLogs-${stackIdAfterStackName}`,
        retention: logs.RetentionDays.ONE_WEEK,
      }
    );

    // Create CloudWatch Logs for Network Firewall Alert Logs
    const networkFirewallAlertLogsLogGroup = new logs.LogGroup(
      this,
      "NetworkFirewallAlertLogsLogGroup",
      {
        logGroupName: `/aws/vendedlogs/networkFirewallAlertLogs-${stackIdAfterStackName}`,
        retention: logs.RetentionDays.ONE_WEEK,
      }
    );

    // Create VPC Flow Logs IAM role
    const flowLogsIamrole = new iam.Role(this, "FlowLogsIamrole", {
      assumedBy: new iam.ServicePrincipal("vpc-flow-logs.amazonaws.com"),
    });

    // Create SSM IAM role
    const ssmIamRole = new iam.Role(this, "SsmIamRole", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonSSMManagedInstanceCore"
        ),
      ],
    });

    // Create VPC Flow Logs IAM Policy
    const flowLogsIamPolicy = new iam.Policy(this, "FlowLogsIamPolicy", {
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["iam:PassRole"],
          resources: [flowLogsIamrole.roleArn],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "logs:CreateLogStream",
            "logs:PutLogEvents",
            "logs:DescribeLogStreams",
          ],
          resources: [flowLogsLogGroup.logGroupArn],
        }),
      ],
    });

    // Atach VPC Flow Logs IAM Policy
    flowLogsIamrole.attachInlinePolicy(flowLogsIamPolicy);

    // Create a VPC for inspection
    const inspectionVpc = new ec2.Vpc(this, "InspectionVpc", {
      cidr: "10.0.0.0/24",
      enableDnsHostnames: true,
      enableDnsSupport: true,
      maxAzs: 2,
      subnetConfiguration: [
        {
          name: "TgwAttachment",
          subnetType: ec2.SubnetType.ISOLATED,
          cidrMask: 28,
        },
        {
          name: "Firewall",
          subnetType: ec2.SubnetType.ISOLATED,
          cidrMask: 28,
        },
      ],
    });

    // Setting VPC Flow Logs for InspectionVpc
    new ec2.CfnFlowLog(this, "FlowLogToLogsForInspectionVpc", {
      resourceId: inspectionVpc.vpcId,
      resourceType: "VPC",
      trafficType: "ALL",
      deliverLogsPermissionArn: flowLogsIamrole.roleArn,
      logDestination: flowLogsLogGroup.logGroupArn,
      logDestinationType: "cloud-watch-logs",
      logFormat:
        "${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status} ${vpc-id} ${subnet-id} ${instance-id} ${tcp-flags} ${type} ${pkt-srcaddr} ${pkt-dstaddr} ${region} ${az-id} ${sublocation-type} ${sublocation-id} ${pkt-src-aws-service} ${pkt-dst-aws-service} ${flow-direction} ${traffic-path}",
      maxAggregationInterval: 60,
    });

    // Create a VPC for Egress
    const egressVpc = new ec2.Vpc(this, "EgressVpc", {
      cidr: "10.0.1.0/24",
      enableDnsHostnames: true,
      enableDnsSupport: true,
      maxAzs: 2,
      natGateways: 2,
      subnetConfiguration: [
        {
          name: "TgwAttachment",
          subnetType: ec2.SubnetType.PRIVATE,
          cidrMask: 28,
        },
        {
          name: "Public",
          subnetType: ec2.SubnetType.PUBLIC,
          cidrMask: 28,
        },
      ],
    });

    // Setting VPC Flow Logs for EgressVpc
    new ec2.CfnFlowLog(this, "FlowLogToLogsForEgressVpc", {
      resourceId: egressVpc.vpcId,
      resourceType: "VPC",
      trafficType: "ALL",
      deliverLogsPermissionArn: flowLogsIamrole.roleArn,
      logDestination: flowLogsLogGroup.logGroupArn,
      logDestinationType: "cloud-watch-logs",
      logFormat:
        "${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status} ${vpc-id} ${subnet-id} ${instance-id} ${tcp-flags} ${type} ${pkt-srcaddr} ${pkt-dstaddr} ${region} ${az-id} ${sublocation-type} ${sublocation-id} ${pkt-src-aws-service} ${pkt-dst-aws-service} ${flow-direction} ${traffic-path}",
      maxAggregationInterval: 60,
    });

    // Create a VPC for Spoke A
    const spokeVpcA = new ec2.Vpc(this, "SpokeVpcA", {
      cidr: "10.0.2.0/24",
      enableDnsHostnames: true,
      enableDnsSupport: true,
      maxAzs: 2,
      subnetConfiguration: [
        {
          name: "TgwAttachment",
          subnetType: ec2.SubnetType.ISOLATED,
          cidrMask: 28,
        },
        {
          name: "Workload",
          subnetType: ec2.SubnetType.ISOLATED,
          cidrMask: 28,
        },
      ],
    });

    // Setting VPC Flow Logs for SpokeVpcA
    new ec2.CfnFlowLog(this, "FlowLogToLogsForSpokeVpcA", {
      resourceId: spokeVpcA.vpcId,
      resourceType: "VPC",
      trafficType: "ALL",
      deliverLogsPermissionArn: flowLogsIamrole.roleArn,
      logDestination: flowLogsLogGroup.logGroupArn,
      logDestinationType: "cloud-watch-logs",
      logFormat:
        "${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status} ${vpc-id} ${subnet-id} ${instance-id} ${tcp-flags} ${type} ${pkt-srcaddr} ${pkt-dstaddr} ${region} ${az-id} ${sublocation-type} ${sublocation-id} ${pkt-src-aws-service} ${pkt-dst-aws-service} ${flow-direction} ${traffic-path}",
      maxAggregationInterval: 60,
    });

    // Create a VPC for Spoke B
    const spokeVpcB = new ec2.Vpc(this, "SpokeVpcB", {
      cidr: "10.0.3.0/24",
      enableDnsHostnames: true,
      enableDnsSupport: true,
      maxAzs: 2,
      subnetConfiguration: [
        {
          name: "TgwAttachment",
          subnetType: ec2.SubnetType.ISOLATED,
          cidrMask: 28,
        },
        {
          name: "Workload",
          subnetType: ec2.SubnetType.ISOLATED,
          cidrMask: 28,
        },
      ],
    });

    // Setting VPC Flow Logs for SpokeVpcB
    new ec2.CfnFlowLog(this, "FlowLogToLogsForSpokeVpcB", {
      resourceId: spokeVpcB.vpcId,
      resourceType: "VPC",
      trafficType: "ALL",
      deliverLogsPermissionArn: flowLogsIamrole.roleArn,
      logDestination: flowLogsLogGroup.logGroupArn,
      logDestinationType: "cloud-watch-logs",
      logFormat:
        "${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status} ${vpc-id} ${subnet-id} ${instance-id} ${tcp-flags} ${type} ${pkt-srcaddr} ${pkt-dstaddr} ${region} ${az-id} ${sublocation-type} ${sublocation-id} ${pkt-src-aws-service} ${pkt-dst-aws-service} ${flow-direction} ${traffic-path}",
      maxAggregationInterval: 60,
    });

    // Create Transit Gateway
    const tgw = new ec2.CfnTransitGateway(this, "Tgw", {
      defaultRouteTableAssociation: "disable",
      defaultRouteTablePropagation: "disable",
    });

    // Create Transit Gateway attachment for InspectionVpc
    const tgwAttachmentForInspectionVpc = new ec2.CfnTransitGatewayAttachment(
      this,
      "TgwAttachmentForInspectionVpc",
      {
        subnetIds: inspectionVpc.selectSubnets({
          subnetGroupName: "TgwAttachment",
        }).subnetIds,
        transitGatewayId: tgw.attrId,
        vpcId: inspectionVpc.vpcId,
        tags: [
          {
            key: "Name",
            value: "TgwAttachmentForInspectionVpc",
          },
        ],
      }
    );

    // Create Transit Gateway attachment for EgressVpc
    const tgwAttachmentForEgressVpc = new ec2.CfnTransitGatewayAttachment(
      this,
      "TgwAttachmentForEgressVpc",
      {
        subnetIds: egressVpc.selectSubnets({
          subnetGroupName: "TgwAttachment",
        }).subnetIds,
        transitGatewayId: tgw.attrId,
        vpcId: egressVpc.vpcId,
        tags: [
          {
            key: "Name",
            value: "TgwAttachmentForEgressVpc",
          },
        ],
      }
    );

    // Create Transit Gateway attachment for SpokeVpcA
    const tgwAttachmentForSpokeVpcA = new ec2.CfnTransitGatewayAttachment(
      this,
      "TgwAttachmentForSpokeVpcA",
      {
        subnetIds: spokeVpcA.selectSubnets({
          subnetGroupName: "TgwAttachment",
        }).subnetIds,
        transitGatewayId: tgw.attrId,
        vpcId: spokeVpcA.vpcId,
        tags: [
          {
            key: "Name",
            value: "TgwAttachmentForSpokeVpcA",
          },
        ],
      }
    );

    // Create Transit Gateway attachment for SpokeVpcB
    const tgwAttachmentForSpokeVpcB = new ec2.CfnTransitGatewayAttachment(
      this,
      "TgwAttachmentForSpokeVpcB",
      {
        subnetIds: spokeVpcB.selectSubnets({
          subnetGroupName: "TgwAttachment",
        }).subnetIds,
        transitGatewayId: tgw.attrId,
        vpcId: spokeVpcB.vpcId,
        tags: [
          {
            key: "Name",
            value: "TgwAttachmentForSpokeVpcB",
          },
        ],
      }
    );

    // Create Transit Gateway route table for InspectionVpc
    const tgwRouteTableForInspectionVpc = new ec2.CfnTransitGatewayRouteTable(
      this,
      "TgwRouteTableForInspectionVpc",
      {
        transitGatewayId: tgw.attrId,
        tags: [
          {
            key: "Name",
            value: "TgwRouteTableForInspectionVpc",
          },
        ],
      }
    );

    // Associate Transit Gateway attachment for InspectionVpc
    new ec2.CfnTransitGatewayRouteTableAssociation(
      this,
      "TgwRouteTableAssociationForInspectionVpc",
      {
        transitGatewayAttachmentId: tgwAttachmentForInspectionVpc.ref,
        transitGatewayRouteTableId: tgwRouteTableForInspectionVpc.ref,
      }
    );

    // Add Route InspectionVpc To EgressVpc
    new ec2.CfnTransitGatewayRoute(this, "TgwRouteInspectionVpcToEgressVpc", {
      transitGatewayAttachmentId: tgwAttachmentForEgressVpc.ref,
      transitGatewayRouteTableId: tgwRouteTableForInspectionVpc.ref,
      destinationCidrBlock: "0.0.0.0/0",
    });

    // Add Route InspectionVpc To SpokeVpcA
    new ec2.CfnTransitGatewayRoute(this, "TgwRouteInspectionVpcToSpokeVpcA", {
      transitGatewayAttachmentId: tgwAttachmentForSpokeVpcA.ref,
      transitGatewayRouteTableId: tgwRouteTableForInspectionVpc.ref,
      destinationCidrBlock: spokeVpcA.vpcCidrBlock,
    });

    // Add Route InspectionVpc To SpokeVpcB
    new ec2.CfnTransitGatewayRoute(this, "TgwRouteInspectionVpcToSpokeVpcB", {
      transitGatewayAttachmentId: tgwAttachmentForSpokeVpcB.ref,
      transitGatewayRouteTableId: tgwRouteTableForInspectionVpc.ref,
      destinationCidrBlock: spokeVpcB.vpcCidrBlock,
    });

    // Create Transit Gateway route table for EgressVpc
    const tgwRouteTableForEgressVpc = new ec2.CfnTransitGatewayRouteTable(
      this,
      "TgwRouteTableForEgressVpc",
      {
        transitGatewayId: tgw.attrId,
        tags: [
          {
            key: "Name",
            value: "TgwRouteTableForEgressVpc",
          },
        ],
      }
    );

    // Associate Transit Gateway attachment for EgressVpc
    new ec2.CfnTransitGatewayRouteTableAssociation(
      this,
      "TgwRouteAssociationTableForEgressVpc",
      {
        transitGatewayAttachmentId: tgwAttachmentForEgressVpc.ref,
        transitGatewayRouteTableId: tgwRouteTableForEgressVpc.ref,
      }
    );

    // Add Route EgressVpc To InspectionVpc
    new ec2.CfnTransitGatewayRoute(this, "TgwRouteEgressVpcToInspectionVpc", {
      transitGatewayAttachmentId: tgwAttachmentForInspectionVpc.ref,
      transitGatewayRouteTableId: tgwRouteTableForEgressVpc.ref,
      destinationCidrBlock: "0.0.0.0/0",
    });

    // Create Transit Gateway route table for SpokeVpcA
    const tgwRouteTableForSpokeVpcA = new ec2.CfnTransitGatewayRouteTable(
      this,
      "TgwRouteTableForSpokeVpcA",
      {
        transitGatewayId: tgw.attrId,
        tags: [
          {
            key: "Name",
            value: "TgwRouteTableForSpokeVpcA",
          },
        ],
      }
    );

    // Associate Transit Gateway attachment for SpokeVpcA
    new ec2.CfnTransitGatewayRouteTableAssociation(
      this,
      "TgwRouteAssociationTableForSpokeVpcA",
      {
        transitGatewayAttachmentId: tgwAttachmentForSpokeVpcA.ref,
        transitGatewayRouteTableId: tgwRouteTableForSpokeVpcA.ref,
      }
    );

    // Add Route SpokeVpcA To InspectionVpc
    new ec2.CfnTransitGatewayRoute(this, "TgwRouteSpokeVpcAToInspectionVpc", {
      transitGatewayAttachmentId: tgwAttachmentForInspectionVpc.ref,
      transitGatewayRouteTableId: tgwRouteTableForSpokeVpcA.ref,
      destinationCidrBlock: "0.0.0.0/0",
    });

    // Create Transit Gateway route table for SpokeVpcB
    const tgwRouteTableForSpokeVpcB = new ec2.CfnTransitGatewayRouteTable(
      this,
      "TgwRouteTableForSpokeVpcB",
      {
        transitGatewayId: tgw.attrId,
        tags: [
          {
            key: "Name",
            value: "TgwRouteTableForSpokeVpcB",
          },
        ],
      }
    );

    // Associate Transit Gateway attachment for SpokeVpcB
    new ec2.CfnTransitGatewayRouteTableAssociation(
      this,
      "TgwRouteAssociationTableForSpokeVpcB",
      {
        transitGatewayAttachmentId: tgwAttachmentForSpokeVpcB.ref,
        transitGatewayRouteTableId: tgwRouteTableForSpokeVpcB.ref,
      }
    );

    // Add Route SpokeVpcB To InspectionVpc
    new ec2.CfnTransitGatewayRoute(this, "TgwRouteSpokeVpcBToInspectionVpc", {
      transitGatewayAttachmentId: tgwAttachmentForInspectionVpc.ref,
      transitGatewayRouteTableId: tgwRouteTableForSpokeVpcB.ref,
      destinationCidrBlock: "0.0.0.0/0",
    });

    // Create Network Firewall rule group
    const icmpAlertStatefulRuleGroup = new networkfirewall.CfnRuleGroup(
      this,
      "IcmpAlertStatefulRuleGroup",
      {
        capacity: 100,
        ruleGroupName: "icmp-alert",
        type: "STATEFUL",
        ruleGroup: {
          rulesSource: {
            statefulRules: [
              {
                action: "ALERT",
                header: {
                  destination: "10.0.0.0/16",
                  destinationPort: "ANY",
                  direction: "ANY",
                  protocol: "ICMP",
                  source: "10.0.0.0/16",
                  sourcePort: "ANY",
                },
                ruleOptions: [
                  {
                    keyword: `msg:"icmp alert"`,
                  },
                  {
                    keyword: "sid:1000001",
                  },
                  {
                    keyword: "rev:1",
                  },
                ],
              },
            ],
          },
        },
      }
    );

    // Create Network Firewall policy
    const networkfirewallPolicy = new networkfirewall.CfnFirewallPolicy(
      this,
      "NetworkFirewallPolicy",
      {
        firewallPolicyName: "InspectionPolicy",
        firewallPolicy: {
          statelessDefaultActions: ["aws:forward_to_sfe"],
          statelessFragmentDefaultActions: ["aws:forward_to_sfe"],
          statefulRuleGroupReferences: [
            {
              resourceArn: icmpAlertStatefulRuleGroup.attrRuleGroupArn,
            },
          ],
        },
      }
    );

    // Create Network Firewall
    const networkFirewall = new networkfirewall.CfnFirewall(
      this,
      "NetworkFirewall",
      {
        firewallName: "NetworkFirewall",
        firewallPolicyArn: networkfirewallPolicy.attrFirewallPolicyArn,
        vpcId: inspectionVpc.vpcId,
        subnetMappings: (() => {
          const firewallSubnetIds: { subnetId: string }[] = new Array();
          inspectionVpc
            .selectSubnets({ subnetGroupName: "Firewall" })
            .subnets.forEach((subnet) => {
              firewallSubnetIds.push({ subnetId: subnet.subnetId });
            });
          return firewallSubnetIds;
        })(),
      }
    );

    // // Setting Network Firewall logs
    new networkfirewall.CfnLoggingConfiguration(
      this,
      "NetworkFirewallFlowLogsToLogs",
      {
        firewallArn: networkFirewall.ref,
        loggingConfiguration: {
          logDestinationConfigs: [
            {
              logDestination: {
                logGroup: networkFirewallFlowLogsLogGroup.logGroupName,
              },
              logDestinationType: "CloudWatchLogs",
              logType: "FLOW",
            },
            {
              logDestination: {
                logGroup: networkFirewallAlertLogsLogGroup.logGroupName,
              },
              logDestinationType: "CloudWatchLogs",
              logType: "ALERT",
            },
          ],
        },
      }
    );

    // Add Route InspectionVpc to Transit Gateway
    inspectionVpc
      .selectSubnets({ subnetGroupName: "Firewall" })
      .subnets.map((subnet, index) => {
        new ec2.CfnRoute(this, `InspectionVpcRouteToTgwt-${index}`, {
          routeTableId: subnet.routeTable.routeTableId,
          destinationCidrBlock: "0.0.0.0/0",
          transitGatewayId: tgw.ref,
        }).addDependsOn(tgwAttachmentForInspectionVpc);
      });

    // Add Route EgressVpc to Transit Gateway
    egressVpc
      .selectSubnets({ subnetGroupName: "Public" })
      .subnets.map((subnet, index) => {
        new ec2.CfnRoute(this, `EgressVpcRouteToTgw-${index}`, {
          routeTableId: subnet.routeTable.routeTableId,
          destinationCidrBlock: "10.0.0.0/16",
          transitGatewayId: tgw.ref,
        }).addDependsOn(tgwAttachmentForEgressVpc);
      });

    // Add Route SpokeVpcA to Transit Gateway
    spokeVpcA
      .selectSubnets({ subnetGroupName: "Workload" })
      .subnets.map((subnet, index) => {
        new ec2.CfnRoute(this, `SpokeVpcARouteToTgw-${index}`, {
          routeTableId: subnet.routeTable.routeTableId,
          destinationCidrBlock: "0.0.0.0/0",
          transitGatewayId: tgw.ref,
        }).addDependsOn(tgwAttachmentForSpokeVpcA);
      });

    // Add Route SpokeVpcB to Transit Gateway
    spokeVpcB
      .selectSubnets({ subnetGroupName: "Workload" })
      .subnets.map((subnet, index) => {
        new ec2.CfnRoute(this, `SpokeVpcBRouteToTgw-${index}`, {
          routeTableId: subnet.routeTable.routeTableId,
          destinationCidrBlock: "0.0.0.0/0",
          transitGatewayId: tgw.ref,
        }).addDependsOn(tgwAttachmentForSpokeVpcB);
      });

    // Add Route InspectionVpc to Network Firewall
    inspectionVpc
      .selectSubnets({ subnetGroupName: "TgwAttachment" })
      .subnets.map((subnet, index) => {
        new ec2.CfnRoute(this, `InspectionVpcRouteToNetworkFirewall-${index}`, {
          routeTableId: subnet.routeTable.routeTableId,
          destinationCidrBlock: "0.0.0.0/0",
          vpcEndpointId: cdk.Fn.select(
            1,
            cdk.Fn.split(
              ":",
              cdk.Fn.select(index, networkFirewall.attrEndpointIds)
            )
          ),
        }).addDependsOn(networkFirewall);
      });

    // Create Security Group
    const spokeVpcASg = new ec2.SecurityGroup(this, "SpokeVpcASg", {
      allowAllOutbound: true,
      vpc: spokeVpcA,
    });
    spokeVpcASg.addIngressRule(
      ec2.Peer.ipv4("10.0.0.0/16"),
      ec2.Port.allTraffic()
    );

    const spokeVpcBSg = new ec2.SecurityGroup(this, "SpokeVpcBSg", {
      allowAllOutbound: true,
      vpc: spokeVpcB,
    });
    spokeVpcBSg.addIngressRule(
      ec2.Peer.ipv4("10.0.0.0/16"),
      ec2.Port.allTraffic()
    );

    // Create EC2 instance
    spokeVpcA
      .selectSubnets({ subnetGroupName: "Workload" })
      .subnets.map((subnet, index) => {
        new ec2.Instance(this, `SpokeVpcAEc2Instance-${index}`, {
          machineImage: ec2.MachineImage.latestAmazonLinux({
            generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
          }),
          instanceType: new ec2.InstanceType("t3.micro"),
          vpc: spokeVpcA,
          vpcSubnets: spokeVpcA.selectSubnets({
            subnetGroupName: "Workload",
            availabilityZones: [spokeVpcA.availabilityZones[index]],
          }),
          securityGroup: spokeVpcASg,
          role: ssmIamRole,
        });
      });

    spokeVpcB
      .selectSubnets({ subnetGroupName: "Workload" })
      .subnets.map((subnet, index) => {
        new ec2.Instance(this, `SpokeVpcBEc2Instance-${index}`, {
          machineImage: ec2.MachineImage.latestAmazonLinux({
            generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
          }),
          instanceType: new ec2.InstanceType("t3.micro"),
          vpc: spokeVpcB,
          vpcSubnets: spokeVpcB.selectSubnets({
            subnetGroupName: "Workload",
            availabilityZones: [spokeVpcB.availabilityZones[index]],
          }),
          securityGroup: spokeVpcBSg,
          role: ssmIamRole,
        });
      });
  }
}

./package.json

{
  "name": "tgw-appliance-mode-network-firewall",
  "version": "0.1.0",
  "bin": {
    "tgw-appliance-mode-network-firewall": "bin/tgw-appliance-mode-network-firewall.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk"
  },
  "devDependencies": {
    "@aws-cdk/assert": "1.118.0",
    "@types/jest": "^26.0.10",
    "@types/node": "10.17.27",
    "aws-cdk": "^1.118.0",
    "jest": "^26.4.2",
    "ts-jest": "^26.2.0",
    "ts-node": "^9.0.0",
    "typescript": "~3.9.7"
  },
  "dependencies": {
    "@aws-cdk/aws-ec2": "^1.118.0",
    "@aws-cdk/aws-iam": "^1.118.0",
    "@aws-cdk/aws-logs": "^1.118.0",
    "@aws-cdk/aws-networkfirewall": "^1.118.0",
    "@aws-cdk/core": "1.118.0",
    "source-map-support": "^0.5.16"
  }
}

npx cdk deployで、AWS CDKでリソースをデプロイすると、以下のようなログが出力されます。

> npx cdk deploy
MFA token for arn:aws:iam::<AWSアカウントID>:mfa/<IAMユーザー名> 644834
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬─────────────────────────────────────┬────────┬─────────────────────────────────────┬───────────────────────────────────────┬───────────┐
│   │ Resource                            │ Effect │ Action                              │ Principal                             │ Condition │
├───┼─────────────────────────────────────┼────────┼─────────────────────────────────────┼───────────────────────────────────────┼───────────┤
│ + │ ${FlowLogsIamrole.Arn}              │ Allow  │ sts:AssumeRole                      │ Service:vpc-flow-logs.amazonaws.com   │           │
│ + │ ${FlowLogsIamrole.Arn}              │ Allow  │ iam:PassRole                        │ AWS:${FlowLogsIamrole}                │           │
├───┼─────────────────────────────────────┼────────┼─────────────────────────────────────┼───────────────────────────────────────┼───────────┤
│ + │ ${FlowLogsLogGroup.Arn}             │ Allow  │ logs:CreateLogStream                │ AWS:${FlowLogsIamrole}                │           │
│   │                                     │        │ logs:DescribeLogStreams             │                                       │           │
│   │                                     │        │ logs:PutLogEvents                   │                                       │           │
├───┼─────────────────────────────────────┼────────┼─────────────────────────────────────┼───────────────────────────────────────┼───────────┤
│ + │ ${SsmIamRole.Arn}                   │ Allow  │ sts:AssumeRole                      │ Service:ec2.${AWS::URLSuffix}         │           │
└───┴─────────────────────────────────────┴────────┴─────────────────────────────────────┴───────────────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬───────────────┬────────────────────────────────────────────────────────────────────┐
│   │ Resource      │ Managed Policy ARN                                                 │
├───┼───────────────┼────────────────────────────────────────────────────────────────────┤
│ + │ ${SsmIamRole} │ arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore │
└───┴───────────────┴────────────────────────────────────────────────────────────────────┘
Security Group Changes
┌───┬────────────────────────┬─────┬────────────┬─────────────────┐
│   │ Group                  │ Dir │ Protocol   │ Peer            │
├───┼────────────────────────┼─────┼────────────┼─────────────────┤
│ + │ ${SpokeVpcASg.GroupId} │ In  │ Everything │ 10.0.0.0/16     │
│ + │ ${SpokeVpcASg.GroupId} │ Out │ Everything │ Everyone (IPv4) │
├───┼────────────────────────┼─────┼────────────┼─────────────────┤
│ + │ ${SpokeVpcBSg.GroupId} │ In  │ Everything │ 10.0.0.0/16     │
│ + │ ${SpokeVpcBSg.GroupId} │ Out │ Everything │ Everyone (IPv4) │
└───┴────────────────────────┴─────┴────────────┴─────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
TgwApplianceModeNetworkFirewallStack: deploying...
[0%] start: Publishing a95c578082cad9562243b7caf4d4c1cc289f0fb43ebb4239be5a6a7a0fa383ea:current
[100%] success: Published a95c578082cad9562243b7caf4d4c1cc289f0fb43ebb4239be5a6a7a0fa383ea:current
TgwApplianceModeNetworkFirewallStack: creating CloudFormation changeset...



 ✅  TgwApplianceModeNetworkFirewallStack

Stack ARN:
arn:aws:cloudformation:us-east-1: <AWSアカウントID>:stack/TgwApplianceModeNetworkFirewallStack/b9c5cbd0-0172-11ec-b571-0a1540e225b5

npx cdk deploy実行完了後にマネージメントコンソールを確認すると、以下の通り、VPCや、Transit Gateway、Network Firewallが作成されていることが確認できます。

アプライアンスモードが無効な状態での疎通確認

検証で作成した、Spoke VPC AのEC2インスタンスAにはSSMセッションマネージャーでログインできます。

SSMセッションマネージャーを使うためには、SSMのエンドポイントに対して、tcp/443で通信できる必要があります。Spoke VPC Aには、SSM用のVPCエンドポイントも、Internet Gatewayも存在していませんが、Egress VPCのNAT Gatewayを経由してSSMのエンドポイントと通信ができるため、SSMセッションマネージャーが使用できます。

Spoke VPC AのEC2インスタンスAからインターネットに出ていく通信の経路は以下の通りです。

それでは、「Spoke VPC AのEC2インスタンスAから、Spoke VPC BのEC2インスタンスAへの通信」をしてみます。

Spoke VPC AのEC2インスタンスAにSSMセッションマネージャーでログインして、Spoke VPC BのEC2インスタンスA(10.0.3.46)に対してpingを打つと、パケットがロスなく全て到達できていることが分かります。

sh-4.2$ ping 10.0.3.46
PING 10.0.3.46 (10.0.3.46) 56(84) bytes of data.
64 bytes from 10.0.3.46: icmp_seq=1 ttl=251 time=3.90 ms
64 bytes from 10.0.3.46: icmp_seq=2 ttl=251 time=1.80 ms
64 bytes from 10.0.3.46: icmp_seq=3 ttl=251 time=1.77 ms
64 bytes from 10.0.3.46: icmp_seq=4 ttl=251 time=1.75 ms
64 bytes from 10.0.3.46: icmp_seq=5 ttl=251 time=1.91 ms
64 bytes from 10.0.3.46: icmp_seq=6 ttl=251 time=1.78 ms
^C
--- 10.0.3.46 ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5008ms
rtt min/avg/max/mdev = 1.750/2.155/3.901/0.783 ms
sh-4.2$

続いて、「Spoke VPC AのEC2インスタンスAから、Spoke VPC BのEC2インスタンスBへの通信」をしてみます。

Spoke VPC AのEC2インスタンスAにSSMセッションマネージャーでログインして、Spoke VPC BのEC2インスタンスB(10.0.3.58)に対してpingを打つと、全てのパケットがロスしていることが分かります。

sh-4.2$ ping 10.0.3.58
PING 10.0.3.58 (10.0.3.58) 56(84) bytes of data.
^C
--- 10.0.3.58 ping statistics ---
35 packets transmitted, 0 received, 100% packet loss, time 34793ms

sh-4.2$

アプライアンスモードの有効化

それでは、Transit Gatewayのアプライアンスモードを有効化します。

「Transit Gatewayのアプライアンスモードを有効化」と表現していますが、アプライアンスモードの設定は、Transit Gateway attachmentに対して行います。今回は、Inspection VPCのTransit Gateway attachmentに対して、アプライアンスモードを有効化します。

まず、Inspection VPCのTransit Gateway attachmentのIDをマネージメントコンソールから確認します。

そして、CloudShellを起動して、以下コマンドでTransit Gatewayのアプライアンスモードを有効化します。

[cloudshell-user@ip-10-1-32-124 ~]$ aws ec2 modify-transit-gateway-vpc-attachment \
>     --transit-gateway-attachment-id tgw-attach-0166964d4a20f8c28 \
>     --options ApplianceModeSupport=enable
{
    "TransitGatewayVpcAttachment": {
        "TransitGatewayAttachmentId": "tgw-attach-0166964d4a20f8c28",
        "TransitGatewayId": "tgw-078e5acef1eef851f",
        "VpcId": "vpc-0d94bfedea274dbf3",
        "VpcOwnerId": "984900217833",
        "State": "modifying",
        "SubnetIds": [
            "subnet-071ffea9d3764240d",
            "subnet-0cb97b2ab114dae87"
        ],
        "CreationTime": "2021-08-20T04:56:28+00:00",
        "Options": {
            "DnsSupport": "enable",
            "Ipv6Support": "disable",
            "ApplianceModeSupport": "enable"
        }
    }
}

"ApplianceModeSupport": "enable"となっている通り、アプライアンスモードを有効化されました。また、"State": "modifying",となっている通り、反映には5分程度時間がかかります。

アプライアンスモードが有効な状態での疎通確認

アプライアンスモードを有効化したので、改めて「Spoke VPC AのEC2インスタンスAから、Spoke VPC BのEC2インスタンスAへの通信」から検証してみます。

Spoke VPC AのEC2インスタンスAにSSMセッションマネージャーでログインして、Spoke VPC BのEC2インスタンスA(10.0.3.46)に対してpingを打つと、アプライアンスモードの有効化前と変わらず、パケットがロスなく全て到達できていることが分かります。

sh-4.2$ ping 10.0.3.46
PING 10.0.3.46 (10.0.3.46) 56(84) bytes of data.
64 bytes from 10.0.3.46: icmp_seq=1 ttl=251 time=3.80 ms
64 bytes from 10.0.3.46: icmp_seq=2 ttl=251 time=1.81 ms
64 bytes from 10.0.3.46: icmp_seq=3 ttl=251 time=1.73 ms
64 bytes from 10.0.3.46: icmp_seq=4 ttl=251 time=1.69 ms
64 bytes from 10.0.3.46: icmp_seq=5 ttl=251 time=1.65 ms
64 bytes from 10.0.3.46: icmp_seq=6 ttl=251 time=1.74 ms
^C
--- 10.0.3.46 ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5009ms
rtt min/avg/max/mdev = 1.652/2.072/3.801/0.774 ms
sh-4.2$

続いて、「Spoke VPC AのEC2インスタンスAから、Spoke VPC BのEC2インスタンスBへの通信」をしてみます。

Spoke VPC AのEC2インスタンスAにSSMセッションマネージャーでログインして、Spoke VPC BのEC2インスタンスB(10.0.3.58)に対してpingを打つと、アプライアンスモードが有効になったことで、パケットがロスなく全て到達できていることが分かります。

sh-4.2$ ping 10.0.3.58
PING 10.0.3.58 (10.0.3.58) 56(84) bytes of data.
64 bytes from 10.0.3.58: icmp_seq=1 ttl=251 time=3.72 ms
64 bytes from 10.0.3.58: icmp_seq=2 ttl=251 time=2.83 ms
64 bytes from 10.0.3.58: icmp_seq=3 ttl=251 time=2.37 ms
64 bytes from 10.0.3.58: icmp_seq=4 ttl=251 time=2.33 ms
64 bytes from 10.0.3.58: icmp_seq=5 ttl=251 time=2.50 ms
64 bytes from 10.0.3.58: icmp_seq=6 ttl=251 time=2.28 ms
^C
--- 10.0.3.58 ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5008ms
rtt min/avg/max/mdev = 2.289/2.677/3.727/0.506 ms
sh-4.2$

Network Firewallのエンドポイントを一つ削除した状態での疎通確認

最後に番外編ということで、Network Firewallのエンドポイントを一つ削除した状態での疎通確認をしてみたいと思います。

以下の図のように、Availability Zone AにあったNetwork Firewall Endpoint Aを削除した状態で正しく通信ができるかを検証します。

まず、Inspection VPCのTransit Gateway subnet A用のRoute Tableの0.0.0.0/0のルートの宛先を、Network Firewall Endpoint AからNetwork Firewall Endpoint Bに変更します。

  • 変更前

  • 変更後

続いて、Network Firewallのエンドポイントを削除します。まず、対象のNetwork Firewallを選択して、関連付けられたポリシーと VPC編集をクリックします。

そして、削除対象のエンドポイントがあるus-east-1a削除をクリックして、保存します。

保存後、エンドポイントの削除処理が始まり、10分程度で以下のようにエンドポイントの削除が完了します。

この状態で、「Spoke VPC AのEC2インスタンスAから、Spoke VPC BのEC2インスタンスAへの通信」から検証してみます。

Spoke VPC AのEC2インスタンスAにSSMセッションマネージャーでログインして、Spoke VPC BのEC2インスタンスA(10.0.3.46)に対してpingを打つと、パケットがロスなく全て到達できていることが分かります。

sh-4.2$ ping 10.0.3.46
PING 10.0.3.46 (10.0.3.46) 56(84) bytes of data.
64 bytes from 10.0.3.46: icmp_seq=1 ttl=251 time=3.50 ms
64 bytes from 10.0.3.46: icmp_seq=2 ttl=251 time=2.36 ms
64 bytes from 10.0.3.46: icmp_seq=3 ttl=251 time=2.33 ms
64 bytes from 10.0.3.46: icmp_seq=4 ttl=251 time=2.42 ms
64 bytes from 10.0.3.46: icmp_seq=5 ttl=251 time=2.33 ms
64 bytes from 10.0.3.46: icmp_seq=6 ttl=251 time=2.42 ms
^C
--- 10.0.3.46 ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5004ms
rtt min/avg/max/mdev = 2.330/2.563/3.502/0.425 ms
sh-4.2$

続いて、「Spoke VPC AのEC2インスタンスAから、Spoke VPC BのEC2インスタンスBへの通信」をしてみます。

Spoke VPC AのEC2インスタンスAにSSMセッションマネージャーでログインして、Spoke VPC BのEC2インスタンスB(10.0.3.58)に対してpingを打つと、パケットがロスなく全て到達できていることが分かります。

sh-4.2$ ping 10.0.3.58
PING 10.0.3.58 (10.0.3.58) 56(84) bytes of data.
64 bytes from 10.0.3.58: icmp_seq=1 ttl=251 time=4.54 ms
64 bytes from 10.0.3.58: icmp_seq=2 ttl=251 time=2.78 ms
64 bytes from 10.0.3.58: icmp_seq=3 ttl=251 time=2.71 ms
64 bytes from 10.0.3.58: icmp_seq=4 ttl=251 time=2.65 ms
64 bytes from 10.0.3.58: icmp_seq=5 ttl=251 time=2.69 ms
64 bytes from 10.0.3.58: icmp_seq=6 ttl=251 time=2.70 ms
^C
--- 10.0.3.58 ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5006ms
rtt min/avg/max/mdev = 2.651/3.015/4.547/0.687 ms
sh-4.2$

Network Firewallのエンドポイントが一つ削除されていても、残っているNetwork FirewallのエンドポイントをVPCのルートテーブルでルーティングすれば、通信できることが分かりました。

Transit Gatewayのアプライアンスモード完全に理解した

あまり使う場面は少ないかも知れないですが、Transit Gatewayのアプライアンスモードを試してみました。

今回は、Network Firewallを間に挟みましたが、他にもGateway Load Balancerと仮想アプライアンス製品を組み合わせたパターンでも対応可能です。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!