見出し画像

SQSからLambdaでSESメール配信する構成をserverless-liftでやってみた

はじめに

こんにちは、SHIFTの開発部門に所属している Katayama です。

今回は、前回の記事(「serverless の Lambda 開発環境として serverless-webpack でトランスパイル、ESLint、エイリアス利用を設定してみた」)の続きで、SQS → Lambda → SES という構成でメール送信を行うサービスを serverless アプリケーションとして開発する、というのをやってみる。

SQS→Lambda の部分はDeploying SQS queuesに書かれている通り(以下の引用を参照)、以下の 2 パターンあるが今回は ② のパターンでやってみようと思う。あとで見ていくが、② の場合は CloudFormation のテンプレートの書き方を把握していなくても簡単に SQS→Lambda のイベント駆動が開発できるのが特徴になる(Why Lift?なども参照)。

① 自身で resources.Resources に CloudFormation のテンプレートを記載する
serverless-liftを利用した 2 パターン

To create an SQS queue in serverless.yml, you can either write custom CloudFormation, or you can use Lift.(serverless.yml で SQS キューを作るには、CloudFormation をカスタムで書くか、Lift を使うかのどちらかです。)

serverless-liftとは、AWS CDKを活用した Serverless Framework を Lambda 関数以外にも拡張するプラグインで、以下のような4つの特徴があると公式に記述がある。

①開発者向け - AWSの知識が不要
②プロダクション対応 - AWSのエキスパートによって構築され、プロダクション用にデフォルトで最適化されている
③侵略的でない - 既存のプロジェクトと統合可能
④ロックインがない - いつでもCloudFormationにイジェクト(移行)可能

※今回、AWS SES の構築は既に完了している前提で serverless アプリケーションを構築していく。SES はドメインやメールアドレスの検証(verify)手順があり、かつサンドボックスモードから本番モードへの変更でも手運用せざるえない部分があるので、あえてサーバレスなどで自動作成するメリットも薄いというのがその理由(Amazon SES の ID の作成と検証などを参照)。

※SESと同様にSNSでもメール配信できるが、SNSはどちらかというと通知という側面が強いため、今回は純粋に不特定な人へのメール送信用のサービスというイメージでSQS→Lambda→SESという serverless アプリケーションを開発をやってみた。

SESのセットアップ

今回は検証目的なので、E メールアドレス ID の作成にある方法で SES の identity を作成していく。

まずは SES のページにアクセスして、Create identity から identity を作成する。

identity を作成しただけは有効にならない。AWS から送られてくる以下のようなメールのリンクを踏み、verify を行う事で有効化される。

以上で AWS SES 側の設定は完了になる(Lambda で SES の SDK を利用してメール送信を行う際に SES の ARN を指定するのでそれはメモしておく)。

※サンドボックスモードの場合、メール送信は verify 済みの identity のみ可能なので受信もとになるメールアドレスも上記の手順で登録しておく(Amazon SES サンドボックス外への移動などを参照)。

serverless アプリケーションの開発

SQS のキューの設定

設定として必要な項目は多くなく、以下のように serverless.yaml を記述するだけでいい。

# serverless.yaml
service: aws-node-serverless-project

frameworkVersion: "3"

provider:
  stage: ${opt:stage, self:custom.defaultStage}
  name: aws
  region: ap-northeast-1
  runtime: nodejs16.x

plugins:
  - serverless-webpack
  - serverless-lift
  - serverless-offline

custom:
  defaultStage: local
  webpack:
    includeModules: true
    packager: "yarn"

constructs:
  sqs2ses-queue:
    type: queue
    worker:
      handler: src/ses2ses.handler
      name: ses2ses-${sls:stage}
      timeout: 5
    alarm: ${param:fromEmailAddress}
    extensions:
      queue:
        Properties:
          MaximumMessageSize: 1024

provider や custom については基本的な serverless の設定になるので、ここでは詳細を省略する(serverless-webpack に関しての設定はserverless の Lambda 開発環境として serverless-webpack でトランスパイル、ESLint、エイリアス利用を設定してみたを参照)。

serverless-lift に関しての設定については以下の通り。

