2014-05-14

iOS Push通知配信サーバーのコネクション管理

前回のエラーハンドリングの話に続いてコネクション管理について。今どきPush通知配信サーバーを自前で用意する事は少ないかもしれないけど、そういう人向けの内容。

コネクションの管理

APNsのドキュメントにこうしろと書いてある。
Best Practices for Managing Connections

You may establish multiple connections to the same gateway or to multiple gateway instances. If you need to send a large number of push notifications, spread them out over connections to several different gateways. This improves performance compared to using a single connection: it lets you send the push notifications faster, and it lets APNs deliver them faster.

Keep your connections with APNs open across multiple notifications; don’t repeatedly open and close connections. APNs treats rapid connection and disconnection as a denial-of-service attack. You should leave a connection open unless you know it will be idle for an extended period of time—for example, if you only send notifications to your users once a day it is ok to use a new connection each day.

Local and Push Notification Programming Guide: Provider Communication with Apple Push Notification Service
つまり
  • 通知毎に接続をOpen/Closeするな、APNsはそれをDoSと扱う
  • Openしたまま維持しろ
  • 大量に配信したい場合は複数接続を張るといいよ
  • 1日に一回送るぐらいなら、その度に新しく接続を作ってもいいよ
 となるので、この通りに実装する。Webサーバーでユーザーからのリクエストをトリガーにして送るとその度にOpen/Closeになる、よってユーザー間等の通知を頻繁に送るアプリの場合はアプリケーションサーバーから通知配信処理のプロセスを分離する方式になるかと。

Gateway Serverからエラーレスポンスが返ってきた場合、向うから接続を切ってくる。その時は新しい接続を作る。

apns-proxy-serverでもスレッド毎にGateway Serverとの接続を持つようにして、接続の維持と並列化を実現している。

原因不明のBroken Pipeエラー

データフォーマットのエラーや、Invalid Tokenエラーとは関係無く、Broken Pipeに遭遇する事がある。Gateway Serverがなんらかの理由でコネクションを切るからだと思われるが、こちらとしては接続を張りなおして、送ろうとした通知からリトライするぐらいしかやれる事は無い。


このエントリーをはてなブックマークに追加

2014-05-12

Apple Push Notification Serviceのエラー処理について

iOSアプリにPush通知をするのに利用するApple Push Notification service(APNs)について。配信数がある程度の規模になると面倒事が増えるのでまとめた。
本稿では疎結合なサービスとして稼動させるPush通知配信サーバーを考える。

Push通知配信サーバーの機能要件

個々のアプリケーションから分離したPush通知配信サーバーを考える場合、要件は大きく分けて次の二つになるだろう。

A. デバイストークンを溜め込んでおき、配信日時を指定して一斉に配信する

  • ゲームのイベントが始まった事を全ユーザーに通知したい
  • ユーザーセグメントを指定してキャンペーンの通知をしたい

B. 都度送信対象のデバイスをアプリケーションから受け取って即時配信をする

  • チャットルームで発言がある度に、チャットルームのメンバーに通知をしたい
  • ユーザー間のmentionを通知したい
Bの場合は、アプリケーションから切り出す必要は無さそうだが、都度送信する通知の数が100 ~ 10万程になるとエラーレスポンスの取得とリトライが必要になり、アプリケーションから切り離したくなる。
どちらの場合も、非機能要件としてコネクション管理とエラーハンドリングが必要となる。

エラー処理の何が難しいか

APNsのドキュメントには、次のようにエラーレスポンスの記述がある。
If you send a notification that is accepted by APNs, nothing is returned. If you send a notification that is malformed or otherwise unintelligible, APNs returns an error-response packet and closes the connection. Any notifications that you sent after the malformed notification using the same connection are discarded, and must be resent. Figure 5-2 shows the format of the error-response packet.


Local and Push Notification Programming Guide: Provider Communication with Apple Push Notification Service
これが厄介なのは
  • 不正と判断された通知以降に送信した通知が破棄される、つまり再送が必要
  • エラーレスポンスが得られるまで、0.3 ~ 0.7秒ほどかかる
  • 正常時は何も得られない、本当に正常なのかエラーレスポンスを取りこぼしたのか判断ができない
  • エラーレスポンスを確認したかにかかわらずコネクションが切断される
といった点があるからで、エラー処理をさぼると「10万ユーザーに通知を送ったつもりが、実は1,000人にしか届いていなかった」という事態が起こる。

最初の一つしかエラーレスポンスが得られない

通知のバイナリフォーマットは複数の通知を一度に送信する事ができる。が、不正な通知が複数含まれていた場合、送信後に得られるエラーレスポンスは最初の一つだけである。複数の通知をまとめて送らずに、1個づつ送信した場合も同様で
  1. デバイストークンAに送信
  2. デバイストークンBに送信 (不正)
  3. デバイストークンCに送信
  4. デバイストークンDに送信 (不正)
  5. デバイストークンEに送信
  6. Bのエラーレスポンスが得られる
  7. コネクションを切られる  (C, D, Eは無かった事に)
