SendGridとAWSを使って、メールを送信するアプリケーションを作成しました

  • URLをコピーしました!

こんにちは、普段PR TIMES STORY(以下STORY)の開発リーダーをしている岩下(@iwashi623)です。

今回はSQSLambdaを使って、AWS Fargate上で動作しているLaravelからメールを送信する基盤を作成したことについて書いていきます。

目次

なぜ作るのか

2/21にPR TIMES Webクリッピングの正式版がリリースされました。

プレスリリース・ニュースリリース...
広報発表の効果測定がプレスリリース管理画面で可能に。PR TIMES Webクリッピング正式版の提供を開始 株式会社PR TIMESのプレスリリース(2023年2月21日 17時00分)広報発表の効果測定がプレスリリース管理画面で可能に。PR TIMES Webクリッピング正式版の提供を開始

正式版をリリースするにあたって、ユーザーにメールを送信したいという要件が生じました。PR TIMES WebクリッピングはFargate上にデプロイされていて、アプリケーションのFWはLaravelを使用しています。

当初はセキュリティグループの25番ポートを開放して、アプリケーションから直接メールを送信しようとしていたのですが、Fargateの仕様を調べていくと、AWSのドキュメントには25番ポートについての記載がありません。また、メールを送信したいアプリケーションがPR TIMES Webクリッピング以外にも今後発生してくる可能性もありました。

なので、PR TIMES Webクリッピングにメールを送信する機能を新規で追加する形ではなく、メールを送信する責務だけを持つ別アプリケーションを作成して、その別アプリケーションを使って今回の要件を満たそうと思いました。

アプリケーションの設計

今回作成したアプリケーションのインフラ設計は以下のようになっています。

アプリケーションの使い方は、

  1. Fagate上の各アプリケーションから、メールの情報(宛先・本文・etc..)の情報を入れたメッセージをSQS defaultへエンキューする。
  2. LambdaはSQS defaultからEventを取得して、キューが入っているときにアプリケーションを実行する。
  3. アプリケーションの中では、とってきたSQSのメッセージ情報をもとにSendGridメール送信用Web APIを叩く。
  4. 各ユーザーにメールが届く。

というのが基本の流れです。

また、SendGridのWebAPIを使用するためには、アカウントごとに作成できるAPI Keyが必要なのですが、こちらはSystem Manager Prameter Storeに暗号化して保存されています。

アプリケーションのコードは以下のような感じです。

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/sendgrid/sendgrid-go"
)
type systemManagerResponse struct {
Parameter systemManagerResponseParameter `json:"Parameter"`
}
type systemManagerResponseParameter struct {
Arn string `json:"ARN"`
DataType string `json:"DataType"`
LastModifiedDate time.Time `json:"LastModifiedDate"`
Name string `json:"Name"`
Selector interface{} `json:"Selector"`
SourceResult interface{} `json:"SourceResult"`
Type string `json:"Type"`
Value string `json:"Value"`
Version int `json:"Version"`
}
/**
SystemManager parameter storeからAPI_KEYを取得する
@see https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html
*/
func getAPIKey() (*string, error) {
url := fmt.Sprintf("http://localhost:2773/systemsmanager/parameters/get?name=%s&withDecryption=true", os.Getenv("API_KEY_PATH"))
req, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Printf("ERROR: %v", err)
return nil, err
}
req.Header.Set("X-Aws-Parameters-Secrets-Token", os.Getenv("AWS_SESSION_TOKEN"))
client := http.Client{
Timeout: 5 * time.Second,
}
res, err := client.Do(req)
if err != nil {
log.Printf("ERROR: %v", err)
return nil, err
}
defer res.Body.Close()
var smr systemManagerResponse
json.NewDecoder(res.Body).Decode(&smr)
if err != nil {
log.Printf("ERROR: %v", err)
return nil, err
}
return &smr.Parameter.Value, nil
}
func sendMail(messageBody string) error {
apiKey, err := getAPIKey()
if err != nil {
log.Printf("ERROR: %v", err)
return err
}
// SQSのキュー情報をそのままメール送信APIに渡すだけ
req := sendgrid.GetRequest(*apiKey, "/v3/mail/send", "https://api.sendgrid.com")
req.Method = "POST"
req.Body = []byte(messageBody)
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
res, err := sendgrid.MakeRequestWithContext(ctx, req)
// @see https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/Mail/errors.html
if err != nil || res.StatusCode != 202 {
log.Printf("ERROR: %v", err)
log.Printf("StatusCode: %v", res.StatusCode)
log.Printf("Response Headers: %v", res.Headers)
log.Printf("Response Body: %v", res.Body)
return fmt.Errorf("%s", "failed to send email.")
}
return nil
}
func handler(ctx context.Context, sqsEvent events.SQSEvent) error {
for _, message := range sqsEvent.Records {
err := sendMail(message.Body)
if err != nil {
log.Printf("ERROR: %v", err.Error())
// 関数を異常終了させてキューを戻す
os.Exit(1)
}
}
return nil
}
func init() {
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
}
func main() {
lambda.Start(handler)
}
view raw main.go hosted with ❤ by GitHub

