👌

[CFn]個人的にFn::ForEachの検証はまだ終わっていなかった為、3層サブネットのVPCを作成して実用性を確認してみた

2023/08/27に公開

私のFn::ForEachの検証はまだ終わっていません

前回以下のような記事を書きました。
https://zenn.dev/mjxo/articles/10500494ba8f1e

ただ、この章のタイトルの文言が今日に至るまで私の脳内を支配していました。

日本国内や海外でもFn::ForEachFn::FindInMapを利用して単独のリソースタイプをコレクション個数分デプロイするテンプレート例をあげた記事は見られるようになった一方、

実際にはFn::ForEachで作成したリソースAとFn::ForEachで作成したリソースBの間で参照関係(!Ref)や依存関係(DependsOn)を設定出来ないと実用的とは言えません。

私の見落としである可能性を完全に否定出来ませんが、日本語・英語問わずいくら探してもそこに強く言及したりアンチパターンを載せた記事やドキュメントが見当たりませんでした。

その為、実際に複数リソースにFn::ForEachを利用してその中のプロパティで参照するとどうなるかを検証する事にしました。

今回作るもの

構成図です。図では表現していませんがRouteTableとRoute、ElasticIP等も作成します。

まずは私が辿り着いたテンプレート

完成テンプレート
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::LanguageExtensions
# ------------------------------------------------------------#
# Parameters:
# ------------------------------------------------------------#
Parameters:
  ZoneList:
    Type: List<String>
    Default: a,c

  SubnetList:
    Type: List<String>
    Default: PublicSubnet,ProtectedSubnet,PrivateSubnet
# ------------------------------------------------------------#
# Mappings:
# ------------------------------------------------------------#
Mappings: 
  # ------------------------------------------------------------#
  # ZoneMappings
  # ------------------------------------------------------------#
  ZoneMappings: 
    a: 
      AvailabilityZone: a

    c: 
      AvailabilityZone: c
  # ------------------------------------------------------------#
  # SubnetMappings
  # ------------------------------------------------------------#
  SubnetMappings: 
    PublicSubnet: 
      Mapping: PublicSubnetMappings

    ProtectedSubnet: 
      Mapping: ProtectedSubnetMappings

    PrivateSubnet: 
      Mapping: PrivateSubnetMappings
  # ------------------------------------------------------------#
  # PublicSubnetMappings
  # ------------------------------------------------------------#
  PublicSubnetMappings: 
    a: 
      CidrBlock: 10.0.0.0/24

    c: 
      CidrBlock: 10.0.1.0/24
  # ------------------------------------------------------------#
  # ProtectedSubnetMappings
  # ------------------------------------------------------------#
  ProtectedSubnetMappings: 
    a: 
      CidrBlock: 10.0.2.0/24

    c: 
      CidrBlock: 10.0.3.0/24
  # ------------------------------------------------------------#
  # PrivateSubnetMappings
  # ------------------------------------------------------------#
  PrivateSubnetMappings:
    a: 
      CidrBlock: 10.0.4.0/24

    c: 
      CidrBlock: 10.0.5.0/24
# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
  # ------------------------------------------------------------#
  # VPC
  # ------------------------------------------------------------#
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      Tags:
        - Key: Name
          Value: VPC
  # ------------------------------------------------------------#
  # InternetGateway
  # ------------------------------------------------------------#
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties: 
      Tags:
        - Key: Name
          Value: InternetGateway
  # ------------------------------------------------------------#
  # VPCGatewayAttachment
  # ------------------------------------------------------------#
  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties: 
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC
  # ------------------------------------------------------------#
  # ZoneLoop
  # ------------------------------------------------------------#
  Fn::ForEach::ZoneLoop:
    - ZoneItem
    - !Ref ZoneList
    # ------------------------------------------------------------#
    # NatGateway
    # ------------------------------------------------------------#
    - PublicSubnet1${ZoneItem}NatGateway:
        Type: AWS::EC2::NatGateway
        Properties:
          AllocationId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}EIPforNatGateway', AllocationId]
          SubnetId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}', SubnetId]
          Tags: 
            - Key: Name
              Value: !Sub PublicSubnet1${ZoneItem}NatGateway
    # ------------------------------------------------------------#
    # EIP
    # ------------------------------------------------------------#
      PublicSubnet1${ZoneItem}EIPforNatGateway: 
        Type: AWS::EC2::EIP
        Properties:
          Domain: vpc
          Tags: 
            - Key: Name
              Value: !Sub PublicSubnet1${ZoneItem}EIPforNatGateway
    # ------------------------------------------------------------#
    # Route
    # ------------------------------------------------------------#
      PublicSubnet1${ZoneItem}Route: 
        Type: AWS::EC2::Route
        Properties:
          DestinationCidrBlock: 0.0.0.0/0
          RouteTableId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}RouteTable', RouteTableId]
          GatewayId: !Ref InternetGateway

      ProtectedSubnet1${ZoneItem}Route: 
        Type: AWS::EC2::Route
        Properties:
          DestinationCidrBlock: 0.0.0.0/0
          RouteTableId: !GetAtt [!Sub 'ProtectedSubnet1${ZoneItem}RouteTable', RouteTableId]
          NatGatewayId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}NatGateway', NatGatewayId]
      # ------------------------------------------------------------#
      # SubnetLoop
      # ------------------------------------------------------------#
      Fn::ForEach::SubnetLoop:
        - SubnetItem
        - !Ref SubnetList
        # ------------------------------------------------------------#
        # Subnet
        # ------------------------------------------------------------#
        - ${SubnetItem}1${ZoneItem}:
            Type: AWS::EC2::Subnet
            Properties:
              VpcId: !Ref VPC
              CidrBlock: !FindInMap [!FindInMap [SubnetMappings,!Ref SubnetItem,Mapping],!Ref ZoneItem,CidrBlock]
              AvailabilityZone: !Sub ap-northeast-1${ZoneItem}
              Tags:
              - Key: Name
                Value: !Sub ${SubnetItem}1${ZoneItem}
        # ------------------------------------------------------------#
        # RouteTable
        # ------------------------------------------------------------#
          ${SubnetItem}1${ZoneItem}RouteTable:
            Type: AWS::EC2::RouteTable
            Properties:
              VpcId: !Ref VPC
              Tags:
                - Key: Name
                  Value: !Sub ${SubnetItem}1${ZoneItem}RouteTable
        # ------------------------------------------------------------#
        # SubnetRouteTableAssociation
        # ------------------------------------------------------------#
          ${SubnetItem}1${ZoneItem}RouteTableAssociation: 
            Type: AWS::EC2::SubnetRouteTableAssociation
            Properties:
              SubnetId: !GetAtt [!Sub '${SubnetItem}1${ZoneItem}', SubnetId]
              RouteTableId: !GetAtt [!Sub '${SubnetItem}1${ZoneItem}RouteTable', RouteTableId]

まずは感謝したい動画

「AWS Events」チャンネルに「AWS On Air ft. Accelerate your CloudFormation authoring experience with Fn::ForEach looping function」という動画を発見しました。
まずはこの動画をアーカイブしてくださった方に感謝を申し上げます。

注目したい部分は22分54秒の画面切り替わり(スクロール)です。

https://www.youtube.com/watch?v=YSsWbHmLGTs

単独のFn::ForEachの中で複数のType(DynamoDB::TableとIAM::Policy)を宣言しています。

私は当初Fn::ForEachはリソースタイプ毎にしか使えない(※1:1)という勘違いしていました。

実際にはFn::ForEachに対して(1:n)のリソース作成が可能だったのです。
(これは私の思い込みによるドキュメント文の見落としかもしれません。)

上記思い込みのせいで私は数日にわたり、NatGatewayやEIPに対してそれぞれForEachを設定し、
文字列を参照させる挑戦を繰り返していました。

その為、DependsOnに文字列か!Refしか置けない事も含めLanguageExtentionで解決しないのはIssueではないかと悩み、cfn-language-discussionに投稿するか何度も悩んだり、LanguageExtentionが実際には何層の組み込み関数ネストを許容しているのかを検証したブログを国内外から探しました。

この動画が全て解決してくれました。
本当に有難うございます。

少し複雑な説明になりますが、お付き合いください。

詳細な説明

Parameters

# ------------------------------------------------------------#
# Parameters:
# ------------------------------------------------------------#
Parameters:
  ZoneList:
    Type: List<String>
    Default: a,c

  SubnetList:
    Type: List<String>
    Default: PublicSubnet,ProtectedSubnet,PrivateSubnet
    

パラメーターについてはList型を2つ用意します。
ひとつはZoneを分けるもの。もう一つはSubnetを分けるものです。

