CloudFormation で ForEach と FindInMap を組み合わせてどこまでできるか試してみた
こんにちは、シマです。
先日、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を利用しようという気持ちでテンプレートを作成してみました。その中で、できること、難しいところが見えてきたのでやってみて良かったと思っています。
本記事がどなたかのお役に立てれば幸いです。