twemproxy 本家ドキュメント日本語訳


このドキュメントについて

twemproxy (nutcracker) が一体何者であり、また、運用にあたり理解が必要であろう箇所について、本家ドキュメントを雑に意訳したものです。

仕事上のメモとして作成したのをそのまま公開するので日本語が残念なところも正直多々あるのですが、これを完成させるまで眠らせておくよりは(いつになることやら)さっさと公開して、今 twemproxy の導入を検討している方や、運用されている方の何かしらの力になればと思っています。

粗い内容ですので、何かございましたら編集リクエストを頂けると幸いです。

README.md

https://github.com/twitter/twemproxy/blob/master/README.md のうち twemproxy の特性について理解しておいた方がいいところをかいつまんで訳したりするコーナー。

twemproxy 概要

twemproxy ("two-em-proxy" と発音) は nutcracker とも呼ばれる、高速で軽量な、memcached、redis プロトコルのプロキシである。
最大の目的は、バックエンドのキャッシュサーバの接続数を減らすことである。
また、それと同時にパイプラインやシャーディングも利用できるようになるため、水平分散によるスケーラブルなキャッシュアーキテクチャの実現ができる。

※訳者コメント

上記 README.md の冒頭の内容なのですが、設計上重要な情報が含まれているのでコメントを挟ませて頂きます。

twemproxy は「シャーディングによる容量のスケールアウト」を目的としておらず、あくまでキャッシュサーバに対する大量アクセスにどう対処するかというところにフォーカスして作られたものとなっています。
それゆえか、リシャーディングの機能は持っておらず、また、redis が永続化データを扱うようなシーンも想定されていないようです。

特徴

  • 速い
  • 軽い
  • 接続を維持できる
  • バックエンドのキャッシュサーバへの接続数を低く保つ
  • リクエストとレスポンスのパイプができる
  • 複数サーバへのプロキシができる
  • 複数のサーバ・プールを同時に持てる
  • 複数サーバ間でのシャーディングを自動でできる
  • memcached (ascii) とredis のプロトコルに完全対応
  • サーバ・プールの設定を YAML で簡単にできる
  • consistent hashing と distribution を含む複数のハッシングアルゴリズムに対応
  • 死んだノードを無効化できる
  • stats が専用のポートから取得できて監視しやすい
  • Linux, *BSD, OS X and Solaris (SmartOS) OS で動く

ゼロ・コピーについて

nutcracker において、流入リクエストと、流出レスポンスに対するメモリは全て、mbuf 内にアロケートされる。
mbuf は zero-copy を有効にする。なぜなら、同じバッファ(その上でクライアントからのリクエストが受信される)はそのサーバへのフォワードにも使われるためだ。
これと近い形で、同じ mbuf (その上でサーバからのレスポンスが受信される)がクライアントへのフォワードにも使われる。

さらに、mbuf に割り当てられたメモリは再利用プールを使って管理される。
これが意味するところは、一度 mbuf がアロケートされると、開放はされれず、再利用プールに戻されるだけだということだ。
デフォルトで、各 mbuf のチャンクは 16KB にセットされる。
これは mbuf のサイズと、nutcracker の対処可能な同時接続数の、トレードオフである。
大きな mbuf は読み込みのシステムコール(nutcracker がリクエストやレスポンスを読み込む時のもの)の回数を減らすことができる。
しかし、大きな mbuf は全てのアクテイブな接続に対して 16KB 以上のメモリ消費をするため、nutcracker がクライアントから大量の同時接続を受けた時に問題になるだろう。
nutcracker にクライアントからの大量同時接続を捌かせるときは、チャンクサイズを 512B のような小さな値を、-mまたは-mbuf-size=N引数で設定するとよいだろう。

どんな設定ができるのか

項目 設定値 意味
listen \d+ サーバプールに対して listen するアドレスとポート
hash * one_at_a_time * md5 * crc16 * crc32 * crc32a * fnv1_64 * fnv1a_64 * fnv1_32 * fnv1a_32 * hsieh * murmur * jenkins ハッシュ関数 何のハッシュ値を計算するのに使うのだろう? 
hash_tag .. (regexp) ハッシュタグの指定に使う ハッシュタグの項目を参照 
distribution * ketama * modula * random キー分散の方法を指定
timeout \d+ タイムアウトを msec で指定する タイムアウトの項目を参照 
backlog \d+ TCPのバックログの引数 デフォルトは512 
preconnect (true|false) プール中の全サーバに、プロセス開始前に繋ぎにいくかどうか デフォルトは false 
redis (true|false) サーバプールが redis か memcached かを指定するところ
redis_auth   redis の認証情報
server_connections   各サーバに対する最大同時接続数 デフォルトで1 server_connections: > 1 の項目も参照 
auto_eject_hosts (true|false) 連続して server_failuer_limit の回数失敗した時に、一時的なサーバ切り離しをするかどうか Liveness の項目も参照
server_retry_timeout \d+ 一時的に切り離されたサーバに対し、どのくらいの時間再接続を待つか msec で指定する デフォルトは 30000 msec 
server_failuer_limit \d+ サーバへのリクエストが何回連続して失敗したら一時的な切り離しをするか デフォルトは 2 
servers   サーバプールに対するサーバアドレス、ポート、重みのリスト