これらを後でFn::ForEach内でFn:ForEachを使うネストをする事で、リソースタイプ毎に2×3で最大6つのリソースを作成する事を想定しています。

Mappings

# ------------------------------------------------------------#
# Mappings:
# ------------------------------------------------------------#
Mappings: 
  # ------------------------------------------------------------#
  # ZoneMappings
  # ------------------------------------------------------------#
  ZoneMappings: 
    a: 
      AvailabilityZone: a

    c: 
      AvailabilityZone: c
  # ------------------------------------------------------------#
  # SubnetMappings
  # ------------------------------------------------------------#
  SubnetMappings: 
    PublicSubnet: 
      Mapping: PublicSubnetMappings

    ProtectedSubnet: 
      Mapping: ProtectedSubnetMappings

    PrivateSubnet: 
      Mapping: PrivateSubnetMappings
  # ------------------------------------------------------------#
  # PublicSubnetMappings
  # ------------------------------------------------------------#
  PublicSubnetMappings: 
    a: 
      CidrBlock: 10.0.0.0/24

    c: 
      CidrBlock: 10.0.1.0/24
  # ------------------------------------------------------------#
  # ProtectedSubnetMappings
  # ------------------------------------------------------------#
  ProtectedSubnetMappings: 
    a: 
      CidrBlock: 10.0.2.0/24

    c: 
      CidrBlock: 10.0.3.0/24
  # ------------------------------------------------------------#
  # PrivateSubnetMappings
  # ------------------------------------------------------------#
  PrivateSubnetMappings:
    a: 
      CidrBlock: 10.0.4.0/24

    c: 
      CidrBlock: 10.0.5.0/24

Mappingsについては少し不思議に思うかもしれません。
ここは後で振り返った時に私の苦悩が垣間見える所です。

この説明は後で詳しくします。

Fn::ForEachを必要としないリソース

# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
  # ------------------------------------------------------------#
  # VPC
  # ------------------------------------------------------------#
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      Tags:
        - Key: Name
          Value: VPC
  # ------------------------------------------------------------#
  # InternetGateway
  # ------------------------------------------------------------#
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties: 
      Tags:
        - Key: Name
          Value: InternetGateway
  # ------------------------------------------------------------#
  # VPCGatewayAttachment
  # ------------------------------------------------------------#
  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties: 
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC

これらは単体で作成する事が確定している為、普段通りになります。
一番上位にあるFn::ForEachのさらに外側に位置します。

Zone毎にひとつずつ必要なリソース

  # ------------------------------------------------------------#
  # ZoneLoop
  # ------------------------------------------------------------#
  Fn::ForEach::ZoneLoop:
    - ZoneItem
    - !Ref ZoneList
    # ------------------------------------------------------------#
    # NatGateway
    # ------------------------------------------------------------#
    - PublicSubnet1${ZoneItem}NatGateway:
        Type: AWS::EC2::NatGateway
        Properties:
          AllocationId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}EIPforNatGateway', AllocationId]
          SubnetId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}', SubnetId]
          Tags: 
            - Key: Name
              Value: !Sub PublicSubnet1${ZoneItem}NatGateway
    # ------------------------------------------------------------#
    # EIP
    # ------------------------------------------------------------#
      PublicSubnet1${ZoneItem}EIPforNatGateway: 
        Type: AWS::EC2::EIP
        Properties:
          Domain: vpc
          Tags: 
            - Key: Name
              Value: !Sub PublicSubnet1${ZoneItem}EIPforNatGateway
    # ------------------------------------------------------------#
    # Route
    # ------------------------------------------------------------#
      PublicSubnet1${ZoneItem}Route: 
        Type: AWS::EC2::Route
        Properties:
          DestinationCidrBlock: 0.0.0.0/0
          RouteTableId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}RouteTable', RouteTableId]
          GatewayId: !Ref InternetGateway

      ProtectedSubnet1${ZoneItem}Route: 
        Type: AWS::EC2::Route
        Properties:
          DestinationCidrBlock: 0.0.0.0/0
          RouteTableId: !GetAtt [!Sub 'ProtectedSubnet1${ZoneItem}RouteTable', RouteTableId]
          NatGatewayId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}NatGateway', NatGatewayId]

最初のFn::ForEachの直下にあるリソース達です。
aとcの二つのZoneに対して一つずつ作成している様子を掴んでいただければ幸いです。

