JMDC TECH BLOG

JMDCのエンジニアブログです

SendGrid の Event Webhook をサーバーレスアーキテクチャで構築した話

こんにちは。プロダクト開発部でバックエンドエンジニアをやっている野田です。

Ruby on Rails 製プロダクトの Pep Up でメールサービスを SendGrid へ移行するプロジェクトを行っていました。 メールサービス移行プロジェクトでは大きく分けて

  • Pep Up から配信するメールを SendGrid から送信できるようにする
  • SendGrid から送信したメールの送信数、開封数、クリック数を計測できるようにする

の2点があったのですが、今日は後者の話をしようと思います。前者も機会があれば話をしたいなと思います。

移行の背景

サービスリリース当初からは別のメールサービスを使っていました。サービスがスケールした段階で改めて他のメールサービスを見てみると、コストや安定性の面で SendGrid が有利になったので移行することに決定しました。

元々使っていたメールサービスではメールの送信率、開封率、クリック率を API で取得していたのですが、取得可能な期間が限られるなどの課題がありました。 SendGrid では送信、開封、クリックなどといったイベントごとに Event Webhook を JSON で飛ばすことができます。Pep Up では AWS を使っています。S3 に JSON を蓄積して Athena で送信数、開封数、クリック数を取得するアーキテクチャにするという方向性で進めていくこととなりました。

以降、 Event Webhook を S3 に蓄積するアーキテクチャに話を絞って進めていきます。

素朴な構成

まずは素朴な構成でやってみます。 Event Webhook を API Gateway で受けて Lambda で S3 へ保存するようにします。API Gateway は HTTP で作成しました。

Lambda Function のサンプルは以下になります。

const AWS = require("aws-sdk");
const s3Bucket = new AWS.S3({ params: { Bucket: process.env.S3_BUCKET } });
const uuid = require("uuid");

exports.handler = (event, _, callback) => {
  let webhookBody = "";
  JSON.parse(event.body).map((item) => {
    webhookBody = webhookBody + JSON.stringify(item) + "\n";
  });

  const filePath = `webhook-events/${uuid.v4()}.json.gz`;
  const data = {
    Key: filePath,
    Body: webhookBody,
  };
  s3Bucket.putObject(data, function (err, data) {
    // 処理
  });
};

簡単のためこちらの実装をまず紹介しましたが、このままでは API Gateway の endpoint を知ってるユーザーなら任意の文字列を POST し S3 へ保存できてしまいます。送信元が SendGrid の自社アカウントの Webhook であることであることを確認した上で S3 へ保存すべきです。 SendGrid では Signed Event Webhook という認証の機構が用意されています。こちらを使って認証を実装していきます。

認証を考えた実装

Signed Event Webhook Requests を有効にすると公開鍵が発行されます。また Event Webhook に X-Twilio-Email-Event-Webhook-Signature, X-Twilio-Email-Event-Webhook-Timestamp ヘッダーがつくようになります。それらと Event Webhook の body で認証を行います。 以下、認証部分のサンプルコードとなります。

//...
const EventWebhook = require("@sendgrid/eventwebhook").EventWebhook;
const EventWebhookHeader = require("@sendgrid/eventwebhook").EventWebhookHeader;
//...
const payload = event.body;
const headers = event.headers;
const signature = headers[EventWebhookHeader.SIGNATURE().toLowerCase()];
const timestamp = headers[EventWebhookHeader.TIMESTAMP().toLowerCase()];

const eventWebhook = new EventWebhook();
const encryptedPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
const verified = eventWebhook.verifySignature(
  encryptedPublicKey,
  payload,
  signature,
  timestamp
);
//...

新たな問題

以上のコードをリリースししばらく運用を続けていました。すると S3 へ保存するときや Athena でクエリするときにしばしば 503 Slow Down が発生しました。原因は prefix ごとのファイル数が多すぎるためです。

AWS では以下の推奨があります。

you can send 3,500 PUT/COPY/POST/DELETE or 5,500 GET/HEAD requests per second per prefix in an S3 bucket.

repost.aws

Event Webhook は送信、開封、クリックごとに POST を送ります。そのため POST 数が莫大になり推奨値を超えてしまいました。また POST に応じてファイルが作成されるので1 prefix 以下に配置されるファイル数が莫大になります。莫大な数のファイルをクエリすることで推奨値を超えてしまい 503 Slow Down が発生しているのでした。

Lambda から直接 S3 put するのではなく、Kinesis Firehose を間に挟みバッファリングして S3 put の回数を減らす・ファイルを大きくしてファイル数を減らすことで解決を図ります。

Kinesis Firehose へ put するコードのサンプルです。

//...
const firehose = new AWS.Firehose();
const firehoseParams = {
  DeliveryStreamName: process.env.FIREHOSE_STREAM_NAME,
  Record: {
    Data: webhookBody
  }
};
firehose.putRecord(firehoseParams, function (err, data) {
  // 処理
});
//...

このアーキテクチャへの変更と、詳細は省きますが Athena のクエリをチューニングしたことにより 503 Slow Downは発生しなくなりました。その後も Pep Up はスケールし続けていますが今のところ特に問題は発生していません。

おわりに

以上の対応は実際はわたし一人でやったわけではなく、メンターをさせてもらっていたエンジニアの助けを借り、Firehose の部分はバックエンド・インフラに強いエンジニアにほぼお任せの形となっていました。チームの協力のもと無事に完成できてよかったです。

Pep Up はローンチ後7年が過ぎスケールして数億件オーダーのデータを扱うようになりました。だからこそ発生する課題に取り組めるので技術的に面白いフェーズだと思います。これからも課題に取り組み続けどんどんよいサービスにしていこうと日々知恵を絞っています。パフォーマンスや Ruby on Rails の課題を解決したい方は是非一度お話ししましょう!

現在JMDCでは、PM/フロントエンド /バックエンド/ データエンジニア等、様々なポジションで募集をしています。詳細は下記の募集一覧からご確認ください。 hrmos.co Webサービス開発・運用に興味がある方はこちら 基幹・業務システム開発・運用に興味がある方はこちら

まずはカジュアルにJMDCメンバーと話してみたい/経験が活かせそうなポジションの話を聞いてみたい等ございましたら、下記よりエントリーいただけますと幸いです。

hrmos.co

★最新記事のお知らせはぜひTwitterをご覧ください!

twitter.com