CloudFormation ForEach 組み込み関数を使ってセキュリティグループを作ってみた

2023.07.29

アノテーション 構築チームのいたくらです。
Fn::ForEach を使ってセキュリティグループを作ってみました。
でも Fn::ForEach だけだと、セキュリティグループを作るのには向いてないかもしれません。

2023/7/30 更新
Fn::ForEach と Fn::FindInMap を組み合わせるといい感じにセキュリティグループを作成できました。
詳細は下記ブログをご覧ください。

Fn::ForEach について

CloudFormation テンプレートの冒頭に Transform: 'AWS::LanguageExtensions' を宣言すると使えるようになる組み込み関数です。
関数の機能としては、ループ処理のような動きをしてくれます。
「のような」と付け足した理由はこれから説明します。

今回は公式ドキュメントといわささんのブログを参考にさせていただきました。

セキュリティグループを作成するテンプレート例

テンプレートが長いので閉じてあります。
以下のトグルを押すとテンプレートが開きます。

CloudFormation テンプレート
AWSTemplateFormatVersion: "2010-09-09"
Transform: 'AWS::LanguageExtensions' # 拡張機能の使用を宣言
# ------------------------------------------------------------#
# Parameters
# ------------------------------------------------------------#
Parameters:
  VpcId:
    Type: String
    Default: "vpc-xxxxxxxxxxxxxxxxx" # あらかじめ作成しておいた VPC ID
  SourceCidr:
    Type: String
    Default: "XX.XX.XX.XX/32" # 自分の Global IP 等を適宜指定
  AllowPort1:
    Type: String
    Default: "80"
  AllowPort2:
    Type: String
    Default: "443"
  FromPort:
    Type: String
    Default: "80"

# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
  SecurityGroupAlb:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Security Group for ALB"
      GroupName: "test-sg-alb"
      Tags:
        - Key: "Name"
          Value: "test-sg-alb"
      VpcId: !Ref VpcId

  'Fn::ForEach::LoopPort': # LoopPort の部分はテンプレート内で一意であること
  - AllowPort # ループ変数
  - - !Ref AllowPort1 # ループ変数で呼び出したい値(公式ドキュメントではコレクションと呼ぶ)
    - !Ref AllowPort2
  - 'SecurityGroupIngress${AllowPort}': # ループで作成されるリソースの論理 ID
      Type: AWS::EC2::SecurityGroupIngress
      Properties:
        CidrIp: !Ref SourceCidr
        FromPort: !Ref FromPort
        ToPort: !Ref AllowPort # ループ変数を使用
        IpProtocol: "TCP"
        GroupId: !Ref SecurityGroupAlb

このテンプレートで作成できたセキュリティグループがこちら。

本当は HTTP と HTTPS のインバウンドルールを設定したかったんですが、実現できなかったです。
以下、Fn::ForEach で実現できなかった理由を書いていきます。

ループ内でループ変数を使える箇所は 1 箇所 のみ!

本当はHTTP と HTTPS のインバウンドルールを設定したいので、最初は以下のコードにしていました。

テンプレート一部抜粋

  'Fn::ForEach::LoopPort':
  - AllowPort
  - - !Ref AllowPort1
    - !Ref AllowPort2
  - 'SecurityGroupIngress${AllowPort}':
      Type: AWS::EC2::SecurityGroupIngress
      Properties:
        CidrIp: !Ref SourceCidr
        FromPort: !Ref AllowPort # ループ変数を使用
        ToPort: !Ref AllowPort # ループ変数を使用
        IpProtocol: "TCP"
        GroupId: !Ref SecurityGroupAlb

これでリソースを作成しようとすると、このようなエラーが出ました。

<日本語訳> ※以降、日本語訳は DeepL を使用しています
AWS::LanguageExtensions の変換に失敗しました:Fn::ForEach で親オブジェクトにキーをマージする際、キー' Type 'が重複しました。ユーザーによってロールバックが要求されました。

うーん・・・??
キーが重複している??
でも AllowPort がちゃんとループしてれば論理 ID が重複することなくない??

悩みまくって上司に相談したら「1回しか使えないとか?」と言われ、うっっわなんで気付けなかったんだ??と悔しがりながら FromPort を固定(Parameters で宣言した FromPort を参照する形へ修正)したら成功しました。

そして、よくよく公式ドキュメントを見返すと、Return value セクションに以下の記載がありました。

Return value
An expanded object that contains the object fragment repeated once for each item in the collection, where the identifier in the fragment is replaced with the item from the collection.

<日本語訳>
オブジェクトフラグメントを含む展開されたオブジェクトで、コレクション内の各アイテムに対して 1 回繰り返されます。

これだ・・・

つまり、「作成するリソースのプロパティが、特定のプロパティ 1 つを除き、それ以外のプロパティの値はすべて同じ」という場合に Fn::ForEach を使うとスマートに書ける。
ということなのかな、と自分は理解しました。