といった動きになる。

エラーレスポンスの種類

Status CodeDescription
0No errors encountered 
1Processing error
2Missing device token
3Missing topic
4Missing payload
5Invalid token size
6Invalid topic size
7Invalid payload size
8Invalid token
10Shutdown
255None (unknown)
Table 5-1 Codes in error-response packet
ドキュメントによると11種類であるが、仕様通りのフォーマットで送信していて遭遇するのは8と10だけである。
8のInvalid tokenは実際に送るまでそうであると判定できない曲者。開発ビルドのアプリで取得されたデバイストークンが本番環境に混入するとInvalid Tokenになる。それ以外にも発生するパターンがあるかもしれない。

2,3,4,5,6,7は送信前のチェックで回避できる。

エラーレスポンスをチェックしてリトライ処理を実装する

リトライ処理には次の実装が必要になる
  • リトライ用に送信済み通知を保持しておく
  • 送信後エラーレスポンスを確認する
  • エラーがあれば、該当通知以降を再送する
エラーレスポンスの確認はブロッキングでrecvするとパフォーマンスに影響が出るが、selectを使ってノンブロッキングでやろうにもタイミングが難しかったので自分はブロッキングで実装した。(つまりノンブロッキングSocketプログラミング力が足りない。)

確実にエラーレスポンスを補足したい場合は、通知を1つ送る度にエラーレスポンスを待つのが良いが、それではパフォーマンス要件を確実に満たせない。0.5秒待つとして1スレッドで10,000通知を送ると83分もかかってしまうからだ。自分は500件毎にチェックするようにした。犠牲になるパフォーマンスはAPNsのドキュメントにあるベストプラクティスにならって接続を複数持つ事で補完する。

ここに書いた内容の具体的な実装はVOYAGE GROUPで利用しているPush通知配信サーバーのコードで見る事ができる。こんな事もあろうかとOSS化してある。
これは都度デバイストークンを受け取って配信を行なう、前述Bの要件にあわせて作っている。

補足:パフォーマンステストのやり方

例えば10万件の通知に不正なトークンを数個仕込みつつ30秒以内に配信できるかテストしたい場合がある、テストなので配信対象デバイスは手元の数個しか無い。この時、端末は機内モード、通知は expiry=0 で送信するとAPNsが到達しなかった通知を破棄してくれるので延々とブルブルする事を回避できる。すぐに機内モードを解除してしまうと通知を受信してしまうので、1分ぐらい空けると良い。

事前にデバイストークンをスクリーニングする

Invalid Tokenは実際に送ってみるまでそうとわからない。だが、Aのデバイストークンを溜め込むタイプのサービスであれば、事前に無音通知を配信して確認ができる。この時、alert、badge、sound無しにしておかないとユーザーからは謎の通知が届いたように見えるので注意。iOSクライアントの実装もそのような通知が届いた場合は無視するようにしておく。
送信後エラーレスポンスをチェックして、Invalid Tokenであれば消す。これをすると、配信時にエラーレスポンスの確認が不要になり爆速で配信ができる。

ASPを使うか、自前でPush通知配信サーバーを運用するか

前述のPush通知配信サーバーの要件
  • A. デバイストークンを溜め込んでおき、配信日時を指定して一斉に配信する
  • B. 都度送信対象のデバイスをアプリケーションから受け取って即時配信をする

Push通知ASPを検討する

ASPといえば最近はMixpanelやGrowth Pushといった分析ツールとセットになったAのタイプが注目を浴びている様子。

Aタイプのサービスは予算と折り合いがつけば使い易い物はあるが、100万通知を1分以内に、といった速度要件を満たせるかは不明。Bの用途で使いたい場合、通知1件毎にHTTPでやりとりする物は避ける。HTTPのオーバーヘッドがでか過ぎていつまで経っても終らないという事態になる。

OSSの何かを検討する

Invalid Tokenが混ざる環境ではリトライ処理が実装されている物でないと、そのまま使えない。apns-proxy-serverの実装に着手する前に調べたが、エラーハンドリングが丁寧なプロダクトは見つからなかった。(良い奴があったら教えてください。)

まとめ

selectを使ったノンブロッキングソケットでのエラー処理は後日試す。エラー処理のワークアラウンドは次の記事にも詳しく書いてあるので、これから実装する場合は参考になるだろう。
あと、apns-proxy-client-pyもOSSとして公開しているので、フィードバックやプルリク等お待ちしております。

このエントリーをはてなブックマークに追加

2014-05-08

バンディットアルゴリズムによる最適化手法 5章

Epsilon-Greedyの次はSoftmax。腕の実装とテスト実行のコードは前に使った物と同じ。

In [22]:
%run './shared_functions.ipynb'

Softmaxアルゴリズムの実装

