検索システムだって高可用性にしたい!SolrCloudを用いた高可用性構成の紹介

f:id:vasilyjp:20180927112657j:plain

こんにちは、バックエンドエンジニアの塩崎です。 最近のTECH BLOGではMatzさんのインタビュー記事を書いたり、RubyKaigiの発表まとめを書いたりして、他人の褌で相撲を取っていました。 今回は心を入れ替えて(?)、自分自身が取り組んだ内容について書きます。

VASILYでは検索用のミドルウェアとしてApache Solr(以下、Solr)を使用しています。 全文検索や、ファセット機能などはMySQLだけでは不十分なために、Solrを併用しています。

Solrのサーバー構成例にはいくつかのパターンがありますが、今回はその中でも最も可用性の高いSolrCloudをサービスインしたので、それについて紹介を行います。

Solrの構成例を幾つか紹介

Solrの構成例は大きく以下の3つに分けられます。 まずは、それぞれについて詳しく説明していきます。

  • スタンドアローン構成
  • master slave構成
  • SolrCloud構成

スタンドアローン構成

f:id:vasilyjp:20180121190746p:plain

スタンドアローン構成は3つの構成例のなかで最もシンプルな構成です。 Solrサーバーは1台のみで、その1台がclientからのすべてのread/writeリクエストを受け付けます。

この構成ではSolrサーバーが死んだ場合にはすべてのread/writeリクエストができない状態になってしまいます。

master slave構成

f:id:vasilyjp:20180121190800p:plain

更新系のリクエストを担当するmaster nodeと参照系のリクエストを担当するslave nodeからなる構成です。 検索インデックスの更新はmaster nodeのみで行い、構築済みのインデックスをslave nodeにコピーします。 インデックスのコピーにはSolrのreplication機能を使います。

参考: Index Replication

この構成ではslave nodeの台数を1台以上にすることで参照系の可用性を上げることができます。 すべてのslave nodeが死なない限り、参照系のリクエストを処理することができます。

他方で書き込み系は冗長構成をとっていないため、唯一のmaster nodeが死んだ場合にはすべての書き込みリクエストが失敗します。

なお、場合によってはSolrクライアントとSolr slaveの間にあるロードバランサーを省略することもできます。 Solrクライアント自身が適当なslave nodeへの通信の振り分けと、各slave nodeの死活監視をすれば、ロードバランサーは不要になります。

SolrCloud構成

f:id:vasilyjp:20180121190814p:plain

SolrCloud構成は基本的にはmaster slave構成と同じですが、より可用性が上がった構成です。

更新系のリクエストを処理するleader nodeと参照系のリクエストを処理するfollower nodeがあります。 そして、それらの間のインデックスの同期はreplication機能によってなされます。

master slave構成との大きな違いはleader node(master node)に障害が発生したときの挙動です。 SolrCloud構成ではleader nodeに障害が発生した場合には、適当なfollower nodeが新しいleader nodeになります。 そのため、クラスタ内のどの1台が死んでも、クラスタとしては正常に動作することができます。

これらの機能を実現するためには、現在のクラスタ情報を保持したり、新たなleader nodeを選出するための仕組みが必要です。 SolrCloudではその機能をzookeeperに担わせています。 そのため、Solrクライアントはzookeeperに対して、現在のクラスタ情報を問い合せて、適切なノードに対して通信を振り分ける必要があります。 また、nodeの追加や削除でクラスタ情報が更新された時にはクライアントもそれに追従する必要があります。

さらに、SolrCloud構成ではシャーディングによる水平分割もサポートしているため、leader nodeの負荷分散を行うこともできます。

それぞれの特徴まとめ

各構成の特徴を「必要な台数」と「可用性」という観点からまとめてみました。

スタンドアローン master slave SolrCloud
必要な台数 1 1 + slave台数 可用性の要求次第で何台でも + zookeeper
参照系が死ぬ条件 1台しかないnodeが死んだとき 全slave nodeが死んだとき 全nodeが死んだとき
更新系が死ぬ条件 1台しかないnodeが死んだとき 1台しかないmaster nodeが死んだとき 全nodeが死んだとき

