Amazon SESのメール送信ログ保存をCloudFormationで設定する

Amazon SESのメール送信ログの保存をCloudFormationで設定・構築してみました。


目的

メールサービスであるAmazon SESですが、メール送信ログの保存には少し手間がかかります。

また色々探してみてもコンソール経由で設定される方が多く、CloudFormationで設定・構築できたら楽だなと思い、作ってみました。


前提

Amazon SESの構築自体の手順は省略します。

本記事は、すでにSESを構築されている前提になります。


登場サービス

  • SES
    • Configuration Sets含む
  • Kinesis Data Firehose
  • S3
  • IAMロール
  • CloudWatch Logs


参考

SESのログの保存にはKinesis Data Firehoseを使用するのですが、実はAWS WAFのログの保存も同じくKinesis Data Firehoseで行います。


そこでWAFのログの方であれば、以前にクラスメソッド様のDevelopers IOで以下のような素晴らしい記事を拝見し自分も構築の参考にさせて頂いたことがあったため、今回こちらの方法を参考にしてSESの方でも使えるようにしてみました。

dev.classmethod.jp


方法

概要

全体的な流れは以下のようになります。

  • CloudFormationでKinesis Data Firehoseなどログ送信の基盤を作成する
  • SESのConfiguration Setsを作成する
    • 後述しますが、ここからはAWS CLIで行います
  • SESにConfiguration Setsを紐付ける
    • デフォルト設定セットとして設定する


コード

ディレクトリ構成

.
├── cfn_yaml
│ └── ses_log.yaml
└── ses_log.sh


GitHubにもあります。

github.com


ses_log.yaml

AWSTemplateFormatVersion: "2010-09-09"

Description: SES Log

Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "ConfigurationSetArn"
        Parameters:
          - ConfigurationSetArn
      - Label:
          default: "Kinesis Firehose"
        Parameters:
          - SizeInMBs
          - IntervalInSeconds
          - CompressionFormat
      - Label:
          default: "S3"
        Parameters:
          - SesLogsBucketName
          - ExpirationInDays

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  ConfigurationSetArn:
    Type: String

  SesLogsBucketName:
    Description: "A name for logs bucket. "
    Type: String

  ExpirationInDays:
    Description: "Indicates the number of days after creation when objects are deleted from Amazon S3"
    Type: Number
    Default: 90

  SizeInMBs:
    Description: "The size of the buffer, in MBs, that Kinesis Data Firehose uses for incoming data before delivering it to the destination."
    Type: Number
    Default: 5
    MinValue: 1
    MaxValue: 128

  IntervalInSeconds:
    Description: The length of time, in seconds, that Kinesis Data Firehose buffers incoming data before delivering it to the destination.
    Type: Number
    Default: 300
    MinValue: 60
    MaxValue: 900

  CompressionFormat:
    Description: "The type of compression that Kinesis Data Firehose uses to compress the data that it delivers to the Amazon S3 bucket. "
    Type: String
    Default: "ZIP"
    AllowedValues: ["GZIP", "Snappy", "UNCOMPRESSED", "ZIP"]

