Redis 2.8系で Redis Sentinel を試してみた

前回の記事では、serf を試してみました。serf は、

– ノードのリストを提示
– 障害検知
– イベントフック

をしてくれるものでした。しかしながらノード単位の検出です。もう少し細かい粒度で、つまりプロセス単位でやりたいですね。。。

私はよく Redis を使います。そこで今回は、Redis プロセスを監視し、フェイルオーバーしてくれる Redis Sentinel を使ってみました。イベントフックも出来るようですが試していません、そのうちやります。

今回試したことは、
– フェイルオーバー
のみです。

Redis の2.6系と2.8系では、項目名など異なるようなので注意してください。
公式ドキュメントとして、Redis Sentinel Documentation を参考にしました。

今回は一台のマシンで試してみました。
本実験で一台のマシンに存在するプロセスは、

– Redis のマスター (1個。127.0.0.1:6379)
– Redis のスレイブ (2個。127.0.0.1:16379, 127.0.0.1:16380)
– Redis Sentinel (3個。127.0.0.1:26379, 127.0.0.1:26380, 127.0.0.1:26381)

です。合計6個です。

まず、マスター*1とスレイブ*2を立ち上げます。ターミナルのタブを大量に使います^^;

# マスター
# redis-2.8.x (x は人それぞれ)のディレクトリに移動して
./src/redis-server

# (新しくターミナルのタブを開いて) スレイブその1
./src/redis-server --port 16379 --slaveof 127.0.0.1 6379

# (新しくターミナルのタブを開いて) スレイブその2
./src/redis-server --port 16380 --slaveof 127.0.0.1 6379

実際に動作するか確認してみます。

# マスターに対して、値をセットしてみます。
$ ./src/redis-cli
127.0.0.1:6379> set key1 value1
OK
127.0.0.1:6379> get key1
"value1"

# スレイブその1から、値を見てみます (↑の値、value1 が出てくればOKです)
$ ./src/redis-cli -p 16379
127.0.0.1:16379> get key1
"value1"

# スレイブその2
$ ./src/redis-cli -p 16380
127.0.0.1:16380> get key1
"value1"

次に、Sentinel を3個立ち上げます。なぜ1つではなく複数の Sentinel を立ち上げるかといえば、投票を行い閾値を超えた充足があるかどうか判定するとのことです。

まず、コンフィグファイルです。3つ必要です。以下のようなファイルを作ります。

sentinel01.conf
# port <sentinel-port>
port 26379
# sentinel monitor <master-name> <ip> <redis-port> <quorum>
sentinel monitor mymaster 127.0.0.1 6379 2
# sentinel down-after-milliseconds <master-name> <milliseconds>
sentinel down-after-milliseconds mymaster 5000
# sentinel failover-timeout <master-name> <milliseconds>
sentinel failover-timeout mymaster 900000
# sentinel parallel-syncs <master-name> <numslaves>
sentinel config-epoch mymaster 2

sentinel02.conf
# port <sentinel-port>
port 26380
# sentinel monitor <master-name> <ip> <redis-port> <quorum>
sentinel monitor mymaster 127.0.0.1 6379 2
# sentinel down-after-milliseconds <master-name> <milliseconds>
sentinel down-after-milliseconds mymaster 5000
# sentinel failover-timeout <master-name> <milliseconds>
sentinel failover-timeout mymaster 900000
# sentinel parallel-syncs <master-name> <numslaves>
sentinel config-epoch mymaster 2

sentinel03.conf
# port <sentinel-port>
port 26381
# sentinel monitor <master-name> <ip> <redis-port> <quorum>
sentinel monitor mymaster 127.0.0.1 6379 2
# sentinel down-after-milliseconds <master-name> <milliseconds>
sentinel down-after-milliseconds mymaster 5000
# sentinel failover-timeout <master-name> <milliseconds>
sentinel failover-timeout mymaster 900000
# sentinel parallel-syncs <master-name> <numslaves>
sentinel config-epoch mymaster 2

># sentinel monitor
>sentinel monitor mymaster 127.0.0.1 6379 2

↑この が閾値となります。この場合ですと sentinel プロセスの2個以上が判断を下したら、そのとおりに行うという意味になります。今回は3つの sentinel プロセスを使いますので、多数決として、quorum には2を選びました。sentinel プロセスが5個なら、quorum は3が妥当なところかなと思います。

