こんにちは、広野です。
最近、社内でメールに 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 テンプレート化したことで他のアカウント、リージョンでもすぐに使用できるようになり、便利になりました。
正直、バウンスは「存在しないメールアドレス」宛てに送られたメールがほとんどです。通知が来たら送信者を突き止めて、入力したメールアドレスが間違ってますよ、と伝える運用をしています。
本記事が皆様のお役に立てれば幸いです。