こんにちは。ホリデー株式会社の内藤です。
ホリデー株式会社では Holiday(https://haveagood.holiday) という新規サービスの開発・運営を行っています。*1
以前投稿した記事でご紹介したように、Holiday では全文検索エンジンとして Elasticsearch を利用しています。
Ruby on Rails で構築されたアプリケーションから Elasticsearch を操作するには、公式 gem である elasticsearch-railsを使うのがとても便利です。 もちろん、Holiday でも活用させてもらっています。
大方の機能についてはこの gem で提供されるもので満足だったのですが、一点だけ、Holiday の運用をしている中で困ることがありました。 それが、サービス公開後のインデックスの再構築です。
elasticsearch-rails
gem には、データのインポート用の Rake Taskが既に用意されています。
使い方は非常に簡単で、下記のようにタスクファイルを作成しrequire
文を一行加えるだけで、
# lib/tasks/elasticsearch.rakerequire'elasticsearch/rails/tasks/import'
マッピングの再構築およびデータのインポートを行う処理を呼び出すことができます。
$ bundle exec rake environment elasticsearch:import:model CLASS='Spot'FORCE=y
しかし、このタスクを実行すると既存のインデックスが上書きされてしまい、まっさらな状態に一度初期化されてから、マッピング定義やデータのインポートが行われることになります。 つまり、この間は適切な検索結果を返すことができなくなるため、サービスを停止せざるを得ないという状況になってしまいます。
サービスを運用していると、「マッピング定義を変更したい」「アナライザーの定義を見直してインデックスを作りなおしたい」ということが度々起きます。 その度にメンテナンス画面を掲出するとなると、継続的にユーザにサービスを提供することができなくなってしまいます。
そこで、サービスを停めること無くインデックスを作り直すためにはどうすればよいのかについて考える必要があります。
本稿では、elasticsearch-rails
gem を使う前提で、前述の問題を解決する方法を実装例を交えて紹介しようと思います。
基本的な考え方
無停止でのインデックス再構築を行うためのアイデアは、Elasticsearch の公式ブログで紹介されています。 Changing Mapping with Zero Downtime
この記事によると、Index Aliasesの仕組みを利用することでこれを実現できるそうです。
Elasticsearch では、インデックスに対して、エイリアス(別名)をつけることができます。
例えば、spots-v1
というインデックスに対して、spots
というエイリアスを付与した場合、spots
に対して行った操作は、実際には spots-v1
に対して行われるようになります。
このようにしておけば、新しいマッピング定義でインデックスを作り直す場合には、裏側で spots-v2
を作っておき、準備完了後に spots-v2
に spots
エイリアスを貼り替えることで、サービスを停止することなくインデックスの再構築ができるというわけです。
では、上記の仕組みを実装に落とし込んでみます。
※ 本稿で紹介するサンプルコードは、GitHub 上でも公開しています。 https://github.com/9toon/es-reindexing-sample
ここからの例で使う Spot
モデルの基本的な設定は以下の通りです。
# app/models/spot.rbclassSpot< ActiveRecord::BaseincludeElasticsearch::ModelincludeElasticsearch::Model::Callbacks index_name "#{Rails.env}-#{Rails.application.class.to_s.downcase}-#{self.name.downcase}" mapping do indexes :id, type: 'string', index: 'not_analyzed' indexes :spot_name, type: 'string', analyzer: 'kuromoji' indexes :address, type: 'string', analyzer: 'kuromoji' indexes :location, type: 'geo_point'end settings index: { number_of_shards: 1, number_of_replicas: 0, } defas_indexed_json(options = {}) { 'id' => id, 'spot_name' => name, 'address' => address, 'location' => "#{lat},#{lon}", } endend
index_name
は参照するインデックス名を指定するのに用います。
実際には、上記で説明したように同名のエイリアスを作成し、それを参照することになります。
インデックスの作成
まずは、インデックスを作成する処理を見てみます。
新しいインデックスを作成する Rake Task は以下のようになります。
# lib/tasks/elasticsearch.rake namespace :elasticsearchdo namespace :indexdo desc "Create a new index. Specify IMPORT=1 for rebuilding from resource" task create: :environmentdo new_index_name = "#{Spot.index_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}" puts "========== create #{new_index_name} =========="Spot.create_index!(name: new_index_name) ifENV['IMPORT'].to_i.nonzero? puts "========== import #{new_index_name} from data sources ==========" batch_size = ENV['BATCH_SIZE'] || 1000Spot.__elasticsearch__.import(index: new_index_name, type: Spot.document_type, batch_size: batch_size) endendendend
インデックス名については、new_index_name = "#{Spot.index_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}"
としているように、モデル内で指定した index_name
の末尾に日時を加えています。
こうすることで、一意にインデックス名を定めることができますし、いくつかの世代のインデックスがあった場合に、どちらがより新しいのかが分かりやすくなるという効果もあります。
上記タスクに含まれる Spot.create_index!
の中身は以下の通りです。
# app/models/spot.rbclassSpot< ActiveRecord::Base ... class<< selfdefcreate_index!(name: ) client = __elasticsearch__.client client.indices.create( index: name, body: { settings: self.settings.to_hash, mappings: self.mappings.to_hash } ) endendend
では、このタスクを実行します。
$ bundle exec rake environment elasticsearch:index:create IMPORT=1========== create development-esreindexingsample::application-spot_20150924_141353 ==================== import development-esreindexingsample::application-spot_20150924_141353 from data sources ==========[INDEX][Spot] Created: development-esreindexingsample::application-spot_20150924_141353
インデックスが正常に作成されたか確かめてみます。
# GET http://localhost:9200/development-esreindexingsample::application-spot_20150924_141353?pretty=1 { "development-esreindexingsample::application-spot_20150924_141353" : { "aliases" : { }, "mappings" : { "spot" : { "properties" : { "address" : { "type" : "string", "analyzer" : "kuromoji" }, "id" : { "type" : "string", "index" : "not_analyzed" }, "location" : { "type" : "geo_point" }, "spot_name" : { "type" : "string", "analyzer" : "kuromoji" } } } }, "settings" : { "index" : { "creation_date" : "1443071633270", "number_of_shards" : "1", "number_of_replicas" : "0", "version" : { "created" : "1070199" }, "uuid" : "TCiNzSnuRIqsj1ZIwM1iOg" } }, "warmers" : { } } }
このように、development-esreindexingsample::application-spot_20150924_141353
という名前で、正常に新しいインデックスが作られたことが確認できました。
エイリアスの貼り替え
あとは、このインデックスに対してエイリアスを付与することで、新旧のインデックスを切り替えられるようにします。 エイリアスを切り替えるタスクは以下の通りです。
# lib/tasks/elasticsearch.rake namespace :elasticsearchdo namespace :aliasdo task switch: :environmentdoraise"INDEX should be given"unlessENV['INDEX'] new_index_name = ENV['INDEX'] puts "========== put an alias named #{Spot.index_name} to #{new_index_name} =========="Spot.switch_alias!(alias_name: Spot.index_name, new_index: new_index_name) endendend
Spot.switch_alias!
の中身は次のようになっています。
# app/models/spot.rbclassSpot< ActiveRecord::Base ... class<< selfdefswitch_alias!(alias_name: , new_index: ) client = __elasticsearch__.client old_indexes = client.indices.get_alias(index: alias_name).keys actions = [] actions << { add: { index: new_index, alias: alias_name } } old_indexes.each do |old_index| actions << { remove: { index: old_index, alias: alias_name } } end client.indices.update_aliases(body: { actions: actions }) endendend
このメソッドは、先ほど作成したインデックスに対してエイリアスを付与し、古くなったインデックスからエイリアスを除去する役割を担います。
このメソッドの内部では、update_aliasesメソッドが呼ばれます。 このメソッドによって、エイリアスの追加・削除を一回のリクエストで同時に行うことができます。
ではこのタスクを呼び出してみましょう。
$ bundle exec rake environment elasticsearch:alias:switch INDEX='development-esreindexingsample::application-spot_20150924_141353'========== put an alias named development-esreindexingsample::application-spot to development-esreindexingsample::application-spot_20150924_141353 ==========
エイリアスの貼り替えが正常に行われたかを確認します。
# GET http://localhost:9200/development-esreindexingsample::application-spot?pretty=1 { "development-esreindexingsample::application-spot_20150924_141353" : { "aliases" : { "development-esreindexingsample::application-spot" : { } }, "mappings" : { "spot" : { "properties" : { "address" : { "type" : "string", "analyzer" : "kuromoji" }, "id" : { "type" : "string", "index" : "not_analyzed" }, "location" : { "type" : "geo_point" }, "spot_name" : { "type" : "string", "analyzer" : "kuromoji" } } } }, "settings" : { "index" : { "creation_date" : "1443071633270", "number_of_shards" : "1", "number_of_replicas" : "0", "version" : { "created" : "1070199" }, "uuid" : "TCiNzSnuRIqsj1ZIwM1iOg" } }, "warmers" : { } } }
このように、先ほど作成したインデックスに対して、エイリアスが貼られているのを確認できました。
まとめ
ここまでで、無停止でのインデックス再構築を行える環境が整いました。
マッピングの再定義やアナライザーの設定変更を無停止で行えるようになると、検索改善の施策を気軽に試すことができるようになります。 新しく作った定義がちょっと違うなーとなれば、ひとつ古いインデックスにエイリアスを貼り替えることで、即座にロールバックすることも可能です。
プロダクション環境で Elasticsearch を使う際には、インデックスを直に指定するのではなく、エイリアスを使って指定するようにしておけば、サービスの運用が非常にやりやすくなるのでオススメです。
本稿が、Rails と Elasticsearch を使っている方々にとって少しでも参考になっていれば幸いです。
なお Holiday では、日本の休日をもっと楽しくしたいエンジニアを募集しています。 もしご興味をお持ちいただけた方がいらっしゃいましたら、ぜひぜひご応募ください!
ご応募はこちらから ↓
*1:元々はホリデー事業室というクックパッド社内の一部署という建て付けで活動していましたが、今年の4月からクックパッドの完全子会社として分割されました