自分で作っておいてなんですが、Lambdaのなかでは特に難しいことはしていないことがわかると思います。

今回はエンキューする段階でSendGridのWeb API実行時にそのまま使えるJSONをメッセージに加えているので、メール送信側のLambdaはメッセージの内容をリクエストボディに入れてAPIを叩いているだけです。

Prameter StoreのKeyをとってくるときに、少しだけ手こずりましたが、結果以下のDocumentとほぼ同じ方法で落ち着きました。

あわせて読みたい
Using Parameter Store parameters in AWS Lambda functions - AWS Systems Manager Learn how to use the AWS Parameters and Secrets Lambda Extension with Parameter Store.

開発Tips

SendGridとは

SendGridは全世界で利用されているメール配信サービスです。

クラウドサービスのためアカウントを作成するだけで即日メールを送信でき、面倒でコストのかかるメールサーバの構築は不要です。

https://sendgrid.kke.co.jp/about/

メールを送信するためのクラウドサービスで、Web APIやSMTP APIを使って簡単にメールを送信することができます。Web APIを使ってメールを送信する際は、以下のようなJSON形式のメッセージをリクエストボディに付与します。

{  "personalizations": [
    {
      "to": [
        {
          "email": "recipient_address@example.com"
        }
      ],
      "subject": "こんにちは!"
    }
  ],
  "from": {
    "email": "from_address@example.com"
  },
  "content": [
    {
      "type": "text/plain",
      "value": "テキストメールです!"
    }
  ]
}

また、Domain Authenticationの設定をすることで、独自ドメインからのメールの送信の際も送信ドメイン認証の設定が完了します。

ハードバウンスのメールアドレスへの配信停止などもSendGrid側で行ってくれるので、メーリングリストの使われていないメールアドレスへメールを送信し続けることによるレピュテーションの低下なども心配しなくて良くなります。

AWSリソースの設定について

SQS

SQSの設定値として注意したいのは、可視性タイムアウトの値です。

可視性タイムアウトはAmazon SQSがメッセージを返した時点で開始します。タイムアウト時間内に、コンシューマーはメッセージを処理して削除します。ただし、コンシューマーでメッセージを削除する前に障害が発生して、[DeleteMessage])アクションが呼び出されないまま可視性タイムアウトの期限が切れると、そのメッセージは他のコンシューマーに見えるようになり、再度受信されます。メッセージを一度だけ受信する必要がある場合、コンシューマーは可視性タイムアウトの時間内にメッセージを削除する必要があります。

すべてのAmazon SQSキューの可視性タイムアウトはデフォルトで 30秒に設定されています。この設定はキュー全体で変更できます。通常、可視性タイムアウトは、アプリケーションがキューのメッセージを処理して削除するまでの最大所要時間に設定します。メッセージを受信したら、キュー全体のタイムアウトを変更しなくても、返されるメッセージに特別な可視性タイムアウトを設定することもできます。詳細については、「タイムリーな方法でのメッセージの処理」セクションのベストプラクティスを参照してください。

メッセージの処理にかかる時間がわからない場合は、ハートビートあなたの消費者プロセスのために:初期可視性タイムアウト (2 分など) を特定し、コンシューマがメッセージで作業している限り、可視性タイムアウトを 1 分ごとに 2 分延長します。

https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html

平たく言うと、「同じメッセージを複数のコンシューマーが処理しないように設定する値」です。

こちらの適切な値はコンシューマー側のワークロードにより異なるのですが、今回のアプリケーションではデフォルトの30秒のままにしています。設定値を0にすると複数のLambdaが起動しているときに同じメールを複数送信してまう可能性がありますし、長過ぎると何らかの原因でLambdaが途中で失敗したときにリトライの実行開始が遅くなります。

Lambda

Lambdaの設定値として注意したいのは、バッチサイズと同時実行数です。

