SendGrid を Google Cloud でラップして構築したメール配信システム

## はじめに

この記事は [株式会社ビットキー Advent Calendar 2023](https://qiita.com/advent-calendar/2023/bitkey) 1日目の記事です。

ビットキーはサービス開発において Google Cloud を活用しています。
また、メール配信を実現するために SendGrid を活用しています。

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

SendGrid を活用することでAPI経由でメールを送信することが可能なのですが、いくつか課題があったのでそれらを解決するためにシステムを構築した話を共有します。
本記事が同じような課題を抱えている方の参考になれば幸いです。

ビットキーには、私が所属するコアとなる機能をプラットフォームとして開発するチームとビジネスドメインに合わせた機能を開発するチーム（本記事では便宜上「システム利用チーム」と呼びます。）が存在します。
本記事はプラットフォームチームがメインとなって構築したシステムの共有となります。

## なぜ構築した？

今回紹介するシステムを構築するきっかけとなった課題として以下のようなものがありました。

- メールを配信したあとの結果はシステムとして追跡してなく SendGrid 内にて検索していた
- 代理店を経由して契約した開発チームではダッシュボードによる検索ができなかった
- メールが未到達となる原因および傾向が調査しづらかった

これらの課題を解決するために実装に至りました。
このシステムを構築することで SendGrid のAPIを各チームが直接叩くことから解放することができます。
そして、システム利用チームはメール配信という目的を達成するために抽象的な概念として扱えることが期待できます。

## どのように解決するか？

抽象的にメール配信サービスを利用してもらうために、インターフェースとして Cloud Pub/Sub を採用しました。
非同期のメッセージングサービスを利用することでシステム利用チームが結果取得のためにポーリングを設定・間隔の調整が不要となります。　
また、プラットフォームシステムとして利用を拡大させていくことを考慮するとスケーラビリティを担保しやすい非同期処理の方がメリットがあると考えました。

### システム構成図

省略している箇所もありますが、おおまかには以下のような構成となります。

![mail.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2961759/c74679f2-f5e8-cdf2-6037-52402a19b65b.png)


### Google Cloud の利用サービス

今回システムを構築するにあたり活用した Google Cloud のサービスはこちらになります。

#### Google Kubernetes Engine （GKE）

マネジメント Kubernetes サービスです。
本システムを組むためには [Cloud Run](https://cloud.google.com/run?hl=ja) などでも十分かと思いますが、本番クラスタがすでに稼働していたため間借りして利用しています。
メインとなるアプリケーション（コンテナイメージ）は Go 言語 にて実装しています。

https://cloud.google.com/kubernetes-engine?hl=ja

#### Cloud Pub/Sub

マネジメントメッセージングサービスです。
非同期処理を実現するために活用しています。

https://cloud.google.com/pubsub?hl=ja

#### Cloud Functions

サーバーレスなランタイム環境です。
システム利用チームが Node.js(TypeScript)にて実装しメインで活用しています。
また、本システムでも利用し Go 言語 にて実装しています。今回は SendGrid からの Webhook アクセス先としています。
（小回りが効くような実装をサーバーレスで実装するのはばっちりユースケースとして合っていると感じます。良い Cloud Functions の使い方ができたのではないかと満足感を持っています。）

https://cloud.google.com/functions?hl=ja

#### Memorystore

マネージドインメモリデータストアです。
本システムでは Redis にて Cloud Pub/Sub が払い出す ID と SendGrid が払い出す ID を管理するために活用しています。

https://cloud.google.com/memorystore?hl=ja

#### Firestore

マネジメントドキュメントデータベース（NoSQL）です。
システム利用チームがメール配信の結果を保存するために活用しています。

https://cloud.google.com/firestore?hl=ja

### SendGrid を用いた処理フロー

再喝になりますが、SendGrid はAPI経由でメール送信を実現するためのサービスです。

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

本システムで SendGrid を呼び出すために以下のライブラリを活用しています。

https://github.com/sendgrid/sendgrid-go

また、Webhook のエンドポイントとして利用している Cloud Functions は以下のドキュメントを参考にして保護しています。

https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features#verify-the-signature

今回構築したシステムにおけるメール配信のフローの全体的なフローは以下のようになります。

<details><summary>全体フロー</summary><div>

```mermaid
sequenceDiagram

participant cf1 as Cloud Functions
participant cf2 as Cloud Functions
participant fs as Firestore
participant ps1 as Cloud PubSub
participant ps2 as Cloud PubSub
participant gke as Google Kubernetes Engine
participant ms as Memorystore
participant ps3 as Cloud PubSub
participant cf as Cloud Funcsions
participant sg as SendGrid

cf1 ->> ps1: Publish Request to Send Email Message
Note right of cf1: contents
ps1 -->> cf1: OK
Note left of ps1: trace_id
cf1 ->> fs: Add
Note right of cf1: trace_id etc...
fs -->> cf1: OK
gke -) ps1: Pull Subscription
ps1 -->> gke: Request to Send Email Message
Note right of ps1: trace_id, contents
gke ->> sg: POST
Note right of gke: contents
sg -->> gke: OK
Note left of sg: id
gke ->> ms: Set
Note right of gke: trace_id, id
ms -->> gke: OK
gke ->> ps1: Ack
sg ->> cf: Webhook
Note left of sg: id, result
cf -->> sg: OK
cf ->> ps3: Publish Webhook Message
Note left of cf: id, result
ps3 -->> cf: OK
gke -) ps3: Pull Subscription
ps3 -->> gke: Webhook Message
gke ->> ms: Get
Note right of gke: id
ms -->> gke: OK
Note left of ms: trace_id
gke ->> ps2: Publish Notification of Email Sending Result Message
Note left of gke: trace_id, result 
ps2 -->> gke: OK
gke ->> ps3: Ack
ps2 ->> cf2: Push Notification of Email Sending Result Message
cf2 ->> fs: Update
fs -->> cf2: OK
cf2 -->> ps2: OK
```

</div></details>

配信完了までには大きく分けて４つのフェイズがあります。

1. メール送信依頼
2. メール送信
3. メール送信結果受信
4. メール送信結果通知

それぞれ解説します。

#### 1. メール送信依頼

1. システム利用側でメール宛先や文言などを生成し Cloud Pub/Sub へとパブリッシュ
2. パブリッシュに成功したらトレースIDを Firestore に保存

```mermaid
sequenceDiagram

participant cf1 as Cloud Functions
participant fs as Firestore
participant ps1 as Cloud PubSub

cf1 ->> ps1: Publish Request to Send Email Message
Note right of cf1: contents
ps1 ->> cf1: OK
Note left of ps1: trace_id
cf1 ->> fs: Add
Note right of cf1: trace_id etc...
fs -->> cf1: OK
```

#### 2. メール送信

1. GKE にてサブスクライブして配信リクエストメッセージを取得
2. SendGrid にメールを送信
3. Memorystore に SendGrid のID：id をキーとして Cloud Pub/Sub のID：trace_id をペアで保存
4. メッセージをアック

```mermaid
sequenceDiagram

participant ps1 as Cloud PubSub
participant gke as Google Kubernetes Engine
participant ms as Memorystore
participant sg as SendGrid

gke ->> ps1: Pull Subscription
ps1 -->> gke: Request to Send Email Message
Note right of ps1: trace_id, contents
gke ->> sg: POST
Note right of gke: contents
sg -->> gke: OK
Note left of sg: id
gke ->> ms: Set
Note right of gke: trace_id, id
ms -->> gke: OK
gke ->> ps1: Ack
```

#### 3. メール送信結果受信

1. SendGrid の Webhook にて Cloud Functions を実行
2. SendGrid が払い出したIDと結果が取得できるので Cloud Pub/Sub にパブリッシュ
3. GKE にてサブスクライブして配信結果メッセージを取得
4. 配信結果メッセージをIDにてトレースIDを Memorystore から取得
5. 配信結果とトレースIDを Cloud Pub/Sub へとパブリッシュ

```mermaid
sequenceDiagram

participant ps2 as Cloud PubSub
participant gke as Google Kubernetes Engine
participant ms as Memorystore
participant ps3 as Cloud PubSub
participant cf as Cloud Funcsions
participant sg as SendGrid

sg ->> cf: Webhook
Note left of sg: id, result
cf -->> sg: OK
cf ->> ps3: Publish Webhook Message
Note left of cf: id, result
ps3 -->> cf: OK
gke ->> ps3: Pull Subscription
ps3 -->> gke: Webhook Message
gke ->> ms: Get
Note right of gke: id
ms -->> gke: OK
Note left of ms: trace_id
gke ->> ps2: Publish Notification of Email Sending Result Message
Note left of gke: trace_id, result 
ps2 -->> gke: OK
gke ->> ps3: Ack
```

#### 4. メール送信結果通知

1. プッシュサブスクライブにて Cloud Functions を起動
2. 取得した配信結果メッセージのトレースIDをもとに Firestore を更新

```mermaid
sequenceDiagram

participant cf2 as Cloud Functions
participant fs as Firestore
participant ps1 as Cloud PubSub
participant ps2 as Cloud PubSub

ps2 ->> cf2: Notification of Email Sending Result Message
cf2 ->> fs: Update
fs -->> cf2: OK
cf2 -->> ps2: OK
```

### 実装におけるポイント紹介

#### 結果受信用の Cloud Pub/Sub トピックはシステム利用チームの数だけ用意

結果受信用の Cloud Pub/Sub はシステム利用チームごとにトピックを用意する設計にしています。
最初の設計では Cloud Pub/Sub　の1トピックに対して 複数チームのシステムをサブスクライバーとして設定する想定でした。
しかし、メッセージの取り違いによって他チームのメッセージを勝手にアックしてしまうなどのリスクが考えられました。
システム利用チームごとにトピックを設定しますが、各チームでのメッセージ処理の実装は「自分が送ったメール配信の結果であれば適切に処理を実施しアックする」「関係ないメッセージであればなにもせずアックする」という実装にしてもらっています。

#### メッセージの型として OpenAPI を利用

Cloud Pub/Sub のメッセージの型には OpenAPI を用いて yaml で定義しています。

https://www.openapis.org/

システム利用チームの開発者とはこちらを用いてコミュニケーションを図っていました。
Go 言語の実装では以下のライブラリを活用して型だけ自動生成しています。

https://github.com/deepmap/oapi-codegen

#### アプリケーションのデプロイについて

Google Kubernetes へのデプロイは ArgoCD を用いて GitOps にて管理しています。
SendGrid からの Webhook に対応する Cloud Functions は gcloud コマンドにてデプロイしています。
デプロイ頻度は多くない想定であるので CIOps などによる自動化などは着手していません。

#### 環境

環境としては以下の4つを準備しています。

- ローカル環境
- 開発環境
- ステージング環境
- プロダクション環境

ローカル環境と開発環境では MailHog という開発用のメールサーバーを用意しています。

https://github.com/mailhog/MailHog

ステージング環境では開発用の SendGrid を利用して実際にメールを飛ばして確認しています。
（本番環境は言わずもがなです。）

また、ローカル環境においては Cloud Pub/Sub のエミュレータを活用しています。

https://cloud.google.com/pubsub/docs/emulator?hl=ja

#### テスト

本システムは基本的に Go言語 にてサービス開発を行なっていますが、E2Eテストの構築はシステム利用チームに合わせて Jest で実装してみました。
結果的にはそこまで活用してもらう場面がなかったのでこれまで実装していた E2E テスト(Python) でよかったのではないかという反省があります。
ローカル環境では MailHog と Cloud Pub/Sub のエミュレータを起動させE2Eテストを実行します。
開発環境では Kubernetes 上に展開している MailHog へ接続して結果をフェッチしています。
MailHog を扱う場合にはメール送信結果をすぐにフェッチできなかったりするのでリトライなどを書いておかないとうまくテストが実行できなかったりするので注意が必要です。

## おわりに

プラットフォームとして SendGrid をより開発者が使いやすくするために Google Cloud にてラップしてシステムを構築しました。
現在、メール配信の分析に関してはシステム利用チームが管理する Firestore に保存されているデータを活用してもらっています。
各チームメール配信結果をどのように利用したいかはユースケースにより異なってくるとは思いますが、プラットフォームチームとしても送信したメールの結果をデータとして保存しておくのはありなのかなと思いました。

今後もプラットフォームチームとして開発者が利用しやすいシステムを構築するのは推し進めていきたいです。

2日目の [株式会社ビットキー Advent Calendar 2023](https://qiita.com/advent-calendar/2023/bitkey) は @moroball14 が担当します！