パイプライン

nutcracker は複数のクライアントの接続を、数台のサーバ接続にプロキシすることができる。
この構造は、ラウンドトリップタイムを抑える目的でリクエストやレスポンスをパイプするのに理想的である。

例えば nutcracker が3つのクライアントからの接続を1つのサーバに対してプロキシしており

  • get key\r\n\
  • set key 0 0 3\r\nval\r\n
  • delete key\r\n

というリクエストを3つの接続からそれぞれ受け取った場合を考える(これは redis ではなく memcached を想定したリクエスト形式である)。
nutcracker はこの時、これらのリクエストを順次実行されるよう、1つのメッセージget key\r\nset key 0 0 3\r\nval\r\ndelete key\r\nにまとめてサーバへ送信する。

パイプラインによって nutcracker はスループットを向上させ、クライアント・サーバ間の extra hop (訳せなかった) をもたらす。

デプロイ

nutcracker を本番環境にデプロイするのであれば、https://github.com/twitter/twemproxy/blob/master/notes/recommendation.md を読み、チューニング可能なパラメータについて理解し、本番環境上で効率的に動作させるべきだ。

recommendation.md

https://github.com/twitter/twemproxy/blob/master/notes/recommendation.md を訳すコーナー。

ログレベル

デフォルトでは、デバッグ・ログは無効になっている。しかし、nutcracker をデバッグ・ログを有効にして運用することは有用であり、ログの冗長度 (verbosity) はLOG_INFOにセットすることで有効になる-v 6または--verbose=6のように) 。
これは実際はあまり大きなオーバーヘッドになならない。ログ出力の際、i の条件分岐のコストがかかるようになるだけだ。

LOG_INFOのレベルにおいて、nutcracker のログは各クライアントとサーバの接続のライフサイクルと、重要なイベント(サーバが hash ring から外れた等)で出力される。
ログの例は以下。

[Thu Aug  2 00:03:09 2012] nc_proxy.c:336 accepted c 7 on p 6 from '127.0.0.1:54009'
[Thu Aug  2 00:03:09 2012] nc_server.c:528 connected on s 8 to server '127.0.0.1:11211:1'
[Thu Aug  2 00:03:09 2012] nc_core.c:270 req 1 on s 8 timedout
[Thu Aug  2 00:03:09 2012] nc_core.c:207 close s 8 '127.0.0.1:11211' on event 0004 eof 0 done 0 rb 0 sb 20: Connection timed out
[Thu Aug  2 00:03:09 2012] nc_server.c:406 close s 8 schedule error for req 1 len 20 type 5 from c 7: Connection timed out
[Thu Aug  2 00:03:09 2012] nc_server.c:281 update pool 0 'alpha' to delete server '127.0.0.1:11211:1' for next 2 secs
[Thu Aug  2 00:03:10 2012] nc_connection.c:314 recv on sd 7 eof rb 20 sb 35
[Thu Aug  2 00:03:10 2012] nc_request.c:334 c 7 is done
[Thu Aug  2 00:03:10 2012] nc_core.c:207 close c 7 '127.0.0.1:54009' on event 0001 eof 1 done 1 rb 20 sb 35
[Thu Aug  2 00:03:11 2012] nc_proxy.c:336 accepted c 7 on p 6 from '127.0.0.1:54011'
[Thu Aug  2 00:03:11 2012] nc_server.c:528 connected on s 8 to server '127.0.0.1:11212:1'
[Thu Aug  2 00:03:12 2012] nc_connection.c:314 recv on sd 7 eof rb 20 sb 8
[Thu Aug  2 00:03:12 2012] nc_request.c:334 c 7 is done
[Thu Aug  2 00:03:12 2012] nc_core.c:207 close c 7 '127.0.0.1:54011' on event 0001 eof 1 done 1 rb 20 sb 8

デバッグ・ログを有効にするには、nutcracker をコンパイルする時に--enable-debug=logconfigureのオプションで指定する必要がある。

可用性 (Liveness をこう訳してみた)

失敗は人生につきものだ。特に、物事が拡散される時には。
障害から復旧させる時、以下のように各サーバに設定をすることを推奨する。

resilient_pool:
  auto_eject_hosts: true
  server_retry_timeout: 30000
  server_failure_limit: 3

