✉️

RailsからSQS × Lambda × SESでメール送信を実装する

2024/09/14に公開

はじめに

はじめまして、教育系スタートアップのLX DESIGNでCTOしている工藤です。
私たちのアプリケーションではメール送信の際にRailsアプリケーションからSESを利用してメールを送信しています。その中で、ある施策の中で一度に数千件のメールを送信する必要がありました。その際に直面するのがAWS SESの送信レート制限です。
https://docs.aws.amazon.com/ja_jp/ses/latest/dg/manage-sending-quotas.html
SESには1秒あたりの送信制限があり、通常のやり方で一度に大量のメールを送信しようとすると、SESの制限により送信完了までに数十秒かかってしまいます。また、他のメール送信がある処理も、この送信待ちに影響されるためアプリケーション全体のパフォーマンスが大幅に低下してしまい、ユーザー体験に大きな影響を与える可能性があります。

実装しているRailsアプリケーションでは、標準の delivery_methodses を使ってAWS SESを直接呼び出し、メール送信を行っていました。この方法だと前述の通りSESの制限に引っかかり、パフォーマンス面で問題が生じます。AWSに送信レートをあげてもらって解決する方法もあるのですが、それでは根本的な解消にはならないため、スケーラビリティを意識したメール送信を行うように変更しました。

SQSとLambdaを使った解決策

この問題を解決するために、AWSのSQSとLambdaを利用してSESからメール送信することにしました。メールを直接SESに送信するのではなく、一度SQS(Simple Queue Service)にメール送信リクエストをキューに登録し、バックグラウンドでLambdaをトリガーしてSESからメールを送信する構成です。

このアプローチの利点は、Railsのアプリケーションがキューにリクエストを投げた瞬間に処理が完了するため、SESの送信を待ったレスポンスの遅延も発生しにくくなります。また、Lambdaがバッチ処理でSESの送信レートをうまく制御することで、SESの制限に引っかからずにスムーズに大量のメールを送信できます。

Rails側の変更を最小限にするために

Railsのコードにも大きな影響を与えたくなかったため、できるだけシンプルな改修で実装できるように意識しました。前述した通り、もともとはRailsの delivery_method で標準の :ses を呼び出していました。これをカスタムdelivery_methodを作成し、delivery_method置き換えることで、コードの大幅な改修をがないように実装しました。

config.action_mailer.delivery_method = :ses

config.action_mailer.delivery_method = :sqs_delivery

このように、Rails側のロジックはそのままにconfigファイルで delivery_method を切り替えるだけで、SQS経由でのメール送信が可能になるようにしました。これにより既存のコードに大きな変更を加えることなく、スケーラブルなメール送信が実現できました。また、configファイルは各環境に存在するため、これまで通り本番環境と開発環境でメール送信方法を分けることも可能になります。

Rails側でのdelivery_methodの実装

カスタムdelivery_methodを利用できるように config/initializers/aws_sqs.rb というファイルを以下のように実装しました

require 'aws-sdk-sqs'
require 'sqs_delivery'

ActionMailer::Base.add_delivery_method :sqs_delivery, SqsDelivery

Aws.config.update({
  region: 'ap-northeast-1',
  credentials: Aws::Credentials.new({access_key}, {secret_key})
})

実際のカスタム関数はapp/mailers/sqs_delivery.rbというファイルを作成し、以下のように実装しています。

require 'aws-sdk-sqs'

class SqsDelivery

  def initialize(settings)
    @settings = settings
    @sqs_client = Aws::SQS::Client.new(region: 'ap-northeast-1')
    @queue_url = {作成したキューのURL}
  end

  def deliver!(mail)
    body = {
      to: mail.to,
      from: mail.from,
      subject: mail.subject,
      html_body: mail.html_part.body.to_s,
      text_body: mail.text_part.body.to_s
    }.to_json

    @sqs_client.send_message(
      queue_url: @queue_url,
      message_body: body,
      message_group_id: {グループID},
      message_deduplication_id: {重複排除ID},
    )
  end
end

ここで実装した SqsDelivery

ActionMailer::Base.add_delivery_method :sqs_delivery, SqsDelivery

delivery_method として追加し、呼び出せるようにしています。そのため以下のようにRailsのAction Mailerで定義した関数から deliver メソッドを呼び出すだけでSQS経由のメール送信が可能となります。

UserMailer.with(user: @user).test_email.deliver

AWS Lambda側のSESへの送信

Railsの方が実装できたらそれを受け取ってSESに流すLambda関数を実装していきます。SQSで作成したキューを今回作成するLambdaのトリガーとして設定する必要やIAMの設定をする必要がありますが、その細かい説明は省略します。
Lambda関数については、Pythonで記述しています。以下が実際のコードです。

import json
import boto3

client = boto3.client('ses', region_name='ap-northeast-1')

def lambda_handler(event, context):
    for record in event['Records']:
        body = json.loads(record['body'])
  
        response = client.send_email(
        Destination={
            'ToAddresses': body['to']
        },
        Message={
            'Body': {
                'Text': {
                    'Charset': 'UTF-8',
                    'Data': body['text_body']
                },
                'Html': {
                    'Charset': 'UTF-8',
                    'Data': body['html_body']
                }
            },
            'Subject': {
                'Charset': 'UTF-8',
                'Data': body['subject'],
            },
        },
        Source=body['from']
        )

    return {
        'statusCode': 200,
        'body': json.dumps("Email Sent Successfully. MessageId is: " + response['MessageId'])
    }

このようにシンプルなものになります。SQSから受け取ったeventのRecordsの中にRailsからmessage_bodyで渡した値が配列で渡るので、この配列の中身をループして取り出して、boto3のses clientに渡してあげるだけです。

まとめ

大量のメール送信を行う際のAWS SESの送信レートの制限による遅延をなくすため、SQSとLambdaを組み合わせて、パフォーマンスとスケーラビリティを向上させることができます。さらにRailsの場合、カスタムdelivery_method作成することで、コード上も影響範囲も最小限に抑えつつシンプルに移行し、システム全体のレスポンス向上ができました。

Discussion