完成イメージ
CloudWatch Logs Insightsの見え方
ゆるく眺めるということで、timestamp、eventtype(送信した、バウンスされた、など)、送信先メールアドレスをとりあえず表示してみました。
CloudWatch Logs Insightsのクエリで、timestamp範囲での検索、eventtypeでの集計、mailアドレスでの検索が可能なので、まずはこのくらいのカラムで良い気がしてます。
アーキ
SESから各イベントをSNSへpublishします。
SNSはログ記載用のlamndaをsubscribeし、受け取ったeventをlambdaへ渡します。
lambdaはSESイベント履歴ロギング用のロググループへイベント内容をputします。
やってみた
SES IDを作成する
SESのメール送信元となるSES IDを作成します。
SES > 設定 > ID に移動する
「IDの作成」をクリックします。
送信元メールアドレスを指定する
今回は簡単のため、メアドでIDを作成します。
「Eメールアドレス」ラジオを選択します。
メールアドレスを設定し、IDを作成する
検証用に用意したメールアドレスを入力し、「IDの作成」をクリックします。
記載の通り、受信したメールを確認できるメールアドレスである必要があります。
IDの作成を確認する
IDが作成されました!!
IDステータスが「検証保留中」であることが確認できます。
入力したメールアドレス宛に検証リンクを含むメールが飛んでいるはずなので、確認しにいきます。
メールアドレスを確認し、IDを有効化する
IDが作成されたので、メールアドレスを検証し、IDを有効化しましょう!
受信メールを確認する
ID作成時に使用したメアドの受信ボックスを確認します。
AWSからメアド検証用リンクを含むメールが飛んでいるはずです。
リンクをクリックし、メアド検証を完了させる
メールに記載のリンクをクリックします。
良い感じの文言が出ればオッケーです。
SES IDのステータスを確認する
再度SES IDの概要画面を表示します。
先ほど「検証保留中」だったIDステータスが「検証済み」に変更されていることが確認できます。
SES→SNSの設定に行きたいのですが、SNSトピックが存在しないといけないため、次は後続リソースを作成していきます!
SNS、lambda、Cloudwatchリソースを作成する
terraformでサクッと作成する
今回はSESの設定が主目的のため、SNS→lambda→cloudwatch logsの部分はterraformでサクッと実装します。
ディレクトリ構造はこんな感じ。
ses-logging/
├── main.tf // 各リソースを定義するtf
├── lambda-ses-function/ // ses実行用lambda
| └── index.mjs
└── lambda-logging-function/ // SNSから実行されるログ用lambda
└── index.mjs
main.tfは長いので省略
SNS→lambda→cloudwatch logs作成Terraform
############################################################################
## terraformブロック
############################################################################
terraform {
# Terraformのバージョン指定
required_version = "~> 1.7.0"
# Terraformのaws用ライブラリのバージョン指定
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.33.0"
}
}
}
############################################################################
## providerブロック
############################################################################
provider "aws" {
# リージョンを指定
region = "ap-northeast-1"
}
locals {
project = "send_email_by_lambda"
}
###########################################################################
## SES実行用Lambda IAMロール
############################################################################
data "aws_iam_policy_document" "assume_lambda" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
resource "aws_iam_role" "lambda_ses" {
name = "${local.project}-lambda-ses-role"
assume_role_policy = data.aws_iam_policy_document.assume_lambda.json
}
data "aws_iam_policy_document" "lambda_ses" {
statement {
effect = "Allow"
actions = [
"ses:SendEmail",
"ses:SendRawEmail"
]
resources = ["*"]
}
}
resource "aws_iam_policy" "lambda_ses" {
name = "${local.project}-lambda-ses-policy"
policy = data.aws_iam_policy_document.lambda_ses.json
}
resource "aws_iam_role_policy_attachment" "lambda_ses" {
role = aws_iam_role.lambda_ses.name
policy_arn = aws_iam_policy.lambda_ses.arn
}
############################################################################
## SES実行用Lambda 関数
#############################################################################
# zip
data "archive_file" "lambda_ses" {
type = "zip"
output_file_mode = "0666"
source_dir = "${path.module}/lambda-ses-function"
output_path = "${path.module}/lambda-ses-function.zip"
}
# 関数
resource "aws_lambda_function" "lambda_ses" {
function_name = "${local.project}_lambda"
role = aws_iam_role.lambda_ses.arn
runtime = "nodejs20.x"
filename = data.archive_file.lambda_ses.output_path
handler = "index.handler"
architectures = ["arm64"]
source_code_hash = filebase64sha256(data.archive_file.lambda_ses.output_path)
# zip内の変更は無視する
lifecycle {
ignore_changes = [filename, source_code_hash]
}
}
############################################################################
## SES証跡用Cloudwatch logs
#############################################################################
resource "aws_cloudwatch_log_group" "lambda_ses_logging" {
name = "/aws/ses/${local.project}-logging"
}
###########################################################################
## SES証跡Lambda IAMロール
############################################################################
resource "aws_iam_role" "lambda_ses_logging" {
name = "${local.project}-lambda-ses-logging-role"
assume_role_policy = data.aws_iam_policy_document.assume_lambda.json
}
data "aws_iam_policy_document" "lambda_ses_logging" {
statement {
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:DescribeLogStreams",
"logs:PutLogEvents",
]
resources = ["${aws_cloudwatch_log_group.lambda_ses_logging.arn}:*"]
}
}
resource "aws_iam_policy" "lambda_ses_logging" {
name = "${local.project}-lambda-ses-logging-policy"
policy = data.aws_iam_policy_document.lambda_ses_logging.json
}
resource "aws_iam_role_policy_attachment" "lambda_ses_logging" {
role = aws_iam_role.lambda_ses_logging.name
policy_arn = aws_iam_policy.lambda_ses_logging.arn
}
############################################################################
## SES証跡用Lambda 関数
#############################################################################
# zip
data "archive_file" "lambda_ses_logging" {
type = "zip"
output_file_mode = "0666"
source_dir = "${path.module}/lambda-logging-function"
output_path = "${path.module}/lambda-logging-function.zip"
}
# 関数
resource "aws_lambda_function" "lambda_ses_logging" {
function_name = "${local.project}_lambda-logging"
role = aws_iam_role.lambda_ses_logging.arn
runtime = "nodejs20.x"
filename = data.archive_file.lambda_ses_logging.output_path
handler = "index.handler"
architectures = ["arm64"]
source_code_hash = filebase64sha256(data.archive_file.lambda_ses_logging.output_path)
# zip内の変更は無視する
lifecycle {
ignore_changes = [filename, source_code_hash]
}
}
############################################################################
## SES証跡用SNS Topic
#############################################################################
resource "aws_sns_topic" "lambda_ses_logging" {
name = "${local.project}-ses-send-topic"
}
resource "aws_sns_topic_subscription" "lambda_ses_logging" {
topic_arn = aws_sns_topic.lambda_ses_logging.arn
protocol = "lambda"
endpoint = aws_lambda_function.lambda_ses_logging.arn
}
resource "aws_lambda_permission" "lambda_ses_logging" {
function_name = aws_lambda_function.lambda_ses_logging.function_name
statement_id = "AllowExecutionFromSNS"
principal = "sns.amazonaws.com"
action = "lambda:InvokeFunction"
source_arn = aws_sns_topic.lambda_ses_logging.arn
}
SES実行用のlambda
特に変なことはしてません。lambda経由でSESからメールを送信します。
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
const ses = new SESClient({ region: "ap-northeast-1" }); // 適宜リージョンを変更
export const handler = async (event) => {
const params = {
Destination: {
ToAddresses: ["makoto@gmail.com"], // 送信先のメールアドレス
},
Message: {
Body: {
Text: { Data: "Hello from AWS Lambda using SES!" }, // メール本文
},
Subject: { Data: "Test Email from Lambda" }, // メール件名
},
Source: "makoto@gmail.com", // 検証済みの送信元メールアドレス
};
try {
const result = await ses.send(new SendEmailCommand(params));
console.log("Email sent successfully:", result);
return { statusCode: 200, body: "Email sent successfully" };
} catch (error) {
console.error("Error sending email:", error);
return { statusCode: 500, body: "Failed to send email" };
}
};
SES実行用履歴ロギング用のlambda
SNSをsubscribeし、SNS経由でSESのeventを特定のロググループに保存します。
// 日次ログストリーム付きSNSイベントロガー
import { CloudWatchLogsClient, PutLogEventsCommand, CreateLogStreamCommand } from "@aws-sdk/client-cloudwatch-logs";
// CloudWatch Logsクライアントの初期化
const logsClient = new CloudWatchLogsClient();
// 設定
const LOG_GROUP_NAME = '/aws/ses/send_email_by_lambda-logging';
export const handler = async (event, context) => {
try {
console.log('受信したイベント:', JSON.stringify(event, null, 2));
// 今日の日付でログストリーム名を生成
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD形式
const LOG_STREAM_NAME = `sns-events-${today}`;
// イベントをログに記録
await logEvent(event, LOG_STREAM_NAME);
return { statusCode: 200, message: 'イベントを記録しました' };
} catch (error) {
console.error('エラー:', error);
throw error;
}
};
/**
* イベントをCloudWatch Logsに記録
*/
async function logEvent(event, streamName) {
try {
// まずログストリームを作成(既に存在する場合はエラーをキャッチして無視)
const createStreamCommand = new CreateLogStreamCommand({
logGroupName: LOG_GROUP_NAME,
logStreamName: streamName
});
try {
await logsClient.send(createStreamCommand);
} catch (error) {
// ResourceAlreadyExistsException は無視(ストリームが既に存在する場合)
if (error.name !== 'ResourceAlreadyExistsException') {
throw error;
}
}
// ログイベントの作成
const logEvent = {
timestamp: Date.now(),
message: JSON.stringify(event, null, 2)
// TODO: SESと疎通後は以下のコードに変更する
// message: event.Records[0].Sns.Message
};
// ログをCloudWatchに送信
const putCommand = new PutLogEventsCommand({
logGroupName: LOG_GROUP_NAME,
logStreamName: streamName,
logEvents: [logEvent]
});
await logsClient.send(putCommand);
} catch (error) {
console.error('ログ記録中のエラー:', error);
throw error;
}
}
以下の部分だけ注意です
// ログイベントの作成
const logEvent = {
timestamp: Date.now(),
message: JSON.stringify(event, null, 2)
// TODO: SESと疎通後は以下のコードに変更する
// message: event.Records[0].Sns.Message
};
message: event.Records[0].Sns.Message
SESからpublishされたeventをそのままログ保存するより、一定スコープをしぼって保存する方がinsightでの可視性があがります。
message: JSON.stringify(event, null, 2)
上述した通り、eventをそのまま投げ込むのは良くないのですが、
この後SNS→lambda→cloudwatch logsの疎通テストをするため、まずはeventをそのまま投げ込む形でデプロイします(SNSで適当なメッセージでテストした場合、event.Records[0].Sns.Messageが取得できず落ちる可能性があるため)。
コメントにもある通り、SNS→lambda→cloudwatch logsの疎通確認後、コードを編集します。
SNS→lambda→cloudwatch logsの疎通を確認する
terraformでリソースがdeployできたら、疎通確認をします。
SNS > トピック > メッセージの発行をクリックする
作成したSNS トピックから、「メッセージの発行」をクリックします。
件名・メッセージ本文を用意し、メッセージを発行する
件名・メッセージ本文を用意し、「メッセージの発行」をクリックします。
実行結果を確認する
cloudwatch logsからロググループにアクセスし、ログを確認します。
SNSからtest用のメッセージを受け取り、ロググループへputしていることが確認できました!
これでついにSESとSNSをつなぐことができます!!!!
// ログイベントの作成
const logEvent = {
timestamp: Date.now(),
- message: JSON.stringify(event, null, 2)
- // TODO: SESと疎通後は以下のコードに変更する
- // message: event.Records[0].Sns.Message
+ message: event.Records[0].Sns.Message
};
ログ保存用lambdaも直しておきます。
SES → SNS連携のため、SES 設定セットを作成する
SESの送信イベントを他サービスへ連携する設定は、SESの「設定セット」というリソースで管理されています。
「設定セット」はSESでのメール送信単位、またはID単位で共通の振る舞いを行わせるために、設定群を管理できるリソースです。
RDSのパラメータグループみたいなものだとふんわり認識しています。
SES 設定セット作成画面へ遷移する
SES > 設定 > 設定セット から、「セットの作成」をクリックします。
送信するイベントタイプを選択する
「すべて選択」をクリックします!レンダリングの失敗などは基本不要ですが、0-100で設定しておいたほうが他者に意図が伝わりやすいと思っています。
「次へ」をクリックします。
送信先を選択する
SESイベントの送信先を選択します。
Cloudwatchのメトリクスや、Firehoseなどが選べます。
SNSを選択し、作成したトピックを指定します。
設定セットの作成を完了する
設定に問題がないことを確認し、設定セットを作成します!!
設定セットの作成を確認する
SES > 設定 > 設定セットから確認します。
作成できてそうです。
作成したSES IDのデフォルト設定セットに、作成したSES 設定セットを指定する
設定セットは、SendEmail API実行時に指定するほか、IDごとにデフォルトのセットを持つことができます。
今回は先ほど作成した設定セットを、IDのデフォルトの設定セットに指定してみます!!
SES > 設定 > ID > 設定セットタブへ移動する
デフォルト設定セットがブランクであることが確認できます。
「編集」をクリックし、設定を更新しましょう。
作成した設定セットを、IDのデフォルトの設定セットに指定する
そのまんまですね
良い感じ!
SESでメールを送信してみる
ではSESを利用してメールを送信し、SNS経由でイベント情報をcloudwatch logに保存してみます。
SESでメールを送信する
作成したlambdaをinvokeし、SES経由でメールを送信してみます。
問題なく動いてそうです。Sonnet3.7最強!
メールも届きました。
logsを確認する
無事logsに実行ログが保存されています。
Send(送信したよ)とDelivery(正常に配信完了したよ)の2つのイベントがログ出力されています。
良い感じですね。
CloudWatch Logs Insightsでゆるく眺める
ログの海をそのまま目で追うのはあまりにも過酷です。
タイトルにもある、CloudWatch Logs Insightsをつかってみます。
CloudWatch Logs Insights画面に移動する
ロググループ画面から、「Logs Insights で表示」をクリックします。
CloudWatch Logs Insights表示画面が表示されます!
クエリを変更する
以下クエリに変更し、実行します。
fields @timestamp, eventType, mail.destination.0
| sort @timestamp desc
| limit 2
ゆるく眺める
きたーーーーー
良い感じに整形されてSESイベント履歴が表示されました!
より詳しく
CloudWatch Logs Insightsの記法については、以下の記事がめっちゃわかりやすいです!
クエリを色々カスタマイズしたいと思うので、ぜひ参考にしてください!!
おわりに
SESの送信履歴を管理・確認したいと思い、アーキを考えてみました。
dynamodbなどに送信イベントを保存する案なども考えたのですが、まずはmvp的にCloudWatch Logs Insightsを使ってどうにかできないか?と実験してみました。
SNSを経由していることもあり、拡張性もあるため、最初はゆるく運用していけばいいんじゃないかなーと感じました!