Parameters:
  ZoneList:
    Type: List<String>
    Default: a,c

ここで重要なのは、例えばPublicSubnetだけに必要なNatGatewayを、
6個のサブネットの中の2つとして捉えない事。
2つのゾーンに対して2として捉える事が重要です。

これらはVPCの中でZoneに対して必要であると捉える事が出来ます。

Subnet毎にひとつずつ必要なリソース

      # ------------------------------------------------------------#
      # SubnetLoop
      # ------------------------------------------------------------#
      Fn::ForEach::SubnetLoop:
        - SubnetItem
        - !Ref SubnetList
        # ------------------------------------------------------------#
        # Subnet
        # ------------------------------------------------------------#
        - ${SubnetItem}1${ZoneItem}:
            Type: AWS::EC2::Subnet
            Properties:
              VpcId: !Ref VPC
              CidrBlock: !FindInMap [!FindInMap [SubnetMappings,!Ref SubnetItem,Mapping],!Ref ZoneItem,CidrBlock]
              AvailabilityZone: !Sub ap-northeast-1${ZoneItem}
              Tags:
              - Key: Name
                Value: !Sub ${SubnetItem}1${ZoneItem}
        # ------------------------------------------------------------#
        # RouteTable
        # ------------------------------------------------------------#
          ${SubnetItem}1${ZoneItem}RouteTable:
            Type: AWS::EC2::RouteTable
            Properties:
              VpcId: !Ref VPC
              Tags:
                - Key: Name
                  Value: !Sub ${SubnetItem}1${ZoneItem}RouteTable
        # ------------------------------------------------------------#
        # SubnetRouteTableAssociation
        # ------------------------------------------------------------#
          ${SubnetItem}1${ZoneItem}RouteTableAssociation: 
            Type: AWS::EC2::SubnetRouteTableAssociation
            Properties:
              SubnetId: !GetAtt [!Sub '${SubnetItem}1${ZoneItem}', SubnetId]
              RouteTableId: !GetAtt [!Sub '${SubnetItem}1${ZoneItem}RouteTable', RouteTableId]

先ほどのFn::ForEachの中にさらにネストしたFn::ForEachを用意しました。
合計で6つのリソースが作られるべきもの達です。

冒頭でもお話ししたようにZoneList × SubnetListにより実現しています。

更に特筆すべき部分

EC2::Routeが二つに分かれている

    # ------------------------------------------------------------#
    # Route
    # ------------------------------------------------------------#
      PublicSubnet1${ZoneItem}Route: 
        Type: AWS::EC2::Route
        Properties:
          DestinationCidrBlock: 0.0.0.0/0
          RouteTableId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}RouteTable', RouteTableId]
          GatewayId: !Ref InternetGateway

      ProtectedSubnet1${ZoneItem}Route: 
        Type: AWS::EC2::Route
        Properties:
          DestinationCidrBlock: 0.0.0.0/0
          RouteTableId: !GetAtt [!Sub 'ProtectedSubnet1${ZoneItem}RouteTable', RouteTableId]
          NatGatewayId: !GetAtt [!Sub 'PublicSubnet1${ZoneItem}NatGateway', NatGatewayId]

ここが二つに分かれている理由はプロパティにあります。

パブリックサブネットに対して必要なRouteには「GatewayId:」が、プロテクテッドサブネットに対して必要なRouteには「NatGatewayId:」が必要です。

これらを分岐させようとするとConditions+IfとConditionを利用しなければいけませんが、

例えばConditionsの中で参照させる値として
AWS::EC2::Routeの論理IDとRouteTableIdをドットで繋いで値を参照出来るでしょうか?
答えはNoです。

AWS::EC2::RouteはGetAttやRefでCidrBlockしか返しません。

これでは分岐を実現出来ません。

そもそもConditionsの中ではパラメーターのValueがどうであるかを判断するものはよく目にしますが、ResourceのプロパティのValueを逆輸入させて「何を参照させるように書いているか」をCloudFormationに判断しろというのも不可能です。

更に言えば、Conditions内で各条件が読み込まれる際に既に解決されているもの以外はエラーを吐き出すでしょう。(リソースが作成された際に決定される値が何かを事前に判断させる事ももちろん出来ない)

