localhost上にDockerでコンテナ化したElasticsearchクラスタを立てて自分用コマンド検索エンジンを作る

newuniverse
145

この記事は RECRUIT MARKETING PARTNERS Advent Calendar 2015 の投稿記事です。

こんちには、料理サプリサーバーサイド担当のnewuniverseです!今回は最近自分の個人プロジェクトで勉強しているElasticsearchについての知見を皆さんに共有したいと思います。

TL;DR

目的

Elasticsearchの入門とDockerと組み合わせて使えること目指す。

課題

エンジニアが普段使っているコマンドって忘れがち。検索できればなぁ……。

目標

crtl + r で履歴検索すればいいところを、あえてElasticsearchでコマンド用の検索エンジンをローカル環境で構築してみる。日本語で説明文入力できて、さらに日本語でも検索可能にする。

環境

  • MacBook Pro (Retina 13-inch、Early 2015)
  • OS X Yosemite
  • Docker version 1.9.0, build 76d6bc9
  • Elasticsearch 2.0.1

手順

  1. Elasticsearch officialのDockerfileレポジトリをいじってdocker imageを作る
  2. localhostでESコンテナを複数立ち上げ、ESクラスタをつくる
  3. sense上でkuromoji-pluginによる日本語検索、コマンド検索
  4. ターミナルから使いやすいようにシェルスクリプトを書く

完成予定図

overview

Elasticsearchの概要

複数のElasticsearchサービスを起動しているnodeをクラスタして、スケールアウトのし易い分散検索エンジンを提供してくれます。今回は1コンテナをelasticsearchの1 nodeとしてlocalhost上でElasticsearchクラスタを形成します。

ElasticsearchではJSON形式のドキュメント(Document)としてデータが保存されます。データの持ち方をRDBと比較してみると以下のようになります。

Relational DB Databases Tables Records Columns
Elasticsearch Indices Types Documents Fields

完成予定図の赤の点線がElasticsearchのindexとなり、それらは一つ以上のshardによって構成されます。

shardはElasticsearchの土台となる検索エンジンライブラリApache Luceneのindexに等しいです。入力されたデータはshardsにストアされます。ここにもindexという言葉が出てくるので、混乱しないよう気をつけて下さい。

Elasticsearch Indexは複数のLucene indices(shards)の集まりと考えられ、Elasticsearchはnode間にまたがるshardsをElasticsearchのIndexにします。基本的にshardはprimaryとreplicaの2種類に分かれています。RDBでいうmasterとslaveと考えてもらっても構いません。shardsの管理などはここでは深く言及しませんが、shardsはデフォルトでprimaryとreplicaがnodes間で分散するように配置され、一つのnodeが落ちてもデータを失わないように設計されています(下図参照)。もしprimaryが失われた場合は自動でreplicaがprimaryへと昇格されます。

node_failure

検索やCRUD操作はRESTfulなAPIが提供されており、一般には9200ポートにhttpリクエストを送る方式を取ります。

# 例:Elasticseachに検索クエリを投げる
GET /INDEX_NAME/TYPE_NAME/_search
{
    "query": {
        "match_phrase": {
            "hobby": "jogging and climbing"
        }
     }
}

Dockerfileレポジトリをいじる

それでは目標達成に向けて実際の作業に入ります。Docker HubにはESの公式imageがすでに存在しますが、そのままのimageを使うと必要とするpluginがなかったり設定がデフォルトになるので、公式のDockerfileに少し手を加えます。

git clone https://github.com/docker-library/elasticsearch.git
cd ./docker-library/elasticsearch/2.0
ls 
Dockerfile           config               docker-entrypoint.sh
ls config/
elasticsearch.yml    logging.yml

ここでconfigディレクトリとdocker-entrypoint.shはDockerfile内で使われるとだけ覚えておいてください。それではDockerfile内を覗いていきましょう!

FROM java:8-jre

...

COPY config /usr/share/elasticsearch/config

