CloudFormation で ForEach と FindInMap を組み合わせてどこまでできるか試してみた

2023.08.09

こんにちは、シマです。
先日、CloudFormationでForEach組み込み関数がリリースされ、以下の記事を書きました。

今回はもう少し踏み込んで、普段作るようなテンプレートをなるべくForEachを利用して作成するとどこまでできて、どこから難しいのか気になったので試してみました。

構成

今回、テンプレートで作成した構成は一般的なWeb三層構造をイメージした以下です。

ALBやEC2、RDSを配置するとより実践的ですが、ForEachを試すという観点では冗長になりそうだったので、今回は割愛しています。

テンプレートファイル

なるべくForEachを利用して作成したテンプレートファイルは以下です。
※折りたたんであります。

template.yml
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::LanguageExtensions

Parameters:
  azList:
    Type: List<String>
    Default: az1,az2
  snList:
    Type: List<String>
    Default: snPub,snWeb,snDb
  rtList:
    Type: List<String>
    Default: rtPub,rtWeb,rtDb
  sgList:
    Type: List<String>
    Default: sgAlb
  sgIngressList:
    Type: List<String>
    Default: albHttps,albHttp

Mappings:
  azMappings:
    az1:
      availabilityZone: ap-northeast-1a
      name: a
      cidrBlock: 192.168.1
    az2:
      availabilityZone: ap-northeast-1c
      name: c
      cidrBlock: 192.168.2
  snMappings:
    snPub:
      cidrBlock: 1.0/24
      nameTags: sn-pub-
      rt: rtPub
    snWeb:
      cidrBlock: 2.0/24
      nameTags: sn-web-
      rt: rtWeb
    snDb:
      cidrBlock: 3.0/24
      nameTags: sn-db-
      rt: rtDb
  rtMappings:
    rtPub:
      nameTags: rt-pub-
    rtWeb:
      nameTags: rt-web-
    rtDb:
      nameTags: rt-db-
  sgMappings:
    sgAlb:
      groupName: sgalb
  sgIngressMappings:
    albHttps:
      fromPort: 443
      toPort: 443
      cidrIp: xx.xx.xx.xx/32
      groupId: sgAlb
      Description: https from xxx
    albHttp:
      fromPort: 80
      toPort: 80
      cidrIp: xx.xx.xx.xx/32
      groupId: sgAlb
      Description: http from xxx

Resources:
#  eip
# ------------------------------------------------------------#
  Fn::ForEach::eipLoop:
    - azItems
    - !Ref azList
    - npingw${azItems}:
        Type: AWS::EC2::EIP
        Properties:
          Tags:
            - Key: Name
              Value: !Sub
                - eip-ngw-${az}
                - az: !FindInMap [azMappings ,!Ref azItems ,name]

#  vpc
# ------------------------------------------------------------#
  vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 192.168.0.0/16
      Tags:
      - Key: Name
        Value: vpc

#  RouteTable
# ------------------------------------------------------------#
  Fn::ForEach::rtLoop:
    - rtItems
    - !Ref rtList
    - Fn::ForEach::azLoop:
      - azItems
      - !Ref azList
      - ${rtItems}${azItems}:
          Type: AWS::EC2::RouteTable
          Properties:
            VpcId: !Ref vpc
            Tags:
            - Key: Name
              Value: !Sub
                - ${rtName}${azName}
                - rtName: !FindInMap [rtMappings, !Ref rtItems, nameTags]
                  azName: !FindInMap [azMappings, !Ref azItems, name]

