SendGrid利用時のメモリ消費問題を改修した話:メール送信におけるパフォーマンス最適化の事例

まえがき

はじめてのかたははじめましてhaya_timeです。

ブログを書く機会が全然なく、大昔に何回か書いたっきりだったので、ニックネームも変わりました。

新しいニックネームの由来は小学校で履修してるはずなので省略しますが、そもそも正解が分からないと思うので悶々としたままブログを読んで頂ければと思いますww

さて、むかーしに書いたSendGridの記事の続きっぽいことを最近実務で行ったので、せっかくなのでブログにしておこうと思います。

内容としては、現在の運用しているシステムでSendGridを使ってメールを送信しているのですが、宛先1件1件ごとに処理を実施していたため、 件数が多くなるとメモリが肥大化しOut Of Memory(OOM)になってしまったので、 今回はその原因を解決するための改修した内容をまとめました。

ざっくりまとめると、今までは1件1件Promiseオブジェクトを作成し1000件ごとに実行を、Promiseオブジェクトを作成する過程でメモリが枯渇しする問題が発生したため、 その対応としてメールアドレスを「personalizations」配列に追加して送信するように改修しました。

techblog.cartaholdings.co.jp

SendGridとは?

おさらいとして、まずはSendGridとは?をもう一度説明していきたいと思います。

SendGridとはメール配信サービスです。

クラウドサービスなので登録すればすぐに使えてライブラリも豊富なので実装しやすくお値段も結構お手軽な感じかなと思います。

また、公式ブログもあり導入手順など色々な情報が上がっているので便利なので、システムのメールサービスとして使用させてもらっています。

興味のある方はぜひHPのブログも参照してみて下さい。

sendgrid.kke.co.jp

動作環境

SendGridを使っているサービスの運用環境は下記の通りです。

  • サーバ:AWS EC2
    • nodejs(v18.x)
    • SendGridライブラリ:Mail Service for the SendGrid v3 Web API(v7.7.x)

ソース改修前

実際のソースをそのまま転記出来ないので、一部処理を省いたり変更していますが、大体はこんな感じ実装していました。

ざっくり解説すると1件ずつ送信処理のPromiseオブジェクトを作成し、1000件ごとに処理を一括実行しています。

リリース当時は問題なく動作していたのですが、この処理だと例えばメール本文にBase64形式の画像が貼り付けられて容量が大きくなった場合、 1000件のオブジェクトを作成する過程でメモリが枯渇してしまいます。

■ループ処理
      // 送信を行う
      let emailAccounts = toAccounts.filter((x) => x.isEmailReceive)
      applogger.info({ msg: emailAccounts.length + '件送信' })
      let cnt = 0
      let promiseList = []
      for (let toObj of emailAccounts) {
        // 配列に処理を追加
        promiseList.push(this.sendEmail(message, toObj))
        cnt++
        if (cnt % 1000 === 0) {
          // 1000件ごとに送信処理を実行
          await Promise.allSettled(promiseList)
          applogger.info({ msg: cnt + '/' + emailAccounts.length + '件送信完了' })
          promiseList = []
        }
      }
      // 残りがあれば送信
      if (promiseList.length > 0) {
        await Promise.allSettled(promiseList)
        applogger.info({
          msg: cnt + '/' + emailAccounts.length + '件送信完了',
        })
      }

下記のソースは今回あまり変更対象になっていないのですが、処理の流れが分かりやすいかなぁと思い添付しました。

■メール送信呼出し元の処理
  /**
   * 宛先に対してメールを送信する
   * @param {Object} message メッセージデータ
   * @param {Object} toObj 宛先オブジェクト
   * @return {Promise}
   */
  sendEmail: function (message, toObj) {
    return new Promise((resolve, reject) => {
      try {
        applogger.info({ msg: 'メール送信処理 開始' })

        // メール内容を結合する
        let contentHtml = message.content + message.contentAttachmentURI + message.contentFooter

        // メール用のパラメータを設定
        const params = {
          to: toObj.email,
          cc: [],
          bcc: [],
          subject: message.subject,
          accountId: toObj.accountId ? toObj.accountId : '',
          content: contentHtml,
        }

        // メール送信
        htmlMail.sendCompileHtmlMail(
          commonConst.MAIL.NFO_MAIL,
          params,
          (err) => {
            if (err) {
              reject(
                commonUtil.createErrorObject(
                  commonConst.HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR,
                  commonMessage.ERR_0010
                )
              )
            } else {
              resolve()
            }
          }
        )
      } catch (err) {
        reject(err)
      } finally {
        applogger.info({ msg: '送信処理 終了' })
      }
    })
  },
