CloudFormationでAWS Network Firewall構築しようとしたらルート設定で困った件

KK
2023-09-01
2023-09-01

年三日坊主のKKです。

弊社は物理サーバのレンタルサーバーを提供しておりますのでサーバーの物理故障対応からは逃れられないのですが、マルチベンダの全サーバから物理ディスクのステータス情報を集める必要がありsmartctlコマンドについて復習しようとしたら、同僚古都の老兵のエンジニアブログで解説されていて大変助かりました。
ありがとう古都の老兵!、ありがとうエンジニアブログ


さて、前回前々回とAWS Network Firewallの設定について解説してきましたが、この検証のきっかけはAWS Network Firewallの構築をCloudFormationで自動化したいというものでした。

普通に考えればCloudFormationのリファレンス(AWS Network Firewall resource type reference)を参照して、手動での設定工程をトレースすれば問題無さそうですが、AWS Network FirewallをマルチAZで構築する場合は落とし穴があります。
上記のリファレンスのここです。

ここではNetwork Firewall作成APIの戻り値(Return values)について解説していますが、各サブネットごとに作成されたファイアウォールエンドポイントIDの戻り値は「特定の順序でリストされているわけではない(The subnets are not listed in any particular order.)」と書かれています。

前回、Network Firewallのルーティング設定について解説した際にも書きましたが、Network Firewallで確実にトラフィックをチェックするためにはアベイラブルゾーン(AZ)毎にトラフィックがファイアウォールエンドポイントを通るようにルーティング設定をする必要があります。
ところがファイアウォールエンドポイントIDの戻り値は特定の順序でリストされない、つまり毎回ランダムな順序で応答が返ってくるということですから、n番目のファイアウォールエンドポイントIDをn番目AZに設定するといった書き方ができません。

これはCloudFormationの問題として2021年にGitHubで Sort EndpointIds by AZ to enable resource composability #15 と問題提起されていますが、改修されていないようです。

とはいえ、どうにかしてルーティング設定しなければいけません。
幸い戻り値ではサブネットとファイアウォールエンドポイントIDのペアは保持されていますので、サブネット部分からAZを特定することは可能です。
可能なんです。
可能なんですが、自分でゼロからコード書くのは面倒だなぁ、と思っていたところ思わぬところにヒントがありました。

前回の記事の参考にした VPC向けファイアウォールのAWS Network Firewallをハンズオンで体感する で紹介されているAWS公式のハンズオン「AWS Network Firewall Workshop」でAWS Network Firewallの設定された環境を構築するCloudFormationのテンプレート anfw-distributed-template-2az.yaml が配布されています。
このyamlコードをCloudFormationスタックにセットして実行すると件のルーティングも含め2つのAZに跨るAWS Network Firewallが完全に設定された環境がデプロイされます。

では、このyamlコードでは前述のAZの問題をどのように解決しているのでしょうか。
以下がその該当部分です。

# Fn::GetAtt for Firewall do not return VPCE Id in ordered format.
# For more details refer to: https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-networkfirewall/issues/15
# Until the bug is fixed we have to rely on custom resource to retrieve AZ specific VPCE Id.

# Lambda Role:
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "AnfwDemo-AnfwLambdaRole-${AWS::Region}"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: root
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !GetAtt RetrieveVpcIdLogGroup.Arn
              - Effect: Allow
                Action:
                  - network-firewall:DescribeFirewall
                Resource: "*"

# Retrieve VpceId Lambda Custom Resource:
  RetrieveVpcIdLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
        LogGroupName: !Sub "/AnfwDemo/Lambda/RetrieveVpceId-${AWS::Region}"
        RetentionInDays: 1

  RetrieveVpceId:
    Type: AWS::Lambda::Function
    DependsOn: RetrieveVpcIdLogGroup
    Properties:
      FunctionName: !Sub "AnfwDemo-RetrieveVpceId-${AWS::StackName}"
      Handler: "index.handler"
      Role: !GetAtt
        - LambdaExecutionRole
        - Arn
      Code:
        ZipFile: |
          import boto3
          import cfnresponse
          import json
          import logging

          def handler(event, context):
              logger = logging.getLogger()
              logger.setLevel(logging.INFO)
              responseData = {}
              responseStatus = cfnresponse.FAILED
              logger.info('Received event: {}'.format(json.dumps(event)))
              if event["RequestType"] == "Delete":
                  responseStatus = cfnresponse.SUCCESS
                  cfnresponse.send(event, context, responseStatus, responseData)
              if event["RequestType"] == "Create":
                  try:
                      Az1 = event["ResourceProperties"]["Az1"]
                      Az2 = event["ResourceProperties"]["Az2"]
                      FwArn = event["ResourceProperties"]["FwArn"]
                  except Exception as e:
                      logger.info('AZ retrieval failure: {}'.format(e))
                  try:
                      nfw = boto3.client('network-firewall')
                  except Exception as e:
                      logger.info('boto3.client failure: {}'.format(e))
                  try:
                      NfwResponse=nfw.describe_firewall(FirewallArn=FwArn)
                      VpceId1 = NfwResponse['FirewallStatus']['SyncStates'][Az1]['Attachment']['EndpointId']
                      VpceId2 = NfwResponse['FirewallStatus']['SyncStates'][Az2]['Attachment']['EndpointId']

                  except Exception as e:
                      logger.info('ec2.describe_firewall failure: {}'.format(e))

                  responseData['FwVpceId1'] = VpceId1
                  responseData['FwVpceId2'] = VpceId2
                  responseStatus = cfnresponse.SUCCESS
                  cfnresponse.send(event, context, responseStatus, responseData)
      Runtime: python3.7
      Timeout: 30

  FirewallVpceIds:
    Type: Custom::DescribeVpcEndpoints
    Properties:
      ServiceToken: !GetAtt RetrieveVpceId.Arn
      Az1: !Ref AvailabilityZone1Selection
      Az2: !Ref AvailabilityZone2Selection
      FwArn: !Ref SpokeVpcAFirewall

冒頭からSort EndpointIds by AZ to enable resource composability #15 へのリンク付きで

# ファイアウォールの Fn::GetAtt は、VPCE ID を順序付けされた形式で返しません。
# 詳細については、https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-networkfirewall/issues/15 を参照してください。
# バグが修正されるまで、AZ 固有の VPCE ID を取得するにはカスタム リソースに依存する必要があります。

と私の困りごとについて書かれています。
要するにLambdaを使ってサブネットからファイアウォールエンドポイントのAZを特定し、カスタムリソースとして定義することで、ルート設定のターゲットに各AZのファイアウォールエンドポイントを指定できるようにしているわけです。

というわけで、AWS公式から配布されているyamlコードですのでありがたく参考にさせて頂きました。
AWSはこういう参考情報が探せばどこかにあるのが助かりますね。

もし同じような問題でお困りの方の参考になれば幸いです。

なお、表示される内容は利用状況やAWSの仕様変更ににより変化しますのでご注意ください。