Elasticsearch のテキスト解析について調べてみた!

前回の記事でマッピングについて調べてみたことを書きましたが、調べていく中でテキスト解析の単語がちらほらと出てきていたので理解しておこうかなと。

テキスト解析とは

公式サイトのドキュメントにて、テキスト解析は以下のように説明されています。日本語に私が訳したので、もしかしたら正確ではないかもしれません。

テキスト解析とは非構造なテキスト、例えばEメールの本文や商品の説明文など、を検索のために最適化された構造に変換するプロセスの事です。

Elasticsearch はテキスト解析を、text フィールドの検索をするとき、またはインデックス化を行うときにするそうです。 text フィールドを含まない場合はこのドキュメント読まなくていいと書いてあるので、テキスト解析が行われるのは text フィールドに限定されているみたいですね。

それで重要になってくるのがトークン化と正規化。ちなみに、これらは英語でそれぞれ Tokenization 、 Normalization と言います。ざっくり説明するとこんなかんじ。

  • トークン化 - テキストを小さい意味のあるまとまりに分解すること。分解された一つの塊をトークンという。
  • 正規化 - それぞれのトークンに対して標準的なフォーマットにすること。例えば大文字を小文字に揃えたり、進行形の形を現在形にしたりして、検索時に完全に一致せずとも、意味として近ければ一致するようにする。

Elasticsearch の Analyzer

Elasitcsearch は Analyzer といわれるものを使って前述のテキスト解析を行います。

Analyzer にはいくつか種類があります。自分で作ることもできるようですが、すでに用意されているものをここでは使ってみます。

Standard analyzer を使ってテキストの解析を行うにはこのようにします。 _analyze に対して POST するとテキストの解析結果を返してくれます。