# Subnet
# ------------------------------------------------------------#
  Fn::ForEach::snLoop:
    - snItems
    - !Ref snList
    - Fn::ForEach::azLoop:
      - azItems
      - !Ref azList
      - ${snItems}${azItems}:
          Type: AWS::EC2::Subnet
          Properties:
            VpcId: !Ref vpc
            CidrBlock: !Sub
              - ${azCidrBlock}${snCidrBlock}
              - azCidrBlock: !FindInMap [azMappings, !Ref azItems, cidrBlock]
                snCidrBlock: !FindInMap [snMappings, !Ref snItems, cidrBlock]
            AvailabilityZone: !FindInMap [azMappings, !Ref azItems, availabilityZone]
            Tags:
            - Key: Name
              Value: !Sub
              - ${snName}${azName}
              - snName: !FindInMap [snMappings, !Ref snItems, nameTags]
                azName: !FindInMap [azMappings, !Ref azItems, name]

        Association${snItems}${azItems}:
          Type: AWS::EC2::SubnetRouteTableAssociation
          Properties:
            SubnetId: !Ref
              Fn::Sub: ${snItems}${azItems}
            RouteTableId: !Ref
              Fn::Sub:
              - ${rt}${azItems}
              - rt: !FindInMap [snMappings, !Ref snItems, rt]

# InternetGateway
# ------------------------------------------------------------#
  igw:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
      - Key: Name
        Value: igw

  AttachmentIgw:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref vpc
      InternetGatewayId: !Ref igw

  Fn::ForEach::azIgwRtLoop:
    - azItems
    - !Ref azList
    - rtIgw${azItems}:
        Type: AWS::EC2::Route
        DependsOn: AttachmentIgw
        Properties:
          RouteTableId: !Ref 
            Fn::Sub: rtPub${azItems}
          DestinationCidrBlock: 0.0.0.0/0
          GatewayId: !Ref igw

# NatGateway
# ------------------------------------------------------------#
  Fn::ForEach::azNgwLoop:
    - azItems
    - !Ref azList
    - ngw${azItems}:
        Type: AWS::EC2::NatGateway
        Properties:
          AllocationId: !GetAtt
            - Fn::Sub: npingw${azItems}
            - AllocationId
          SubnetId: !Ref
            Fn::Sub: snPub${azItems}
          Tags:
          - Key: Name
            Value: !Sub
            - ngw-${azName}
            - azName: !FindInMap [azMappings, !Ref azItems, name]

  Fn::ForEach::azNgwRtLoop:
    - azItems
    - !Ref azList
    - rtNgw${azItems}:
        Type: AWS::EC2::Route
        Properties:
          RouteTableId: !Ref 
            Fn::Sub: rtWeb${azItems}
          DestinationCidrBlock: 0.0.0.0/0
          NatGatewayId: !Ref 
            Fn::Sub: ngw${azItems}

#  Security Group
# ------------------------------------------------------------#
  Fn::ForEach::sgLoop:
    - sgItems
    - !Ref sgList
    - ${sgItems}:
        Type: AWS::EC2::SecurityGroup
        Properties:
          GroupName: !FindInMap [sgMappings, !Ref sgItems, groupName]
          GroupDescription: !FindInMap [sgMappings, !Ref sgItems, groupName]
          VpcId: !Ref vpc
          SecurityGroupEgress:
          - IpProtocol: -1
            CidrIp: 0.0.0.0/0
          Tags:
          - Key: Name
            Value: !FindInMap [sgMappings, !Ref sgItems, groupName]

  Fn::ForEach::sgIngressLoop:
    - sgIngressItems
    - !Ref sgIngressList
    - ${sgIngressItems}:
        Type: AWS::EC2::SecurityGroupIngress
        Properties:
          IpProtocol: !FindInMap [sgIngressMappings, !Ref sgIngressItems, ipProtocol, DefaultValue: tcp]
          FromPort: !FindInMap [sgIngressMappings, !Ref sgIngressItems, fromPort]
          ToPort: !FindInMap [sgIngressMappings, !Ref sgIngressItems, toPort]
          CidrIp: !FindInMap [sgIngressMappings, !Ref sgIngressItems, cidrIp]
          GroupId: !Ref
            Fn::FindInMap: [sgIngressMappings, !Ref sgIngressItems, groupId]
          Description: !FindInMap [sgIngressMappings, !Ref sgIngressItems, Description]