注意が必要な点としては、SolrCloud構成ではzookeeperの構築も必要な点が挙げられます。 zookeeperはSolrCloudの中で非常に重要な役割を果たすため、zookeeper自体の冗長構成も必要です。 zookeeperを冗長構成にするためには最低3台のnodeが必要なので、その考慮が必要です。

上の表を見ると、SolrCloud構成のみが参照系・更新系ともに単一障害点(SPOF)をなくすことのできる構成なことがわかります。 そのかわりに、SolrCloud構成では必要とされるサーバーが多くなりがちです。 SPOFをゼロにしようとした場合は、Solrサーバー 2台とzookeeperサーバー3台の合計5台が最低限必要です。 そのため、個人サービスやまだ小規模なサービスであれば、スタンドアローン構成やmaster slave構成を採用するのが良いのではないかと思います。

SolrCloudの検証

構築してみる

さて、ここからはSolrCloudの検証について話していきます。 対象読者はSolrCloudの構築をしたことはないけど、スタンドアローン構成やmaster slave構成のSolrの構築をしたことがある人です。

Solrの構築を一度もしたことがない人は以下の書籍やオンライン資料などを参考にシンプルな構成を試してみてから読んで見てください。

改訂第3版 Apache Solr入門 Apache Solr Reference Guide

環境

今回の検証は以下の環境で行いました。

$ java -version
java version "1.8.0_73"
Java(TM) SE Runtime Environment (build 1.8.0_73-b02)
Java HotSpot(TM) 64-Bit Server VM (build 25.73-b02, mixed mode)
$ system_profiler SPHardwareDataType
Hardware:
    Hardware Overview:
      Model Name: MacBook Pro
      Model Identifier: MacBookPro11,3
      Processor Name: Intel Core i7
      Processor Speed: 2.5 GHz
      Number of Processors: 1
      Total Number of Cores: 4
      L2 Cache (per Core): 256 KB
      L3 Cache: 6 MB
      Memory: 16 GB
      Boot ROM Version: MBP112.0142.B00
      SMC Version (system): 2.19f12
      Serial Number (system): ************
      Hardware UUID: ********-****-****-****-************

すべてのSolrプロセスは同一のMacの中で起動させています。

検証に使用したSolrのバージョンは2018/01/19時点で最新バージョンである7.2.1です。

構築手順

Solrにはサンプルの環境を簡単に立ち上げることのできるモードがあります。 このモードでは対話的にreplica数やshard数を打ち込むと、それに応じてイイカンジにSolrCloudが立ち上がります。 ですが、あまりにも簡単に立ち上がってしまうので、今回はstep by stepで進めて行きたいと思います。

まずはzookeeperを立ち上げます。 本番環境で運用するときには冗長構成が必要ですが、とりあえずは1台のみの構成にします。

$ wget http://ftp.jaist.ac.jp/pub/apache/zookeeper/zookeeper-3.4.11/zookeeper-3.4.11.tar.gz
$ tar xvf zookeeper-3.4.11.tar.gz
$ cd zookeeper-3.4.11

conf/zoo.cfgに以下の設定ファイルを配置します。

tickTime=2000
dataDir=/tmp/zookeeper
clientPort=2181

以下のコマンドでzookeeperを起動します。

bin/zkServer.sh start

次にSolrCloudのnodeを立ち上げます。 今回はreplica数とshard数の両方を2で構築してみます。 node数は合計で2 * 2 = 4必要です。

SolrCloudでは大抵の設定ファイルはzookeeperによって管理されます。 ですが、zookeeperと接続するための設定だけは各nodeに配置する必要があります。 この部分は共通なので、以下のsolr.xmlを用意します。

<?xml version="1.0" encoding="UTF-8" ?>