POST _analyze
{
  "analyzer": "standard",
  "text": "2 birds flew into the Sky."
}
{
  "tokens" : [
    {
      "token" : "2",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<NUM>",
      "position" : 0
    },
    {
      "token" : "birds",
      "start_offset" : 2,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "flew",
      "start_offset" : 8,
      "end_offset" : 12,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "into",
      "start_offset" : 13,
      "end_offset" : 17,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "the",
      "start_offset" : 18,
      "end_offset" : 21,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "sky",
      "start_offset" : 22,
      "end_offset" : 25,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

返ってきた情報にはトークンや、文字の位置、種類などがありますね。

Standard Analyzer 含め、 Analyzer は以下のような構造になっているみたいです。

f:id:bau1537:20200704203147j:plain

Character filter でテキストストリームの変換、 Tokenizerトークン化、 Token filterトークンの変換をして、解析終了といった流れでしょうか。

このようなテキスト解析はドキュメントが保存されるときのテキストフィールド、または、検索するときに全文検索文字列に対して行われます。前者を Index time analysis 後者を Search time analysis と言うようです。それぞれに別の analyzer を使用することができるようですが、殆どの場合において同じものを使うでしょうね。

f:id:bau1537:20200704203205j:plain

Analyzer の検証

上述した REST API を使えば Analyzer の検証をすることができます。

エンドポイントに使用したい Analyzer または、フィルターなどを組み合わせて、その動き方を素早く確認できます。 standard Tokenizer に html_strip Character filter を指定するにはこうします。 standard Tokenizer は文法をベースにしたトークン化を行い、 html_strip はテキストからHTML要素を抜き取ります。

POST _analyze
{
  "tokenizer": "standard",
  "char_filter": ["html_strip"],  
  "text": ["this is a <b>test</b>"]
}
{
  "tokens" : [
    {
      "token" : "this",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "is",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "a",
      "start_offset" : 8,
      "end_offset" : 9,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "test",
      "start_offset" : 13,
      "end_offset" : 21,
      "type" : "<ALPHANUM>",
      "position" : 3
    }
  ]
}

HTML タグを消してテキスト解析されていますね。

このときの Analyzer のイメージはこんな感じ。

f:id:bau1537:20200704203224j:plain

このエンドポイントを使って Stemmer というトークンフィルターも試してみましょう。 Stemmer は複数の言葉に対応している、言葉をその原型に変換するフィルターです。例えば Books から Book に、 walking から walk に変換してくれたりします。

GET /_analyze
{
  "tokenizer": "standard",
  "filter": ["stemmer"], 
  "text": "I had a lot of books."
}
{
  "tokens" : [
    {
      "token" : "I",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "had",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "a",
      "start_offset" : 6,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "lot",
      "start_offset" : 8,
      "end_offset" : 11,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "of",
      "start_offset" : 12,
      "end_offset" : 14,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "book",
      "start_offset" : 15,
      "end_offset" : 20,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

booksbook に変換されてますね。

このときの Analyzer のイメージはこんな感じ。

f:id:bau1537:20200704203238j:plain

シノニム

Analyzer にシノニムの設定をすることで、同義語を変換できるようになります。公式のドキュメントでは domain name system を dns に変換する例が載っていますね。

実際に変換してみるとこんな感じ。

GET /_analyze
{
  "tokenizer": "standard",
  "filter": [{"type": "synonym_graph", "synonyms":["domain name system => dns"]}], 
  "text": "domain name system is fragile"
}
{
  "tokens" : [
    {
      "token" : "dns",
      "start_offset" : 0,
      "end_offset" : 18,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "is",
      "start_offset" : 19,
      "end_offset" : 21,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "fragile",
      "start_offset" : 22,
      "end_offset" : 29,
      "type" : "<ALPHANUM>",
      "position" : 2
    }
  ]
}

解析結果で dns が SYNONYM 型として返ってきていますね。上記の例ではシノニムの定義を直接書きましたが、外部ファイルから読み取ることもできます。

また、フィルターを synonym_graph に設定しましたが、他に synonym というフィルターもあるみたいです。これらの違いはドキュメントに説明があったのですが、正直良くわかりませんでした。 Tokenizer はテキストを Token graph と言われる指向性非循環グラフに変換するのですが、「synonym は複数のトークンを特定の箇所に追加できるけれど Tokeng graph の positionLength という値が不正になる可能性があるので検索するときには気をつけようね」、みたいなことが書いてあります。

検索には synonym_graph 、インデックスの作成には synonym を使用すれば一旦問題はないようです。詳しく書いてある記事はこのあたりでした。

使用したアイコン

Icons made by Freepik from www.flaticon.com

Elasticsearch のマッピングについて調べてみた!

ドキュメントの保存と検索はざっくりとわかったので、マッピングについて調べました。

マッピングとは

ドキュメントがどのように保存され、どのようにインデックスが作成されるかを定義するのがマッピングらしいです。RDBで言うスキーマかなと最初思ったのですが、だいぶ違うものみたいなので同じようなものとは考えないほうがいいと思います。

公式サイトのドキュメントでは以下のように説明がありました。

Mapping is the process of defining how a document, and the fields it contains, are stored and indexed.

Mapping | elastic docs

マッピングは以下の2つの定義を持っています。

  • Meta-fields - ドキュメントに関連付けられたメタデータをどのように扱うかを設定するために使用
  • Fields or properties - ドキュメントに関連するフィールドか、プロパティのリスト

定義だけだとわかりにくいので、実際に触って見る必要がありそうです。

マッピングを静的に設定

マッピングは動的または静的に設定できます。ここでは静的に設定してみます。

マッピングの設定は REST API で行うことができるようです。インデックスを作成すると同時にマッピングを指定します。

PUT /employee
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "age": { "type": "integer" },
      "belog": { "type": "keyword" }
    }
  }
}

プロパティとしてドキュメントが持つフィールドとその格納方法を指定しました。

どのようなマッピングになっているかは以下のリクエストで確認できます。

GET /employee/_mapping
{
  "employee" : {
    "mappings" : {
      "properties" : {
        "age" : {
          "type" : "integer"
        },
        "belog" : {
          "type" : "keyword"
        },
        "name" : {
          "type" : "text"
        }
      }
    }
  }
}

マッピングが正しく設定されているかを検証してみました。 textkeyword フィールドはそれぞれデータの持ち方が異なります。 text全文検索のために Elasticsearch が文字列を解析しますが、 keyword の場合は文字列全体を一つの値として扱うみたいです。ざっくりと説明すると、 text の場合は文字列が要素(動詞とか、形容詞とか)に分割されて、keyword はされないといった感じでしょうか。

text フィールドに対して全文検索すると、文字の一部分が一致するだけでヒットするようです(一部分という表現が曖昧なのはよくわかっていないからです。このあたりは後で調査)。全文検索には match 句を使います。

POST /employee/_doc
{
  "name": "BOOK STORE",
  "age": 27,
  "belong": "normal deveoper"
}
GET /employee/_search
{
  "query": {
    "match": {
      "name": "BOOK"
    }
  }
}
{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.2876821,
    "hits" : [
      {
        "_index" : "employee",
        "_type" : "_doc",
        "_id" : "_tZGCnMBWU8rPLHxmgBS",
        "_score" : 0.2876821,
        "_source" : {
          "name" : "BOOK STORE",
          "age" : 27,
          "belong" : "normal developper"
        }
      }
    ]
  }
}

keyword フィールドは文字列が全部一致しないと検索結果に表示されません。 keyword フィールドに対する文字列の検索では term 句を使用します。検索結果は長いので省略します。

GET /employee/_search
{
  "query": {
    "term": {
      "belong": {
        "value": "normal"
      }
    }
  }
}

文字列全部が一致していれば、ヒットします。

GET /employee/_search
{
  "query": {
    "term": {
      "belong": {
        "value": "normal developper"
      }
    }
  }
}

ちなみに、フィールドのデータ・タイプは以下に網羅されています。詳細な定義や説明も載ってます。

複数フィールドの設定

Elasticsearch では一つのフィールドに対して、データ・タイプを複数設定することが可能です。例えば textkeyword を同時に定義し、検索するときにどちらのデータ・タイプとして使用するか、指定します。複数のデータ・タイプを持てることによって、検索がしやすくできるということでしょうね。

というわけで、上記でやってみたマッピング定義を再び使い、複数フィールドの設定をしてみます。

一度インデックスを削除。

DELETE /employee

再び、インデックスを作成すると同時にマッピングを定義します。

PUT /employee
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "age": { "type": "integer" },
      "belong": { 
        "type": "text",
        "fields": {
          "raw": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      }
    }
  }
}

上記では belong フィールドに複数のデータ・タイプを定義しました。 fields という項目がありますが、この項目を指定することで複数のデータ・タイプを定義できます。 fields には2つ目のデータ・タイプと、2つ目の名前を定義します。上記では raw という名前で keyword データ・タイプを定義しました。 ignore_above という項目は keyword データ・タイプを使用するときに追加で指定できる項目で、文字列の長さが指定数以上のときにインデックスを作らない設定ができます(おそらく負荷の関係?)。

