Elasticsearch のインデックスを無停止で再構築する

こんにちは。ホリデー株式会社の内藤です。

ホリデー株式会社では 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.rake
require '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 に対して行われるようになります。

f:id:qtoon:20150925151056p:plain

このようにしておけば、新しいマッピング定義でインデックスを作り直す場合には、裏側で spots-v2 を作っておき、準備完了後に spots-v2spots エイリアスを貼り替えることで、サービスを停止することなくインデックスの再構築ができるというわけです。

f:id:qtoon:20150925151144p:plain

では、上記の仕組みを実装に落とし込んでみます。

※ 本稿で紹介するサンプルコードは、GitHub 上でも公開しています。 https://github.com/9toon/es-reindexing-sample

ここからの例で使う Spot モデルの基本的な設定は以下の通りです。

# app/models/spot.rb
class Spot < ActiveRecord::Base
  include Elasticsearch::Model
  include Elasticsearch::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,
  }

  def as_indexed_json(options = {})
    { 'id'        => id,
      'spot_name' => name,
      'address'   => address,
      'location'  => "#{lat},#{lon}",
    }
  end
end

index_name は参照するインデックス名を指定するのに用います。 実際には、上記で説明したように同名のエイリアスを作成し、それを参照することになります。

インデックスの作成

まずは、インデックスを作成する処理を見てみます。

新しいインデックスを作成する Rake Task は以下のようになります。

# lib/tasks/elasticsearch.rake
namespace :elasticsearch do
  namespace :index do
    desc "Create a new index. Specify IMPORT=1 for rebuilding from resource"
    task create: :environment do
      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)

      if ENV['IMPORT'].to_i.nonzero?
        puts "========== import #{new_index_name} from data sources =========="

        batch_size = ENV['BATCH_SIZE'] || 1000
        Spot.__elasticsearch__.import(index: new_index_name, type: Spot.document_type, batch_size: batch_size)
      end
    end
  end
end

インデックス名については、new_index_name = "#{Spot.index_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}" としているように、モデル内で指定した index_name の末尾に日時を加えています。 こうすることで、一意にインデックス名を定めることができますし、いくつかの世代のインデックスがあった場合に、どちらがより新しいのかが分かりやすくなるという効果もあります。

上記タスクに含まれる Spot.create_index! の中身は以下の通りです。

# app/models/spot.rb
class Spot < ActiveRecord::Base

  ...

  class << self
    def create_index!(name: )
      client = __elasticsearch__.client

      client.indices.create(
        index: name,
        body: { settings: self.settings.to_hash, mappings: self.mappings.to_hash }
      )
    end
  end
end

では、このタスクを実行します。

$ 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 :elasticsearch do
  namespace :alias do
    task switch: :environment do
      raise "INDEX should be given" unless ENV['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)
    end
  end
end

Spot.switch_alias! の中身は次のようになっています。

# app/models/spot.rb
class Spot < ActiveRecord::Base

  ...

  class << self
    def switch_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 })
    end
  end
end

このメソッドは、先ほど作成したインデックスに対してエイリアスを付与し、古くなったインデックスからエイリアスを除去する役割を担います。

このメソッドの内部では、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月からクックパッドの完全子会社として分割されました

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/