この記事で確認できたこと

S3とSecrets Managerのエラーを越え、日報ページの公開まで進んだところで、LambdaはSESの SendEmail で403になりました。送信元ドメインは検証済みで、受信先もSESへ登録しています。実機ログに出たResourceは、作業記録上では検証済み受信先Identityの形式でした。

その後、IAM Resourceへ送信元と受信先のIdentityを並べ、FromとRecipientsのConditionを残した状態で手動送信に成功しています。ただし、AWS公式はSendEmailのResourceを送信に使うIdentityとして説明し、受信先は ses:Recipients のConditionで制限しています。この差があるため、「受信先IdentityをResourceへ追加したことだけが原因解消だった」とは断定しません。

メールが届いたあと、EventBridge Schedulerを毎朝5時、Asia/Tokyoで有効にしました。今回のLambda画面ではトリガー一覧が0件に見えたため、Scheduler側のターゲットARN、実行ロール、Payload、状態を照合しています。この画面確認は設定の整合性までで、毎朝5時の実行成功とは分けて扱います。

まず、独自ドメインをSESで認証した

メールの送信元は、SAMEKORO Daily用の独自ドメインアドレスにしました。記事では実際のメールアドレスを載せず、daily@<site-domain> と表記します。

個別アドレスではなくサイトのドメインをSESで検証し、Easy DKIMを有効にしました。Route 53で同じドメインを管理していたため、検証用のCNAMEレコードはSESの画面から作成できました。

最初は、メールアドレスへ確認リンクが届く形を想像していました。ドメイン認証では、DNSへDKIM用レコードを置き、SESがその管理を確認します。Route 53にCNAMEが並び、SES側の状態が検証済みに変わったところで、ようやく送信元の準備がつながりました。

今回のSESはサンドボックス内です。そのため、送信元だけでなく検証用の受信先もIdentityとして確認しました。AWS公式のSESの検証済みIdentityに関する説明でも、サンドボックスではメールボックスシミュレーター以外の受信先を検証する必要があると確認しました。本番アクセスへ移行していない段階では、この違いを飛ばせません。

SESのSendEmailが403になった

起きたこと

Lambdaは日報をS3へ保存し、CloudFrontのInvalidationまで進みました。そのあと、SES送信で止まりました。

ses:SendEmail is not authorized
resource: arn:aws:ses:ap-northeast-1:<account-id>:identity/<verified-recipient>

送信元のIdentityはIAMポリシーへ入れていたため、最初はSESの検証状態かリージョンが違うのだと思いました。

最初に疑ったこと

SESはIdentityを作ったリージョンごとに扱うため、Lambdaの SES_REGION とIdentityのリージョンを見比べました。送信元ドメインと受信先も検証済みです。

サンドボックスだから送れないのかとも考えましたが、ログは「未検証」ではなく、特定の受信先Identity ARNに対する ses:SendEmail が許可されていないと示しています。ここで、SES画面よりIAM Resourceへ戻りました。

原因として確認できた差分と、断定しないこと

SamekoroDailyRuntimePolicySendDailyMail は、送信元ドメインのIdentityだけをResourceに指定していました。一方、作業記録に残した403のResourceは、検証済み受信先Identityの形式です。ポリシーにないIdentityがエラーへ出ていた、という差分までは確認できました。

ただし、AWS公式のSESアクセス制御では、SendEmailのResourceは送信に使えるIdentityを指定し、受信先は ses:Recipients のConditionで制限する形です。実機記録との説明が一致しないため、受信先IdentityのResource不足を一般的な原因として扱いません。公開前には、403の原文、送信時のFromとDestination、変更前後のポリシーを同じ画面で再確認します。

どう切り分けたか

次の四つを同じリージョンで見比べました。

  • SESの送信元ドメインIdentity
  • SESの検証済み受信先Identity
  • Lambda環境変数の送信元と送信先
  • 403に出たResourceとIAMポリシーのResource

エラーが指していたのは、IAMポリシーへ書いていない受信先側でした。ログに出たARN全文は公開せず、Identityの種類と末尾だけを確認しています。

変更した内容と、その後の結果