感想

今回作成したテンプレート内では、RouteTableを作成しているところのように、単純に複数作成するようなケースはとても有用であると感じました。しかし一方で、変化するパラメータの数が多くなると、Mappingsで定義する数も増え、ForEachで楽をした分Mappingsが増えるということになってしまいます。また、当たり前ですが、全てにおいてForEachを利用すると複雑化してしまい、可読性の低下やバグの原因になってしまうため、無理をせずに便利だなと感じれるところで利用することがよいです。さらに複雑な条件分岐でループ処理をするなら素直にCDKの利用がベストだと感じました。
せっかくなので、少し複雑になってしまったところや、やりたかったけど出来なかったところについて、下記で簡単にご紹介いたします。

①計算ができない

サブネットのCIDRを設定する際に、VPCのCIDRから計算が出来れば便利だなと感じましたが、テンプレート内では計算ができません。今回はパラメータとして文字列を与え、文字列の結合でパターンが分けられるようにテンプレートに都合の良いパラメータで作成しています。

②ForEachの変数でIFが使えない

ForEach内の変数により条件分岐をさせて、作成するリソースを変化させたいことがありました。しかし、組み込み関数にIFではParametersの内容による分岐は可能ですがForEach内の変数を使った条件分岐はできませんでした。

③組み込み関数の中での組み込み関数利用

Subで文字列結合や変数置き換えや、Refで指定する文字列が、Mappingsの変数と絡むケースが多かったです。例えば、以下のようなケースです。

  RouteTableId: !Ref
    Fn::Sub:
    - ${rt}${azItems}
    - rt: !FindInMap [snMappings, !Ref snItems, rt]

RouteTableId は物理IDを与える必要があるため、論理IDに対してRefを使用すること(!Ref xxxx)が一般的です。今回のケースでは、Mappingsに格納しているためFindInMapで取りに行く必要があり、その値をSubで結合して論理IDを生成しています。

また、組み込み関数の中で組み込み関数を利用する場合はサポートされているかどうかを意識する必要があります。例えば、Refの中でサポートされている組み込み関数は以下のページの最下部に記載されています。

サポートされている関数
AWS::LanguageExtensions 変換トランスフォームを使用すると、Ref関数内で次の関数を使用できます。
Fn::Base64
Fn::FindInMap
Fn::If
Fn::Join
Fn::Sub
Fn::ToJsonString
Ref

記載されている通り、「AWS::LanguageExtensions 変換トランスフォーム」を利用する前提で利用可能です。また、前述の前提の場合は短縮形式で利用できないことに注意が必要です。

短縮形式の YAML 構文は、AWS::LanguageExtensions 変換でのみ使用できる組み込み関数のテンプレート内ではサポートされていません。

そのため、Refの中で利用するSubは !Sub では利用できず、Fn::Sub: として指定する必要があります。一方で、例の中でそのあとに続いているSubの中で利用するFindInMapは、AWS::LanguageExtensions 変換が不要で利用できるため、短縮形式での利用が可能です。

論理ID単位でのみForEachが可能

例えばタグを複数与える際には以下のようにテンプレートファイルに記載します。

  Tags:
  - Key: Name
    Value: xxx
  - Key: Env
    Value: prd
  - Key: Bill
    Value: xxxSystem

なるべくForEachを利用しようという気持ちで上記を見ると、ForEachを使いたくなる見た目をしています。しかし、ForEachは論理ID単位でループ処理をさせる必要があるため、上記のような繰り返しにはForEachを利用することはできませんでした。

最後に

今回はなるべくForEachを利用しようという気持ちでテンプレートを作成してみました。その中で、できること、難しいところが見えてきたのでやってみて良かったと思っています。

本記事がどなたかのお役に立てれば幸いです。