検索はこうなります。

belongtext として使う場合。

GET /employee/_search
{
  "query": {
    "match": {
      "belong": "normal"
    }
  }
}

belongkeyword として使う場合。

GET /employee/_search
{
  "query": {
    "term": {
      "belong.raw": {
        "value": "normal developper"
      }
    }
  }
}

メタフィールド

マッピングではメタフィールドの振る舞いを設定できるみたいです。メタフィールドは全て _ で始まる規則があるようで、検索結果の値に含まれています。また、検索にも使用することができます。

メタフィールドは以下のページに一覧と詳しい解説があります。

検索結果に含まれているメタフィールドは例えばこんな感じかなと。

{
  (省略)
    "hits" : [
      {
        "_index" : "employee",
        "_type" : "_doc",
        "_id" : "ifZgDXMBoJXfK5lP7faD",
        "_score" : 1.0,
        "_source" : {
          "name" : "BOOK STORE",
          "age" : 27,
          "belong" : "normal developper"
        }
      }
    ]
  }
}

ドキュメントの一意のIDを表す _id メタフィールドの値を使用した検索はこうなります。IDの指定が配列になっているのは複数のIDを検索できるからですね。

GET employee/_search
{
  "query": {
    "terms": {
      "_id": ["ifZgDXMBoJXfK5lP7faD"]
    }
  }
}

_id メタフィールドのID値は Get API でも使用されます。

GET employee/_doc/ifZgDXMBoJXfK5lP7faD

_source メタフィールドはドキュメント本体を示すメタフィールドになります。マッピングの定義で _source に含める、含めないフィールドを設定することができるようです。あくまでも _source に含まれるかを設定できるようなので、含まれていない値を使った検索もできます。

// age フィールドを除外
PUT employee
{
  "mappings": {
    "_source": {
      "excludes": [
          "age"
        ]
    }
  }
}

// ドキュメントを追加。除外されるフィールドがあってもいい。
POST /employee/_doc
{
  "name": "BOOK STORE",
  "age": 27,
  "belong": "normal developper"
}

// 除外されているフィールドを使って検索
GET employee/_search
{
  "query": {
    "term": {
      "age": "27"
    }
  }
}

// 検索結果に age は含まれない
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "employee",
        "_type" : "_doc",
        "_id" : "1BnyDXMBkS7Mh8swfSKP",
        "_score" : 1.0,
        "_source" : {
          "belong" : "normal developper",
          "name" : "BOOK STORE"
        }
      }
    ]
  }
}

Elasticsearch の検索(Query DSL)について調べてみた!

前回に引き続き Elasticsearch についてです。今回は検索の Query DSL について調べてみようかと。

チートシート発見したので、ドキュメント詳しく読むのがめんどくさいときは参考にしようと思います。

QueryDSL

Elasticsearch は検索の種類が Query DSL と、SQL DSL とあります。どちらかというと Query DSL の方が一般的かなーと思います。チュートリアルも Query DSL で書かれていましたし。

Query DSLJSONを元に検索を行います。大きく2つの句で構成されています。

  • Leaf query - 特定のフィールドに指定された値があるかを検索します。 match, term, range 句等がそれに当たります。
  • Compound query - 他の Compound query や Leaf query を組み合わせた検索をします。 booldis_max 句等がそれに当たります。

関係性としてはこんな感じかなと。

f:id:bau1537:20200623101320j:plain

具体的な検索の条件は Leaf query 句で指定し、条件の組み合わせに Compound query を使用する、と覚えれば良さそうです。では次に具体的なクエリを見ていきたいと思います。

Boolean query

Compound query の代表的な句は bool 句かなと思います。 bool 句では真偽値の組み合わせによってドキュメントを検索します。 bool 句は最大で4つのタイプ(オカレンスというようです。和訳すると発生ですが、どういう意味...?)の Leaf query を包含できます。例えば must にはそれに一致するドキュメントを検索し、 must_not では一致しないドキュメントを検索します。これらオカレンスを組み合わせることで、複数の条件を指定することができます。例えば以下の例では mustfilter を使用し、計3つの Leaf query で検索条件を指定しています。

f:id:bau1537:20200622175144j:plain

上記の例では以下の条件を満たすドキュメントを検索します。

  • machine.oswin 文字列が含まれる。
  • tagssuccess 文字列が含まれる。
  • geo.srcIN 文字列が含まれる。

filtermust では Leaf query の扱い方が非常によく似ていますが、微妙に違います。どちらもその内容に一致するドキュメントを検索するので、検索結果のドキュメントに違いはありません。が、関連性スコアと言われる値を計算するか、しないか、で差異があります。

関連性スコア

関連性スコアとは調べれば調べるほど色々と出てくるのですが、とりあえずは検索条件にドキュメントがどれほど一致しているかを示す数値のことと覚えれば良さそうです。関連性スコアは返却値の hits.hits._score フィールドに格納されています。

関連性スコアは検索条件の全ての句が影響を与えるわけではないそうです。全ての検索条件には以下の2つのコンテキストがあり、コンテキストによって関連性スコアに影響を与えるかが決定します。

  • query context - 検索条件がドキュメントと一致するかに加え、どの程度一致するかまで調べ、関連性スコアを計算
  • filter context - 検索条件がドキュメントと一致するかのみを調べ、関連性スコアは計算しない

例で上げた bool 句は must が query context であり、 filter は filter context になります。よって、どちらの Leaf query もドキュメントの検索条件として働きますが、関連性スコアに影響を与えるのは must のみになるということですね。

