はじめに

CloudFormationでFn::ForEachを使ったループ処理が2023年6月から利用出来るようになり、革命の予感を感じたので色々試行錯誤してみました。
結果から言うと、求めてるレベルの利便性は得られなかったですが、今までよりは確実に少しの作業で複数のログ転送設定ができるようになりました。
試行錯誤した内容については最後に記載しています。
ループ機能で CloudFormation のオーサリング体験を加速させましょう

余談ですが、最近Kinesis Data Firehose → Data Firehoseへ改名があったので、
本ブログではData Firehoseといたします。

Amazon Data Firehose (旧 Amazon Kinesis Data Firehose) のご紹介

CloudWatch Logs → S3について

前提としてCloudWatch LogsからS3にログを継続的に転送するためにはData Firehoseを間に挟む必要があります。
その際、基本的には1つのロググループに対して1つのData Firehoseが必要となります。
大量のロググループがある場合、サブスクリプションフィルターとData Firehoseの設定作業は無限に続きます。
ということで今回のループ処理と相性が良さそうだと思い、ループ処理で作ってみました。

登場サービス

CloudFormationで作成されるもの

  • ロググループ
  • サブスクリプションフィルター
  • Data Firehose
  • IAMポリシー/IAMロール(ロググループからData Firehoseに出力する)
  • IAMポリシー/IAMロール(Data FirehoseからS3に出力する)

事前に用意するもの

  • ログ出力先のS3

CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::LanguageExtensions
Description: 'Kinesis Data Firehose and CloudWatch Subscription Filters'

Parameters:
  CwogsPolicyName:
    Type: String
    Default: "cwlogs-policy"
  CwogsRoleName:
    Type: String
    Default: "cwlogs-role"
  KdfPolicyName:
    Type: String
    Default: "kdf-policy"
  KdfRoleName:
    Type: String
    Default: "kdf-role"
  S3BucketArn:
    Type: String

  CreateLogGroup: #ロググループを作成するかどうか
    Type: String
    Description: CreateLogGroup
    AllowedValues: ['yes', 'no']
    Default: 'yes'

  LogsParameters: #mapを呼び出すためにループで回すリスト
    Type: CommaDelimitedList
    Description: Edit with yaml
    Default: LogPara1, LogPara2, LogPara3

Mappings: # ループコンテンツのパラメータ
  LogsMappings: # firehoseでは「/」などの特殊文字は使えないのでlognameとfirehosenameは別々で定義
    LogPara1:
      LogName: "/aws/ec2/var/log/messages"
      firehoseName: "kinesis-ec2-messages-delivery-stream"
    LogPara2:
      LogName: "/aws/ec2/var/log/audit/audit.log"
      firehoseName: "kinesis-ec2-audit-delivery-stream"
    LogPara3:
      LogName: "/aws/ec2/var/log/secure"
      firehoseName: "kinesis-ec2-secure-delivery-stream"

Conditions:
  ShouldCreateLogGroup: !Equals [!Ref CreateLogGroup, 'yes']