<solr>

  <solrcloud>

    <str name="host">${host:}</str>
    <int name="hostPort">${jetty.port:8983}</int>
    <str name="hostContext">${hostContext:solr}</str>

    <bool name="genericCoreNodeNames">${genericCoreNodeNames:true}</bool>

    <int name="zkClientTimeout">${zkClientTimeout:30000}</int>
    <int name="distribUpdateSoTimeout">${distribUpdateSoTimeout:600000}</int>
    <int name="distribUpdateConnTimeout">${distribUpdateConnTimeout:60000}</int>
    <str name="zkCredentialsProvider">${zkCredentialsProvider:org.apache.solr.common.cloud.DefaultZkCredentialsProvider}</str>
    <str name="zkACLProvider">${zkACLProvider:org.apache.solr.common.cloud.DefaultZkACLProvider}</str>

  </solrcloud>

  <shardHandlerFactory name="shardHandlerFactory"
    class="HttpShardHandlerFactory">
    <int name="socketTimeout">${socketTimeout:600000}</int>
    <int name="connTimeout">${connTimeout:60000}</int>
  </shardHandlerFactory>

</solr>

そして、4node分のsolr_homeディレクトリを作り、solr.xmlをコピーします。

mkdir -p solr_home/node1/solr
mkdir -p solr_home/node2/solr
mkdir -p solr_home/node3/solr
mkdir -p solr_home/node4/solr

cp solr.xml solr_home/node1/solr
cp solr.xml solr_home/node2/solr
cp solr.xml solr_home/node3/solr
cp solr.xml solr_home/node4/solr

最後に、以下のコマンドを実行することで各nodeが立ち上がります。

bin/solr start -cloud -s solr_home/node1/solr -p 8001 -z 127.0.0.1:2181 -h 127.0.0.1
bin/solr start -cloud -s solr_home/node2/solr -p 8002 -z 127.0.0.1:2181 -h 127.0.0.1
bin/solr start -cloud -s solr_home/node3/solr -p 8003 -z 127.0.0.1:2181 -h 127.0.0.1
bin/solr start -cloud -s solr_home/node4/solr -p 8004 -z 127.0.0.1:2181 -h 127.0.0.1

この状態で、127.0.0.1:8001にアクセスするとSolrのwebコンソールが見えます。 左側のメニューのCloudという項目がSolrCloud特有の項目で、ここからzookeeper内のデータを見たり、クラスタの状態を確認したりすることができます。 まだこの時点ではcollectionを作っていないため、Cloud画面にはほとんど情報がありません。

f:id:vasilyjp:20180121192108p:plain

次にcollectionを作成します。 ここではポート8001で起動しているnodeに対してcreate_collectionを行っていますが、他のnodeに対してcreate_collectionをしても結果は変わりません。

bin/solr create_collection -c collection1 -d server/solr/configsets/_default -p 8001 -shards 2 -replicationFactor 2

collectionの作成に成功すると、自動的に各nodeからleader選出が行われ、それぞれのnodeがどれかのshardに属します。 どのnodeがleaderになるか、どのnodeがどのshardに属するのかということはその時々で変わります。

f:id:vasilyjp:20180121192121p:plain

次にcollectionに対してfieldを追加していきます。 fieldの追加はweb uiから行う方法とSchema APIから行う方法の2つがあります。 今回はSchema APIを経由して行います。

Schema API

以下のコマンドでpint型のfield1を追加します。

curl -X POST -H 'Content-type:application/json' --data-binary '{
  "add-field":{
     "name":"field1",
     "type":"pint",
     "stored":true,
     "indexed": true }
}' http://localhost:8001/solr/collection1/schema

同様にして、field2、field3も追加します。

Schema APIによるfieldの追加によってzookeeperで管理されているmanaged-schemaファイルの更新が行われます。 そして、その変更を各nodeが取り込むことによって、全nodeでfield追加が反映されます。

ドキュメントの投入を行います。 サンプルとして各fieldに対して0〜100までの乱数を設定したドキュメントを10000件投入します。

require 'rsolr'

solr_host = '127.0.0.1'
solr_port = 8001
solr_collection = 'collection1'

solr_url = "http://#{solr_host}:#{solr_port}/solr/#{solr_collection}"
rsolr = ::RSolr.connect(url: solr_url, retry_503: 5, retry_after_limit: 1)

(1..10000).each do |i|
  doc = {}
  doc[:id] = i
  (1..3).each do |j|
    doc["field#{j}".to_sym] = ::Random.rand(100)
  end
  rsolr.add(doc)
  rsolr.commit
