小ネタです
Varnishを使う上で冗長化をどうしようと悩むことが多々有ります。
単純に横に並べてLBでバランシングしてもキャッシュの同期をどうしようという問題にぶち当たります。
VarnishSoftwareがサブスクリプションで提供しているVarnishPlusでは同一階層のVarnishにおいてキャッシュオブジェクトのレプリケーションを行うVarnish High Availabilityという機能が存在しますがコミュニティ版のVarnishでは存在しません。
(VarnishPlusについてはそのうち記事書こうと思います)
強引にVCLでSquidのsiblingのような動きをするように書くことも出来なくないのですが個人的にはオススメできません。
幾つか理由があるのですが一番大きい理由がRace conditionに陥るからです。
VarnishはこれはThundering Herd問題に対処するために同時に同じリクエストが来た場合でもバックエンド/オリジンに行くリクエストはひとつです。
簡単にいうとロックしています。これは単体サーバではうまく動きますが同一階層においてキャッシュを同期しようとすると問題が起きます。
図のように同時に同じリクエストが来て、どちらもオブジェクトを持っていない場合にRace conditionになります。
これを防ぐためにreq.hash_ignore_busyをtrueにするという手もありますが台数が増えた時にどうするかとか運用が手間ですし、VCLをミスったら破綻するような危険を持つべきではないと考えています。
つまり同一階層においてキャッシュを安全かつ簡単に同期する方法はVarnishPlus以外に存在しません。
また、Varnishはrestartすると基本的にキャッシュがすべて吹き飛びます。
persistentもあるじゃないかという話もありますが非推奨になったうえ運用上ケアすべき問題が多く癖が強過ぎて普通の人には使いづらいです。
これらも含めて様々な問題に対処するために多段構成を組むことがあります。
- 全体でのキャッシュ同期
- 重複リクエストによるオリジン負荷
- サーバダウン時のオリジン負荷
- 効率的なキャッシュの保持
上記は問題の一部ですが1つずつ解説します。
全体でのキャッシュ同期
全体でキャッシュが同期されているということはどういうことでしょうか?
あくまで個人的な考えですが、全体でTTLの整合性がとれていることだと考えています。
例えばTTLが60秒のオブジェクトとVarnish2台で考えてみましょう
- Varnish-1にアクセスしてキャッシュされる(TTL=60s)
- 30秒まつ
- Varnish-2にアクセスしてキャッシュされる(TTL=30s)
- さらに30秒後
- Varnish-1/2両方でキャッシュがexpireする
こんなかんじです。
つまり最初にアクセスされた時間を全体で把握していて、TTL内であればキャッシュを保持していないインスタンスでも経過時間を減算しておくということです。
これは静的コンテンツで上書きをしないのであればさほど考える必要はありませんが(消されたらbanすればOK)動的コンテンツの場合は注意を払う必要があります。
こんなケースを考えてみましょう
- 10分毎に更新されるランキングページがある
- しかし実際はアクセスされる度に集計されてVarnish側でTTLを10分としている
この場合で同期がとれていない場合アクセスの度に最新のランキングだったり、少し前のランキングだったりと目まぐるしく変わる可能性が高いです。
そこで多段構成です。
Varnishの標準で用意されているdirectorはランダムやハッシュ等で振り分けが可能です。
Varnishがキャッシュオブジェクトを特定するのはHostとURLを使っています。(server.ipも使っては居ますがここでは一旦置いておきます)
そこで同じキーを使ってハッシュ振り分けを行うことで同じHostとURLを保つ場合は常に同じ2段目のVarnishにアクセスします。
vcl 4.0; import directors; probe healthcheck { .request = "GET /healthcheck/check.html HTTP/1.1" "Host: xxxx.xxxx" "Connection: close"; .timeout = 2s; .window = 5; .threshold = 3; .interval = 1s; } backend ws01 {.probe=healthcheck;.host = "192.168.1.1";.port = "80";} backend ws02 {.probe=healthcheck;.host = "192.168.1.2";.port = "80";} sub vcl_init{ new ws_hash = directors.hash(); ws_hash.add_backend(ws01, 1.0); ws_hash.add_backend(ws02, 1.0); } sub vcl_recv{ set req.backend_hint = ws_hash.backend(req.url + ":" + req.http.host); }
Varnishはキャッシュしてからの経過時間であるAgeヘッダをレスポンスし、またこれを解釈してTTLから減算します。
つまり図のような構成でHash振り分けを行った場合(TTLは60秒とします)
- (黒線)/hogeにアクセスする。
- 1Aにキャッシュがないので2Aにリクエスト
- 2Aにキャッシュがないのでオリジンにリクエスト
- 2Aでキャッシュする(TTL=60s/Age=0s)
- 1Aでキャッシュする(TTL=60s/Age=0s)
- 10秒待つ
- (赤線)/hogeにアクセスする。
- 1Bにキャッシュがないので2Aにリクエスト
- 2Aがレスポンス(Age=10s)
- 1Bでキャッシュする(TTL=60s/Age=10s)
- 10秒待つ
- (青線)/hogeにアクセスする。
- 1Cにキャッシュがないので2Aにリクエスト
- 2Aがレスポンス(Age=20s)
- 1cでキャッシュする(TTL=60s/Age=20s)
- 40秒後
- 1A/1B/1C/2AにおいてTTL=AgeとなりキャッシュがExpireする
(Expire周辺の計算は変数が多くわりかし複雑なんですがここでは単純化しています)
このようにすべてのオブジェクトが同時に消えることがある程度期待できます。
ここである程度としているのはexpire前にnukeしてしまったり、1段目に行き渡ってない状態で2段目が死んだりした時のことは考えていないからです。
これも考慮に入れる必要がある場合は動的コンテンツ側で適切なヘッダをつける必要があるでしょう。
重複リクエストによるオリジン負荷
Varnishを複数並べる理由はいくつかあります。冗長構成を取るためにだったり、トラフィックが増えてきたのでそれを捌くための増設だったりです。
単純に横に並べてしまうと最悪、同じリクエストで最大並べた台数分のリクエストが来る可能性があります。
これも多段構成にすることで解決できます。
同一リクエストは2段目で必ず同一サーバを経由するために1段目がいくら増えようともオリジンに行くリクエストは1つです。
サーバダウン時のオリジン負荷
キャッシュサーバが落ちれば当然ですがキャッシュが無くなるので再度オリジンに取得しに行きます。
キャッシュに依存しているシステムほどキャッシュが吹き飛んだ時にオリジンの負荷が一気に上がり負荷が増え、最悪の場合連鎖障害になることが有ります。
しかし多段構成を組んでいる場合は余り影響を受けない、もしくは影響を小さくすることが出来ます。
1段目が死亡しても2段目がキャッシュを保持しているのでオリジンの負荷はそこまで増えません。
同じように2段目が死亡しても1段目がキャッシュを保持しているのでオリジンの負荷の上がり方はある程度抑えられます。
効率的なキャッシュの保持
1段目はクライアントからの激しいリクエストを受けるため、高速なstorage(mallocやSSD/PCIeSSDなどのfile)が必要です。
ここはいくらサーバを増やしてもキャッシュの保持容量は単一サーバでのstorageサイズとなります。ランダムにそれぞれのサーバにリクエストされるためです。
もちろん現金で殴るという手段も取れなくはないのですが(僕を現金で殴ってくれる人募集しています)、1台落ちるとわりかし被害が大きくなりやすいのでそこはバランスをみてやるべきでしょう。
多段構成の場合で2段目は多少遅いstorageでも問題がありません。(とはいってもSSDはほしいです)
理由は既に1段目である程度のリクエストをシェーブしているのと、よくアクセスされるオブジェクト(=ホットデータ)はほぼほぼ1段目に集中することが期待できるため全体で高速にレスポンスすることが可能です。
また、ハッシュ振り分けを行う場合は当然ですが2段目で重複オブジェクトを持ちません。
そのためキャッシュの保持容量は単純に足したサイズとなり、よりオリジンの負荷軽減に役に立ちます。
ここまで多段構成イイヨーイイヨーという話をしましたが多段構成でも注意すべきところがあります。
- AppとVarnishが同居している多段構成においての振り分けについて
AppとVarnishが同居している多段構成においての振り分けについて
例えば上図のようにOrigin(App)が分離しているケースは問題ありませんが
このように2段目のVarnishがAppと同居しているケースを考えてみましょう。(2段目のVarnishは必ずlocalのappにリクエストを行う)
当然ながら1段目はハッシュで振り分けを行っています。
そして動的コンテンツの場合はすべてのリクエストをキャッシュ出来ないことが多いです。
むしろキャッシュ出来ないものが多いケースのほうが多いと思います。
当然ながらキャッシュ出来ないリクエストはオリジンに直撃します。
そしてたいていの場合キャッシュ出来ないリクエストは
- 会員情報を扱っていてユーザ毎に内容が異なる
- POSTやPUTなどそもそもオリジンに確実にリクエストを通さないと行けない
- などなど
だったりでだいたい同じURLだったりします。
URLが同じということはハッシュが同一ということなので負荷が寄ります。
せっかく負荷を減らすために多段にしたのに本末転倒といえるでしょう。
じゃぁどうするかというとキャッシュするリクエストとキャッシュしないリクエストで振り分けを変えることです。
vcl 4.0; import directors; probe healthcheck { .request = "GET /healthcheck/check.html HTTP/1.1" "Host: xxxx.xxxx" "Connection: close"; .timeout = 2s; .window = 5; .threshold = 3; .interval = 1s; } backend ws01 {.probe=healthcheck;.host = "192.168.1.1";.port = "80";} backend ws02 {.probe=healthcheck;.host = "192.168.1.2";.port = "80";} backend ws03 {.probe=healthcheck;.host = "192.168.1.3";.port = "80";} backend ws04 {.probe=healthcheck;.host = "192.168.1.4";.port = "80";} sub vcl_init{ new ws_hash = directors.hash(); ws_hash.add_backend(ws01, 1.0); ws_hash.add_backend(ws02, 1.0); ws_hash.add_backend(ws03, 1.0); ws_hash.add_backend(ws04, 1.0); new ws_rand = directors.random(); ws_rand.add_backend(ws01, 1.0); ws_rand.add_backend(ws02, 1.0); ws_rand.add_backend(ws03, 1.0); ws_rand.add_backend(ws04, 1.0); } sub vcl_recv{ ... if(キャッシュするリクエストの場合){ //キャッシュする set req.backend_hint = ws_hash.backend(req.url + ":" + req.http.host); return(hash); }else{ //キャッシュしない set req.backend_hint = ws_rand.backend(); return(pass); } }
こうすることでキャッシュも効率的に行なえますし、負荷も適切に割り振り出来ます。
まとめ
多段構成を行うことで幸せになれるポイントを示せたんじゃないかなと思います。
もちろんデメリットがゼロかというとそうではなく、多段にすることで経由するサーバが増えるためその分latencyは悪化しますが、大抵の場合はそれを補う効果が得られます。
これらのメリットや考えるポイントはごく一部で他にも地域を考慮したりとか3段目つくったりとか、ホットデータ専用の隔離を作ったりとかいろいろ行うことによっていろいろ違います。
これらの階層構造は何も自社環境だけで留まるわけではなく、各CDNもまたひとつの層と考えて最適な構造を考えるのも面白いと思います。
※注意事項
現在(4.0.2)のVarnishですがヘルスチェックで引っかかって振り分け落とされた負荷がそのまま特定のバックエンドに寄るというバグが有ります。
masterでは修正されていますが4.0.3に適用されるか微妙なので注意が必要です。
また修正後についてもハッシュ振り分けについてはfail時の振り分けで全体で再計算が走るので同様に注意が必要です。
本家ではデフォルトで提供するのはシンプルにしたいということでいわゆるconsistent hashingはサポートしないと言っているので
そのような機能があるvmod(vslp)を使うと良いと思います