読者です 読者をやめる 読者になる 読者になる

Mercari Engineering Blog

We're the engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

ハイパフォーマンスGaurun〜メルカリの大規模プッシュ配信を支えるミドルウェア〜

SREチームのcubicdaiyaです。

今回は本ブログでも何度か紹介しているGaurunを利用したメルカリのプッシュ配信基盤とGaurunのパフォーマンスを最大化する方法について紹介します。

github.com

改めて紹介するとGaurunはスマホアプリ向けのプッシュ通知サーバです。APNsやGCMへのプッシュ通知処理をHTTP + JSONベースのAPIでラップして大量のプッシュ通知を素早く送信することができるのが特徴です。

メルカリのプッシュ配信基盤

メルカリのプッシュ配信基盤はnginxによるL7ロードバランサーとGaurunで構成されています。

f:id:cubicdaiya:20161108085250p:plain

APIサーバ(e.g. 商品の購入や発送等のイベント通知)やジョブワーカ、バッチ(e.g. キャンペーン等による一斉配信)からはGaurunが提供するHTTP + JSONベースのAPIを利用してiOSやAndroidの端末へのプッシュ通知に必要な情報(端末の種類、トークン等)を送ります。Gaurunは送られたリクエストボディを元にAPNsやGCMのプロトコルに合わせたリクエストを生成し、プッシュ通知を行います。

プッシュ通知処理をAPIサーバやバッチサーバで行わずにGaurunに中継して行うのは以下の理由からです。

プッシュ通知処理によるレイテンシの最小化

Gaurunはリクエストを受け取ったらクライアントに対して即座にレスポンスを返し、プッシュ通知自体は非同期で行うのでクライアント側のプッシュ通知処理で発生するレイテンシを最小化することができます。特にAPIサーバではユーザに素早くレスポンスを返す必要があるのでレイテンシを最小化することは非常に重要です。

一斉プッシュ配信のスループット向上

プッシュ通知のようにネットワークレイテンシが大きい処理を一斉に行う場合、単体のバッチプログラムだけで高いスループットを出すのには限界があります。なので、バッチプログラムではプッシュ通知に必要なデータをGaurunにキューイングするだけにしてレイテンシの大きい実際のプッシュ通知処理はすべてGaurunが行うようにしています。(Gaurunはある意味メッセージキューとジョブワーカーを内包したHTTPサーバとも言えます)

そして前段に配置したnginxによるL7ロードバランサでキューイングリクエストを複数のGaurunに分散させることでプッシュ通知のスループットをさらに向上させています。

Gaurunのアーキテクチャ

Gaurunは利用する側から見るとプッシュ通知に必要なAPIを提供するHTTPサーバです。例えばcurlで次のようなJSONをGaurunに送るとAPNsにプッシュ通知リクエストを送ってくれます。

$ curl \
    -H "Content-Type: application/json" \
    -X POST "http://gaurun-host:1056/push" \
    -d '{"notifications": [ {"token":["token-string"],"platform":1,"message":"Hello, iOS"} ] }'

HTTPサーバの実装にはGoの標準ライブラリであるnet/httpを利用しています。net/httpを利用したサーバは素で数千〜数万req/secのスループットを叩き出せるのでGaurunのようなHTTPベースのちょっとしたAPIサーバには十分な性能が出せます。とは言えプッシュ通知のようにネットワークレイテンシの大きい処理を大量に並行して行うにはそれなりの工夫が必要になります。

Gaurunを利用したプッシュ通知処理の流れ

Gaurunを利用したプッシュ通知処理の流れは大まかに表現すると以下のようになります。

f:id:cubicdaiya:20161108085526p:plain

クライアントからプッシュ通知リクエストを受け取ったGaurunはリクエストボディの内容をチャネルベースのキューにエンキューした後、クライアントにレスポンスを返します。(エンキュー自体もゴルーチンで非同期に実行されます)