end

ドキュメントの検索は通常のsolrと同じです。 以下のコマンドで1つめのnodeに対して検索を行います。

curl http://localhost:8001/solr/collection1/select?indent=on&q=*:*&wt=json

port番号の部分を変更することで、他のnodeに対して検索リクエストを投げることもできますが、返される結果は同じです。 これは検索リクエストを受け取ったnodeが他のshardのデータを取得し、その結果をマージしているためです。

クライアントから使ってみる

RubyからSolrCloudを使う方法を説明します。

RubyからSolrを使うためにはrsolrというgemを使用することが多いです。 SolrCloudを使うためにはzookeeperへの問い合わせなどが必要なため、rsolrの機能だけでは不十分です。

そのため、enigmoさんが公開しているgemであるrsolr-cloudを併用します。 rsolr-cloudはrsolrのアダプターとして機能し、zookeeperへのクラスタ情報の問い合わせや、リクエストの種類に応じて適切なnodeに通信を振り分ける機能などを提供します。

rsolr-cloud

注意点はrsolr-cloudを使うときにはrsolrのメジャーバージョンを1にする必要があることです。 そのため、以下にGemfileでバージョンを指定する必要があります。

gem 'rsolr', '~>1.1.2' # rsolrのv2以上はrsolr-cloud未対応のため
gem 'rsolr-cloud'

あとは、以下のようにすることで、SolrCloudに対するクエリを投げることができるようになります。

require 'zk'
require 'rsolr/cloud'

zoopeepers = [
  'localhost:2181',
  'localhost:2182',
  'localhost:2183',
]

zk = ZK.new(zookeepers.join(','))
cloud_connection = RSolr::Cloud::Connection.new(zk)
solr_client  = RSolr::Client.new(cloud_connection)

# 必ずcollectionを指定する必要あり
response = solr_client.get('select', collection: 'collection1', params: {q: '*:*'})

可用性の検証

SolrCloudの可用性を検証してみます。 上の環境で作ったSolrCloudの環境で何台かのnodeを落としてみて、その時の挙動を確認してみます。

サーバーの突然死を再現するためには、以下のkillコマンドを使いました。

kill -9 <PID>

注)この検証では、zoopeekerが死ぬことについては想定していません。

無事なケース

leaderが突然死ぬ時

masterが突然死する場合は、書き込みできない期間が数秒間発生してしまいます。 これはSolrCloudクラスターが次のleaderを選出するための期間です。

一方でmaster slave構成の場合には人間が手動でmasterへの昇格を行う必要があり、数分〜数十分かかってしまいがちなことを考えると、SolrCloud構成の可用性の高さがうかがい知れます。

なお、この挙動が嫌な場合には、書き込み処理をsidekiqなどの非同期処理を用いて実装するのが良いと思います。

followerが突然死ぬ時

followerが突然死する場合は一切のリクエストが失敗することなく、SolrCloudは正常に動作を続けます。 これはzookeeperが各nodeの死活監視を行い、Solrクライアントはzookeeper内のドキュメントの変更を検知したときに接続先情報の変更をしているためです。

ダメなケース

上でSolrCloudの可用性が高いことを紹介しましたが、流石のSolrCloudでも復帰できないケースもあります。

シャード内の全nodeが死ぬ時

ある特定のシャードに属するすべてのnodeが死んだ場合、SolrCloudクラスタは正常に機能を提供できなくなります。 そのため、シャード内のレプリカの数は単にreadの負荷分散という観点だけからではなく、可用性という観点からも決める必要があります。

まとめ

SolrCloud構成の利点や構成方法について説明をしました。 いくつかあるSolrの代表的な構成例の中でも一番サーバー台数が必要な構成ですが、可用性も一番高い構成です。 検索サーバーが落ちたことによる緊急メンテナンスが辛かった経験のある方は是非検討してみてください。

VASILYでは現状のサーバー構成に満足せず、ユーザーにとってより良い体験を提供するために常に新しい仕組みを導入していきたい人を募集しています。 興味のある方は以下のリンクからご応募ください。

カテゴリー