Softmaxは過去に得た腕毎の報酬の期待値を元に、期待値が高い腕を多く試行する。
期待値をどれだけ利用するかは温度パラメータ $tau$ で制御する
  • $tau \rightarrow \infty$ の時、完全にランダムに腕を選択する
  • $tau \rightarrow 0$ の時、過去の期待値に従う
In [24]:
class Softmax(object):
    def __init__(self, temperature):
        self.counts = None
        self.values = None
        self.temperature = temperature
        
    def initialize(self, n_arms):
        # 腕を何回引いたか
        self.counts = zeros(n_arms, dtype=int)
        # 引いた腕の報酬の平均値
        self.values = zeros(n_arms)
    
    @staticmethod
    def categorical_draw(probs):
        z = random.random()
        cum_prob = 0.0
        for i in range(len(probs)):
            prob = probs[i]
            cum_prob += prob
            if cum_prob > z:
                return i
        return len(probs) - 1
    
    def select_arm(self):
        z = sum([exp(v/self.temperature) for v in self.values])
        probs = [exp(v / self.temperature) / z for v in self.values]
        return self.categorical_draw(probs)
    
    def update(self, chosen_arm, reward):
        # 腕を選んだ回数をインクリメント
        self.counts[chosen_arm] += 1
        n = self.counts[chosen_arm]
        
        # 腕の平均報酬額を更新
        value = self.values[chosen_arm]
        new_value = ((n-1)/float(n)) * value + (1/float(n)) * reward
        self.values[chosen_arm] = new_value
In [23]:
# 結果プロット用の処理
def plot_results(simulate_num, horizon, best_arm, results):
    fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(15, 5))
    plot1, plot2, plot3 = axes
    
    x = range(horizon)
    
    for result in test_results:
        accuracy = zeros(horizon)
        reward_ave = zeros(horizon)
        cumulative_rewards = zeros(horizon)

        param, chosen_arms_m, rewards_m, cumulative_rewards_m = result

        for i in xrange(horizon):
            best_arm_selected_count = len(filter(lambda choice: choice == best_arm, chosen_arms_m[:,i]))
            accuracy[i] = best_arm_selected_count / float(simulate_num)
            reward_ave[i] = average(rewards_m[:,i])
            cumulative_rewards[i] = average(cumulative_rewards_m[:,i])
            
        plot1.plot(x, accuracy, label='%10.2f' % param)
        plot2.plot(x, reward_ave, label='%10.2f' % param)
        plot3.plot(x, cumulative_rewards, label='%10.2f' % param)

    plot1.legend(loc=4)
    plot1.set_xlabel('Time')
    plot1.set_ylabel('Probability of Selecting Best Arm')
    plot1.set_title('Accuracy of the \nSoftmax Algorithm')
    
    plot2.legend(loc=4)
    plot2.set_xlabel('Time')
    plot2.set_ylabel('Average Reward')
    plot2.set_title('Performance of the \nSoftmax Algorithm')
    
    plot3.legend(loc=4)
    plot3.set_xlabel('Time')
    plot3.set_ylabel('Cumulative Reward of Chosen Arm')
    plot3.set_title('Cumulative Reward of the \nSoftmax Algorithm')

実行

Epsilon-Greedyより、報酬の高い腕に集中するのが速い。
In [30]:
SIMULATE_NUM = 5000
HORIZON = 250

means = [0.1, 0.1, 0.1, 0.1, 0.9]
random.shuffle(means)
arms = map(lambda mu: BernoulliArm(mu), means)
best_arm = array(means).argmax()

test_results = []
for temperature in [0.1, 0.2, 0.4, 0.8, 1.6]:
    algo = Softmax(temperature)
    chosen_arms_mat, rewards_mat, cumulative_rewards_mat = test_algorithm(algo, arms, SIMULATE_NUM, HORIZON)
    test_results.append([temperature, chosen_arms_mat, rewards_mat, cumulative_rewards_mat])
plot_results(SIMULATE_NUM, HORIZON, best_arm, test_results)

腕の報酬にわずかな差しか無い場合

期待値の差が小さくなるため、腕を選択する頻度も近くなる。よって最も報酬の高い腕を選択する確率は上がりにくい。
In [26]:
SIMULATE_NUM = 5000
HORIZON = 250

means = [0.2, 0.2, 0.2, 0.2, 0.3]
random.shuffle(means)
arms = map(lambda mu: BernoulliArm(mu), means)
best_arm = array(means).argmax()

test_results = []
for temperature in [0.1, 0.2, 0.4, 0.8, 1.6]:
    algo = Softmax(temperature)
    chosen_arms_mat, rewards_mat, cumulative_rewards_mat = test_algorithm(algo, arms, SIMULATE_NUM, HORIZON)
    test_results.append([temperature, chosen_arms_mat, rewards_mat, cumulative_rewards_mat])
plot_results(SIMULATE_NUM, HORIZON, best_arm, test_results)

このエントリーをはてなブックマークに追加