こんにちは, プロダクト開発部の今村です. ここ一年ほど, 主にグノシーのプッシュ通知基盤の部分的なリプレイスや機能追加をしていました. この記事ではプッシュ基盤の構成を紹介したいと思います.
概要
まずはプッシュ通知の種類を整理します. 今回扱うのは, 多数のユーザーに同じ内容を送るような通知です. 重要なニュースが発生したときに送る速報や, キャンペーン情報の通知などが該当します. 対照的に, ユーザーごとに異なる内容を送る通知もあります. 例えば社内で定時プッシュと呼ばれている機能では, ユーザーごとにパーソナライズされた記事を毎日決まった時間に送ります. このような通知はこの記事では (ほぼ) 扱いません.
プッシュ通知基盤に求められる機能としては, 内容と対象ユーザーを指定して通知を送れること, 社内管理画面など他のプログラムから呼び出して使えることが挙げられます. 性能面では柔軟なスケーリングが必要になります. これは, 通知を送っていない時は計算リソースを使わない一方で, 送信時には (対象デバイス数にして) 数百万, 一千万といった数のデータを扱う必要があるからです. また, 単に多数のデバイスを捌くだけではなく, 速報を送る際には文字通り速報性, つまり立ち上がりの早さも必要になります.
以上を踏まえて, グノシーのプッシュ通知基盤は以下のような構成になっています.
細かい部分は以下で順を追って説明していきますが, 全体としては,
- 計算リソースは主にLambdaを使い, SQSと組み合わせて並列化する
- RDBやS3から送信先デバイスのトークンを取得し, FCM (Firebase Cloud Messaging) 経由で通知を送る
という構成になっています. 以前は運用担当者が手動でEC2インスタンスを増減させる構成になっていたのですが, 起動に時間がかかって送信が遅くなる, 起動/停止を忘れる, といった問題が起こっていました. そこで, 柔軟かつ自動的なスケーリングのためにサーバーレスな環境を選択肢とし, ECS/EKS on Fargateに比べて起動時間で優れるLambdaを採用しました. 一方でLambdaだと性能や実行時間の制限があるので, SQSと合わせて並列化することでカバーしています.
FCMのAPIを呼び出す部分
ユーザーに通知を送る部分ではFCMを使っています. グノシーでは基本的にAWSを使っており, この箇所も以前はAmazon SNSを使っていたのですが, 送信APIのスロットリングに悩まされた結果FCMに乗り換えたという経緯があります.
FCMは通知内容 (社内用語ではペイロード) と送信先デバイスのトークンを指定して通知を送るためのAPIを提供しており, 500デバイス分までバッチ化して呼び出すことができるので, 必要なデータを500件単位で上流からSQSに積み, Lambdaで処理しています.
上流となるのは主にこの記事で紹介する (速報等を送るための) Lambdaですが, 定時プッシュなど, 他の種類のプッシュを送るプログラムも末端ではこの仕組みを共用しています. 外部APIとのインターフェイスやロギングなどを共通化できるというメリットがあります.
また, FCMには事前に作成したユーザーグループ向けに通知を一斉送信するトピック機能があり, スループットの観点では効率的なのですが, グノシーでは現状使っていません. 主な理由として
- 社内のA/Bテスト環境と紐づけるなどの柔軟な送信対象の選択がしづらい
- ユーザーごとに別の通知を送るユースケースに対応できない
- 送信速度が早すぎるとバックエンドのサーバーのスケーリングが間に合わなくなり, そこの改善策を合わせて考える必要がある
というものがあります.
サーバーのスケーリング
ニュースアプリであるグノシーでは, 重要なニュースを速報として通知したタイミングでリクエスト数が急増します. この時のトラフィックに耐えるには通常のオートスケーリングでは間に合わないので, プッシュ通知基盤からバックエンドサーバーにスケーリング用の通知を送っています. 詳細なスケーリング方法については, 過去にテックブログで紹介されているのでそちらを参考にしてください.
送信対象の読み込み
FCMを呼び出す箇所では送信先のデバイストークンを指定していました. デバイストークンはユーザー登録のタイミングでRDBに保存されますが, パフォーマンスや柔軟な対象選択のためにデータ基盤を使うようにしています.
データ基盤にはRDBのスナップショットやユーザーの行動ログなどが入っています. 送信対象を選ぶためのクエリをSQLで書くことで, 全ユーザー向けの通知, 特定のA/Bテストの対象ユーザー向けの通知, 特定のキャンペーンバナーをクリックしたユーザー向けの通知など, 色々なユーザーグループに向けて通知を送ることができます.
また, 送信対象の読み込みは (データ基盤であっても) 時間がかかる処理なので, Lambdaから直接読み込むのではなく, 別途ECSタスクを用意してデバイストークンをS3にロードするようにしています. これによって通知の送信と送信対象の読み込みを別のタイミングで実行することができます. 速報の場合は定時処理で事前に読み込みを済ませておき高速に送信する, キャンペーン通知などアドホックかつ早さが要らない場合は送信のタイミングで読み込む, といった使い分けをしています.
送信の流れ
以上を踏まえて, 通知が送られる流れを見てみます.
管理画面など外部のプログラムから, 通知基盤のエントリーポイントとなるLambda (一番左) を呼び出します. 送信対象ファイルの更新が必要であれば, 1つ目のLambdaから更新用のECSタスクを呼び出し, StepFunctionsでラップされた2つめのLambdaで更新完了まで待機します. 送信対象ファイルが事前に更新されている場合はここは素通りするだけです.
左から2番目のLambdaはS3上のファイル一覧を取得し, 1ファイル1メッセージでSQSに積みます. そこから呼び出された3番目のLambdaがファイルの中身を取得し, 500件ごとにチャンク化してSQSに積むことで通知を送ります*1.
その他の工夫
重複配信の防止
この通知基盤にはLambdaの非同期呼び出し, SQSの通常キューなど, 実行がat least onceな要素が多く含まれています. そのままだと同じ処理が複数回行われる可能性がありますが, 同じ内容の通知が何度も届くのはユーザーにとって不快なので, 重複配信をしないように気を付ける必要があります.
対処として, SQSの可視性タイムアウトを適切に設定する*2ほか, FCMなど外部リソースを呼び出すLambdaは呼び出しが冪等になるようにしています. 具体的には, ペイロードや対象グループのIDから生成した一意なキー使い, Dynamo DBテーブルに条件付き書き込みを行うことで, そのメッセージが処理済みがどうかを判定するようにしています.
パフォーマンス調整
送信所要時間をモニタリングしたところ, SQS→Lambda→FCMの部分が律速になっていることが分かりました. Lambdaの同時実行数制限は問題ないことを事前に確認していたのですが, SQSから呼び出す際にはさらにLambdaを呼び出すためのプロセスが60個/分しか増えないという制限があり, これによって関数の数が増えず, メッセージが捌ききれなくなっていたのです. SQSからLambdaを呼び出す際のバッチサイズ, つまり1つの関数が処理するメッセージ数を増やすことで対処しました.
おわりに
以上, グノシーのプッシュ通知基盤の構成を紹介しました.
サーバーレスがメインということもあり, 今のところ運用上の課題は生じていませんが, 送信速度にはまだまだ改善の余地があると思っています. 現状だと速報に数分程度の時間がかかっており, 以前と比べると早くなっているのですが, 速報がより早く届くに越したことはありません. パフォーマンスチューニングや部分的なFCMトピックの使用など, できそうなことは色々あるので, 今後もよりよい通知基盤を目指していきたいと思っています.
読んでいただきありがとうございました!
*1:チャンク化するLambdaからそのままFCMを呼び出してもいいのですが, 上述した他の種類のプッシュとの共通化のために一回SQSを挟んでいます
*2:6 * 関数タイムアウト + バッチウインドウより大きくするという推奨があるのでこれに合わせています