バッチサイズは1実行あたりに読み込める最大のSQSメッセージ数です。デフォルトが10で最大値は標準キューは10000、FiFOキューは10です。今回のアプリケーションでは1を設定しています。理由はメールの送信成功・失敗の扱いを個別に行いたいからです。例えば1実行の中で送信に成功したメールと失敗したメールが混ざっていたとき、関数全体としては失敗扱いになります(失敗したメールがあった時は異常終了させないと、LambdaからDeleteMessageが走ってメッセージがSQSに残らないため)。その際、同時に実行された成功したメッセージまで失敗したことになってSQSに残ることになります。結果として、リトライしたときに最初の実行で成功したメールも含めて送信することになってしまいます。

こちらは今回のケースだと都合が悪いため、Lambdaの負荷や料金が気になり始めるまではバッチサイズは1にしておくことにしました。

同時実行数はそのままで、同時に実行されるLambdaの数です。前述した可視性タイムアウトを設定してあれば、複数のLambdaが同時に起動しても問題ないため、任意の数を指定すれば良いと思います。今回のアプリケーションでは空白にして同時実行されないようになっていますが、こちらも負荷が気になり始めたら引き上げる予定です。

注意点としては、何も申請をしていないと1アカウントの1リージョンあたりのLambdaの最大実効数は1000となっていることです。

あわせて読みたい
Lambda quotas - AWS Lambda Maximum sizes, limits, and quotas for Lambda functions and API requests.

こちらは全てのLambda含めての数となりますので、他に重要なワークロードを担っているLambdaがある場合、そちらがスケールできなくなる恐れがあります。なので、制限の引き上げ申請や同時実行数の調整を行うことをおすすめします。

エラー・異常系の処理について

今回のアプリケーションでは、エラーやエラー通知に関しては以下のように扱っています。

  • Lambda上で何かしらのエラーがでて、関数が異常終了した場合、SQSにキューが残る。
    • Lambdaアプリケーションでエラーが出て場合は、Lambdaの「Errors」メトリクスをもとにCloudWatch Alermが発火する。
  • 5回を超えて関数が異常終了するキューに関しては、「SQS deadletter」に移動する。
    • Deadletterにキューが入った場合、SQSの「ApproximateNumberOfMessagesVisible」メトリクスをもとにCloudWatch Alermが発火する。
  • Lambdaのスロットリング発生時やメモリ使用率80%超えの際にもCloudWatch Alermが発火する。

AWSのコンソール上エラーが出るだけでは誰も検知できないので、SNSとChatbotを使って任意のSlackチャンネルに通知を飛ばせるようにしました。ただ、こちらの組み合わせではメッセージがSlackに送られてくるだけで、メンションが付きません。

この問題は、CloudWatchで発火するトピックをサブスクライブするLambdaを作って、そのLambda内で

  • メッセージにメンションを付与した新SNSトピックをPublishする
  • 直接Slack APIを叩いて送信する

などで解消できそうなので、随時改修予定です。

また、現状のLambdaアプリケーションは決まったフォーマットのメッセージをそのままリクエストボディに入れてSendGridのAPIを叩くことを想定して作られています。なので複数のアプリケーションがこちらのメール送信基盤を使うと、同じフォーマットのメッセージをSQS送ることになります。その際、仮に送信に失敗してdeadletterにメッセージが入った時、メッセージのフォーマットにアプリケーションごとの差異がないため、どのアプリケーションが送信したメッセージなのか調査が難しいです。また、メールの種類によっては再送が必須な場合もあると思います。

このあたりも、実際に複数のアプリケーションがこちらの基盤を使うようになったときにアップデートをする予定なので、改修次第本ブログで発信していければと思っています。

最後に

今回作ったサービス(機能?)は、完全に他のアプリケーションから切り離された別サービスでしたが、AWSやSendGridなどのクラウドサービスを組み合わせることで、1スプリント(弊社では二週間)ほどで設計と実装を終えることができました。

これからもクラウドサービスをガンガン使ってアプリケーションをアップデートしていきたいです。

また、上記のような新規アプリケーションの設計やクラウドサービスを利用した挑戦に興味のある方は、カジュアル面談でお話できると嬉しいです。

株式会社PR TIMES
カジュアル面談申し込みフォーム - 株式会社PR TIMES 株式会社PR TIMESでは現在00-0. カジュアル面談(全職種OK)を募集しています。
  • URLをコピーしました!

この記事を書いた人

PR TIMES STORYの開発をしています。
AWSのSAA/DVA/SOA/SAPを持ってます。

目次