最後にConditionはプロパティ単位を分岐させません。これらが全て出来るとしても2つのType: AWS::EC2::Routeリソースタイプ(どちらかが選ばれる)を作成する必要があり、比較するとConditionに関わる行数が無意味に増えただけになります。

その為ここはZonalLoopになります。

プロテクテッドサブネットにルートが必要ない事も理由のひとつです。

パブリックサブネットのRouteTableがそれぞれに存在する

同じInternetGatewayへの0.0.0.0/0ルートを持つパブリックサブネットのRouteTableがそれぞれに存在します。

ここは本来一つで構いませんが、Fn::ForEachを利用してRouteTableを作成しようとするとこれを避けて通れません。

プロテクテッドとプライベート用、或いはプロテクテッドサブネット用のみをZonalLoopのスコープにおいて、パブリック用を最上位のFn::ForEachの外に置く事も可能ですが、これは私の好みではありません。テンプレート作成当初に決めた出来る限りFn::ForEachを適用させたいという方針に一貫性がないと感じます。

Fn::FindInMapの中にFn::FindInMapがネストされている

        # ------------------------------------------------------------#
        # Subnet
        # ------------------------------------------------------------#
        - ${SubnetItem}1${ZoneItem}:
            Type: AWS::EC2::Subnet
            Properties:
              VpcId: !Ref VPC
              CidrBlock: !FindInMap [!FindInMap [SubnetMappings,!Ref SubnetItem,Mapping],!Ref ZoneItem,CidrBlock]

Mappings部分について先ほど「苦悩」があったと表現しましたが、

!FindInMap内でSubが使えればこのようにはしませんでした。
SubnetItemとZoneItemを文字列連結可能だからです。

内側にあるFindInMapでそれがパブリックかプロテクテッドかプライベートかを参照します。
そうすると以下の部分からKeyを探します。

対応するValueには次のMappingの名前があります。

Mappings: 
  # ------------------------------------------------------------#
  # SubnetMappings
  # ------------------------------------------------------------#
  SubnetMappings: 
    PublicSubnet: 
      Mapping: PublicSubnetMappings

    ProtectedSubnet: 
      Mapping: ProtectedSubnetMappings

    PrivateSubnet: 
      Mapping: PrivateSubnetMappings

このValueは、外側のFindInMapの第一引数でKeyに化けます。

以下のそれぞれのMappingsのKeyから、第二引数で指定されたZoneの一文字(a or c)をKeyにCidrを参照させます。

  # ------------------------------------------------------------#
  # PublicSubnetMappings
  # ------------------------------------------------------------#
  PublicSubnetMappings: 
    a: 
      CidrBlock: 10.0.0.0/24

    c: 
      CidrBlock: 10.0.1.0/24
  # ------------------------------------------------------------#
  # ProtectedSubnetMappings
  # ------------------------------------------------------------#
  ProtectedSubnetMappings: 
    a: 
      CidrBlock: 10.0.2.0/24

    c: 
      CidrBlock: 10.0.3.0/24
  # ------------------------------------------------------------#
  # PrivateSubnetMappings
  # ------------------------------------------------------------#
  PrivateSubnetMappings:
    a: 
      CidrBlock: 10.0.4.0/24

    c: 
      CidrBlock: 10.0.5.0/24

行数はどの程度減ったのか

全ての余分な行を削除した結果の比較です。
Fn::ForEachを利用しないで作成した場合:190行
Fn::ForEachを利用して作成した場合:110行

約40%の削減が出来たと言えます。

デザイナーでどう表示されるか

Fn::ForEachを利用しないで読み込ませた場合

Fn::ForEachを利用して読み込ませた場合:

デザイナーはFn::ForEachについてCustomResouceの存在を表現します。
この違いは気になるので、後日理由を考えてみる事にしたいと思いました。

以上でした

CDKを利用する人々からすればこの試みに含まれるいくつかの苦労は大変滑稽なものかもしれません。

私自身Fn::ForEachの登場が素晴らしいか、このテンプレートの姿が美しいかについては今は言及しないでおく事にしたいと思います。

この組み込み関数の登場は単なる機能追加ではなく、CFnが保っていた潔癖さをある種壊してその形を変化させる道を模索するもののように感じるからです。

あまりにも長い記事になってしまう為、私の失敗パターンはまた別のブログでまとめる事にしました。
世界のどなたかのお役に立つ部分があれば私は幸せです。

お読みいただき有難うございました。

Discussion