auto_eject_hostsを有効にすると死んだサーバをhash ring から切り離せることを保証し、server_failure_limitは consecutive failures have been encountered on that said server (訳せなかった…)。
server_retry_timeoutが 0 でない場合、不正にサーバを「永遠に死んだ」ものとしてマーキングすることがないよう保証する。特に、瞬間的な障害の場合にだ。
server_retry_timeoutserverfailuer_limitの組み合わせによる制御は、恒久的な復旧と、瞬間的な障害の間でのトレードオフとなる。

切り離されたサーバは、retry timeoutが経過するまでは、どんなリクエストに対しても hash ring に含まれることがなくなる。
これは This will lead to data partitioning as keys originally on the ejected server will now be written to a server still in the pool (訳せなかった…)。

サーバ切り離しに直面した際にリクエストが常に成功することを保証するには (auto_eject_hostsが有効な場合)、nutcracker 自体がリクエストのリトライをしないものの、クライアント層でリトライの仕組みが実装されている必要がある。
このクライアント側のリトライ数はserver_failuer_limitの値よりも大きい必要があり、それがオリジナルのリクエストが生存しているサーバに接続される可能性を保証する。

タイムアウト

timeoutパラメータを調整するのは、nutcracker において正義だ。それはどんなサーバ・プールに対してもであって、クライアント側のタイムアウトを信用してしまうよりもいいことだ。
設定例は以下。

resilient_pool_with_timeout:
  auto_eject_hosts: true
  server_retry_timeout: 30000
  server_failure_limit: 3
  timeout: 400

クライアント側のタイムアウトだけを信用することは、オリジナルなリクエストがクライアント~プロキシ間接続でタイムアウトし場合に、不利な影響がある。
プロキシ~サーバ間の接続の問題は依然として起きたままとなる。
このことは、クライアントがオリジナルのリクエストをリトライするとさらに悪化する。

デフォルトで nutcracker は、サーバへ送られるどんなリクエストに対しても、永遠に待つようになっている。
しかしながらtimeoutパラメータが設定されると、サーバからのレスポンスがないリクエストは、そのパラメータで設定した時間 (単位は msec)でタイムアウトし、エラーレスポンスSERVER_ERROR timed out \r\nがクライアントに返されるようになる。

エラー・レスポンス

サーバ障害時のリクエストでは必ず、クライアントに-ERR <errno description>というレスポンスを生成して返す(redisの場合の形式)。

-ERRSERVER_ERROR(こちらは memcached の場合) というレスポンスをチェックすることで、瞬間的な障害が起きた場合に、オリジナル・リクエストをするクライアントはリトライをすることができるようになる。

read, writev and mbuf

(↑の方に書いた ゼロ・コピー の話と同じっぽいので読み飛ばした)

mbuf-size=N の引数はどう考えたらよい?

クライアントからの接続は全て、少なくとも1つの mbuf を消費する。
1つのリクエストの受付に、2つのコネクションを必要とする。1つはクライアントからプロキシ、もう1つはプロキシからサーバだ。

断片化するリクエスト、例えばget foo bar\r\nのような、はget foo\r\nget bar \r\nに断片化し、2つの mbuf をリクエストに、さらに2つの mbuf をレスポンスに消費する。
よってN個に断片化するリクエストは 2N の mbuf を必要とする。
mbuf については、良い言い方をすればメモリは再利用プールから与えられる。一度 mbuf がアロケートされると、二度と開放されることはなく、再利用プールに戻される。
悪い言い方をすれば、一度 mbuf がアロケートされたら二度と開放されず、開放された mbuf は再利用プールに必ず戻されてしまう。
これはしかしながら、再利用プールの閾値のパラメータを与えることで、簡単に修正できる。

よってもし nutcracker が1000台のクライアントからの接続と、100台のサーバへの接続を扱っているのであれば、最大で (max(1000, 100) * 2 * mbuf-size) 分のメモリを mbuf に消費する。
もしクライアントがパイプされていないリクエストを送ってきたとすると、デフォルトの mbuf-size である 16K で計算して、合計で 32M を消費することになる。

もっと言うと、もし平均で各リクエストが10のフラグメント(断片)から成っているとすると、メモリの消費は 320M にもなる。
クライアント接続が1000ではなく10000だったら、メモリ消費は3.2Gにもなるだろう。
mbuf-size をデフォルトの16Kではなく 512bytes とすれば、同じシナリオの場合でも 10M 程度までメモリ消費量は落ちる。

これが、大量のコネクションや、multi-get のような幅広いリクエストに対して、小さな 512 のような小さな mbuf-size を割り当てるべき理由である。

最大キー長