Resources:
  # ------------------------------------------------------------#
  # IAM Role for SES to Kinesis
  # ------------------------------------------------------------#
  SesLogRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-SesLogRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: ses.amazonaws.com
            Condition:
              StringEquals:
                AWS:SourceAccount: !Ref AWS::AccountId
                AWS:SourceArn: !Ref ConfigurationSetArn
      Policies:
        - PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Action:
                  - firehose:PutRecordBatch
                Effect: Allow
                Resource: !GetAtt SesLogDeliveryStream.Arn
          PolicyName: ses_log_role_policy

  # ------------------------------------------------------------#
  # SES Logging
  # ------------------------------------------------------------#
  SesLogsS3Bucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketName: !Ref SesLogsBucketName
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Id: !Sub "ExpirationIn-${ExpirationInDays}Days"
            ExpirationInDays: !Ref "ExpirationInDays"
            Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      VersioningConfiguration:
        Status: Enabled

  SesLogsS3BucketPolicy:
    Type: "AWS::S3::BucketPolicy"
    Properties:
      Bucket:
        Ref: SesLogsS3Bucket
      PolicyDocument:
        Statement:
          - Action:
              - "s3:AbortMultipartUpload"
              - "s3:GetBucketLocation"
              - "s3:GetObject"
              - "s3:ListBucket"
              - "s3:ListBucketMultipartUploads"
              - "s3:PutObject"
              - "s3:PutObjectAcl"
            Effect: Allow
            Resource:
              - "Fn::Join":
                  - ""
                  - - "arn:aws:s3:::"
                    - Ref: SesLogsS3Bucket
                    - /*
              - "Fn::Join":
                  - ""
                  - - "arn:aws:s3:::"
                    - Ref: SesLogsS3Bucket
            Principal:
              AWS: !Sub "${SesLogFirehoseRole.Arn}"

  SesLogDeliveryStream:
    Type: AWS::KinesisFirehose::DeliveryStream
    Properties:
      DeliveryStreamName: !Sub "aws-ses-logs-${AWS::StackName}"
      DeliveryStreamType: DirectPut
      S3DestinationConfiguration:
        BucketARN: !Sub "${SesLogsS3Bucket.Arn}"
        BufferingHints:
          SizeInMBs: !Ref SizeInMBs
          IntervalInSeconds: !Ref IntervalInSeconds
        CloudWatchLoggingOptions:
          Enabled: true
          LogGroupName: !Sub "/aws/kinesisfirehose/aws-ses-logs-${AWS::StackName}"
          LogStreamName: S3Delivery
        CompressionFormat: !Ref CompressionFormat
        EncryptionConfiguration:
          NoEncryptionConfig: NoEncryption
        ErrorOutputPrefix: "ses-logs-error-"
        Prefix: "ses-logs-"
        RoleARN: !Sub "${SesLogFirehoseRole.Arn}"

  SesLogDeliveryStreamLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/kinesisfirehose/aws-ses-logs-${AWS::StackName}"
      RetentionInDays: 400

  SesLogDeliveryStreamLogStream:
    Type: AWS::Logs::LogStream
    Properties:
      LogGroupName: !Ref SesLogDeliveryStreamLogGroup
      LogStreamName: S3Delivery

  SesLogFirehoseRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-SesLogFirehoseRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: firehose.amazonaws.com
      Policies:
        - PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Action:
                  - glue:GetTable
                  - glue:GetTableVersion
                  - glue:GetTableVersions
                Effect: Allow
                Resource: "*"
              - Action:
                  - s3:AbortMultipartUpload
                  - s3:GetBucketLocation
                  - s3:GetObject
                  - s3:ListBucket
                  - s3:ListBucketMultipartUploads
                  - s3:PutObject
                Effect: Allow
                Resource:
                  - !Sub "${SesLogsS3Bucket.Arn}"
                  - !Sub "${SesLogsS3Bucket.Arn}/*"
                  - arn:aws:s3:::%FIREHOSE_BUCKET_NAME%
                  - arn:aws:s3:::%FIREHOSE_BUCKET_NAME%/*
              - Action: kms:Decrypt
                Effect: Allow
                Resource: !Sub "arn:aws:kms:${AWS::Region}:${AWS::AccountId}:key/%SSE_KEY_ID%"
              - Action:
                  - lambda:InvokeFunction
                  - lambda:GetFunctionConfiguration
                Effect: Allow
                Resource: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:%FIREHOSE_DEFAULT_FUNCTION%:%FIREHOSE_DEFAULT_VERSION%"
              - Action: logs:PutLogEvents
                Effect: Allow
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/kinesisfirehose/aws-ses-logs-${AWS::StackName}:log-stream:*"
              - Action:
                  - kinesis:DescribeStream
                  - kinesis:GetShardIterator
                  - kinesis:GetRecords
                Effect: Allow
                Resource: !Sub "arn:aws:kinesis:${AWS::Region}:${AWS::AccountId}:stream/%FIREHOSE_STREAM_NAME%"
          PolicyName: firehose_delivery_role_policy

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
  SesLogDeliveryStreamArn:
    Value: !GetAtt SesLogDeliveryStream.Arn
    Export:
      Name: !Sub "SesLogDeliveryStream-Arn-${AWS::StackName}"

  SesLogRoleArn:
    Value: !GetAtt SesLogRole.Arn
    Export:
      Name: !Sub "SesLogRole-Arn-${AWS::StackName}"


ses_log.sh

#!/bin/bash

set -eu

cd $(dirname $0)

CfnTemplate="./cfn_yaml/ses_log.yaml"

Region=${AWS_DEFAULT_REGION:-ap-northeast-1}

### -------------------------------- ###
###     ドメインなどを記入
### -------------------------------- ###
StackPrefix="example"
Domain="example.com"

### -------------------------------- ###
###  SES Log用のKinesis Firehose作成
### -------------------------------- ###
CfnStackName="${StackPrefix}-SES-Log"
AccountID=$(aws sts get-caller-identity --query "Account" --output text)
SesLogsBucketName=`echo "${CfnStackName}-${Region}" | tr '[:upper:]' '[:lower:]'`
ConfigurationSetName=$(echo ${Domain} | sed -e "s/\./-/g")
ConfigurationSetArn="arn:aws:ses:${Region}:${AccountID}:configuration-set/${ConfigurationSetName}"

### Kinesis設定項目
ExpirationInDays="400"
SizeInMBs="5"
IntervalInSeconds="300"
CompressionFormat="GZIP"

aws cloudformation deploy \
    --region ${Region} \
    --stack-name ${CfnStackName} \
    --template-file ${CfnTemplate} \
    --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \
    --no-fail-on-empty-changeset \
    --parameter-overrides \
    SesLogsBucketName=${SesLogsBucketName} \
    ExpirationInDays=${ExpirationInDays} \
    SizeInMBs=${SizeInMBs} \
    IntervalInSeconds=${IntervalInSeconds} \
    CompressionFormat=${CompressionFormat} \
    ConfigurationSetArn=${ConfigurationSetArn} 


### -------------------------------- ###
###     Configuration Setの設定
###  ※東京でCFn対応していないのでCLIで
### -------------------------------- ###
if [[ -z $(aws ses describe-configuration-set \
    --configuration-set-name "${ConfigurationSetName}" \
    --output json \
    2>/dev/null || :) ]]; then

    # configuration set作成
    aws ses create-configuration-set \
        --configuration-set Name=${ConfigurationSetName} \
        > /dev/null

    CfnOutput=$(aws cloudformation describe-stacks --stack-name "${CfnStackName}")

    DeliveryStreamARN=$(echo "${CfnOutput}" \
    | jq '.Stacks[].Outputs[] | select(.ExportName == "SesLogDeliveryStream-Arn-'${CfnStackName}'") | .OutputValue' \
    | tr -d '"')

    IAMRoleARN=$(echo "${CfnOutput}" \
    | jq '.Stacks[].Outputs[] | select(.ExportName == "SesLogRole-Arn-'${CfnStackName}'") | .OutputValue' \
    | tr -d '"')

    # event destination
    EventDestination="{
        \"Name\": \"${ConfigurationSetName}-KinesisFirehose\",
        \"Enabled\": true,
        \"MatchingEventTypes\": [\"send\", \"reject\", \"bounce\", \"complaint\", \"delivery\", \"open\", \"click\", \"renderingFailure\"],
        \"KinesisFirehoseDestination\": {
            \"IAMRoleARN\": \"${IAMRoleARN}\",
            \"DeliveryStreamARN\": \"${DeliveryStreamARN}\"
        }
    }"

    # configuration setにevent destination(配信先情報)を設定
    aws ses create-configuration-set-event-destination \
        --configuration-set-name ${ConfigurationSetName} \
        --event-destination "${EventDestination}" \
        > /dev/null

    # SESにconfiguration setを紐付け(デフォルト設定セット)
    aws sesv2 put-email-identity-configuration-set-attributes \
        --email-identity "${Domain}" \
        --configuration-set-name ${ConfigurationSetName} \
        > /dev/null
fi

echo "Finished."


補足

ses_log.yaml

WAFのログ保存と同じKinesis FirehoseとそのIAMロールが肝ではあるのですが、それだけではなくSESがKinesis Firehoseに配信するためのIAMロールを追加で作成しています。

こちらに関してはAWSのリファレンスに記載があります。

docs.aws.amazon.com

  # ------------------------------------------------------------#
  # IAM Role for SES to Kinesis
  # ------------------------------------------------------------#
  SesLogRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-SesLogRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: ses.amazonaws.com
            Condition:
              StringEquals:
                AWS:SourceAccount: !Ref AWS::AccountId
                AWS:SourceArn: !Ref ConfigurationSetArn
      Policies:
        - PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Action:
                  - firehose:PutRecordBatch
                Effect: Allow
                Resource: !GetAtt SesLogDeliveryStream.Arn
          PolicyName: ses_log_role_policy


また、シェルの方でそのIAMロールとKinesis FirehoseのARNを使用してConfiguration SetのEvent destinationを作成するため、OutputsとしてそれらのARNを出力しています。

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
  SesLogDeliveryStreamArn:
    Value: !GetAtt SesLogDeliveryStream.Arn
    Export:
      Name: !Sub "SesLogDeliveryStream-Arn-${AWS::StackName}"

  SesLogRoleArn:
    Value: !GetAtt SesLogRole.Arn
    Export:
      Name: !Sub "SesLogRole-Arn-${AWS::StackName}"



ses_log.sh

まず、SESのConfiguration SetをなぜCloudFormationではなくCLIで作成しているかという点です。


これは、東京リージョンでのCloudFormationでConfiguration Setまわり(ConfigurationSetEventDestination)の作成が対応していなかったからです。


実際にCloudFormationテンプレートを書いて東京リージョンで実行してみたら・・・

An error occurred (ValidationError) when calling the ValidateTemplate operation: Template format error: Unrecognized resource types: [AWS::SES::ConfigurationSetEventDestination]

その後バージニアリージョンで同じテンプレートを実行したら走りました。

今回自分はSESを東京リージョンで構築していたため、CLIで実行することにしました。


SESの東京リージョン対応は比較的最近なので、このようにCloudFormationで対応していなかったり、またCognito統合(CognitoでSESを使用してメール送信する機能)にも対応していなかったりというような点があるので注意が必要です。

docs.aws.amazon.com


ちなみにConfiguration Set自体の作成に関しては、CloudFormationドキュメントにこう書いてありました。そのため、こちらはCloudFormationでの実行を試してすらいません。

Not currently supported by AWS CloudFormation.

docs.aws.amazon.com



また、event destinationというログの配信情報などの設定ですが、ここでは配信できる情報全て(send, reject, bounce, complaint, delivery, open, click, renderingFailure)を設定しています。

必要に応じて絞ると良いかと思います。

さらにここで先ほどCloudFormationで構築したKinesis FirehoseとSES用のIAMロールをConfiguration Setに紐付けます。

    # event destination
    EventDestination="{
        \"Name\": \"${ConfigurationSetName}-KinesisFirehose\",
        \"Enabled\": true,
        \"MatchingEventTypes\": [\"send\", \"reject\", \"bounce\", \"complaint\", \"delivery\", \"open\", \"click\", \"renderingFailure\"],
        \"KinesisFirehoseDestination\": {
            \"IAMRoleARN\": \"${IAMRoleARN}\",
            \"DeliveryStreamARN\": \"${DeliveryStreamARN}\"
        }
    }"


また、最後のSESにConfiguration Setを紐づける点ですが、これだけsesv2 apiを実行しています。

これはただ調べた時にこのapiが出てそのまま使っただけです・・・

できれば全部v2で統一したかったですが、少し大変だったので今後の課題にしました。

    # SESにconfiguration setを紐付け(デフォルト設定セット)
    aws sesv2 put-email-identity-configuration-set-attributes \
        --email-identity "${Domain}" \
        --configuration-set-name ${ConfigurationSetName} \
        > /dev/null


最後に

SESのメール送信ログの保存の設定をCloudFormationで行いました。

コンソールでポチポチやるのが大変な場合はきっと使えるのではないかと思います。