HTTP / HTTPS のように、あるポート番号に絞りたいときはループ変数を 2 回使用する必要があるので、ポート番号違いの AWS::EC2::SecurityGroupIngress を Fn::ForEach で複数作成するのは、ちょっと向いてなさそうだなと思いました。

エラーから学んだ注意点

以降、検証している間に出くわしたエラーから学んだ注意点を書いていきます。

コレクションは文字列の配列でなければならない

以下のように、ポート番号をコレクションとして直接記述して作成を試みました。

テンプレート一部抜粋

  'Fn::ForEach::LoopPort':
  - AllowPort
  - - 80
    - 443
  - 'SecurityGroupIngress${AllowPort}':
      Type: AWS::EC2::SecurityGroupIngress
      Properties:
        CidrIp: !Ref SourceCidr
        FromPort: !Ref FromPort
        ToPort: !Ref AllowPort # ループ変数を使用
        IpProtocol: "TCP"
        GroupId: !Ref SecurityGroupAlb

このようなエラーが出ました。

<日本語訳>
AWS::LanguageExtensions の変換に失敗しました:Fn::ForEach コレクションは文字列のリストでなければなりません。ユーザーによってロールバックが要求されました。

公式ドキュメントに以下の記載がありました。

Collection
The collection of values to iterate over. This can be an array in this parameter, or it can be a Ref to a CommaDelimitedList.

<日本語訳>
反復処理する値のコレクション。このパラメータには配列を指定するか、CommaDelimitedList への Ref を指定します。

CommaDelimitedList ってところで String だけと暗に分かるっちゃ分かるんですが、Integer の配列もいけるんじゃないかって少し思っちゃいませんか?
残念ながら文字列しかコレクションとして指定できないので、お気をつけください。

コレクションの値には記号を含められない

「送信元 IP だけ変えて、ポート番号やプロトコルは変えない=特定のプロパティ 1 つを除き、それ以外のプロパティの値はすべて同じ」に当てはまるじゃん!と思い以下のテンプレートを記述して作成を試みました。

以下のトグルを押すとテンプレートが開きます。

CloudFormation テンプレート
AWSTemplateFormatVersion: "2010-09-09"
Transform: 'AWS::LanguageExtensions'
# ------------------------------------------------------------#
# Parameters
# ------------------------------------------------------------#
Parameters:
  VpcId:
    Type: String
    Default: "vpc-xxxxxxxxxxxxxxxxx"
  AllowIp1:
    Type: String
    Default: "XX.XX.XX.XX/32"
  AllowIp2:
    Type: String
    Default: "YY.YY.YY.YY/32"

# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
  SecurityGroupAlb:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Security Group for ALB"
      GroupName: "test-sg-alb"
      Tags:
        - Key: "Name"
          Value: "test-sg-alb"
      VpcId: !Ref VpcId

  'Fn::ForEach::LoopCidr':
  - AllowIp
  - - !Ref AllowIp1
    - !Ref AllowIp2
  - 'SecurityGroupIngress${AllowIp}':
      Type: AWS::EC2::SecurityGroupIngress
      Properties:
        CidrIp: !Ref AllowIp
        FromPort: 80
        ToPort: 80
        IpProtocol: "TCP"
        GroupId: !Ref SecurityGroupAlb

このようなエラーが出ました。

<日本語訳>
AWS::LanguageExtensions の変換に失敗しました:OutputKey 'SecurityGroupIngressXX.XX.XX.XX/32' は英数字でなければなりません。ユーザーによってロールバックが要求されました。

論理 ID って英数字だもんなぁ、そりゃそうだよなぁ。
ループ変数(=中身はコレクション)がリソースの論理 ID に使われるのが必須条件なので(※1)、送信元 IP をループ処理してインバウンドルールを作成することはできないことが分かりました。。。残念。。。

※1
OutputKey
The key in the transformed template. ${Identifier} must be included within the OutputKey parameter. For example, if Fn::ForEach is used in the Resources section of the template, this is the logical Id of each resource.

<日本語訳>
変換されたテンプレートのキー。OutputKey パラメータ内に ${Identifier} を含める必要があります。例えば、テンプレートの Resources セクションで Fn::ForEach が使用されている場合、これは各リソースの論理 Id になります。

あとがき

ブログの冒頭、「関数の機能としては、ループ処理「のような」動きをしてくれます。」
とお伝えした理由を分かって頂けたでしょうか。
ループ変数の使用回数制限があり、一般的なループ処理とは異なる感じがしたので、「のような」と付けてみました。

今回はセキュリティグループに焦点を当てていたので、Fn::ForEach の良さが引き出せなかった感がありますが、Fn::ForEach と相性がいいリソースもあると思います(公式ドキュメントの Example もいくつかありますし)。

また、今回は CloudFormation テンプレートの Resources セクションでの使用でしたが、Fn::ForEach は Conditions / Outputs セクションでも使用できるので、試してみたいと思います。

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。
「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。
現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、アノテーション株式会社WEBサイトをご覧ください。