AWSSESを介して非同期でメールを送信する
公開: 2022-03-10ほとんどのアプリケーションは、ユーザーと通信するために電子メールを送信します。 トランザクションメールは、サイトへの登録後に新しいユーザーを歓迎したり、パスワードをリセットするためのリンクをユーザーに提供したり、ユーザーが購入した後に請求書を添付したりする場合など、ユーザーがアプリケーションを操作することによってトリガーされるメールです。 これらの以前のすべてのケースでは、通常、ユーザーに1通の電子メールのみを送信する必要があります。 ただし、他の場合には、ユーザーがサイトに新しいコンテンツを投稿するときなど、アプリケーションはさらに多くの電子メールを送信する必要があり、すべてのフォロワー(Twitterなどのプラットフォームでは数百万のユーザーになる可能性があります)は通知。 この後者の状況では、適切に設計されていないため、電子メールの送信がアプリケーションのボトルネックになる可能性があります。
それが私の場合に起こったことです。 ユーザーがトリガーしたアクション(すべてのフォロワーへのユーザー通知など)の後に20通の電子メールを送信する必要があるサイトがあります。 当初は、人気のあるクラウドベースのSMTPプロバイダー(SendGrid、Mandrill、Mailjet、Mailgunなど)を介して電子メールを送信することに依存していましたが、ユーザーへの応答には数秒かかります。 明らかに、これらの20通の電子メールを送信するためにSMTPサーバーに接続すると、プロセスが大幅に遅くなりました。
調べた後、私は問題の原因を見つけました:
- 同期接続
アプリケーションはSMTPサーバーに接続し、プロセスの実行を続行する前に、同期的に確認応答を待ちます。 - 高遅延
私のサーバーはシンガポールにありますが、使用していたSMTPプロバイダーのサーバーは米国にあるため、往復接続にかなりの時間がかかります。 - SMTP接続の再利用性なし関数を呼び出して電子メールを送信すると、関数はすぐに電子メールを送信し、その瞬間に新しいSMTP接続を作成します(すべての電子メールを収集し、要求の最後に1つのSMTPですべて一緒に送信することはできません)繋がり)。
#1のため、ユーザーが応答を待たなければならない時間は、電子メールの送信にかかる時間に関係しています。 #2のため、1通のメールを送信する時間は比較的長くなります。 また、#3のため、20通のメールを送信する時間は1通のメールを送信する時間の20倍になります。 1通のメールだけを送信してもアプリケーションがそれほど遅くなることはないかもしれませんが、20通のメールを送信すると確かに遅くなり、ユーザーエクスペリエンスに影響を与えます。
この問題を解決する方法を見てみましょう。
トランザクションメールの性質に注意を払う
何よりもまず、すべての電子メールの重要性が同じではないことに注意する必要があります。 メールは大きく分けて、優先メールと非優先メールの2つのグループに分類できます。 たとえば、ユーザーがアカウントにアクセスするためのパスワードを忘れた場合、受信トレイにパスワードリセットリンクが記載されたメールがすぐに届くはずです。 それは優先メールです。 対照的に、フォローしている誰かが新しいコンテンツを投稿したことを通知する電子メールを送信することは、ユーザーの受信トレイにすぐに到着する必要はありません。 それは非優先メールです。
ソリューションでは、これら2つのカテゴリの電子メールの送信方法を最適化する必要があります。 プロセス中に送信される優先度の高い電子メールはごくわずか(おそらく1つまたは2つ)であり、電子メールの大部分は優先度の低いものであると想定して、ソリューションを次のように設計します。
- 優先度の高い電子メールは、アプリケーションが展開されているのと同じリージョンにあるSMTPプロバイダーを使用することで、高遅延の問題を簡単に回避できます。 優れた調査に加えて、これには、アプリケーションをプロバイダーのAPIと統合することが含まれます。
- 優先度の低い電子メールは非同期で送信でき、多数の電子メールが一緒に送信されるバッチで送信できます。 アプリケーションレベルで実装するには、適切なテクノロジースタックが必要です。
次に、電子メールを非同期に送信するテクノロジースタックを定義しましょう。
テクノロジースタックの定義
注:私のウェブサイトはすでにAWS EC2でホストされているため、スタックをAWSサービスに基づくことにしました。 そうしないと、複数の企業のネットワーク間でデータを移動することによるオーバーヘッドが発生します。 ただし、他のクラウドサービスプロバイダーを使用してソリューションを実装することもできます。
私の最初のアプローチは、キューを設定することでした。 キューを介して、アプリケーションにメールを送信せずに、メールの内容とメタデータをキューに入れてメッセージを公開し、別のプロセスでキューからメッセージを取得してメールを送信することができます。
ただし、SQSと呼ばれるAWSのキューサービスを確認したところ、次の理由から、適切なソリューションではないと判断しました。
- 設定はかなり複雑です。
- 標準のキューメッセージには、上位256 kbの情報しか保存できません。これは、電子メールに添付ファイル(請求書など)が含まれている場合は不十分な場合があります。 また、大きなメッセージを小さなメッセージに分割することは可能ですが、複雑さはさらに増します。
次に、セットアップがはるかに簡単な他のAWSサービスであるS3とLambdaを組み合わせることで、キューの動作を完全に模倣できることに気付きました。 データを保存および取得するクラウドオブジェクトストレージソリューションであるS3は、メッセージをアップロードするためのリポジトリとして機能し、イベントに応答してコードを実行するコンピューティングサービスであるLambdaは、メッセージを選択して操作を実行できます。
つまり、次のようにメール送信プロセスを設定できます。
- アプリケーションは、メールコンテンツとメタデータを含むファイルをS3バケットにアップロードします。
- 新しいファイルがS3バケットにアップロードされるたびに、S3は新しいファイルへのパスを含むイベントをトリガーします。
- Lambda関数はイベントを選択し、ファイルを読み取り、メールを送信します。
最後に、メールの送信方法を決定する必要があります。 Lambda関数がAPIと相互作用するように、既存のSMTPプロバイダーを引き続き使用するか、SESと呼ばれるメールの送信にAWSサービスを使用することができます。 SESの使用には、長所と短所の両方があります。
利点:
- AWS Lambda内からの使用は非常に簡単です(2行のコードが必要です)。
- 安価です。ラムダ料金は関数の実行にかかる時間に基づいて計算されるため、AWSネットワーク内からSESに接続する方が外部サーバーに接続するよりも時間がかからず、関数が早く終了し、コストが低くなります。 。 (アプリケーションがホストされているのと同じリージョンでSESが利用できない場合を除きます。私の場合、EC2サーバーが配置されているアジア太平洋(シンガポール)リージョンではSESが提供されていないため、一部に接続したほうがよい場合があります。アジアベースの外部SMTPプロバイダー)。
欠点:
- 送信されたメールを監視するための統計はあまり提供されておらず、より強力な統計を追加するには追加の作業が必要です(たとえば、開かれたメールの割合やクリックされたリンクの追跡は、AWS CloudWatchを介して設定する必要があります)。
- 優先メールの送信にSMTPプロバイダーを使用し続けると、統計情報が1か所にまとめられなくなります。
簡単にするために、以下のコードではSESを使用します。
次に、プロセスとスタックのロジックを次のように定義しました。アプリケーションは通常どおり優先度の高いメールを送信しますが、優先度の低いものの場合は、メールの内容とメタデータを含むファイルをS3にアップロードします。 このファイルは、メールを送信するためにSESに接続するLambda関数によって非同期的に処理されます。
ソリューションの実装を始めましょう。
優先メールと非優先メールの区別
つまり、これはすべてアプリケーションに依存するため、メールごとに決定する必要があります。 WordPress用に実装したソリューションについて説明します。これには、関数wp_mail
からの制約を回避するためのハックが必要です。 他のプラットフォームの場合、以下の戦略も機能しますが、ハックを必要としない、より優れた戦略が存在する可能性があります。
WordPressでメールを送信する方法は、関数wp_mail
を呼び出すことですが、これを変更したくないので(たとえば、関数wp_mail_synchronous
またはwp_mail_asynchronous
を呼び出すことによって)、 wp_mail
の実装は同期と非同期の両方のケースを処理する必要があります。メールがどのグループに属しているかを知る必要があります。 残念ながら、 wp_mail
は、その署名からわかるように、この情報を評価するための追加のパラメーターを提供していません。
function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() )
次に、電子メールのカテゴリを見つけるために、ハッキーなソリューションを追加します。デフォルトでは、電子メールを優先グループに属し、 $to
に特定の電子メールが含まれている場合(例:[email protected])、または$subject
が特別な文字列で始まる場合(例:「[Non-priority!]」)、それは非優先グループに属します(そして、対応する電子メールまたは文字列を件名から削除します)。 wp_mail
はプラグイン可能な関数であるため、functions.phpファイルに同じ署名を持つ新しい関数を実装するだけでオーバーライドできます。 最初は、ファイルwp-includes / plugin.phpにある元のwp_mail
関数と同じコードが含まれており、すべてのパラメーターを抽出します。
if ( !function_exists( 'wp_mail' ) ) : function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) { $atts = apply_filters( 'wp_mail', compact( 'to', 'subject', 'message', 'headers', 'attachments' ) ); if ( isset( $atts['to'] ) ) { $to = $atts['to']; } if ( !is_array( $to ) ) { $to = explode( ',', $to ); } if ( isset( $atts['subject'] ) ) { $subject = $atts['subject']; } if ( isset( $atts['message'] ) ) { $message = $atts['message']; } if ( isset( $atts['headers'] ) ) { $headers = $atts['headers']; } if ( isset( $atts['attachments'] ) ) { $attachments = $atts['attachments']; } if ( ! is_array( $attachments ) ) { $attachments = explode( "\n", str_replace( "\r\n", "\n", $attachments ) ); } // Continue below... } endif;
次に、それが非優先度であるかどうかを確認します。非優先度の場合は、関数send_asynchronous_mail
で別のロジックにフォークします。そうでない場合は、元のwp_mail
関数と同じコードを実行し続けます。
function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) { // Continued from above... $hacky_email = "[email protected]"; if (in_array($hacky_email, $to)) { // Remove the hacky email from $to array_splice($to, array_search($hacky_email, $to), 1); // Fork to asynchronous logic return send_asynchronous_mail($to, $subject, $message, $headers, $attachments); } // Continue all code from original function in wp-includes/pluggable.php // ... }
関数send_asynchronous_mail
では、メールをS3に直接アップロードする代わりに、グローバル変数$emailqueue
にメールを追加するだけです。この変数から、リクエストの最後に1つの接続ですべてのメールをまとめてS3にアップロードできます。
function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) { global $emailqueue; if (!$emailqueue) { $emailqueue = array(); } // Add email to queue. Code continues below... }
メールごとに1つのファイルをアップロードすることも、1つのファイルに多数のメールが含まれるようにバンドルすることもできます。 $headers
にはメールメタ(from、content-typeとcharset、CC、BCC、reply-toフィールド)が含まれているため、同じ$headers
がある場合はいつでも、メールをグループ化できます。 このようにして、これらのメールはすべて同じファイルでS3にアップロードでき、 $headers
メタ情報はメールごとに1回ではなく、ファイルに1回だけ含まれます。
function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) { // Continued from above... // Add email to the queue $emailqueue[$headers] = $emailqueue[$headers] ?? array(); $emailqueue[$headers][] = array( 'to' => $to, 'subject' => $subject, 'message' => $message, 'attachments' => $attachments, ); // Code continues below }
最後に、関数send_asynchronous_mail
はtrue
を返します。 このコードはハッキーであることに注意してくださいtrue
は通常、電子メールが正常に送信されたことを意味しますが、この場合、まだ送信されておらず、完全に失敗する可能性があります。 このため、 wp_mail
を呼び出す関数は、 true
応答を「電子メールが正常に送信された」として処理するのではなく、キューに入れられたことを確認する必要があります。 そのため、この手法を優先度の低い電子メールに制限して、失敗した場合にプロセスがバックグラウンドで再試行を続けることができ、ユーザーが電子メールがすでに受信トレイにあることを期待しないようにすることが重要です。
function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) { // Continued from above... // That's it! return true; }
S3へのメールのアップロード
前回の記事「AWSS3を介した複数のサーバー間でのデータの共有」では、S3でバケットを作成する方法と、SDKを介してファイルをバケットにアップロードする方法について説明しました。 以下のすべてのコードは、WordPressのソリューションの実装を継続しているため、SDK forPHPを使用してAWSに接続します。
抽象クラスAWS_S3
(前回の記事で紹介)から拡張してS3に接続し、リクエストの最後にバケット「async-emails」にメールをアップロードできます( wp_footer
フックを介してトリガーされます)。 電子メールをインターネットに公開したくないため、ACLを「プライベート」のままにしておく必要があることに注意してください。
class AsyncEmails_AWS_S3 extends AWS_S3 { function __construct() { // Send all emails at the end of the execution add_action("wp_footer", array($this, "upload_emails_to_s3"), PHP_INT_MAX); } protected function get_acl() { return "private"; } protected function get_bucket() { return "async-emails"; } function upload_emails_to_s3() { $s3Client = $this->get_s3_client(); // Code continued below... } } new AsyncEmails_AWS_S3();
グローバル変数$emailqueue
に保存されているヘッダーのペア=> emaildataの反復を開始し、ヘッダーが空の場合は関数get_default_email_meta
からデフォルトの構成を取得します。 以下のコードでは、ヘッダーから「from」フィールドのみを取得しています(すべてのヘッダーを抽出するコードは、元の関数wp_mail
からコピーできます)。
class AsyncEmails_AWS_S3 extends AWS_S3 { public function get_default_email_meta() { // Code continued from above... return array( 'from' => sprintf( '%s <%s>', get_bloginfo('name'), get_bloginfo('admin_email') ), 'contentType' => 'text/html', 'charset' => strtolower(get_option('blog_charset')) ); } public function upload_emails_to_s3() { // Code continued from above... global $emailqueue; foreach ($emailqueue as $headers => $emails) { $meta = $this->get_default_email_meta(); // Retrieve the "from" from the headers $regexp = '/From:\s*(([^\<]*?) <)? ?\s*\n/i'; if(preg_match($regexp, $headers, $matches)) { $meta['from'] = sprintf( '%s <%s>', $matches[2], $matches[3] ); } // Code continued below... } } }
(.+?)>class AsyncEmails_AWS_S3 extends AWS_S3 { public function get_default_email_meta() { // Code continued from above... return array( 'from' => sprintf( '%s <%s>', get_bloginfo('name'), get_bloginfo('admin_email') ), 'contentType' => 'text/html', 'charset' => strtolower(get_option('blog_charset')) ); } public function upload_emails_to_s3() { // Code continued from above... global $emailqueue; foreach ($emailqueue as $headers => $emails) { $meta = $this->get_default_email_meta(); // Retrieve the "from" from the headers $regexp = '/From:\s*(([^\<]*?) <)? ?\s*\n/i'; if(preg_match($regexp, $headers, $matches)) { $meta['from'] = sprintf( '%s <%s>', $matches[2], $matches[3] ); } // Code continued below... } } }
最後に、メールをS3にアップロードします。 お金を節約する目的で、ファイルごとにアップロードするメールの数を決定します。 Lambda関数は、100ミリ秒のスパンで計算された、実行に必要な時間に基づいて課金されます。 関数に必要な時間が長くなるほど、関数のコストは高くなります。
メールごとに1つのファイルをアップロードしてすべてのメールを送信すると、多くのメールに対して1つのファイルをアップロードするよりもコストがかかります。これは、関数の実行によるオーバーヘッドが、多くのメールに対して1回だけではなく、メールごとに1回計算されるためです。また、多くのメールを送信するためです。一緒に100msのスパンをより完全に満たします。
そのため、ファイルごとに多くのメールをアップロードします。 メールは何通ですか? Lambda関数の最大実行時間(デフォルトでは3秒)があり、操作が失敗した場合、失敗した場所からではなく、最初から再試行を続けます。 したがって、ファイルに100通の電子メールが含まれていて、最大時間が経過する前にLambdaが50通の電子メールを送信できた場合、ファイルは失敗し、操作の実行を再試行して、最初の50通の電子メールをもう一度送信します。 これを回避するには、最大時間が経過する前に処理するのに十分であると確信しているファイルごとの電子メールの数を選択する必要があります。 私たちの状況では、ファイルごとに25通の電子メールを送信することを選択できます。 メールの数はアプリケーションによって異なります(メールが大きいほど送信に時間がかかり、メールの送信時間はインフラストラクチャによって異なります)。そのため、適切な数を見つけるためにいくつかのテストを行う必要があります。
ファイルのコンテンツは単純なJSONオブジェクトであり、プロパティ「meta」の下に電子メールメタが含まれ、プロパティ「emails」の下に電子メールのチャンクが含まれています。
class AsyncEmails_AWS_S3 extends AWS_S3 { public function upload_emails_to_s3() { // Code continued from above... foreach ($emailqueue as $headers => $emails) { // Code continued from above... // Split the emails into chunks of no more than the value of constant EMAILS_PER_FILE: $chunks = array_chunk($emails, EMAILS_PER_FILE); $filename = time().rand(); for ($chunk_count = 0; $chunk_count < count($chunks); $chunk_count++) { $body = array( 'meta' => $meta, 'emails' => $chunks[$chunk_count], ); // Upload to S3 $s3Client->putObject([ 'ACL' => $this->get_acl(), 'Bucket' => $this->get_bucket(), 'Key' => $filename.$chunk_count.'.json', 'Body' => json_encode($body), ]); } } } }
簡単にするために、上記のコードでは、添付ファイルをS3にアップロードしていません。 メールに添付ファイルを含める必要がある場合は、 SendEmail
(以下のLambdaスクリプトで使用)の代わりにSES関数SendRawEmail
を使用する必要があります。
メール付きのファイルをS3にアップロードするロジックを追加したら、次にLambda関数のコーディングに進むことができます。
ラムダスクリプトのコーディング
Lambda関数はサーバーレス関数とも呼ばれます。サーバー上で実行されないためではなく、開発者がサーバーについて心配する必要がないためです。開発者はスクリプトを提供するだけで、クラウドがサーバーのプロビジョニング、デプロイ、スクリプトを実行します。 したがって、前述のように、Lambda関数は関数の実行時間に基づいて課金されます。
次のNode.jsスクリプトは、必要なジョブを実行します。 バケットに新しいオブジェクトが作成されたことを示すS3「Put」イベントによって呼び出されます。関数は次のとおりです。
- 新しいオブジェクトのパス(変数
srcKey
の下)とバケット(変数srcBucket
の下)を取得します。 -
s3.getObject
を介してオブジェクトをダウンロードします。 -
JSON.parse(response.Body.toString())
を介してオブジェクトのコンテンツを解析し、電子メールと電子メールメタを抽出します。 - すべての電子メールを繰り返し、
ses.sendEmail
を介して送信します。
var async = require('async'); var aws = require('aws-sdk'); var s3 = new aws.S3(); exports.handler = function(event, context, callback) { var srcBucket = event.Records[0].s3.bucket.name; var srcKey = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, " ")); // Download the file from S3, parse it, and send the emails async.waterfall([ function download(next) { // Download the file from S3 into a buffer. s3.getObject({ Bucket: srcBucket, Key: srcKey }, next); }, function process(response, next) { var file = JSON.parse(response.Body.toString()); var emails = file.emails; var emailsMeta = file.meta; // Check required parameters if (emails === null || emailsMeta === null) { callback('Bad Request: Missing required data: ' + response.Body.toString()); return; } if (emails.length === 0) { callback('Bad Request: No emails provided: ' + response.Body.toString()); return; } var totalEmails = emails.length; var sentEmails = 0; for (var i = 0; i < totalEmails; i++) { var email = emails[i]; var params = { Destination: { ToAddresses: email.to }, Message: { Subject: { Data: email.subject, Charset: emailsMeta.charset } }, Source: emailsMeta.from }; if (emailsMeta.contentType == 'text/html') { params.Message.Body = { Html: { Data: email.message, Charset: emailsMeta.charset } }; } else { params.Message.Body = { Text: { Data: email.message, Charset: emailsMeta.charset } }; } // Send the email var ses = new aws.SES({ "region": "us-east-1" }); ses.sendEmail(params, function(err, data) { if (err) { console.error('Unable to send email due to an error: ' + err); callback(err); } sentEmails++; if (sentEmails == totalEmails) { next(); } }); } } ], function (err) { if (err) { console.error('Unable to send emails due to an error: ' + err); callback(err); } // Success callback(null); }); };
次に、Lambda関数をAWSにアップロードして設定する必要があります。これには以下が含まれます。
- LambdaにS3へのアクセス許可を付与する実行ロールを作成します。
- すべてのコードを含む.zipパッケージを作成します。つまり、作成するLambda関数と必要なすべてのNode.jsモジュールを作成します。
- CLIツールを使用してこのパッケージをAWSにアップロードします。
これらの方法は、AWSサイトのAmazonS3でのAWSLambdaの使用に関するチュートリアルで適切に説明されています。
ラムダ関数でS3を接続する
最後に、バケットとLambda関数を作成したら、両方をフックする必要があります。これにより、バケットに新しいオブジェクトが作成されるたびに、Lambda関数を実行するイベントがトリガーされます。 これを行うには、S3ダッシュボードに移動し、バケット行をクリックします。バケット行には、そのプロパティが表示されます。
次に、[プロパティ]をクリックし、[イベント]の項目までスクロールして、[通知の追加]をクリックし、次のフィールドに入力します。
- 名前:通知の名前。例:「EmailSender」。
- イベント: 「Put」。これは、バケットに新しいオブジェクトが作成されたときにトリガーされるイベントです。
- 送信先: 「ラムダ関数」;
- Lambda:新しく作成されたLambdaの名前。例:「LambdaEmailSender」。
最後に、S3バケットを設定して、しばらくするとメールデータを含むファイルを自動的に削除することもできます。 このために、バケットの[管理]タブに移動し、新しいライフサイクルルールを作成して、メールの有効期限が切れる日数を定義します。
それでおしまい。 この時点から、メールのコンテンツとメタを含む新しいオブジェクトをS3バケットに追加すると、Lambda関数がトリガーされ、ファイルが読み取られ、SESに接続してメールが送信されます。
私は自分のサイトにこのソリューションを実装しましたが、再び高速になりました。外部プロセスにメールを送信することをオフロードすることで、アプリケーションが20通または5000通のメールを送信しても違いはなく、アクションをトリガーしたユーザーへの応答は次のようになります。すぐに。
結論
この記事では、1回のリクエストで多数のトランザクションメールを送信することがアプリケーションのボトルネックになる理由を分析し、問題に対処するためのソリューションを作成しました。アプリケーション内から(同期的に)SMTPサーバーに接続する代わりに、次のことができます。 AWS S3 + Lambda + SESのスタックに基づいて、外部関数から非同期でメールを送信します。
電子メールを非同期で送信することにより、アプリケーションは何千もの電子メールを送信できますが、アクションをトリガーしたユーザーへの応答は影響を受けません。 ただし、ユーザーがメールが受信トレイに届くのを待たないようにするために、メールを優先度と非優先度の2つのグループに分割し、非優先度のメールのみを非同期で送信することも決定しました。 WordPressの実装を提供しましたが、これは電子メールを送信するための関数wp_mail
の制限のためにかなりハッキーです。
この記事からの教訓は、サーバーベースのアプリケーションのサーバーレス機能が非常にうまく機能することです。WordPressなどのCMSで実行されているサイトは、クラウドに特定の機能のみを実装することでパフォーマンスを向上させ、移行による複雑さを大幅に回避できます。完全にサーバーレスアーキテクチャへの非常に動的なサイト。