・worker(詳細はWorkerを参照)
SQS にキューが登録された後、イベント駆動で起動される Lambda 関数を設定する
serverless で設定できる Lambda の設定(AWS Lambda Functions)は全て設定可能(今回は Lambda の関数名と timeout の設定のみだが)

・alarm(詳細はAlarmを参照)
以下のようなフローでデッドレターキューにキューが入った場合にCloudWatchAlarmでメール通知ができるようになるが、そのメールを送信するアドレスを設定できる

・${param:fromEmailAddress}
Parametersに書かれている機能で、CLI からパラメータを指定できるようにしている(今回はリポジトリ管理したくないセキュアな情報を CLI から渡すために--param オプションを利用したが、セキュアな情報を serverless.yaml 上に展開したい場合にはReference Variables using the SSM Parameter Storeに書かれているように、SSM に登録している値を引っ張ってくるという方法もある)

・extensions(詳細はExtensionsを参照)
CloudFormation の設定項目(例えばAWS::SQS::Queueにある項目)を追加で設定できる項目で、今回は Queue のメッセージサイズの上限を 1024bytes (1 KiB)にしている。
今回はシンプルな設定という事で、SQS の設定としては上記にしているが、他にもConfiguration referenceにあるような追加設定ができる。また、基本的にProduction readyに書かれている通り、本番運用を想定した設定がデフォルトである程度させれているため、追加でカスタマイズせずともそのまま利用できる部分も多くあると思われる。
SQS の設定は上記で完了になる。続いて Lambda 関数の実装をしていく。

Lambda 関数の実装

serverless-webpack の設定を行っているので、Lambda 関数は ES Modules で実装できる(詳細はserverless の Lambda 開発環境として serverless-webpack でトランスパイル、ESLint、エイリアス利用を設定してみたを参照)。実装としては以下のようになる。

// ./src/sqs2ses.js
import { strict as assert } from "assert";
import { createLogger, format, transports } from "winston";
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";

const logger = createLogger({
  level: "info",
  format: format.combine(format.timestamp(), format.splat(), format.json()),
  transports: [new transports.Console()],
});

const sendmail = async (options = {}) => {
  assert.ok(options.sesClient, "options.sesClient must be required");
  assert.ok(options.message, "options.message must be required");
  assert.ok(options.sesIdentityArn, "options.sesIdentityArn must be required");

  const {
    sesClient,
    message: { body: jsonStringBody },
    sesIdentityArn,
  } = options;

  const body = JSON.parse(jsonStringBody);

  const { mailTo, bodtText, subject, mailFrom } = body;
  assert.ok(mailTo, "mailTo must be required");
  assert.ok(bodtText, "bodtText must be required");
  assert.ok(subject, "subject must be required");
  assert.ok(mailFrom, "mailFrom must be required");

  const params = {
    Destination: {
      ToAddresses: [mailTo],
    },
    Content: {
      Simple: {
        Body: {
          Text: {
            Charset: "UTF-8",
            Data: bodtText,
          },
        },
        Subject: {
          Charset: "UTF-8",
          Data: subject,
        },
      },
    },
    FromEmailAddress: mailFrom,
    FromEmailAddressIdentityArn: sesIdentityArn,
  };

  const command = new SendEmailCommand(params);
  await sesClient.send(command);

  logger.info({ message: "mailsend success", mailTo }); // 本来メアドはハッシュ化等で匿名化すべき
};

// eslint-disable-next-line import/prefer-default-export
export const handler = async (event) => {
  assert.ok(
    event && event.Records && !event.Records.length,
    "event and event.Records must be required"
  );

  const {
    STAGE: stage,
    REGION: region,
    SES_IDENTITY_ARN: sesIdentityArn,
  } = process.env;
  assert.ok(stage, "stage must be required");
  assert.ok(region, "region must be required");
  assert.ok(sesIdentityArn, "sesIdentityArn must be required");

  const sesClient = new SESv2Client({ region });

  try {
    await Promise.all(
      event.Records.map(async (message) => {
        try {
          await sendmail({ sesClient, message, sesIdentityArn });
        } catch (e) {
          logger.error({ message: e.message, stack: e.stack });
        }
      })
    );
  } catch (e) {
    logger.error({ message: e.message, stack: e.stack });
  }
};