VOLUME /usr/share/elasticsearch/data

COPY docker-entrypoint.sh /

ENTRYPOINT ["/docker-entrypoint.sh"]

EXPOSE 9200 9300

CMD ["elasticsearch"]

ここでは説明に必要な部分のみ抜粋しています。COPY config /usr/share/elasticsearch/configでは正に先ほどのconfigディレクトリ(Elasticsearchの設定ファイル)をコンテナ内環境にコピーしています。imageを作る前にここも先に設定してしまおうということですね。

ENTRYPOINT ["/docker-entrypoint.sh"]docker runする際に実行されるコマンドがdocker-entrypoint.shに記述されています。

今回日本語を扱うため日本語用アナライザーkuromoji plugin、そしてESのクラスタの状態モニタリングや監視ができるmarvel pluginを入れたいと思います。marvelは、ESのデータ可視化ツールKibanaに依存しますので、後ほどKibana専用のdocker imageも作成したいと思います1)もちろんESと同じコンテナ内にインストールしてもいいのですが、Dockerの軽量性を失いたくないのと、Kibanaが落ちてESのnodeも一緒に落ちては困ります

FROM java:8-jre

...
# marvelをインストール
RUN plugin install license
RUN plugin install marvel-agent

# kuromojiをインストール
RUN plugin install analysis-kuromoji

COPY config /usr/share/elasticsearch/config

...

以上のようにESのプラグインコマンドを実行するように記述しましょう!

次はESのconfigディレクトリ内のelasticsearch.ymlを編集していきます。今回ESのlogは扱いませんのでlogging.ymlの設定は行いません。

network.host: 0.0.0.0

中身がこれだけなので、ここで必要最低限の設定を追記していきます。

# bindするhostとCluster内でnodeがお互いをコネクトする際にpublishするhostを一度に指定している
network.host: 0.0.0.0

# ESでは同じネットワーク内の同じクラスタ名を持つnodeでクラスタを形成する
cluster.name: rmp-advent-es

# nodeを区別するため名前をつけますが、動的に生成したいので環境変数で設定しましょう。
# コンテナを起動する際に環境変数からNODE_NAME=hogeで渡してあげます
node.name: ${NODE_NAME}

# mac環境でdockerを動かす際、virtualboxで仮想マシンを起動し、その環境でコンテナを立てる
# Elasticsearchのクラスタはzen discoveryを使ってnodeを探すので、仮想マシンhostのipを明示的に渡す。
discovery.zen.ping.unicast.hosts: ["192.168.99.100"]
# MacでDocker環境を開くときに付与されるIPがこれです。
docker is configured to use the default machine with IP 192.168.99.100

docker build -t rmp_advent_es:latest ~/docker-library/elasticsearch/2.0

より詳しい設定を知りたいという方はES configurationを参考にしてみてください。

このパートで最後となるKibanaのdocker imageを作ってみましょう。要領はElasticsearchと同じで、こちらもmarvelとsense pluginをインストールしたいので、公式のDockerfileに以下のように追記します。

...
  && tar -xz --strip-components=1 -C /opt/kibana -f kibana.tar.gz \
  && touch /opt/kibana/optimize/.babelcache.json \     #エラー対処
  && chmod 755 /opt/kibana/optimize/.babelcache.json \ #エラー対処
  && chown -R kibana:kibana /opt/kibana \
  && rm kibana.tar.gz
	
ENV PATH /opt/kibana/bin:PATH

RUN kibana plugin --install elasticsearch/marvel/latest
RUN kibana plugin --install elastic/sense
...
docker build -t rmp_advent_kibana:latest ~/docker-library/kibana/4.2

docker images
REPOSITORY                      TAG                 IMAGE ID            CREATED             VIRTUAL SIZE
rmp_advent_kibana     latest              891c4799f8cc        32 minutes ago      294.9 MB
rmp_advent_es         latest              b12fbe8fd217        41 minutes ago      345.3 MB
java                            8-jre               20e756f23350        13 days ago         310.5 MB
debian                          jessie              a604b236bcde        13 days ago         125.1 MB