その後、あらかじめ起動してあるゴルーチンのワーカー達が以下のサイクルでプッシュ通知処理を実行します。

  1. プッシュ通知リクエストの内容をチャネルからデキューする
  2. 実際のプッシュ通知処理を実行するゴルーチンを生成する(生成されたゴルーチンは実際のプッシュ通知処理を実行した後終了する)
  3. 1に戻る

Gaurunでは各ワーカー毎に同時に起動できるプッシュ通知処理用ゴルーチン(以下プッシャー)の数をpusher_maxパラメータで設定することで、最大でワーカー数 x pusher_maxの数だけ同時にプッシュ通知処理を実行することができます。

以前はAPNsの制約もあって、プッシャーを生成せずに各ワーカーが同期的にプッシュ通知処理を実行していましたが、昨年から提供されているAPNS Provider APIを利用することで上記のアーキテクチャに変更することが可能になりました。

Gaurunのパフォーマンスを最大化する

続いてGaurunのパフォーマンスを最大化する方法について解説していきます。

ワーカー数とプッシャー数およびキューのサイズ

Gaurunはlistenするポート番号やGCMのAPIキー、APNsのSSL証明書のパス等の動作に必要な情報のほかにパフォーマンスに関するいくつかの設定をTOMLで記述することができます。(各設定パラメータはgaurun/CONFIGURATION.mdにまとまっています。) その中でも特にGaurunのパフォーマンスに影響を与えるのがcoreセクション内の以下のパラメータです。

[core]
workers = 8    # キューからのデキュー、同期プッシュを実行するゴルーチン(ワーカ)の数
queues = 8192  # キューのサイズ
pusher_max = 0 # プッシャーの最大同時起動数(0の場合は各ワーカが同期的にプッシュを実行する)

各パラメータの意味はさきほどの解説で大体イメージできるのではないかと思います。ただ、プッシュ通知処理のスループットはGaurun自体の性能だけでなく、APNsやGCMとのネットワークレイテンシに大きく左右されるので状況に応じて最適な値を設定する必要があります。

短い時間で大量のプッシュ通知リクエストをGaurunに送ると、当然キューのサイズが膨れていくので大量に配信することが想定される場合はあらかじめqueuesの値を大きめにしておきましょう。キューのサイズが限界に達してもチャネルへのエンキューはゴルーチンで非同期に行われるため、クライアントへのレスポンスが遅延することはありませんが、ゴルーチンがどんどん溜まっていくため、使用メモリが増えていくことになります。また、キューの利用状況や起動中のゴルーチンの数はGaurunが提供するモニタリングAPI(後述)で取得することができます。

APNs、GCM固有のパフォーマンスチューニング

GaurunではAPNsやGCMとのHTTPS接続に関するパラメータを3つ用意しています。(keepalive_timeoutは本記事作成時点ではmasterのHEADでのみ利用可能)

  • timeout:HTTPクライアントの接続やレスポンスの読み込み時間を含む処理全体のタイムアウト値(秒)
  • keepalive_timeout:キープアライブ接続を維持する時間(秒)
  • keepalive_conns:キープアライブ状態にする接続の数
  • retry:プッシュ送信失敗時の最大リトライ数

各パラメータはAPNsとGCMで個別に設定します。

[ios]
# ...(その他の設定)
timeout = 10
keepalive_timeout = 30
keepalive_conns = 10
retry = 1

[android]
# ...(その他の設定)
timeout = 10
keepalive_timeout = 30
keepalive_conns = 20
retry = 1

特に、大量に配信する場合にkeepalive_connsが小さいとタイムアウトが起きたり、エラーが返ってくることがあります。

バルクリクエストによるキューイングの高速化

GaurunのプッシュAPIはリクエストボディに複数のプッシュ通知リクエストを含めることを許しています。以下のリクエストボディだとiOSとAndroid向けに1つずつのプッシュ通知リクエストをまとめてエンキューします。