Resourceを配列にし、送信元ドメインと検証済み受信先の二つを許可しました。記事では識別情報をマスクすると、次の形です。

"Resource": [
  "arn:aws:ses:ap-northeast-1:<account-id>:identity/<site-domain>",
  "arn:aws:ses:ap-northeast-1:<account-id>:identity/<verified-recipient>"
]

ここで手早く Resource: "*" へは広げませんでした。Conditionでは、送信元を daily@<site-domain>、送信先を検証用受信アドレスへ限定したままです。

"Condition": {
  "StringEquals": {
    "ses:FromAddress": "daily@<site-domain>"
  },
  "ForAllValues:StringEquals": {
    "ses:Recipients": ["<verified-recipient>"]
  }
}

この変更後、今回の手動実行は daily.completed まで進み、メールを受信しました。確認できたのは「ポリシー変更前に403」「変更後に手動送信成功」という順番です。受信先IdentityをResourceへ追加する形を、そのままほかの環境へ適用するための例にはしません。

次回の確認ポイント

SES 403では、まずエラーに出たResourceと、実際のFrom、Destination、IAM Resourceを同じ記録へ残します。AWS公式の基本形である「送信IdentityをResource、受信先を ses:Recipients」と照合し、差がある場合はポリシーを変える前のログを保存します。Identityの検証状態とリージョンも同時に確認します。

stateを確認してから再試験した

SESエラーは、S3公開とCloudFront更新のあとに起きました。そのため、単純にstateを消して最初からやり直すと、同じ日報の再公開や二重送信につながる可能性があります。

前の記事で整理したとおり、publishedmailStatus を先に確認しました。published=trueunknown-after-send-attempt なら、受信箱とSES側の記録を見ずに削除しません。

原因を直したから再実行してよい、ではありません。どこまで進んだ失敗なのかをstateで判断する必要があります。この順番は、SESのように外部へ送った直後の成否が曖昧になりうる処理ほど大事だと感じました。

修正後、実際にメールが届いた

IAMポリシーを更新し、安全に再試験できる状態だと確認してからLambdaを実行しました。今回の作業記録では daily.completed まで進み、日報メールを実際に受信しています。

最初のS3 404から、Secrets ManagerのAccessDenied、failed state、SES 403と順番にログが変わりました。受信箱へメールが出たあとも、CloudWatch Logsの daily.completed と日報ページ、stateを同じ対象日で照合しています。ひとつの設定だけを見るのではなく、サービスをまたぐたびに、Lambdaが誰としてどのリソースを使うのかを合わせる作業でした。

本番のcontentHash、SES messageId、実行時間、使用メモリは公開前にCloudWatch Logsの画像で再確認します。ローカルのDRY RUNにある値を、本番実行の値としては使いません。

毎朝5時のEventBridge Schedulerを作った

手動実行で最後まで進んだあと、EventBridge Schedulerへつなぎました。設定した内容は次のとおりです。

  • スケジュール名:samekoro-daily-0500
  • cron式:0 5 ? *
  • タイムゾーン:Asia/Tokyo
  • フレキシブルタイムウィンドウ:オフ
  • ターゲット:Lambda samekoro-daily
  • Payload:{}
  • イベントの最大保持時間:1時間
  • 最大再試行回数:2回
  • DLQ:初回はなし
  • 状態:有効

cronだけならUTCへ換算する方法も考えましたが、今回はタイムゾーンをAsia/Tokyoとして明示しました。見直した時に「日本時間の朝5時」とそのまま読めるほうが、自分の運用では分かりやすいからです。

フレキシブルタイムウィンドウはオフにしました。朝のどこかで動けばよいのではなく、5時に始め、起きた頃に届いていてほしい仕組みだからです。AWS公式のフレキシブルタイムウィンドウの説明では、有効にすると指定した時間幅の中でターゲットが呼び出されます。

再試行は2回、イベントの最大保持時間は1時間にしました。EventBridge Schedulerのスケジュール管理では、失敗時の扱いを再試行ポリシーとDLQで設定します。ただしLambda側にも同日実行を止めるstateがあります。Schedulerがもう一度呼び出しても、すでにstateがある場合は二重送信せず終了する設計です。

