はじめに

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

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
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の恩恵は少ないですが、他で利用する際まとめて複数の値を渡すことができるので応用が効きやすい利点があります。

ループのコード

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 以下ループ
  '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でカウントを進めるような記述ができないからボツ。

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

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