これで下準備は完了です。

localhostでESコンテナを複数立ち上げ、ESクラスタをつくる

ここまででElasticsearchのnodeを作るためのrmp_advent_es imageとkibanaのrmp_advent_kibana imageが揃いました。あとはこれらのimageからコンテナを起動するだけです。

# 一つ目のElasticsearch node
docker run --name es0 -p 9200:9200 -p 9300:9300 -e "NODE_NAME=node0" -d rmp_advent_es
# 二つ目
docker run --name es1 -p 9201:9200 -p 9301:9300 -e "NODE_NAME=node1" -d rmp_advent_es
# 三つ目
docker run --name es2 -p 9202:9200 -p 9302:9300 -e "NODE_NAME=node2" -d rmp_advent_es
# Kibana
docker run --name kibana -p 5601:5601 -e "ELASTICSEARCH_URL=http://192.168.99.100:9200" -d rmp_advent_kibana

docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                                            NAMES
14ec90507af1        rmp_advent_kibana   "/docker-entrypoint.s"   5 hours ago         Up 5 hours          0.0.0.0:5601->5601/tcp                           kibana
48febc0fcda2        rmp_advent_es       "/docker-entrypoint.s"   5 hours ago         Up 5 hours          0.0.0.0:9202->9200/tcp, 0.0.0.0:9302->9300/tcp   es2
09b5b588dce9        rmp_advent_es       "/docker-entrypoint.s"   5 hours ago         Up 5 hours          0.0.0.0:9201->9200/tcp, 0.0.0.0:9301->9300/tcp   es1
a8c68d79fdef        rmp_advent_es       "/docker-entrypoint.s"   5 hours ago         Up 5 hours          0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp   es0

docker run ... コンテナ起動
--name es0 ... コンテナに名前を付与
-p 9200:9200 ... ホストの9200をpublishして、コンテナの9200にforward
-p 9300:9300 ... クラスタ間のコミュニケーションは9300番台を使うので、nodeとして見つかるためホストの9300番台をコンテナの9300にforwardさせる
-e "NODE_NAME=node0" ... node名を環境変数で渡す

クラスタの状態はブラウザからhttp://192.168.99.100:9200/_cluster/health?prettyにアクセスすることで確認できます。

sense上で日本語検索、コマンド検索

kibanaにsenseをインストールしたので、webクライアントから色々クエリを投げてみましょう。
http://192.168.99.100:5601/app/senseにまずアクセス。

sense

senseを開くと上図のような画面が現れます。Serverというテキスト欄にElasticsearch nodeのアドレスを入れましょう。senseは言わばRubyのirb、Swiftのplaygroundのようなもので、クエリを記述できるエディタ(左)と結果を表示してくれる機能(右)を提供してくれます。

まずはElasticsearchのindex(database)の作成とその設定をします。上図のPUT /rmp {...}の部分で行っています。queryのパラメータ({}の部分)はJSON形式で書きます。

主にkuromojiの設定を行っています。設定の解説については省略するのでこちらを参考にして下さい。

次にPUT /rmp/commands/1部分ではidを指定して、rmp indexの中のcommands typeにcommanddescriptionというfieldをもつドキュメントを作成しています。

//結果
{
  "_index": "rmp",
  "_type": "commands",
  "_id": "1",
  "_version": 1,
  "_shards": {
    "total": 2,
    "successful": 2,
    "failed": 0
  },
  "created": false
}

予めidを持たない、あるいはidを指定したくない場合POST /rmp/commandsのように作成することも可能で、以下のレスポンスを見ると"_id": "AVFrA4-VEhQu5QLbLVdu",となっていることが確認できます。