Lambdaのトリガーが0件に見えて少し迷った

Schedulerを作成したあと、今回のLambda画面ではトリガー一覧が0件に見えました。「関連付けに失敗したのでは」と、ここで少し迷いました。

確認したのはEventBridge Scheduler側です。スケジュールを開き、ターゲットが samekoro-daily のARNになっていること、実行ロール、Payload、状態、次回実行時刻を見ました。

この時点で確認できたのは、「Lambda画面では0件に見えたこと」と「Scheduler側では対象LambdaのARN、実行ロール、Payload、状態が設定されていたこと」です。0件表示だけを根拠に設定失敗とは判断しませんでしたが、Scheduler側の設定が合っているだけで実行成功とも判断していません。

AWS公式のLambda向けScheduler手順でも、Schedulerは実行ロールを使ってLambdaを非同期で呼び出し、実際に呼び出されたかはCloudWatch Logsで確認する流れです。今回の記事も、画面の見え方と実行結果を分けて残します。

最終確認はCloudWatch Logsで行う

スケジュールが有効でも、毎朝の処理が成功した証拠にはなりません。最終確認は、実行時刻を過ぎたあとにCloudWatch Logsで行います。

見るのは daily.started、対象日、daily.completed または daily.failed です。そのうえで、受信箱と samekorolab.com/daily/ に同じ日付が一件だけあることを確認します。

今回確認できたのは、手動実行での完了と、Schedulerの設定・有効化までです。毎朝5時の初回自動実行結果は、実際の実行後に追記します。設定画面が完成していることと、定時実行が成功したことは分けて残します。

それでも送れない場合の追加確認

同じ403でも、今回と原因が同じとは限りません。次の順で確認します。

  1. SES Identityを作ったリージョンと SES_REGION
  2. 送信元ドメインの検証状態とEasy DKIM
  3. サンドボックス内なら受信先の検証状態
  4. エラーに出たIdentity ARNとIAM Resource
  5. ses:FromAddressses:Recipients のCondition
  6. stateの publishedmailStatus

Schedulerで呼ばれない場合は、スケジュールの状態、次回実行時刻、ターゲットARN、実行ロール、PayloadをScheduler側で見ます。そのあとCloudWatch Logsに呼び出しが届いているかを確認します。

今回ハマったポイント

最初に起きたのは、日報公開まで進んだあとでのSES 403です。送信元と送信先は検証済みだったため、最初にSESのリージョンとサンドボックス状態を確認しました。ログを読むと、ResourceはIAMポリシーへ書いていない受信先Identityの形式です。両IdentityをResourceへ並べ、FromとRecipientsのConditionを残した変更後に手動送信は成功しましたが、AWS公式仕様との差があるため単独原因とは断定しません。

そのあとSchedulerを作ると、今回のLambda画面ではトリガー一覧が0件に見えました。Scheduler側でターゲットARN、実行ロール、Payload、状態を照合し、少なくとも作成内容が意図したLambdaを向いていることまでは確認しました。実際の定時呼び出しは、実行時刻後のCloudWatch Logsで別に確認します。

次回はSES 403の原文と変更前ポリシーを先に保存し、公式仕様と差があれば因果関係を急いで決めません。SchedulerはLambda側の表示だけで判断せず、Scheduler側の設定と実行時刻後のCloudWatch Logsをセットで見ます。

今回の学び

今回一番役立ったのは、403という数字より、その後ろに出たResourceを読むことでした。権限エラーを「IAMが足りない」で止めず、どのIdentityに対する拒否なのかまで追ったことで、Resource: "*" へ広げずに変更前後を比較できました。

Schedulerも、作成画面で有効になっただけでは動作確認になりません。GUIでターゲットと実行ロールを確かめ、最後はCloudWatch Logs、受信箱、日報ページを同じ日付で照合する。サービス単体ではなく、次のサービスへ渡る地点を見ることが今回の学びでした。


次回は、手動実行で届いたメールとWeb版を、スマートフォンでも読みやすい形へ直した過程を振り返ります。