■メール送信処理のメイン

      // メールの送信先リスト作成
      let personalizations = []
      for (let toObj of emailAccounts) {
        personalizations.push({
          to: toObj.email,
          custom_args: {
            accountId: toObj.accountId ? toObj.accountId : '',
          },
        })
      }

      let request = {
        personalizations: personalizations,
        from: config.get('mailConfig.mediaInfoFrom'),
        reply_to: mediaInfoReply_to,
        subject: _s.unescapeHTML(subjectCompiler(data)),
        content: [
          {
            type: 'text/html',
            value: htmlCompiler(data),
          },
        ]
        attachments: inlineAttachments,
      }

改修結果

改修方法として、1000件分のメールアドレスが入った「personalizations」配列と本文を入れたPromiseオブジェクトを作成するようにしました。

ちなみにSendGridの仕様として一度に設定出来る件数は1000件なので、1000件ごとにPromiseオブジェクトを作成するループ処理はそのままとなっています。それでも今まで数千件オブジェクト作成していたのが、数件になるんでメモリに余裕が出来ますね。

同じように1000件溜まったらPromiseオブジェクトを実行というようにしています。

こんな感じでソースを修正しました。

■ループ処理
      // メール送信
      applogger.info({ msg: emailAccounts.length + '件送信開始' })
      let cnt = 0
      let emailList = []
      let promiseList = []
      for (let toObj of emailAccounts) {
        emailList.push(toObj)
        cnt++
        if (cnt % 1000 === 0) {
          // 1000件ごとに送信処理を作成する(SendGridのMultipleの上限が1000件のため)
          promiseList.push(sendEmail(message, emailList))
          // await sendEmail(message, emailList)
          applogger.info({
            msg: cnt + '/' + emailAccounts.length + '件処理完了',
          })
          emailList = []
        }
      }
      // 残りがあれば送信処理追加
      if (emailList.length > 0) {
        promiseList.push(sendEmail(message, emailList))
        applogger.info({
          msg: cnt + '/' + emailAccounts.length + '件処理完了',
        })
      }

      // 送信処理実施
      await Promise.allSettled(promiseList)
      applogger.info({
        msg: cnt + '/' + emailAccounts.length + '件送信完了',
      })

呼出し元を修正したのでメール送信処理のメイン部分も少し変更しています。

■メール送信処理のメイン
      // メールの送信先リスト作成
      let personalizations = []
      for (let toObj of emailAccounts) {
        personalizations.push({
          to: toObj.email,
          custom_args: {
            accountId: toObj.accountId ? toObj.accountId : '',
          },
        })
      }

      let request = {
        personalizations: personalizations,
        from: config.get('mailConfig.mediaInfoFrom'),
        reply_to: mediaInfoReply_to,
        subject: _s.unescapeHTML(subjectCompiler(data)),
        content: [
          {
            type: 'text/html',
            value: htmlCompiler(data),
          },
        ]
        attachments: inlineAttachments,
      }

動作確認

動作確認してみました。

本文が422200文字あるメールを4252件送信したときのログです。

これもログをそのまま載せれないので省略していますが、無事1000件ごとにメール処理を実行されメモリが枯渇することなく全件送信出来ました。

リリース後も本文の容量が大きいメールがいくつか送信されていますが、とくにOOMが発生することなく運用しています。

2023-11-01T17:04:41.509 メッセージ送信 メッセージ件名: xxxxxxxx メッセージ本文の文字数: 422200
2023-11-01T17:04:41.727 4252件送信開始
2023-11-01T17:04:41.728 情報発信メール送信処理 開始
2023-11-01T17:04:41.801 1000/4252件処理完了
2023-11-01T17:04:41.801 情報発信メール送信処理 開始
2023-11-01T17:04:41.872 2000/4252件処理完了
2023-11-01T17:04:41.872 情報発信メール送信処理 開始
2023-11-01T17:04:41.914 3000/4252件処理完了
2023-11-01T17:04:41.914 情報発信メール送信処理 開始
2023-11-01T17:04:41.954 4000/4252件処理完了
2023-11-01T17:04:41.954 情報発信メール送信処理 開始
2023-11-01T17:04:41.975 4252/4252件処理完了

まとめ

今回はOOMが発生していた問題を改修する内容を書きました。 事前に実際に送信される件数とか確認して、テストも実施してるのですが・・・やはり本番稼働すると色々と問題って発生しますね。。。

前のブログに書いているのですが、このシステムはメールの開封数や、本文内のリンクのクリック数などを計測しています。 開封率は2~3割と低いといえば低いのですが、ダイレクトメールとしては一般的な開封率らしいですね。

まずまずの開封率で、結構見てくれている人はいるんだなぁーって思いながら今回の改修で安定してメールを提供出来るようになってホッと一息つけたかなと思います。

またブログのネタが出来たら書いていきたいと思いますので、それまで皆様お達者で!!

参考文献

sendgrid.kke.co.jp https://www.npmjs.com/package/@sendgrid/mail