これは エンジニアが知っておくべき メール送信・運用ノウハウ、メールの認証技術やセキュリティについて投稿しよう! by blastengine 1 日目の記事です。
自社の Web アプリケーションでは、これまで Postfix を MTA(Mail Transfer Agent)として使用してメール通知機能を実装してきましたが、運用負担を考えて blastengine への段階的移行を進めました。
移行後の実装の際に気をつけたことについて、簡単に触れたいと思います。
移行前
自社の Web アプリケーションは AWS 上に構築し運用していますが、言語環境標準のメール送信パッケージ等から、
- EC2 で稼働するアプリケーションは EC2 上の Postfix
- コンテナ環境で稼働するアプリケーションはサイドカーコンテナの Postfix
を経由して、AWS 外にある自社のメールサーバー(Postfix)からメールを送信していました。
バウンスメールについては、バッチ処理によりこのサーバーから IMAP で取得していました。
移行後
同時並行で進めたコンテナ移行によって EC2 で稼働するアプリケーションはほぼなくなりましたが、Web アプリケーションでは直接 blastengine の SDK を使わずに、
- DynamoDB テーブル(メール送信用)にレコードを追加する
- メール送信に必要な情報を属性として登録
実装のみを行い、そこから先のメール送信については、
- DynamoDB テーブル(メール送信用)レコード追加をトリガーに DynamoDB Streams で Lambda 関数(メール送信用)を起動
- Lambda 関数(メール送信用)で blastengine SDK を使ってトランザクションメールを送信
- blastengine の Web API へのリクエストが成功したら DynamoDB テーブル(メール送信用)からレコードを削除するとともに別の DynamoDB テーブル(メール送信履歴用)に必要情報を転記
という流れで Web アプリケーションとは独立して処理を行い、バウンスメールの情報については、
- API Gateway にリクエストが届いたら Lambda 関数(バウンス Webhook 用)で取得
という実装を行いました。
こちらの記事に書いたものを、実際のユースケースに合わせてアレンジして実装したイメージになります。
DynamoDB ではなく SQS など一般的なキューのサービスを使っても良いのですが、何らかの理由で送信リクエストが失敗した場合にレコードの(ダミーの)列値を更新することで DynamoDB Streams のトリガーを再発火し、再送信リクエストを手動で簡単に行うことができるので、DynamoDB を使っています。
実装のポイント
特に以下の点に配慮して実装しました。
- Web アプリケーション側の実装を簡素化する
- Web アプリケーションのレスポンス時間がのびないように配慮する
- blastengine のレートリミットを考慮して送信する
1. Web アプリケーション側の実装を簡素化する
Web アプリケーションにとってメール通知機能は「メインの機能」ではなく「副次的な機能」であることがほとんどです。
そのため、メール送信処理は Web アプリケーションの外側に実装しておいたほうが、アプリケーションコードの「見通し」がよくなります。
今回は blastengine の Web API へのメール送信リクエストに関するエラーリトライ処理も含めて Lambda 関数(メール送信用)側に実装していますが、これは後述する blastengine のレートリミットの扱いにも関係してきます。
DynamoDB へのリクエストエラー時のリトライは DynamoDB の SDK が内包しているため、結果として Web アプリケーション側のエラーハンドリングの実装が簡素化できます。
2. Web アプリケーションのレスポンス時間がのびないように配慮する
前述のとおり、この実装では Web アプリケーションで「DynamoDB テーブル(メール送信用)にレコードを追加する」処理のみを行なっていますが、複数通のメールを同時に送信する場合、1 行ずつ直列・同期でレコードを PUT してしまうとレコード追加の処理の分アプリケーションのレスポンス時間がのびてしまうため、
- 並列・非同期で PUT する
- 例えば Java では
CompletableFuture.allOf()
(または仮想スレッド)、JavaScript ではPromise.all()
を使うイメージ
- 例えば Java では
または、
-
batchWriteItem
で複数レコードをまとめて処理- ただし同時 25 通までの制限あり
- 処理がわかりにくくなるので基本的に多用しない
- ただし同時 25 通までの制限あり
のような工夫が必要です。
実際に Web アプリケーションに実装してみたところ、従来の「Web アプリケーションと同居している SMTP サーバーに対して(言語環境標準のメール送信パッケージ等から)メール送信リクエストを送る」ケースと比べて「Web API に DynamoDB の PUT リクエストを送る」ほうがリクエスト 1 回あたりの所要時間が長く、直列・同期で処理しようとするとどうしてもメール送信リクエスト処理完了までの時間が長くなってしまいました。
3. blastengine のレートリミットを考慮して送信する
公式リファレンスに書かれているとおり blastengine には「1 分間に 500 回まで」のレートリミット(リクエスト数制限)があります。
対処としては
- レートリミットエラーが発生したらインターバルを入れてリトライする
- 複数の blastengine アカウントを発行してアカウントを使い分けるか複数アカウントからの送信に分散してレートリミットに到達しないようにする
- レートリミットを緩和するオプション(有料)を利用する
があります。
レートリミットを緩和するオプションは有料ですが、比較的リーズナブルな料金設定になっています。
「ちょっとしたスパイクが発生した時以外にはレートリミットを超えることがないのでもったいない」と思いがちですが、運用が簡単になり、トータルで見るとコスト低減につながるのでオススメです。
詳細は、公式リファレンスに書かれている問い合わせフォームから確認してみてください。
レートリミットオーバーが発生した後その状態がクリアされるタイミングについては、非公式情報ですが「1 分毎」のような長めのタイミングではなくもっと短いタイミングで行われるようです。
そのため、Lambda 関数(メール送信用)では、以下のような実装をおこなっています。
- 基本はこちらの記事の実装のとおり
- 1 つの Lambda インスタンスの中では並列・非同期ではなく直列・同期で Web API にリクエストを送信
- DynamoDB Streams では、DynamoDB テーブルの内部的なパーティションごとにストリームが形成されるので、実際には Lambda インスタンスは同時に複数起動する
- Web API リクエストエラーが発生したときは、一旦リクエストを止めて数秒のインターバルの後リトライして、成功したら続きを送信する
- 先の記事の実装からインターバル時間などを変更(短縮)
- 1 つの Lambda インスタンスの中では並列・非同期ではなく直列・同期で Web API にリクエストを送信
Lambda 関数内の処理を並列化してしまうとリトライの実装が複雑化してしまうため、あえて直列・同期で処理しています。
なお、前述のとおり Lambda インスタンス自体は並列で起動するので、全体としては並列処理になります。
また、実装に使っている blastengine SDK でエラー内容の判別が難しいこともあり、レートリミットエラーとそれ以外の Web API リクエストエラーは区別せずにリトライ処理をおこなっています。
その他、レートリミット到達を完全に防げるわけではありませんが、DynamoDB Streams の Lambda 関数起動トリガー設定でバッチサイズを減らすことで「緩和」することはできそうです。
余談ですが
レートリミット時の動作確認をしようとしてうっかり blastengine に全力でメール送信リクエストを送ったらサポートの方に怒られました(当然)。
真似しないでください。
(本番の Web API 以外に、何らかの動作確認ができる環境や仕組みがあると良いですね…)
明日(2 日目)は kitazaki さんです。
JPAAWG 7th General Meeting、わたしも参加しました。