//結果
{
  "_index": "rmp",
  "_type": "commands",
  "_id": "AVFrA4-VEhQu5QLbLVdu",
  "_version": 1,
  "_shards": {
    "total": 2,
    "successful": 2,
    "failed": 0
  },
  "created": true
}

作成したドキュメントはGET /rmp/commands/1として取得することが可能です。

//結果
{
  "_index": "rmp",
  "_type": "commands",
  "_id": "1",
  "_version": 2,
  "found": true,
  "_source": {
    "command": "ls -la",
    "description": "ディレクトリの情報を全部見る"
  }
}

最後に簡単な検索を試します。ElasticsearchではQuery DSLを使って検索をかけるのが一般的になります。

//Query DSLテンプレート
"query": {
    QUERY_NAME: {
        ARGUMENT: VALUE,
        ARGUMENT: VALUE,...
    }
}

GET /rmp/commands/_searchで「プロセス」という単語にマッチするドキュメントを検索しています。
検索結果はレスポンスの"hits"に格納されていて、ヒット件数(total)やヒットしたドキュメントとクエリとの関連度のスコア(_score)を出してくれます。

全文検索についてはこちらから読み進めていくことをオススメします。

ターミナルから使いやすいようシェルスクリプトを書く

Kibanaのsense pluginから色々とElasticsearchのAPIの使い方を紹介しました。

ただ毎度senseを開いてAPIを叩きに行くのは面倒なので、ここでは簡単なシェルスクリプトを書いて、コマンドでElasticsearchにドキュメントのinsertとsearchができるようにします(もちろんgo言語など好きな言語で処理を書いても構いません)。

#insert.sh
#!/bin/sh
read -p "Please input your command: " input_command
read -p "Please add description: " description

params="
{
  \"command\": \"${input_command}\",
  \"description\": \"${description}\"
}"
curl -XPOST "http://192.168.99.100:9200/rmp/commands" -d "${params}"

insert.shではcommandとdescriptionのを入力するよう求め、Query parameterを作ってcurlコマンドでPOSTしています。

#search.sh
#!/bin/sh
params="
{
  \"query\":
    {
      \"multi_match\":
      {
        \"query\": \"$1\",
        \"fields\": [ \"command\", \"description\" ]
      }
    }
}"
curl -XGET "http://192.168.99.100:9200/rmp/commands/_search?pretty&filter_path=hits.hits._source&_source=description,command" -d "${params}"

search.shではコマンドの第一引数をクエリテキストとして使い、_search APIの引数に&filter_path=hits.hits._source&_source=description,commandを入れることで、レスポンスに必要な情報だけを出すようにフィルタリングしてあります。

使用した結果を見てみます。

$ ./insert.sh
Please input your command: ssh -i ~/.ssh/id_rsa rmp@123.456.78.9 -p 12345
Please add description: 踏み台へアクセス
{"_index":"rmp","_type":"commands","_id":"AVFrssVbEhQu5QLbLX-1","_version":1,"_shards":{"total":2,"successful":2,"failed":0},"created":true}%

$ ./search.sh 踏み台
{
  "hits" : {
    "hits" : [ {
      "_source":{"description":"踏み台へアクセス","command":"ssh -i ~/.ssh/id_rsa rmp@123.456.78.9 -p 12345"}
    } ]
  }
}

作業は以上です。検索ライフをエンジョイしていきましょう!w

終わりに

執筆中にElasticsearchの2.1とKibanaの4.3がリリースされていて、『アップデート速っ!』となりました。今回データの永続化などに関して触れられなかったので、そちらに関しては次回あらためてご紹介します。また、今後はクラウド環境などでマルチホストでElasticsearchのクラスタを構築することを試してみようと考えています。こちらにつきましてもいずれご紹介できたらと思っています。

脚注

脚注
1 もちろんESと同じコンテナ内にインストールしてもいいのですが、Dockerの軽量性を失いたくないのと、Kibanaが落ちてESのnodeも一緒に落ちては困ります