一方で should を使用することで 条件に一致する必要はないけれど、一致すれば関連性スコアを上げるといった事もできます。例えば以下のように書きます。

GET /kibana_sample_data_logs/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "machine.os": "win" } },
        { "match": { "tags": "success"  } }
      ],
      "should": { "match": { "geo.src": "IN" } }
    }
  }
}

Elasticsearch を使ってみた!

お仕事で Elasticsearch という単語を何度も聞くようになったのでとりあえずどんなものか知っておこうかと。

Elasticsearch とは

What is Elasticsearch? | 公式ドキュメント

公式サイトでは次の一文で Elasticsearch とは何かを説明しています。

Elasticsearch is the distributed search and analytics engine at the heart of the Elastic Stack.(Elasticsearchとは、Elastic Stack の中心的な検索と分析の分散型のエンジンです)

Elastic Stack とは Elasticsearch を含めた複数の製品をまとめた総称のことで、その中で中心的な立ち位置の製品であると書かれています。要は様々な種類のデータを貯めて、検索、分析ができる製品と認識しておけば間違いはないかと思います。

用途の例がいくつか記載されていましたのでこちらにも載せておきます。

  • アプリやウェブサイト上に検索機能を追加する
  • ログやメトリクス、セキュリティイベントを保存し解析する
  • リアルタイムに変化するデータを機械学習に使用する
  • 地理情報システムとして空間情報を管理する
  • バイオインフォマティック研究のツールとして遺伝データの保存及び処理に使う

インストールと起動

インストールと起動は簡単です。今回はVirtualBox上にCentos7を立ち上げてそこにインストールしました。インストールするためにVagrant、Ansibleを使用しましたのでそちらの中身を以下に載せておきます。これらはElasticsearchのインストールに必須なわけではありません。ただ、色々といじくり回した挙げ句最初からやり直したいと思うことがしばしばあるので、再構築をすぐできるようにしてみました。

f:id:bau1537:20200622101636p:plain

VagrantファイルではAnsibleのコントロールノードとホストノードをそれぞれ定義しておき、 vagrant up で2つのインスタンスが起動するようにしました。

Vagrant.configure("2") do |config|
  config.vm.box = "centos/7"

  // ansible を実行するインスタンス
  config.vm.define "ansible-controll-node" do |controll|
    controll.vm.network "private_network", ip: "192.168.50.1", virtualbox__intnet: true
  end

  // elasticsearch をインストールするインスタンス
  config.vm.define "ansible-host-node" do |host|
    host.vm.network "private_network", ip: "192.168.50.2", virtualbox__intnet: true
    // elasticsearch のビジュアライズツールである kibana にアクセスするためにポートフォワーディングを設定します。
    host.vm.network :forwarded_port, guest: 5601, host: 5601
    // デフォルトの構成ではメモリが512MBなので、4GB程度に増やしてます。
    host.vm.provider "virtualbox" do |v|
      v.memory = 4096
    end
  end

end

ホストノードが保持しているAnsibleの構成ファイルはロールとして定義し、後で色々とカスタムしやすくしておきました。Ansibleを再実行するたびにrpmファイルをダウンロードすると時間がかかるので、rpmファイル自体はローカルにおいといてホストにコピーする構成にしてみました。