{
    "notifications" : [
        {
            "token" : ["xxx"],
            "platform" : 1,
            "message" : "Hello, iOS!"
        },
        {
            "token" : ["yyy"],
            "platform" : 2,
            "message" : "Hello, Android!"
        }
    ]
}

なお、一度にエンキューできるプッシュ通知リクエストの最大数はcoreセクションのnotification_maxによって設定できます。(この値を越えた数を一度にエンキューしようとするとHTTP 400 Bad Requestが返ります)

[core]
notification_max = 100

メルカリではバルクリクエストを利用することで全ユーザ向けに一斉プッシュ配信を実行する場合でも短い時間(10〜20分程度)でGaurunにすべてのプッシュをキューイングすることができています。

不要なプッシュ通知用トークンのスクリーニング

Gaurunはプッシュが失敗した際にエラーログに次のようなトークンとエラー内容を含むJSONを吐くので不要なトークンをスクリーニングする際はこれを利用します。

{"level":"error","time":"2016/11/08 07:10:08 JST","message":"Hello, iOS!","id":1,"platform":"ios","token":"token-string","type":"failed-push","ptime":0.841,"error":"bad device token","badge":10,"sound":"default"}

メルカリではプッシュ通知用のトークンをMySQLに格納しており、プッシュ通知を行う際は必要な分をMySQLから取り出してGaurunに送ります。トークンの数は日々増えていくので期限切れ等で届かなくなったトークンを定期的に除去して、不要なプッシュ通知を行わないようにしています。

プッシュのスループット制御

さきほど解説したようにGaurunではプッシャーの最大同時起動数をcoreセクションのpusher_maxで設定します。しかし、Gaurunの起動中にプッシュのスループットを調整したい場合に備えてPUT /config/pushersというAPIを提供しています。このAPIを利用すれば起動中のGaurunのプッシャーの数を変更してプッシュのスループットを調整することができます。

# pusher_maxを動的に変更する
curl -XPUT -s "http://127.0.0.1:1056/config/pushers?max=24"

Gaurunの稼働状況を確認する

Gaurunは自身の稼働状況を知らせるために次のAPIを提供しています。

メルカリではこれらのAPIを利用してMackerelKuradoでGaurunの稼働状況をモニタリングしています。@kazeburoによるMackerelプラグインもあります。

GET /stat/app

GET /stat/appはキューの利用状況や起動しているプッシャーの数、APNsやGCMへのプッシュ通知の成功/失敗数をJSONで返します。

$ curl -s http://127.0.0.1:1056/stat/app | jq '.'
{
 "queue_max": 81920,
 "queue_usage": 0,
 "pusher_max": 64,
 "pusher_count": 0,
 "ios": {
  "push_success": 5,
  "push_error": 0
 },
 "android": {
  "push_success": 6,
  "push_error": 1
 }

GET /stat/go

GET /stat/gogolang-stats-api-handerを利用してGoのCPU、メモリ、GCの利用状況や起動中のゴルーチン数等の値をJSONで返します。

# 起動中のゴルーチンの数を取得
$ curl -s http://127.0.0.1:1056/stat/go | jq '.goroutine_num'
48

まとめ

Gaurunを利用したメルカリのプッシュ配信基盤とGaurunのパフォーマンスを最大化する方法について解説しました。メルカリでは商品の購入や発送等のイベント通知に加えて内製のCRMツールによるプッシュの一斉配信を行っていますが、全ユーザ向けに配信する場合でも数台のGaurunサーバで1時間程度ですべてのプッシュ配信が完了するようにしています。

実際には workerspusher_max の値やGaurunのサーバ台数を調整することでもっと短い時間で完了することも可能ですが、あんまり早くプッシュし過ぎるとAPIサーバやDBサーバに負荷がかかってしまうのであえて台数やパラメータは抑えめにしています。