なお、このconfファイルは、運用していてファイルオーバーなどが起きると書き換えが起こります。具体例を挙げますと、フェイルオーバーして slave がマスターに昇格すると、.confファイルの監視対象のマスターのアドレスとポートが書き換えられます。一旦sentinelを落としても次回起動時は、素直に.confファイルを読むだけで問題なく動作するというわけです。手動書き換えが必要ないということです。

さて、↑に挙げた3つのコンフィグファイルを使って、sentinel プロセスを立ち上げます。

# sentinel その1の立ち上げ
$ ./src/redis-sentinel sentinel01.conf

# 別のターミナルのタブで、sentinel その2の立ち上げ
$ ./src/redis-sentinel sentinel02.conf

# 別のターミナルのタブで、sentinel その3の立ち上げ
$ ./src/redis-sentinel sentinel03.conf

↑で、その3を立ち上げた時には以下のようになります。

$ ./src/redis-sentinel sentinel03.conf 
[4288] 02 Jan 02:47:23.210 * Max number of open files set to 10032
                _._                                                  
           _.-``__ ''-._                                             
      _.-``    `.  `_.  ''-._           Redis 2.8.2 (00000000/0) 64 bit
  .-`` .-```.  ```\/    _.,_ ''-._                                   
 (    '      ,       .-`  | `,    )     Running in sentinel mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 26381
 |    `-._   `._    /     _.-'    |     PID: 4288
  `-._    `-._  `-./  _.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |           http://redis.io        
  `-._    `-._`-.__.-'_.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |                                  
  `-._    `-._`-.__.-'_.-'    _.-'                                   
      `-._    `-.__.-'    _.-'                                       
          `-._        _.-'                                           
              `-.__.-'                                               

[4288] 02 Jan 02:47:23.211 # Sentinel runid is a8b457ca95c446dccb9a35fbc5c3befaed7899c1
[4288] 02 Jan 02:47:23.212 * +slave slave 127.0.0.1:16379 127.0.0.1 16379 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:47:23.212 * +slave slave 127.0.0.1:16380 127.0.0.1 16380 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:47:23.490 * +sentinel sentinel 127.0.0.1:26380 127.0.0.1 26380 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:47:24.716 * +sentinel sentinel 127.0.0.1:26379 127.0.0.1 26379 @ mymaster 127.0.0.1 6379

↑slave一覧と、sentinel一覧が表示されていますね。どうやらRedisのpubsub機能を用いて、リスト管理しているようです。賢いですね。

さて、ひとまず
– Redis のマスター * 1
– Redis のスレイブ * 2
– マスターを監視する Sentinel * 3
を立ち上げました。

次に、マスターを落としてみて、slave のどちらかが昇格するかどうか見てみます。

# マスターのプロセスを探します
$ ps aux | grep redis-server
root      3793  0.0  0.0  37944  2344 pts/0    Sl+  02:29   0:00 ./src/redis-server *:6379
root      3895  0.0  0.0  37944  2372 pts/1    Sl+  02:30   0:00 ./src/redis-server *:16379                              
root      3899  0.0  0.0  37944  2372 pts/2    Sl+  02:30   0:00 ./src/redis-server *:16380                              
root      4454  0.0  0.0   7828   880 pts/9    S+   02:54   0:00 grep redis-server

# ↑より、どうやら 3793 のプロセスIDのようなので、それを kill します。
$ kill -9 3793

↑が行われたとき、sentinel プロセスがどういうログを出力しているか見てみます。

[4288] 02 Jan 02:47:23.211 # Sentinel runid is a8b457ca95c446dccb9a35fbc5c3befaed7899c1
[4288] 02 Jan 02:47:23.212 * +slave slave 127.0.0.1:16379 127.0.0.1 16379 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:47:23.212 * +slave slave 127.0.0.1:16380 127.0.0.1 16380 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:47:23.490 * +sentinel sentinel 127.0.0.1:26380 127.0.0.1 26380 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:47:24.716 * +sentinel sentinel 127.0.0.1:26379 127.0.0.1 26379 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:24.790 # +sdown master mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:24.848 # +odown master mymaster 127.0.0.1 6379 #quorum 2/2
[4288] 02 Jan 02:54:24.848 # +new-epoch 3
[4288] 02 Jan 02:54:24.848 # +try-failover master mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:24.848 # +vote-for-leader a8b457ca95c446dccb9a35fbc5c3befaed7899c1 3
[4288] 02 Jan 02:54:24.849 # 127.0.0.1:26379 voted for a8b457ca95c446dccb9a35fbc5c3befaed7899c1 3
[4288] 02 Jan 02:54:24.849 # 127.0.0.1:26380 voted for a8b457ca95c446dccb9a35fbc5c3befaed7899c1 3
[4288] 02 Jan 02:54:24.948 # +elected-leader master mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:24.948 # +failover-state-select-slave master mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:25.006 # +selected-slave slave 127.0.0.1:16380 127.0.0.1 16380 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:25.006 * +failover-state-send-slaveof-noone slave 127.0.0.1:16380 127.0.0.1 16380 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:25.062 * +failover-state-wait-promotion slave 127.0.0.1:16380 127.0.0.1 16380 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:25.815 # +promoted-slave slave 127.0.0.1:16380 127.0.0.1 16380 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:25.815 # +failover-state-reconf-slaves master mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:25.872 * +slave-reconf-sent slave 127.0.0.1:16379 127.0.0.1 16379 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:26.817 * +slave-reconf-inprog slave 127.0.0.1:16379 127.0.0.1 16379 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:27.842 * +slave-reconf-done slave 127.0.0.1:16379 127.0.0.1 16379 @ mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:27.893 # +failover-end master mymaster 127.0.0.1 6379
[4288] 02 Jan 02:54:27.893 # +switch-master mymaster 127.0.0.1 6379 127.0.0.1 16380
[4288] 02 Jan 02:54:27.893 * +slave slave 127.0.0.1:16379 127.0.0.1 16379 @ mymaster 127.0.0.1 16380
[4288] 02 Jan 02:54:27.900 * +slave slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 16380
[4288] 02 Jan 02:54:32.963 # +sdown slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 16380

↑では、
>[4288] 02 Jan 02:54:24.790 # +sdown master mymaster 127.0.0.1 6379
で127.0.0.1:6379のプロセスが落ちている、と判断しています。sdown は subjectively downですね。次に、
>[4288] 02 Jan 02:54:24.848 # +odown master mymaster 127.0.0.1 6379 #quorum 2/2
となっています。odown、つまり objectively down となります。quorum を満たしたことが分かります(他の2つのsentinelプロセスも、sdown 判定したということ)。
そして、リーダーを選出したりスレイブの書き換えが行われています。
>sentinel down-after-milliseconds mymaster 5000
と設定したとおり、約5秒で切り替わっていますね。

>[4288] 02 Jan 02:54:25.815 # +promoted-slave slave 127.0.0.1:16380 127.0.0.1 16380 @ mymaster 127.0.0.1 6379
とありますが、2つのスレイブのうち、127.0.0.1:16380 の方がマスターに昇格したようです。
実際に見てみます↓

$ ./src/redis-cli -p 16380
127.0.0.1:16380> info replication
# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=16379,state=online,offset=69658,lag=0
master_repl_offset:69658
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:69657
127.0.0.1:16380> 

確かに、”role:master”となっていますね。さらに、slaveも再設定されています。いたせりつくせりですね、さすが Redis という感じがします。

ちなみに、自動的に書き換わった sentinel のコンフィグファイルを見てます。

$ cat sentinel01.conf 
# port <sentinel-port>
port 26379
# sentinel monitor <master-name> <ip> <redis-port> <quorum>
sentinel monitor mymaster 127.0.0.1 16380 2
# sentinel down-after-milliseconds <master-name> <milliseconds>
sentinel down-after-milliseconds mymaster 5000
# sentinel failover-timeout <master-name> <milliseconds>
sentinel failover-timeout mymaster 900000
# sentinel parallel-syncs <master-name> <numslaves>
sentinel config-epoch mymaster 3
# Generated by CONFIG REWRITE
dir "/root/my_repos/software/redis-2.8.2"
sentinel known-slave mymaster 127.0.0.1 16379
sentinel known-slave mymaster 127.0.0.1 6379
sentinel known-sentinel mymaster 127.0.0.1 26380 0a6f3fbd3ed7f5323918ef4f1cadc3a798b68bfa

sentinel known-sentinel mymaster 127.0.0.1 26381 a8b457ca95c446dccb9a35fbc5c3befaed7899c1

↑実際に書き換わっていますね。

今回は以上になります。イベントフックや、アプリケーション・サーバーがどのようにして昇格したマスターにアクセスするか(/etc/hosts を書き換えるとか、iptables だとか、VIP だとかいろいろとありますね)についてはいつか検証して見る予定です。

Serf を動かしてみる

注意: Serf の勉強中ですのでおそらく誤りや勘違いがあります。


最近話題の Serf を試してみました。
いまいちよく分かっていないのですが動作させてみました。
個人的に出来たらいいなと思うこととしては、
– 障害の高速で確実な検知
– 障害を検知したら登録したスクリプトを走らせる
です。
今回、動作可能ノード一覧の取得と、障害検知までをやりました。イベントフックはまだです。

そもそも Serf とは、
– 生きているノードのリストの提供
– 障害検知とリカバリ
– イベントフック
を行ってくれる、分散ノード管理ツールとのことです(公式サイトより)。

SWIMと呼ばれるゴシッププロトコルをベースにしているとのことです。論文はこちら。著者は現在グーグルにいるようですね。

何はともあれ動かしてみます。

今回試した構成図は以下のようになります。
serf用に4台の物理マシン(=ノード)、観測用に1台の物理マシンを用意しました。合計5台で試してみます。
serf1~serf4は、それぞれの動いているノードで動いているserf agentです。
環境は、
– 4台の物理マシン: Debian
– 1台の観測用マシン: MacBook
です。

serf_example

さて、まずはそれぞれのノードに serf をインストールします。

$ cd 
$ wget https://dl.bintray.com/mitchellh/serf/0.3.0_linux_amd64.zip
$ unzip

# serf を動かします。
$ ./serf agent -bind=0.0.0.0:5000 -rpc-addr=0.0.0.0:7373

↑この操作を4台分行います。また、クライアントにおいてもインストールします。クライアントではserfを動かす必要はありません。SerfのNode Nameは一意である必要があります。つまり同じ名前を使っているとうまく行きません。

今回私が用いたマシンは以下のように命名しました。隣にIPアドレスがついていますがこれは個人によりけりだと思います。

– atom (= 192.168.0.144)
– pikachu (= 192.168.1.3)
– ratta (= 192.168.1.2)
– e5200 (= 192.168.1.118)

atomのターミナルで、「./serf join IPADDR:PORT」としてjoinします。具体的には以下のとおりです。
# pikachu node
$ ./serf join 192.168.1.3:5000
Successfully joined cluster by contacting 1 nodes.

# ratta node
$ ./serf join 192.168.1.2:5000
Successfully joined cluster by contacting 1 nodes.

# e5200 node
$ ./serf join 192.168.1.118:5000
Successfully joined cluster by contacting 1 nodes.

Serfにリーダーの概念はないようです(Decentralizedってあるからそりゃそうですが)が、ひとまずある特定のノードのターミナル上で、joinコマンドで他のノードを追加してみました。それぞれのノードから追加してみたいのですがどうやれば良いのかよく分かりません。。。(-> どうやら最初にjoinするときは、特定の存在しているメンバーに接続する必要があるようです。それは知っておく必要があるとのことです。P2Pでよく見られる最初の一歩問題ってやつですね。それ以降は、存在しているどのノードに接続してもOKのようです(http://www.serfdom.io/intro/getting-started/join.html より))

さて、ノード一覧を取得してみます。クライアントのマシンのターミナルでatomノードに問い合わせします。以下のようにしてみます。

$ ./serf members -rpc-addr=192.168.0.144:7373
atom    192.168.0.144:5000    alive    
pikachu    192.168.1.3:5000    alive    
ratta    192.168.1.2:5000    alive    
e5200    192.168.1.118:5000    alive    

無事、取得出来ました。もちろん、他のserfノードに問い合わせても同じ結果が得られます。

$ ./serf members -rpc-addr=192.168.1.118:7373
e5200    192.168.1.118:5000    alive    
atom    192.168.0.144:5000    alive    
pikachu    192.168.1.3:5000    alive    
ratta    192.168.1.2:5000    alive

もちろんCAP定理より明らかですが、これはAPであり、一貫性はweakです。

さて次に、障害検知をしてみます。たいへんナイーブなやり方ですが、ストップウォッチ片手にpikachuノードのUTPケーブルを引っこ抜いて、クライアントから「./serf members -rpc-addr=192.168.0.144:7373」を連打してみます。

結果、8.3sec程度で障害を検知しました。↓のように、pikachuがfailedになっています。

$ ./serf members -rpc-addr=192.168.1.118:7373
e5200    192.168.1.118:5000    alive    
atom    192.168.0.144:5000    alive    
pikachu    192.168.1.3:5000    failed    
ratta    192.168.1.2:5000    alive  

さて今度は逆にUTPケーブルを復活させてみます。
-> 5.1sec程度で復活しました↓。

$ ./serf members -rpc-addr=192.168.1.118:7373
e5200    192.168.1.118:5000    alive    
atom    192.168.0.144:5000    alive    
pikachu    192.168.1.3:5000    alive    
ratta    192.168.1.2:5000    alive  

もう一度抜いてみますと、7.3sec程度でfailedとなりました。このように検出できるのは面白いですね。UTPケーブル引っこ抜きが障害の代表例かと言うとかなり怪しいのでは思ったので、ごく普通にpikachuノードのserfプロセスを落として検出までの時間を計測してみました。
-> 8.1secでした。UTPケーブル引っこ抜きとあまり変わりありません。今度は復帰させてみました。
-> 16.8secでした。

面倒なので試行回数を増やすことはしませんが、5sec程度で検知したり20sec程度掛かったりといろいろでした。1分以上掛かったこともありました。公式サイトには、

Serf automatically detects failed nodes within seconds

http://www.serfdom.io/intro/


と書いていますが、数秒で検知と行きませんでした。もちろん私が何が間違ったコンフィグをしている可能性はあります。

という訳で今回は、

– Serf を動作させる
– 生きているノード一覧を取得してみる
– 障害検知してみる

を行いました。次回はイベントフックを試してみたいと思います。

補遺

Serfの構成図のgraphviz

graph serf {

layout="circo";

subgraph serf_cluster
{
node [style=filled];

serf1--serf2 [style="dashed"];
serf1--serf3 [style="dashed"];
serf1--serf4 [style="dashed"];
serf2--serf3 [style="dashed"];
serf2--serf4 [style="dashed"];
serf3--serf4 [style="dashed"];

label = "Serf Cluster";
}

client--serf1;
}

Apache ZooKeeper を Debian で使ってみる

とある事情により、Apache Zookeeper を使ってみました。

そもそも ZooKeeper とはなんぞやという感じですが、

ZooKeeper とは

ZooKeeper は、設定情報の保守、名前付け、分散同期化の提供、および各種グループサービスの提供を目的とした集中型サービスです。分散アプリケーションでは、こうした種類のサービスのすべてを何らかの形で利用しています。分散アプリケーションを実装する場合、実装のたびに大量の作業が発生し、それに伴って不可避的に生じるバグとレースコンディションの修正に追われるのが実状です。上に挙げた種類のサービスの実装は決して容易ではないので、アプリケーションでは当初はこれらのサービスの実装を適当に済ませることが多く、あとで変更が生じたりすると対応できずに管理が困難になります。これらのサービスが適切に実装されている場合でも、実装方法に違いがあると、アプリケーションを配置する段階で管理が複雑になります。ZooKeeper は、これらのサービスの本質的な部分を取り出し、集中型のコーディネーションサービスへの非常にシンプルなインタフェースとして提供することを目的に開発されています。

http://oss.infoscience.co.jp/hadoop/zookeeper/


要は分散管理のためのサービスですね。複数台のマシンを活用していて、コンフィグなどを一元化しておきたい・一元化しておいてクライアントから書き換えたい、などの要求がよくあると思います。ただし、もともとは Hadoop 由来のプロジェクトらしく、C か Java でのみネイティブアクセス可能のようです。きちんと使える Rest インターフェースはなさそうですね。

インストールからクライアント UI まで試してみます。OS は Debian を想定しています。事前に Java 等が必要かもしれません。apt-get で入れておいてください。

$ cd

# 2013-12-25時点の最新版です。とは言え、↓は2012年後半のものですね、枯れていると思って良いのでしょうか。
$ wget http://ftp.riken.jp/net/apache/zookeeper/stable/zookeeper-3.4.5.tar.gz

# 解凍します
$ tar zxvf zookeeper-3.4.5.tar.gz

$ cd zookeeper-3.4.5/

# conf/zoo.cfgというファイルを作ります。中身は、↓でcatしています。
$ vi conf/zoo.cfg
$ cat conf/zoo.cfg 
tickTime=2000
dataDir=/var/zookeeper
clientPort=2181

# ZooKeeper を走らせます
$ bin/zkServer.sh start
JMX enabled by default
Using config: /root/my_repos/zookeeper-3.4.5/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED

# 走っているか確認します。下の例ではJavaと出ているのでOKです
$ netstat -natp | grep java
tcp6       0      0 :::35091                :::*                    LISTEN      2444/java       
tcp6       0      0 :::2181                 :::*                    LISTEN      2444/java 

# クライアントを使ってみます
$ bin/zkCli.sh -server 127.0.0.1:2181
Connecting to 127.0.0.1:2181
2013-12-25 10:58:55,527 [myid:] - INFO  [main:Environment@100] - Client environment:zookeeper.version=3.4.5-1392090, built on 09/30/2012 17:52 GMT
2013-12-25 10:58:55,533 [myid:] - INFO  [main:Environment@100] - Client environment:host.name=ratta
2013-12-25 10:58:55,534 [myid:] - INFO  [main:Environment@100] - Client environment:java.version=1.7.0_25
2013-12-25 10:58:55,536 [myid:] - INFO  [main:Environment@100] - Client environment:java.vendor=Oracle Corporation
2013-12-25 10:58:55,537 [myid:] - INFO  [main:Environment@100] - Client environment:java.home=/usr/lib/jvm/java-7-openjdk-amd64/jre
2013-12-25 10:58:55,538 [myid:] - INFO  [main:Environment@100] - Client environment:java.class.path=/root/my_repos/zookeeper-3.4.5/bin/../build/classes:/root/my_repos/zookeeper-3.4.5/bin/../build/lib/*.jar:/root/my_repos/zookeeper-3.4.5/bin/../lib/slf4j-log4j12-1.6.1.jar:/root/my_repos/zookeeper-3.4.5/bin/../lib/slf4j-api-1.6.1.jar:/root/my_repos/zookeeper-3.4.5/bin/../lib/netty-3.2.2.Final.jar:/root/my_repos/zookeeper-3.4.5/bin/../lib/log4j-1.2.15.jar:/root/my_repos/zookeeper-3.4.5/bin/../lib/jline-0.9.94.jar:/root/my_repos/zookeeper-3.4.5/bin/../zookeeper-3.4.5.jar:/root/my_repos/zookeeper-3.4.5/bin/../src/java/lib/*.jar:/root/my_repos/zookeeper-3.4.5/bin/../conf:
2013-12-25 10:58:55,539 [myid:] - INFO  [main:Environment@100] - Client environment:java.library.path=/usr/java/packages/lib/amd64:/usr/lib/jni:/lib:/usr/lib
2013-12-25 10:58:55,540 [myid:] - INFO  [main:Environment@100] - Client environment:java.io.tmpdir=/tmp
2013-12-25 10:58:55,542 [myid:] - INFO  [main:Environment@100] - Client environment:java.compiler=<NA>
2013-12-25 10:58:55,543 [myid:] - INFO  [main:Environment@100] - Client environment:os.name=Linux
2013-12-25 10:58:55,544 [myid:] - INFO  [main:Environment@100] - Client environment:os.arch=amd64
2013-12-25 10:58:55,545 [myid:] - INFO  [main:Environment@100] - Client environment:os.version=3.5.0-43-generic
2013-12-25 10:58:55,546 [myid:] - INFO  [main:Environment@100] - Client environment:user.name=root
2013-12-25 10:58:55,547 [myid:] - INFO  [main:Environment@100] - Client environment:user.home=/root
2013-12-25 10:58:55,548 [myid:] - INFO  [main:Environment@100] - Client environment:user.dir=/root/my_repos/zookeeper-3.4.5
2013-12-25 10:58:55,550 [myid:] - INFO  [main:ZooKeeper@438] - Initiating client connection, connectString=127.0.0.1:2181 sessionTimeout=30000 watcher=org.apache.zookeeper.ZooKeeperMain$MyWatcher@3b7db74b
Welcome to ZooKeeper!
2013-12-25 10:58:55,611 [myid:] - INFO  [main-SendThread(localhost:2181):ClientCnxn$SendThread@966] - Opening socket connection to server localhost/127.0.0.1:2181. Will not attempt to authenticate using SASL (unknown error)
JLine support is enabled
2013-12-25 10:58:55,620 [myid:] - INFO  [main-SendThread(localhost:2181):ClientCnxn$SendThread@849] - Socket connection established to localhost/127.0.0.1:2181, initiating session
[zk: 127.0.0.1:2181(CONNECTING) 0] 2013-12-25 10:58:55,770 [myid:] - INFO  [main-SendThread(localhost:2181):ClientCnxn$SendThread@1207] - Session establishment complete on server localhost/127.0.0.1:2181, sessionid = 0x1432775d2d30000, negotiated timeout = 30000

WATCHER::

WatchedEvent state:SyncConnected type:None path:null

[zk: 127.0.0.1:2181(CONNECTED) 0] 

# ls, create, get, set 操作をしてみます。
# まず ls です
[zk: 127.0.0.1:2181(CONNECTED) 0] ls /
[zookeeper]

# create してみます
[zk: 127.0.0.1:2181(CONNECTED) 1] create /piyo fuga     
Created /piyo

# lsして新しく出来ているか確認します
[zk: 127.0.0.1:2181(CONNECTED) 2] ls /
[zookeeper, piyo]

# get してみます
[zk: 127.0.0.1:2181(CONNECTED) 3] get /piyo
fuga
cZxid = 0x2
ctime = Wed Dec 25 11:00:33 JST 2013
mZxid = 0x2
mtime = Wed Dec 25 11:00:33 JST 2013
pZxid = 0x2
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 4
numChildren = 0

# set で先ほどの/piyoを書き換えて、getして確認してみます
[zk: 127.0.0.1:2181(CONNECTED) 4] set /piyo fugafuga
cZxid = 0x2
ctime = Wed Dec 25 11:00:33 JST 2013
mZxid = 0x3
mtime = Wed Dec 25 11:01:36 JST 2013
pZxid = 0x2
cversion = 0
dataVersion = 1
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 8
numChildren = 0

[zk: 127.0.0.1:2181(CONNECTED) 5] get /piyo
fugafuga
cZxid = 0x2
ctime = Wed Dec 25 11:00:33 JST 2013
mZxid = 0x3
mtime = Wed Dec 25 11:01:36 JST 2013
pZxid = 0x2
cversion = 0
dataVersion = 1
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 8
numChildren = 0

ls, create, get, set のみ試しましたが、うまくやれば使えそうですね。そして、よくあるファイルシステムとは違って、ZooKeeperのディレクトリというものは、それ自体が値と子ディレクトリの二つを持つようです。これを znode と呼ぶようですね。具体的には、たとえば /dir という znode は、値として value、子ノードとしてchild1, child2, child3を持ちます。クライアントを使って操作してみます。

# まず znode を確認してみます
[zk: 127.0.0.1:2181(CONNECTED) 63] ls /
[zookeeper]

# dir という znode を作ります。
[zk: 127.0.0.1:2181(CONNECTED) 64] create /dir value
Created /dir

# どういうものか get してみます。どうやら"value"という値が書き込まれているようです。
[zk: 127.0.0.1:2181(CONNECTED) 65] get /dir
value
cZxid = 0x1f
ctime = Wed Dec 25 11:15:31 JST 2013
mZxid = 0x1f
mtime = Wed Dec 25 11:15:31 JST 2013
pZxid = 0x1f
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 5
numChildren = 0

# 子 znode を3つ作ってみます
[zk: 127.0.0.1:2181(CONNECTED) 66] create /dir/child1 value-child1
Created /dir/child1
[zk: 127.0.0.1:2181(CONNECTED) 68] create /dir/child2 value-child2
Created /dir/child2
[zk: 127.0.0.1:2181(CONNECTED) 69] create /dir/child3 value-child3
Created /dir/child3

# dir を get してみます。numChildrenが3となっており、先ほど作ったznodeの数と一致します。
[zk: 127.0.0.1:2181(CONNECTED) 70] get /dir
value
cZxid = 0x1f
ctime = Wed Dec 25 11:15:31 JST 2013
mZxid = 0x1f
mtime = Wed Dec 25 11:15:31 JST 2013
pZxid = 0x22
cversion = 3
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 5
numChildren = 3

できていますね。普通のファイルシステムとは感覚が異なりますが、慣れでしょう。

InstagramのID生成をPHPで行う

分散データベースやKVSではどのようにIDを生成するかが問題になります。採番サーバーを立てると、

– 遅い、スケールしない
– 単一障害点(そしてreplicaの用意が難しい)

となり、あまり具合がよくありません。

複数ノードで同時にIDを生成するのは、いわゆる誕生日問題がついてきてなかなか難しいようです。

分散ID生成といえば、UUID、MongoDBのObjecdtID、TwitterのSnowFlake、それをシンプルにしたというSimpleFlakeなどがあります。

ID生成の要求としては、

– 出来るだけ短い
– 高速に生成可能 (1ノードあたり最低でも 10k gen/sec 程度でしょうか)
– たくさんのノードで生成してもどのIDもユニークになる、ほぼ重複しない

あたりでしょうか。

Instagramで行っているIDの生成手法が簡単かつ↑の要求を満たしていると感じたのでPHPで実装してみました。InstagramのID生成法の抜粋を↓に載せます。

Each of our IDs consists of:

41 bits for time in milliseconds (gives us 41 years of IDs with a custom epoch)
13 bits that represent the logical shard ID
10 bits that represent an auto-incrementing sequence, modulus 1024. This means we can generate 1024 IDs, per shard, per millisecond

Let’s walk through an example: let’s say it’s September 9th, 2011, at 5:00pm and our ‘epoch’ begins on January 1st, 2011. There have been 1387263000 milliseconds since the beginning of our epoch, so to start our ID, we fill the left-most 41 bits with this value with a left-shift:

http://instagram-engineering.tumblr.com/post/10853187575/sharding-ids-at-instagram


日本語で書くと、

– 41ビットのタイムスタンプ(millisec)。ただし基点の時間を引いておく。41年間持つ。
– 13ビットのシャードID (2^13 = 8192個のシャードID)
– 10ビット。カウンターを1024で割った数値

ですね。カウンターは保存領域が必要になるので、今回は決め打ちの値にしました。実際にカウントアップする場合は、SQLite等使って管理すると、ネットワーク障害等に強いかと思います(なんとなく遅そうな気もしますが。。。とは言え昨今のファイルシステムの場合、CoWでしょうから、SQLiteでもほぼオンメモリ処理になる可能性は高い気がします。LevelDBもいいかもしれませんね)。

こんな感じのコードになりました。

<?php

$shard_id = 7777; // < 8192
$seq = 7777777 % 1024;
$offset = 1283758447; // You can get current UnixTime by PHP's function "time()"
$instagram_millisec = (int) ((microtime(TRUE) - $offset) * 1000);

// 2^(64-41) = 8388608, 2^(64-41-13) = 1024
$id = $instagram_millisec * 8388608 + $shard_id * 1024 + $seq;

echo "Instagram ID:\n";
echo $id . "\n";
echo "PHP_INT_MAX:\n";
echo PHP_INT_MAX . "\n";

出力
Instagram ID:
838889700096313097
PHP_INT_MAX:
9223372036854775807

とりあえず、$offset = 1283758447; としましたが、実運用の際はもっとtime()に近い値で良いと思います。

ちなみに、本当に正しいIDなのかどうか検証するために、

$id = sprintf("%041b%013b%010b", $instagram_millisec, $shard_id, $seq);
printf("%d\n", bindec($id));

として比較してみたところ一致しますので、たぶん正しく出来たと思います。

この値を保存する際のフォーマット(intか文字列にするか)は、DB/KVS次第だと思います。何でも文字列にして保存するKVSとかありますのでそのときは文字列にした方が効率的かなと思います。ちなみに文字列であれば、0-9、a-zの36進数を使うと小さくなって良いのかなと思います。「$id_str = base_convert($id, 10, 36);」こんな感じですね。

実運用の際は、shard_idとseqのビット数を調整してポリシーに合わせると良いかなと思いました。

ちなみにsequenceをsqliteで書いてみました↓。setup_table.sqlもseq.phpも同じディレクトリにおいてください。

setup_table.sql
-- please read in SQLite!
--
-- Installation:
--
-- $ cd .conf
-- $ sqlite3 s.sqlite3
-- sqlite> .read setup_table.sql
-- sqlite> .exit

create table t1 (s integer primary key);
insert into t1 (s) values(0);

seq.php
<?php

echo getSequence() . "\n";

function getSequence() {
	try {
		$pdo = new PDO("sqlite:" . dirname(__FILE__) . "/s.sqlite3", "", "", array(PDO::ATTR_PERSISTENT => TRUE));
		$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

		$update = $pdo->prepare("update t1 set s = s + 1");
		$select = $pdo->prepare("select s from t1");

		$pdo->beginTransaction();
		$update->execute();
		$select->execute();
		$pdo->commit();

		$row = $select->fetch(PDO::FETCH_ASSOC);
		$seq = (int) $row["s"];
		return $seq;
	} catch (PDOException $e) {
		die("FAILURE. Detail: " . $e->getMessage());
	}
}