memcached の ascii プロトコルの仕様では、キーは最大で250文字となっている。
キーは空白や\r\nといった文字も含を含まない。
redis に対しては、こうした制限はない。
しかしながら、 nutcracker ではメモリの隣り合った領域にキーが格納される。
nutracker では全てのリクエストとレスポンスが mbuf に格納されるため、redis のキー長は mbuf 内でデータが専有可能な最大サイズで制限される(mbuf_data_size())。
これは、もし redis インスタンスがおおきなキーを扱う場合、それに見合う大きな mbuf サイズを-mまたは--mbuf-size=Nでコマンドライン引数として指定する必要がある。

Consistent Hashing のためのノード名

twemproxy におけるサーバ・クラスタは、以下のどちらの文字列リスト形式でも記述することができる。

  • host:port:weight
  • host:port:weight name

具体的には

servers:
 - 127.0.0.1:6379:1
 - 127.0.0.1:6380:1
 - 127.0.0.1:6381:1
 - 127.0.0.1:6382:1

または

servers:
 - 127.0.0.1:6379:1 server1
 - 127.0.0.1:6380:1 server2
 - 127.0.0.1:6381:1 server3
 - 127.0.0.1:6382:1 server4

前者の設定では、キーは直接 host:port:weight の3つ組にマッピングされ、後者でではノード名(この次に host:port の組にマッピングされる)にマッピングされる。
後者の設定では、ことなるサーバにノードを再割り当てすることが自由であり、それをhash ring の配布なしに行える。
それゆえ、auto_eject_hosts(サーバの自動切り離しのことと思われ)を無効にしている場合は、この設定が理想的である。
詳細は https://github.com/twitter/twemproxy/issues/25 を参照。

ちなみに、ノード名を consistent hashing で使う時は、twemproxy は weight の値を無視するので気をつけられたし。

ハッシュタグ

ハッシュタグ http://oldblog.antirez.com/post/redis-presharding.html は、キーの一部を使ってハッシュ値を計算するのに使われる。
ハッシュタグが存在している場合、タグ内にあるキーの一部を、consitent hashing のキーとして使う。
そうでない場合、キー全体を同じ用途として用いることになる。
ハッシュタグは、タグ内のキーが同じ限りにおいて、異なるキーを同じサーバに割り当てることを可能にする 。

例えば、サーバ・プール beta の設定が以下である時、hash_tag は2つの文字 "{}" で指定されている。
これは、キーuser:{user1}:idsuser:{user1}:tweetsが同じサーバにマッピングされることになる。なぜなら、ハッシュ値はuser1に対して計算されることになるからだ。
一方で例えばuser:user1:idsのようなキーであれば、この文字列全体でハッシュ値を計算することになる。

beta:
  listen: 127.0.0.1:22122
  hash: fnv1a_64
  hash_tag: "{}"
  distribution: ketama
  auto_eject_hosts: false
  timeout: 400
  redis: true
  servers:
   - 127.0.0.1:6380:1 server1
   - 127.0.0.1:6381:1 server2
   - 127.0.0.1:6382:1 server3
   - 127.0.0.1:6383:1 server4

キャッシュ・プールの状態を可視化

nutcracker を本番環境で運用している時、生きているノードと、切り離されたノードを知りたくなるだろう。
この問への答えは簡単で、生きている/死んでいるサーバ(任意のキャッシュプールの一部)を時系列のグラフとして生成すればよい。
これをするためには、グラフ化クライアントが以下の情報を nutcracker の stats から取得する。

  • server_eof: サーバの接続が普通に切られた数。永続的な接続をしているので、これが増えたらおかしい
  • server_timedout: サーバ側の接続タイムアウト数。これが増えたらヤダ
  • server_err: その他、サーバ側のエラー数。これが増えたらヤダ

よって、与えられたサーバにおいて、切り離された数の累積は以下で計算される。

(server_err + server_timedout + sever_eof) / server_failure_limit

えーと、以下、訳すのダルくなったのでそのまま書く

A diff of the above value between two successive time intervals would generate a nice timeseries graph for ejected servers.

You can also graph the timestamp at which any given server was ejected by graphing server_ejected_at stat.

server_connections: > 1

twemproxy は、いくつかのクライアント同時接続と、少ないサーバ接続があるような状況を想定している。
重要なこととして、「read my last write (俺の最後の書き込みを読め)」という制約が必ずしも成立しない場合がある。
それは twemproxy に という設定がされている場合である。

server_connections: > 1

これを説明するにあたり、twemproxy がserver_connections: 2と設定されている状況を考える。
クライアントがパイプされたリクエストを生成し、最初のリクエストがパイプライン中でset foo 0 0 3 \r\nbar\r\n(書き込み)であり、2つ目のリクエストがget foo \r\n(読み込み)である場合、期待される foo に対する値は bar である。
しかしながら、2つのサーバ接続の設定がされていると、write と read が別のサーバに送られる可能性があり、期待するレスポンスが返らないことがある。
要約すると、クライアントが「read my last write」の制約を期待している場合は、server_connections: 1を設定するか、または twemproxy に対して同期的なリクエストをする必要がある。