Resources:
# CloudWatch Logs To Kinesis Data Firehose Policy
  CWLogsPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub ${CwogsPolicyName}
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action:
              - 'firehose:*'
            Resource:
              - !Sub "arn:aws:firehose:${AWS::Region}:${AWS::AccountId}:*"
          - Effect: Allow
            Action:
              - 'iam:PassRole'
            Resource:
              - !Sub "arn:aws:iam::${AWS::AccountId}:role/${CwogsRoleName}"
    # CloudWatch Logs To Kinesis Data Firehose Role
  CWLogsRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${CwogsRoleName}
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - !Sub "logs.${AWS::Region}.amazonaws.com"
            Action:
              - 'sts:AssumeRole'
      Path: /
      ManagedPolicyArns:
        - !Ref CWLogsPolicy

  # Kinesis Data Firehose To S3 Policy
  KdfPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub ${KdfPolicyName}
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action: 'glue:GetTableVersions'
            Resource: "*"
          - Effect: Allow
            Action:
              - 's3:PutObject'
              - 's3:PutObjectAcl'
              - 's3:GetObject'
              - 's3:ListBucketMultipartUploads'
              - 's3:AbortMultipartUpload'
              - 's3:ListBucket'
              - 's3:GetBucketLocation'
            Resource:
              - !Sub ${S3BucketArn}
              - !Sub ${S3BucketArn}/*

  # Kinesis Data Firehose To S3 Role
  KdfRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${KdfRoleName}
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - firehose.amazonaws.com
            Action: 'sts:AssumeRole'
            Condition:
              StringEquals:
                sts:ExternalId: !Sub "${AWS::AccountId}"
      Path: /
      ManagedPolicyArns:
        - !Ref KdfPolicy
  
# 以下ループ
  'Fn::ForEach::LogGroupLoop':
    - LogParameter
    - !Ref LogsParameters

    - '${LogParameter}': #ロググループ作成
        Type: AWS::Logs::LogGroup
        Condition: ShouldCreateLogGroup
        Properties: 
          LogGroupName: !FindInMap [LogsMappings, !Ref LogParameter, LogName]
          RetentionInDays: 1

      # KinesisFirehose
      'Kdf${LogParameter}':
        Type: AWS::KinesisFirehose::DeliveryStream
        Properties:
          DeliveryStreamName: !FindInMap [LogsMappings, !Ref LogParameter, firehoseName]
          DeliveryStreamType: DirectPut
          S3DestinationConfiguration:
            BucketARN: !Sub "${S3BucketArn}"
            Prefix: !Join [ "", [ "cw_logs/", !FindInMap [LogsMappings, !Ref LogParameter, LogName], "/" ] ]
            CloudWatchLoggingOptions:
              Enabled: false
            RoleARN: !GetAtt KdfRole.Arn

      # SubscriptionFilter
      'SubscriptionFilter${LogParameter}':
        Type: AWS::Logs::SubscriptionFilter
        Properties:
          LogGroupName: !FindInMap [LogsMappings, !Ref LogParameter, LogName]
          FilterName: ALL
          DestinationArn: !GetAtt
            - !Sub 'Kdf${LogParameter}'
            - Arn
          RoleArn: !GetAtt CWLogsRole.Arn 
          Distribution: ByLogStream
          FilterPattern: ""

S3のarnとログ転送したいロググループ名、firehose名を指定すれば利用可能な内容としています。
また、必要に応じてLogsParameters、LogsMappingsを変更すれば何件分でも対応可能です。
IAM部分は1つあれば十分なので、ループ処理からは除外。

ループ処理を行う際、渡す値はmapと組み合わせるのが現状一番良い気がします。
なぜわざわざここでリストとMapを使っているかというと、今回で一番苦労した点で以下の制限があるためです。

論理 ID は英数字(A-Za-z0-9)とし、テンプレート内で一意である必要があります。

ドキュメント

対してロググループには「_」,「-」,「/」などの記号が入っていることが一般的であり、ループ処理内で変数を違う値を差し込む必要があります。

この条件から与える値を「論理IDで使用する変数」と「指定するロググループの文字列」を分ける必要がありました。
ちなみに今回は値が少ないのでmapの恩恵は少ないですが、他で利用する際まとめて複数の値を渡すことができるので応用が効きやすい利点があります。

ループのコード

# 以下ループ
  'Fn::ForEach::LogGroupLoop':
    - LogParameter
    - !Ref LogsParameters

    - '${LogParameter}': #ロググループ作成
        Type: AWS::Logs::LogGroup
        Condition: ShouldCreateLogGroup
        Properties: 
          LogGroupName: !FindInMap [LogsMappings, !Ref LogParameter, LogName]
          RetentionInDays: 1

      # KinesisFirehose
      'Kdf${LogParameter}':
        Type: AWS::KinesisFirehose::DeliveryStream
        Properties:
          DeliveryStreamName: !FindInMap [LogsMappings, !Ref LogParameter, firehoseName]
          DeliveryStreamType: DirectPut
          S3DestinationConfiguration:
            BucketARN: !Sub "${S3BucketArn}"
            Prefix: !Join [ "", [ "cw_logs/", !FindInMap [LogsMappings, !Ref LogParameter, LogName], "/" ] ]
            CloudWatchLoggingOptions:
              Enabled: false
            RoleARN: !GetAtt KdfRole.Arn

      # SubscriptionFilter
      'SubscriptionFilter${LogParameter}':
        Type: AWS::Logs::SubscriptionFilter
        Properties:
          LogGroupName: !FindInMap [LogsMappings, !Ref LogParameter, LogName]
          FilterName: ALL
          DestinationArn: !GetAtt
            - !Sub 'Kdf${LogParameter}'
            - Arn
          RoleArn: !GetAtt CWLogsRole.Arn 
          Distribution: ByLogStream
          FilterPattern: ""

今回のメインはこちらの処理。
コード自体はシンプルで、ロググループ,Data Firehose,サブスクリプションフィルターを作る処理をループ化しています。
LogsParametersの数だけループし、LogsParametersの値を参照して、LogsMappingsの値を呼び出すようにしました。

ロググループ作成分岐

CreateLogGroupの値を使って分岐を作っています。

  • Yes:新規でロググループごと作成する場合
  • No:既存ロググループにログ転送処理を作成する場合

試してみたけどだめだったこと

  • Length関数の利用

一般的なプログラム言語のようにリストの配列数を取得して、それを論理IDの変数として回せないか。
そもそもCloudFormationでカウントを進めるような記述ができないからボツ。

  • ロググループの作成分岐

指定したロググループ作成する際、既に存在している場合は作成しないように自動的に分岐させたかったのですが、ループ内の処理に組み込むと結構厄介なことになったので選択式としました。
こちらは検証しきれてないので、なにかしら手段はあるかもしれません。