上記の実装に関して一部補足をする。
・event.Records
 Lambda が受けとるイベントオブジェクトから Records を取り出しているが、このイベントオブジェクトにどのようなキーが含まれているか?はAmazon SQS での Lambda の使用などが参考になる

・new SendEmailCommand(params)
 SES の v2 Client のコマンドを生成する際に渡す params はAPI v2 Reference   SendEmailが参考になる(今回はメール本文は単なるテキストメールだが、HTML 形式でメール本文を送信する事もできる)

Lambda の実装は上記の通りだが、Lambda 内で利用する環境変数や Lambda の権限の設定が必要になるので、serverless.yaml 側も以下のように変更する(省略部分は「SQS のキューの設定」の章で設定したものと同じ)。

# serverless.yaml
# 省略

provider:
  # 省略
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - ses:SendEmail
            - ses:SendRawEmail
          Resource:
            - !Sub arn:aws:ses:ap-northeast-1:${AWS::AccountId}:identity/${param:fromEmailAddress}
            - !Sub arn:aws:ses:ap-northeast-1:${AWS::AccountId}:identity/${param:toEmailAddress}

plugins:
  # 省略

custom:
  # 省略

constructs:
  sqs2ses-queue:
    type: queue
    worker:
      # 省略
      environment:
        STAGE: ${self:provider.stage}
        REGION: ${self:provider.region}
        SES_IDENTITY_ARN: !Sub arn:aws:ses:ap-northeast-1:${AWS::AccountId}:identity/${param:fromEmailAddress}
    # 省略

追加で環境変数と、Lambda の権限(Permissions)を設定している。権限の方は SES でメール送信を行うのに必要な IAM を設定している。1 点注意として、SES がサンドボックスモードの場合、メールの受信先になるメールアドレスも Identity として登録しておく必要があり、Lambda もそのメール受信先の Identity に対する IAM を持つ必要がある。
以上で全ての開発は完了になる。続いてこれを実際に Deploy し、メールが送信されるか?を検証してみる。

実際に Deploy して検証する

上記の設定で serverless のアプリケーションを構築する準備は整ったので、実際に Deploy して SQS にメッセージを送信後、Lambda が起動しメール送信されるか?を確認してみる。
まず、以下のように Deploy を行う(--param はParametersに書かれている、自分で任意のパラメータを渡せる機能)。

study@localhost:~/workspace/learn-serverless (main *)
$ yarn deploy --param=fromEmailAddress=********@outlook.jp --param=toEmailAddress=********@gmail.com
yarn run v1.22.19
$ sls deploy --stage production --param=fromEmailAddress=********@outlook.jp --param=toEmailAddress=********@gmail.com

Deploying aws-node-serverless-project to stage production (ap-northeast-1)
Package lock found - Using locked versions
Packing external modules: @aws-sdk/client-sesv2@^3.213.0, nanoid@^3.3.4, winston@^3.8.2

✔ Service deployed to stack aws-node-serverless-project-production (256s)

functions:
  hello: hello-lambda-production (3.4 MB)
  sqs2ses-queueWorker: ses2ses-production (3.4 MB)
sqs2ses-queue: https://sqs.ap-northeast-1.amazonaws.com/************/aws-node-serverless-project-production-sqs2ses-queue

Need a better logging experience than CloudWatch? Try our Dev Mode in console: run "serverless --console"
Done in 261.14s.

Deploy が完了した後は、実際に SQS にメッセージを送ってみる。これを AWS CLI(send-message)で行うと、以下のように SQS にメッセージが送信でき、Lambda のログに success が出力され、メールが送信できている事が確認できる。

study@localhost:~/workspace/learn-serverless (main *)
$ yarn aws.sqs.send-message
yarn run v1.22.19
$ aws sqs send-message --queue-url https://sqs.ap-northeast-1.amazonaws.com/**********/aws-node-serverless-project-production-sqs2ses-queue --message-body '{"mailTo": "********@gmail.com", "bodtText": "test", "subject": "テスト送信", "mailFrom": "********@outlook.jp" }'
{
    "MD5OfMessageBody": "a06c00c2504a74abc2a3894c25f7be15",
    "MessageId": "42b198b8-f9b5-4031-a818-6d366bc6257b"
}
Done in 2.10s.

まとめとして

