こんにちは、広野です。
最近、社内でメールに DKIM 署名をする対応をしてまして、その機に Amazon SES (Simple Email Service) のメール送信ログアーキテクチャを見直しました。DKIM 署名の記事は後輩に任せましたので、私は Amazon SES のログ取得・通知について書きます。
アーキテクチャ
Amazon SES はデフォルトでは送信ログ取得・通知機能を用意してくれていません。そのため、AWS サービスを組み合わせてデプロイする必要があります。
これ一択、とは言いませんが、安価でスケーラブルなログ取得アーキテクチャは以下がスタンダードなアーキテクチャになると考えております。AWS CloudFormation を活用してデプロイできるようにしました。一部、手環境によって構成が変わると思ったところを手動作業にするようにしています。
- Amazon SES のログ取得は、設定セットを利用します。ログの出力先として Amazon Data Firehose を指定した設定セットを Amazon SES の ID で手動選択する仕様になっています。
- Amazon SES のバウンス通知は、フィードバック通知を使用します。通知先として Amazon SNS を手動選択する仕様になっています。Amazon SNS からの通知方法は管理者が選択できるようにするため、設定はしていません。図ではメールでの通知を想定して書いております。
- Amazon Data Firehose が受け取ったログは Amazon S3 バケットに保存されます。それらを Amazon Athena からクエリしやすいよう、External Table としてカタログ化しておきます。
AWS CloudFormation テンプレート
とりあえず以下のテンプレートを流すと、赤枠のリソースがデプロイされます。手動作業については後述します。
AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates a S3 Bucket, a Data Firehose delivery stream with dynamic partition support for collecting SES logs, a SNS topic and an Athena WG.
Parameters:
# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
LogRetentionInDays:
Type: Number
Description: The retention period (days) for SES logs. Enter an integer between 35 to 540.
Default: 400
MaxValue: 540
MinValue: 35
TagValue:
Type: String
Description: Tag value for Cost key.
Default: SES
MaxLength: 30
MinLength: 1
Resources:
# ------------------------------------------------------------#
# S3
# ------------------------------------------------------------#
S3BucketSesLogs:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub ses-logs-${AWS::AccountId}-${AWS::Region}
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerPreferred
LifecycleConfiguration:
Rules:
- Id: AutoDelete
Status: Enabled
ExpirationInDays: !Ref LogRetentionInDays
Tags:
- Key: Cost
Value: !Ref TagValue
# ------------------------------------------------------------#
# SES invoke Data Firehose Role (IAM)
# ------------------------------------------------------------#
SesFirehoseRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub SesFirehoseRole-ses-logs-${AWS::AccountId}-${AWS::Region}
Description: This role allows SES to push logs to Data Firehose.
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service:
- ses.amazonaws.com
Action:
- sts:AssumeRole
Path: /
Policies:
- PolicyName: !Sub SesFirehosePolicy-ses-logs-${AWS::AccountId}-${AWS::Region}
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- firehose:PutRecord
- firehose:PutRecordBatch
Resource: !Sub "arn:aws:firehose:${AWS::Region}:${AWS::AccountId}:deliverystream/ses-logs-${AWS::AccountId}-${AWS::Region}"
- Effect: Allow
Action:
- logs:PutLogEvents
Resource: !GetAtt LogGroupFirehoseSesLogs.Arn
# ------------------------------------------------------------#
# Data Firehose Role (IAM)
# ------------------------------------------------------------#
FirehoseRoleSesLogs:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub FirehoseRole-ses-logs-${AWS::AccountId}-${AWS::Region}
Description: This role allows Data Firehose to delivery logs in S3.
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- firehose.amazonaws.com
Action:
- sts:AssumeRole
Path: /
Policies:
- PolicyName: !Sub FirehosePolicy-ses-logs-${AWS::AccountId}-${AWS::Region}
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- "s3:AbortMultipartUpload"
- "s3:GetBucketLocation"
- "s3:GetObject"
- "s3:ListBucket"
- "s3:ListBucketMultipartUploads"
- "s3:PutObject"
Resource:
- !Sub "arn:aws:s3:::${S3BucketSesLogs}"
- !Sub "arn:aws:s3:::${S3BucketSesLogs}/*"
- Effect: Allow
Action:
- "logs:PutLogEvents"
Resource:
- !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/firehose/*"
DependsOn:
- S3BucketSesLogs
# ------------------------------------------------------------#
# Data Firehose delivery stream
# ------------------------------------------------------------#
FirehoseStreamSesLogs:
Type: AWS::KinesisFirehose::DeliveryStream
Properties:
DeliveryStreamName: !Sub ses-logs-${AWS::AccountId}-${AWS::Region}
DeliveryStreamType: DirectPut
ExtendedS3DestinationConfiguration:
BucketARN: !Sub "arn:aws:s3:::${S3BucketSesLogs}"
Prefix: "partitioned/!{partitionKeyFromQuery:EventType}/!{timestamp:yyyy/MM/dd}/"
ErrorOutputPrefix: "errorLog/!{firehose:error-output-type}/dt=!{timestamp:YYYY}-!{timestamp:MM}-!{timestamp:dd}/"
BufferingHints:
IntervalInSeconds: 300
SizeInMBs: 128
CompressionFormat: GZIP
RoleARN: !GetAtt FirehoseRoleSesLogs.Arn
DynamicPartitioningConfiguration:
Enabled: true
RetryOptions:
DurationInSeconds: 300
CloudWatchLoggingOptions:
Enabled: true
LogGroupName: !Ref LogGroupFirehoseSesLogs
LogStreamName: S3Delivery
ProcessingConfiguration:
Enabled: true
Processors:
- Type: MetadataExtraction
Parameters:
- ParameterName: MetadataExtractionQuery
ParameterValue: '{EventType: .eventType}'
- ParameterName: JsonParsingEngine
ParameterValue: JQ-1.6
Tags:
- Key: Cost
Value: !Ref TagValue
DependsOn:
- S3BucketSesLogs
- LogGroupFirehoseSesLogs
# ------------------------------------------------------------#
# Data Firehose LogGroup (CloudWatch Logs)
# ------------------------------------------------------------#
LogGroupFirehoseSesLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub /aws/firehose/ses-logs-${AWS::AccountId}-${AWS::Region}
RetentionInDays: !Ref LogRetentionInDays
Tags:
- Key: Cost
Value: !Ref TagValue
LogStreamFirehoseSesLogs:
Type: AWS::Logs::LogStream
Properties:
LogGroupName: !Ref LogGroupFirehoseSesLogs
LogStreamName: S3Delivery
DependsOn:
- LogGroupFirehoseSesLogs
# ------------------------------------------------------------#
# SES Configuration Set
# ------------------------------------------------------------#
ConfigurationSet:
Type: AWS::SES::ConfigurationSet
Properties:
Name: !Sub ses-logs-${AWS::AccountId}-${AWS::Region}
DeliveryOptions:
TlsPolicy: REQUIRE
ConfigurationSetEventDestination:
Type: AWS::SES::ConfigurationSetEventDestination
Properties:
ConfigurationSetName: !Ref ConfigurationSet
EventDestination:
Name: !Ref FirehoseStreamSesLogs
Enabled: True
MatchingEventTypes:
- send
- delivery
- reject
- bounce
- complaint
- renderingFailure
- deliveryDelay
- subscription
KinesisFirehoseDestination:
DeliveryStreamARN: !GetAtt FirehoseStreamSesLogs.Arn
IAMRoleARN: !GetAtt SesFirehoseRole.Arn
DependsOn:
- ConfigurationSet
- FirehoseStreamSesLogs
- SesFirehoseRole
# ------------------------------------------------------------#
# Athena WorkGroup
# ------------------------------------------------------------#
AthenaWorkgroup:
Type: AWS::Athena::WorkGroup
Properties:
Description: !Sub Athena Workgroup for SES logs
Name: !Sub ses-logs-${AWS::Region}
RecursiveDeleteOption: true
State: ENABLED
Tags:
- Key: Cost
Value: !Ref TagValue
WorkGroupConfiguration:
EnforceWorkGroupConfiguration: false
PublishCloudWatchMetricsEnabled: true
RequesterPaysEnabled: false
ResultConfiguration:
OutputLocation: !Sub s3://${S3BucketSesLogs}/athenaAdhocQueries/
# ------------------------------------------------------------#
# Glue Database
# ------------------------------------------------------------#
GlueDatabase:
Type: AWS::Glue::Database
Properties:
CatalogId: !Ref AWS::AccountId
DatabaseInput:
Description: !Sub Glue database for SES logs
Name: !Sub ses-logs-${AWS::Region}
# ------------------------------------------------------------#
# SNS Topic
# ------------------------------------------------------------#
SNSTopic:
Type: AWS::SNS::Topic
Properties:
TracingConfig: PassThrough
DisplayName: !Sub ses-logs-${AWS::Region}
FifoTopic: false
Tags:
- Key: Cost
Value: !Ref TagValue
手動作業
AWS CloudFormation テンプレートでデプロイした後、以下の作業をする必要があります。
- Amazon SES の対象 ID への設定セット割当
- Amazon SES の通知設定
Amazon SES の対象 ID への設定セット割当
AWS CloudFormation テンプレートにより、ses-logs-AWSアカウント番号-リージョン名 の名前で SES 設定セットが出来上がります。これを、ログを取りたい ID のデフォルト設定セットに割り当てます。これで Amazon SES から Amazon Data Firehose にログが送信されます。
Amazon SNS の通知設定
AWS CloudFormation テンプレートにより、おそらく ses-logs-から始まる名前の Amazon SNS トピックが作成されます。これを、ログを取りたい ID のフィードバック通知に設定します。これでバウンス、苦情があったときに Amazon SNS に通知されます。
ただし、この Amazon SNS トピックには通知先は設定されていませんので、メールアドレスなどでサブクスライブする作業をお忘れなく。
Amazon S3 ログの確認方法
Amazon S3 に送られたログは JSON 形式になっており、Amazon Data Firehose のコントロール下でバッファリングされたログデータが順次書き込まれていきます。ファイル名もランダムな名前になっており、直接ファイルを操作して検索するのは至難の業です。そのため、Amazon Athena で検索するのが最も効率的です。
検索を効率化するために、Amazon Athena External Table (Amazon S3 などのデータソースを検索しやすくするビューのようなもの) を AWS CloudFormation でデプロイしてあるので、そこに対して Amazon Athena から SQL でクエリします。
以下のように、Amazon Athena のクエリエディタでデータベース ses-logs-リージョン名、テーブル seslogs が作成されているのでそれに対してクエリを打ちます。
あまり出力結果を成型できていませんが、以下のサンプル SQL でとりあえずログは見られます。適宜、SQL は変えてみて欲しいです。
ある特定の日の配送ログ
SELECT * FROM seslogs
where event_type = 'Delivery'
and day = '2024/07/08';
event_type を Bounce や Complaint に変更すれば、バウンスや苦情のログを検索できます。すみませんが出力は成型できていません。
ある特定の期間のバウンスログ(成型済)
バウンスログについては若干成型したものがあります。
SELECT
bounce.bounceType as bouncebounceType
, bounce.bouncesubtype as bouncebouncesubtype
, bounce.feedbackid as bouncefeedbackid
, bounce.timestamp as bouncetimestamp
, bounce.reportingMTA as bouncereportingmta
, mail.messageId as messageid
, mail.timestamp as timestamp
, mail.source as source
, mail.commonHeaders.subject as subject
, mail.commonHeaders.to as to
, element_at(mail.headers,2) as replyto
, mail.tags.ses_source_ip as ses_source_ip
, mail.tags.ses_outgoing_ip as ses_outgoing_ip
FROM seslogs
where event_type = 'Bounce'
and day between '2024/11/01' and '2024/11/30';
表形式で結果が見られます。
まとめ
いかがでしたでしょうか。
以前は Amazon OpenSearch Service でログ保管していたので、かなり課金額が安くなりました。AWS CloudFormation テンプレート化したことで他のアカウント、リージョンでもすぐに使用できるようになり、便利になりました。
正直、バウンスは「存在しないメールアドレス」宛てに送られたメールがほとんどです。通知が来たら送信者を突き止めて、入力したメールアドレスが間違ってますよ、と伝える運用をしています。
本記事が皆様のお役に立てれば幸いです。