[vagrant@localhost role_getting_started]$ tree
.
|-- main.yml
`-- roles
    `-- elk
        |-- elasticsearch
        |   |-- files
        |   |   `-- elasticsearch.rpm
        |   `-- tasks
        |       `-- main.yml
        `-- kibana
            |-- files
            |   `-- kibana.rpm
            `-- tasks
                `-- main.yml

8 directories, 5 files
- name: copy elasticsearch rpm file
  copy:
    src: elasticsearch.rpm
    dest: /home/vagrant
- name: install elasticsearch
  yum:
    name: elasticsearch.rpm
    state: present
- name: start elasticsearch service
  service:
    name: elasticsearch
    state: started
    enabled: yes

Elasticsearch の REST API を叩くために色々と便利な Kibana というツールも入れておきます。ちなみに、Kibana も Elastic Stack の一部の製品ですので Elasticsearch との相性もバッチリです。

- name: copy kibana rpm
  copy:
    src: kibana.rpm
    dest: /home/vagrant
- name: install kibana
  yum:
    name: kibana.rpm
    state: present
- name: start kibana service
  service:
    name: kibana
    state: started
    enabled: yes

デモデータを投入し検索してみる

Kibana にアクセスすると最初の画面で「Add sample data」というボタンがあります。このボタンを押すと、デモデータを投入できる画面に飛ばされるので、そこからデータを投入できます。今回は「Sample web logs」を投入しました。おそらくですが、サーバーへのアクセスログみたいな感じかなと思います。

デモデータを投入した後、検索をしてみましょう。検索には一般的に REST API を使用するようなので今回はその方法で検索します。検索に使用できるDSLはQueryとSQLがありますが、Queryでやってみます。

REST API なのでHTTP通信できるクライアントソフトがあれば何でも大丈夫なのですが、KibanaのConsole画面を使えばより直感的にElasticsearchのAPIを叩くことができるのでおすすめです。Console画面はKibanaの最初の画面のメニューからアクセスできます。

まずは検索条件無しで検索してみます。検索は基本、どのデータの集合に対して(インデックス)、どのような条件で(QueryDSL)検索するかを指定します。インデックスはURLの一部で指定し、検索条件はボディにJSONとして詰めて送ります。(他にもいろいろやり方はあるようです。)

f:id:bau1537:20200622101656p:plain

検索のレスポンスには色々と情報が含まれています。以下のリンク先にレスポンス内容の各項目について詳細な説明があります。

以下はその一部です。

  • hits - 検索に一致したドキュメントとメタデータを含むオブジェクト(Elasticsearchでは格納するデータをドキュメントと呼ばれる単位で管理します。)
    • total.value - 検索条件に一致したドキュメント数
    • hits - 検索条件に一致したドキュメントの配列
      • _id - ドキュメントのID。ID値はインデックス内で一意
      • _source - ドキュメント本体

次はドキュメントの持っている一部のフィールドをもとにした検索もやってみます。以下のリクエストは(おそらく)Windows OS からのアクセスのドキュメントを検索しています。

GET /kibana_sample_data_logs/_search
{
  "query": {
    "match": {
      "machine.os": "win"
    }
  }
}

検索条件なしのときはレスポンスのドキュメント数が 10000 でしたが、8512 に変わったのでうまく行っているようです。レスポンスの内容を見ても、該当するフィールドが「win 8」といったドキュメントのみが返却されます。

検索条件なしのときは match_all 句を指定していましたが、検索条件ありのときは match 句を指定しました。 match 句は全文クエリと言われるカテゴリに属しています。全文クエリは Elasticsearch が分析したテキストを検索することができ、 match 句は指定されたテキストや数値などが一致するドキュメントを返します。上記の検索であれば machine.os フィールドに win テキストが含まれているドキュメントを返します。

次は検索についてもう少し詳しくまとめていきたいと思います。

使用したアイコン

Icons made by Freepik from www.flaticon.com

Dockerで経路制御によりブリッジネットワーク間で通信してみる!

Dockerのブリッジネットワークを使えばコンテナ同士をネットワーク経由で接続させる事ができます。ブリッジネットワークはユーザーが任意に作成することができ、異なるブリッジネットワークに接続しているコンテナは通信できない仕組みになっています。が、ブリッジネットワーク間にコンテナを作成し、経路制御して異なる2つのブリッジネットワークに接続しているコンテナ間で通信させてみようと思います。

やりたいことはこんな感じ。

centos-net1 コンテナと centos-net2 コンテナ が通信できるようにします。図の通り、2つのコンテナは異なるブリッジネットワークに接続されているので centos-router コンテナがパケットのフォワーディングを担当します。

ルーティング(経路制御)ってなんだっけ

Wikipedia先生によるとこう定義されています。

ルーティング(英: routing)あるいは経路制御(けいろせいぎょ)とは、データを目的地まで送信するために、コンピュータネットワーク上のデータ配送経路を決定する制御の事である。

簡単に言ってしまえばパケットをサブネットを超えて送信したいときの制御のことですね。同じをサブネット内であればIPアドレスを元に直接宛先のホストへパケットの送信要求を投げることができます。しかし自分が所属していないネットワークに存在するホスト宛にパケットを送信したい場合、ルーティングが必要になります。

ルーティングテーブルとはルーティングをするときに、パケットをどのホストに対して投げるかを決定する際に用いる表のことです。例えばこんな感じ。

# route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         172.19.0.1      0.0.0.0         UG    0      0        0 eth0
172.19.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0

ルーティングテーブルは以下のように使用されます。

1. 宛先IPアドレスと一致するエントリがあるか調べる。あるのであれば、Gatewayに記載されているIPアドレスに向けてパケットを投げる。このとき、Gateway0.0.0.0 に設定されているものは自身の所属するサブネットのためルーティングは不要になる(ここは明記されているドキュメントを見つけられなかったので要調査です)。
2. 宛先IPアドレスと一致するエントリがない場合、デフォルトルートのGatewayに向けてパケットを投げる。デフォルトルートはDestination0.0.0.0 になっているエントリ。

ネットワークとコンテナの作成

ではではDockerでルーティングテーブルをいじくり、2つのブリッジネットワークをつなげてみましょう。

ブリッジネットワークを2つ作成します。図と同じようにするため、サブネットアドレスを指定しています。

$ docker network create net1 --subnet=172.19.0.0/16
d40bf931a362d32c2b1f37470fa24247d290e017f74ceb4fea3b38c947d87fb1
$ docker network create net2 --subnet=172.20.0.0/16
863b0f24ad6f99207733ba13e28f6dc16d265693112186c0521fd4cb8e49f972

コンテナをそれぞれ立ち上げ、作成したブリッジネットワークにつなげます。コンテナの起動オプションとして privileged を指定していますが、これはルーティングテーブルを変更するために必要な権限周りのオプションです。

$ docker run -dit --name centos-net1 --privileged --net="net1" centos:centos7
2f08362d149303f33a49c4a718063437ad842228d83850a005e937db0b89f69f
$ docker run -dit --name centos-net2 --privileged --net="net2" centos:centos7
997e496125cc5b5f29e2bd7c48850b1bfa001aea334d6e5f8afea3906d4c0d9a
$ docker run -dit --name centos-router --net="net1" centos:centos7
a9f0e3a4d0bdb9d8d378a317f8cfce31ca3504d13897a9f6ad9909bcdbd4ce4d
$ docker network connect net2 centos-router

それぞれのコンテナに接続してルーティングテーブルとパケット通信を監視するソフトウェアをインストールします。

# yum -y install net-tools tcpdump traceroute

ルーティングテーブルの確認

centos-net1 のルーティングテーブルを確認してみます。ルーティングテーブルは routeコマンドで表示できます。

# route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         172.19.0.1      0.0.0.0         UG    0      0        0 eth0
172.19.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0

Destination0.0.0.0 となっているエントリはデフォルトルートですね。デフォルトルートはどのエントリにも一致しない場合にどのIPアドレス宛にパケットを送り出すかを表すエントリです。上記の場合は自身の所属するサブネット以外への通信はすべて 172.19.0.1 にパケットを送り出す設定になっています。なので、pingcentos-net2 コンテナに向けて打っても 172.19.0.1 に向けてパケットが飛ばされるので届きません。試しに打ってみましょう。

centos-net2 コンテナのIPアドレス172.20.0.2ですね。

$ docker network inspect net2
〜一部を抜粋〜
"Containers": {
            "997e496125cc5b5f29e2bd7c48850b1bfa001aea334d6e5f8afea3906d4c0d9a": {
                "Name": "centos-net2",
                "EndpointID": "cc7611fcb5ae71c7a8a79fb658a374b98c15d95b4f42848b7eb70fac05317208",
                "MacAddress": "02:42:ac:14:00:02",
                "IPv4Address": "172.20.0.2/16",
                "IPv6Address": ""
            },
            "a9f0e3a4d0bdb9d8d378a317f8cfce31ca3504d13897a9f6ad9909bcdbd4ce4d": {
                "Name": "centos-router",
                "EndpointID": "6a8f1aa134751cd3232a214e08599ac0f00cf37ad06cb714a99a8d3fa92b37c1",
                "MacAddress": "02:42:ac:14:00:03",
                "IPv4Address": "172.20.0.3/16",
                "IPv6Address": ""
            }
        },
〜一部を抜粋〜

centos-net1 コンテナから centos-net2 コンテナに向けてpingを打ちます。

# ping 172.20.0.2
PING 172.20.0.2 (172.20.0.2) 56(84) bytes of data.
^C
--- 172.20.0.2 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2006ms

100% packet loss と表示されて通信できていないことがわかりますね。デフォルトルートである 172.19.0.1 にパケットが送出されているかをネットワークの経路を表示できる traceroute コマンドで確認してみます。

# traceroute 172.20.0.2 -n
traceroute to 172.20.0.2 (172.20.0.2), 30 hops max, 60 byte packets
 1  172.19.0.1  2.570 ms  2.281 ms  2.052 ms

ルーティングテーブルの変更

ルーティングテーブルを変更して centos-net1 と centos-net2 コンテナが通信できるようにします。centos-net1 のルーティングテーブルで 172.20.0.0/16 のネットワークに向けたパケットを centos-router コンテナに向けて投げるようにし、centos-router コンテナがパケットをフォワーディング(転送)するようにします。

# route add -net 172.20.0.0 netmask 255.255.0.0 gw 172.19.0.3 eth0
# route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         172.19.0.1      0.0.0.0         UG    0      0        0 eth0
172.19.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0
172.20.0.0      172.19.0.3      255.255.0.0     UG    0      0        0 eth0

centos-net2 でも応答ができるよう同じ要領でルーティングテーブルに変更を加えます。

# route add -net 172.19.0.0 netmask 255.255.0.0 gw 172.20.0.3 eth0
# route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         172.20.0.1      0.0.0.0         UG    0      0        0 eth0
172.19.0.0      172.20.0.3      255.255.0.0     UG    0      0        0 eth0
172.20.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0

centos-router ではIPフォワーディングが有効になっているかを確認します。

# sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1

ここまで設定できたら centos-net1 から centos-net2 に向けてpingを打ってみましょう。

# ping 172.20.0.2
PING 172.20.0.2 (172.20.0.2) 56(84) bytes of data.
64 bytes from 172.20.0.2: icmp_seq=1 ttl=63 time=2.63 ms
64 bytes from 172.20.0.2: icmp_seq=2 ttl=63 time=0.318 ms
64 bytes from 172.20.0.2: icmp_seq=3 ttl=63 time=0.177 ms
64 bytes from 172.20.0.2: icmp_seq=4 ttl=63 time=0.164 ms
64 bytes from 172.20.0.2: icmp_seq=5 ttl=63 time=0.191 ms
^C
--- 172.20.0.2 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4075ms
rtt min/avg/max/mdev = 0.164/0.696/2.634/0.970 ms

疎通できましたね。

パケットが centos-router を経由しているかも確認してみましょう。

# traceroute 172.20.0.2 -n
traceroute to 172.20.0.2 (172.20.0.2), 30 hops max, 60 byte packets
 1  172.19.0.3  0.258 ms  0.114 ms  0.340 ms
 2  172.20.0.2  0.461 ms  0.422 ms  0.128 ms

centos-router (172.19.0.3)を経由しているのがわかりますね。

centos-router のTCPパケットをキャプチャしてみるとIPフォワーディングの様子を見ることができます。

# tcpdump -i eth0 -n
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
01:48:39.190754 IP 172.19.0.2 > 172.20.0.2: ICMP echo request, id 22, seq 53, length 64
01:48:39.190981 IP 172.20.0.2 > 172.19.0.2: ICMP echo reply, id 22, seq 53, length 64
01:48:40.213036 IP 172.19.0.2 > 172.20.0.2: ICMP echo request, id 22, seq 54, length 64
# tcpdump -i eth1 -n
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), capture size 262144 bytes
01:49:06.738801 IP 172.19.0.2 > 172.20.0.2: ICMP echo request, id 22, seq 80, length 64
01:49:06.738874 IP 172.20.0.2 > 172.19.0.2: ICMP echo reply, id 22, seq 80, length 64
01:49:07.763733 IP 172.19.0.2 > 172.20.0.2: ICMP echo request, id 22, seq 81, length 64

172.19.0.0/16 に所属するeth0インターフェイス172.20.0.0/16 に所属するeth1インターネットそれぞれでパケットをフォワーディングしている様子が見れました。

使用したアイコン

Icons made by Freepik from www.flaticon.com

DockerでARPテーブルを確認してみる!

仕事の都合上Linuxやネットワークの勉強をしています。深く関わったことがない領域ですし、字を追っただけでは理解に限界があるなーと思いまして、実際に触って、見て理解しようかと思いました。というわけでこの記事はその記録というわけです。

タイトル通りARPテーブルを見てネットワークの仕組みの一部を覗いてみようかと。環境はDockerを使います。

$ docker --version
Docker version 19.03.8, build afacb8b

ARPってなんだっけ

ARP (Address Resolution Protocol)はIPv4アドレスからMACアドレスを解決するために使用される通信プロトコルです。ネットワークに接続されたコンピュータが通信するときには通信相手のIPアドレスに向けてパケットを送信します。しかし、実際にはデータリンク層があるのでIPアドレスMACアドレスの対応を管理しなくてはいけません。ARPIPアドレスは分かっているけれど、MACアドレスがわからない場合に単一のサブネットワークの内部で解決を行います。


まずDockerコンテナを同じネットワークに接続させる

Dockerコンテナのネットワークについては以前の記事で書きました。今回も同じ要領で2つのコンテナをデフォルトのブリッジネットワークに接続させます。

centos1 と centos2 のコンテナをデフォルトのブリッジネットワークに接続させます。

$ docker run -dit --name centos1 centos:centos7
6ce3b9540c9eb79a672c4b438f761967f6315068a199c8309b8c7678660c9fcb
$ docker run -dit --name centos2 centos:centos7
6899cf57b849c5f164b26b8b12dd78ebb9ac78679673847cee240855b65a16b7
$ docker network inspect bridge
~(略)~
        "Containers": {
            "6899cf57b849c5f164b26b8b12dd78ebb9ac78679673847cee240855b65a16b7": {
                "Name": "centos2",
                "EndpointID": "cc0278128b0743be739b61567f3831f2371ea2f211182345292876f0c35eecf2",
                "MacAddress": "02:42:ac:11:00:03",
                "IPv4Address": "172.17.0.3/16",
                "IPv6Address": ""
            },
            "6ce3b9540c9eb79a672c4b438f761967f6315068a199c8309b8c7678660c9fcb": {
                "Name": "centos1",
                "EndpointID": "87a14892863da5410cbdd93962540efb796b7a33f99c3ed39c6f96e28c271958",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
~(略)~

centos1 でARPテーブルを表示します。ARPテーブルとはそのホストが記憶しているIPアドレスMACアドレスの対応です。初期状態ではコマンドが入っていないのでインストールしています。(ARPテーブルは arpコマンドで表示できます。)

$ docker attach centos1
[root@6ce3b9540c9e /]# yum -y install net-tools
[root@6ce3b9540c9e /]# arp -n
Address                  HWtype  HWaddress           Flags Mask            Iface
172.17.0.1               ether   02:42:c0:38:4f:cb   C                     eth0

まだ何も通信していませんがARPテーブルに一つのエントリがキャッシュされています。このアドレスはルーティングテーブルを見てみるとデフォルトルートになっていることがわかります。デフォルトルートとは簡単に言うと経路制御表でマッチしなかった場合どのIPアドレス宛にパケットを送信するかを表すものです。(ルーティングテーブルは netstat -r コマンドで表示できます。)

[root@6ce3b9540c9e /]# netstat -r -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags   MSS Window  irtt Iface
0.0.0.0         172.17.0.1      0.0.0.0         UG        0 0          0 eth0
172.17.0.0      0.0.0.0         255.255.0.0     U         0 0          0 eth0

余談になりますがデフォルトルートになっている 172.17.0.1 アドレスはDockerのデフォルトブリッジで設定されているゲートウェイアドレスと一致します。このあたりの知識整理は機会があればまた後でしておきたいですね。

コンテナ間で通信してARPテーブルが変わるか確認する

centos1 から centos2 へpingコマンドを使って通信し、ARPテーブルがどのように変わるか確認してみましょう。

[root@6ce3b9540c9e /]# ping 172.17.0.3
PING 172.17.0.3 (172.17.0.3) 56(84) bytes of data.
64 bytes from 172.17.0.3: icmp_seq=1 ttl=64 time=0.220 ms
64 bytes from 172.17.0.3: icmp_seq=2 ttl=64 time=0.113 ms
64 bytes from 172.17.0.3: icmp_seq=3 ttl=64 time=0.248 ms
64 bytes from 172.17.0.3: icmp_seq=4 ttl=64 time=0.116 ms
^C
--- 172.17.0.3 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3082ms
rtt min/avg/max/mdev = 0.113/0.174/0.248/0.061 ms
[root@6ce3b9540c9e /]# arp -n
Address                  HWtype  HWaddress           Flags Mask            Iface
172.17.0.1               ether   02:42:c0:38:4f:cb   C                     eth0
172.17.0.3               ether   02:42:ac:11:00:03   C                     eth0

最後の行に新しいエントリが追加されているのがわかりますね。これは centos2 のIPアドレスMACアドレスに一致します。つまり、centos1 と centos2 はARPによってIPアドレスMACアドレスの情報を共有できたということになります。ちなみに、pingコマンドで受信側になった centos2 でも同じようにARPテーブルが更新されます。

使用したアイコン

Icons made by Freepik from www.flaticon.com

GoでJSONを扱う方法を調べてみた

GW何をしようかと考えた末に、久しぶりにGoを触ってみようかなと思いまして、JSONの使い方を調べてみました。(Goを触るのが1年半ぶりくらいなので、A Tour of Goをやり直しました。Goはドキュメントが非常によく整備されていて助かります。)

ポイント

  • encoding/json パッケージにJSONを扱う機能が揃っている
  • MarshalUnmarshal でGoの型とJSON文字列の相互変換ができる

encoding/json パッケージに大体の機能がある

encoding パッケージにはバイト文字列やテキスト文字列をエンコーディングする関数や構造体が定義されています。その中に json パッケージがあり、JSON文字列を扱う関数や構造体が定義されています。

encoding/json

Encoding

Encodingするには json.Marshal を使います。

func Marshal(v interface{}) ([]byte, error)

引数に与えられた値をJSON文字列としてバイトのスライスで返してくれます。

type colorGroup struct {
    ID     int
    Name   string
    Colors []string
}

// Marshal
// go type to json string
group := colorGroup{
    ID:     1,
    Name:   "Reds",
    Colors: []string{"Crimson", "Red", "Ruby", "Maroon"},
}
b, err := json.Marshal(group)
if err != nil {
    log.Fatal(err)
}
os.Stdout.Write(b)
// output
// {"ID":1,"Name":"Reds","Colors":["Crimson","Red","Ruby","Maroon"]}

基本的な型は Marshalエンコーディングすることができますが、Channel, complex, functionエンコーディング することができません。また、ポインタはエンコーディングできますが、ポインタが指し示す値が対象になるようです。

Decoding

Decodingするには json.Unmarshal を使います。

func Unmarshal(data []byte, v interface{}) error

引数にJSON文字列を渡すことでデコーディングしてくれます。

type colorGroup struct {
    ID     int
    Name   string
    Colors []string
}

// UnMarshal
// json string to go type
groupString := `{
  "id": 1,
  "name": "Orange",
  "colors": ["Red", "Ruby"]
  }`
var unMarshalColor colorGroup
json.Unmarshal([]byte(groupString), &unMarshalColor)
fmt.Println(unMarshalColor)
// output
// {1 Orange [Red Ruby]} 

タグを使ったカスタマイズ

Encoding と Decoding では、構造体にタグを使うことで振る舞いを変更することができます。構造体のタグについては詳しく知らないんですが、構造体のフィールドに追加の属性で文字列を追加することができ、この文字列のことをタグというらしいです。タグはリフレクションで読み取ることが出来るようです。

Go spec struct

タグの使用例としてJSONのフィールド名を変更することができます。タグは json をキーとします。 以下の構造体では ID フィールドにタグを付けてフィールド名を変更しています。

type colorGroup struct {
    ID     int `json:"number"`
    Name   string
    Colors []string
}

colorGroup をEncodingすると、以下のJSONになります。 ID フィールドが number としてEncodingされていますね。

{"number":1,"Name":"Reds","Colors":["Crimson","Red","Ruby","Maroon"]}

上記のJSONをDecodingすると、以下の値になります。 numberID フィールドにマッピングされています。

{1 Reds [Crimson Red Ruby Maroon]}

また、タグに omitempty を付けることで、フィールドが特定の値(0やfalse、nilなど)のときにEncodingから省略するように出来ます。例えば以下の構造体では IDomitempty を付けています。

type colorGroup struct {
    ID     int `json:"number"`
    Name   string
    Colors []string
}

これをEncodingすると、 ID が省略されます。

group := colorGroup{
    ID:     0,
    Name:   "Reds",
    Colors: []string{"Crimson", "Red", "Ruby", "Maroon"},
}
// output
// {"Name":"Reds","Colors":["Crimson","Red","Ruby","Maroon"]}

Streaming で Encode と Decode

jsonパッケージにはJSONをStreamingで読み込み・書き込みするための Decoder Encoder 型を定義しています。それぞれ NewDecoder NewEncoderio.Reader io.Writer インターフェイスをラップした値を取得できます。これらの型を使えば、HTTPやWebSocketでJSONをやり取りする事ができるようになります。

使い方はこんな感じになります。標準入力で読み込んだ値を Decode し、キーが Name の値だけを標準出力に向けて Encode します。関数の最初の2行で Decoder Encoder を作成しています。

func main() {
    dec := json.NewDecoder(os.Stdin)
    enc := json.NewEncoder(os.Stdout)
    for {
        var v map[string]interface{}
        if err := dec.Decode(&v); err != nil {
            log.Println(err)
            return
        }
        for k := range v {
            if k != "Name" {
                delete(v, k)
            }
        }
        if err := enc.Encode(&v); err != nil {
            log.Println(err)
        }
    }
}

終わりに

GoでJSONを扱う方法は調べるとまだまだ出てくるのですが、とりあえずの基本としてはこのような内容を抑えておけば良さそうです。標準のパッケージでJSONを扱う事ができるのはかなり便利ですね。そして、何より直感的に書く事ができるので迷う事なく理解できた感じがします。

参考