今回は serverless-lift を利用した、SQS→Lambda→SES でメール送信を行う serverless アプリケーションの構築をやってみた。
serverless-lift を利用する事で SQS→Lambda の部分の開発で CloudFormation の設定をほとんど意識することなく構築できたと思う。そのため、素早く serverless アプリケーションの開発をする上では serverless-lift を利用するのは 1 つの選択肢になると感じた。

ただ、serverless-lift では Deploy して初めて SQS→Lambda→SES の疎通確認ができるようになるというデメリットもあった。
AWS 上に Deploy してからでないと確認できないのは開発者体験としてあまり良くなく、また AWS の設定・アカウント発行なども必要になりインフラとの調整も入り、開発を素早く進めていくにはローカルでSQS→Lambda→SES を動かせるとよいだろう。

そこで次回は serverless-offline を利用して、SQS→Lambda→SES の開発をローカルで疎通確認の検証を行ってから Deploy する、というのをやってみたいと思う。

おまけ

alarm の設定に関して

上記ではサーバレスで CloudWatchAlarm の設定もしていたが、以下のように SNS をサブスクライブするか?のメールが飛んでくるので、サーバレス管轄ではない方がいいかもしれない(手動での作業が必要になるので)

ちなみに、実際に Alarm が実際に送信されると以下のようなメールが届く。

同一 serverless に複数の Lambda 関数がある場合の IAM Permissions について

1 つの serverless で複数の Lambda 関数を管理していて、かつそれぞれの権限が違う場合には、provider.iam の設定はできなくなる(provider.iam は全ての Lambda 関数に共通で適用される IAM なので)。

その場合にはCustom IAM Rolesを利用して、各 Lambda 関数に適用される権限を変える事ができるが、公式に以下のように書かれている通り、カスタムの IAM ロールを作成して設定する場合、サーバレスフレームワーク側で自動で付与していた CloudWatchLogs などの権限が Lambda に自動で付与されなくなるので、全部の権限(IAM ロール)を手動で設定してあげる必要がある。

WARNING: You need to take care of the overall role setup as soon as you define custom roles.(警告:カスタムロールを定義したら、すぐに全体のロールセットアップを行う必要があります。) That means that iam.statements you've defined on the provider level won't be applied anymore. Furthermore, you need to provide the corresponding permissions for your Lambdas logs and stream events.(つまり、プロバイダレベルで定義した iam.statement はもう適用されないということです。さらに、Lambdas のログやストリームイベントに対して、対応するパーミッションを与える必要があります。)

例えば、今回開発していた SQS→Lambda→SES では、Lambda 関数が 1 つしかなかったのでカスタムの IAM ロールの設定はしなかったが、仮にカスタムの IAM ロールで設定する場合、以下のようになるだろう。

resources:
  Resources:
    sqs2lambda2sesRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: sqs2lambda2sesRole-${sls:stage}
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        ManagedPolicyArns:
          - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        Policies:
          - PolicyName: sqs2lambda2sesRolePolicy-${sls:stage}
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - sqs:ReceiveMessage
                    - sqs:DeleteMessage
                    - sqs:GetQueueAttributes
                    - sqs:SendMessage
                  Resource:
                    - !Sub arn:aws:sqs:ap-northeast-1:${AWS::AccountId}:${construct:sqs2ses-queue.queueArn}
                - Effect: Allow
                  Action:
                    - ses:SendEmail
                    - ses:SendRawEmail
                  Resource:
                    - !Sub arn:aws:ses:ap-northeast-1:${AWS::AccountId}:identity/${param:fromEmailAddress}
                    - !Sub arn:aws:ses:ap-northeast-1:${AWS::AccountId}:identity/${param:toEmailAddress}

constructs:
  sqs2ses-queue:
    type: queue
    worker:
      # 省略
      role: sqs2lambda2sesRole
      environment:
        # 省略
    # 省略

※${construct:sqs2ses-queue.queueArn}の部分は、Variablesに書かれているものを利用した書き方

《この公式ブロガーの記事一覧》


\明日の記事もお楽しみに!/


執筆者プロフィール:Katayama Yuta
認証認可(SHIFTアカウント)や課金決済のプラットフォーム開発に従事。リードエンジニア。
経歴としては、SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。その後現在のプラットフォーム開発に参画。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/