Elasticで使えるschema on read、ランタイムフィールドを導入する

著者

歴史的に、Elasticsearchはデータ検索を高速化するschema on writeに依拠してきました。Elasticは現在、Elasticsearchにschema on readの機能を追加しています。これにより、インジェスト後にドキュメントのスキーマを変更する、あるいは検索クエリの一部にしか存在しないフィールドを生成するなど、フレキシブルな処理が可能になります。schema on readとschema on writeの2つの選択肢が揃ったことで、ユーザーはニーズに応じて、パフォーマンスとフレキシビリティのバランスを取ることができます。

Elasticはschema on readのためのソリューションとして、ランタイムフィールドを導入しました。これは、クエリ時に限りデータを要求するフィールドです。ランタイムフィールドはインデックスマッピング内、またはクエリ内で定義され、定義後すぐに検索リクエストやアグリゲーション、絞り込み、並べ替えに利用することができます。またランタイムフィールドはインデックスされないため、ランタイムフィールドを追加することでインデックスサイズが大きくなることはありません。それどころかストレージコストを引き下げ、インジェストを高速化します。

その反面、トレードオフになる部分もあります。ランタイムフィールドに対してクエリをかけるとコストがかさむ可能性があることから、一般的な検索やフィルターは、引き続きインデックス済みフィールドにマッピングされる必要があります。またランタイムフィールドはインデックスサイズを引き下げるにもかかわらず、検索スピードを下げる可能性があります。Elasticはランタイムフィールドをインデックス済みフィールドと併用し、ユースケースに応じてインジェストスピード、インデックスサイズ、フレキシビリティ、検索パフォーマンスの最適なバランスを見出すアプローチを推奨しています。

ランタイムフィールドを簡単に追加

ランタイムフィールドを定義する最も簡単な方法は、クエリ内で定義するやり方です。たとえば、以下のようなインデックスがあるとします。

 PUT my_index
 {
   "mappings": {
     "properties": {
       "address": {
         "type": "ip"},
       "port": {
         "type": "long"
       }
     }
   } 
 }

ここに、いくつかのドキュメントを読み込みます。

 POST my_index/_bulk
 {"index":{"_id":"1"}}
 {"address":"1.2.3.4","port":"80"}
 {"index":{"_id":"2"}}
 {"address":"1.2.3.4","port":"8080"}
 {"index":{"_id":"3"}}
 {"address":"2.4.8.16","port":"80"}

以下の記述で、静的な文字列が入った2つのフィールドを連結することができます。

 GET my_index/_search
 {
   "runtime_mappings": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     }
   },
   "fields": [
     "socket"
   ],
   "query": {
     "match": {
       "socket":"1.2.3.4:8080"
     }
   }
 }

レスポンスは次のようになります。

…
     "hits" : [
       {
         "_index" : "my_index",
         "_type" : "_doc",
         "_id" :"2",
         "_score" :1.0,
         "_source" : {
           "address" :"1.2.3.4",
           "port" :"8080"
         },
         "fields" : {
           "socket" : [
             "1.2.3.4:8080"
           ]
         }
       }
     ]

この例では、runtime_mappingsセクションにフィールドソケット(field socket)を定義しました。具体的には、短いPainlessスクリプトを使って、ドキュメントごとにソケットの値を計算する方法を定義しています(+を使って静的文字列‘:’の入ったアドレスフィールドの値と、ポートフィールドの値との連結を示します)。次に、クエリでフィールドソケットを使用しました。フィールドソケットはエフェメラルなランタイムフィールドで、このクエリのためだけに存在し、クエリの実行時に計算されます。ランタイムフィールドで使用するPainlessスクリプトを定義する際は、計算された値を返すemitを含める必要があります。

ソケットがクエリごとに定義する必要はなく、複数のクエリで使えるフィールドである場合は、次のコールでシンプルにマッピングに追加することができます。

 PUT my_index/_mapping
 {
   "runtime": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     } 
   } 
 }

これで、クエリにフィールドの定義を含める必要はありません。以下はその例です。

 GET my_index/_search
 {
   "fields": [
     "socket"
  ],
   "query": {
     "match": {
       "socket":"1.2.3.4:8080"
     }
   }
 }

"fields": ["socket"]のステートメントは、ソケット(socket)フィールドの値を表示したい場合のみ必要です。これですべてのクエリでフィールドソケットを使用できますが、フィールドソケットはインデックスの中に存在するわけではなく、インデックスサイズを引き上げることもありません。ソケットの計算は、クエリがリクエストする場合だけ、必要なドキュメントに限って行われます。

フィールドをフレキシブルに利用可能

ランタイムフィールドは、インデックス済みフィールドと同じAPIを経由してエクスポーズされるため、1つのクエリでランタイムフィールドを持つインデックスと、インデックス済みフィールドを持つインデックスを参照できます。どのフィールドをインデックスするか、また、どのフィールドをランタイムフィールドとして維持するかは、ユーザー側でフレキシブルに指定できます。このようにフィールドの生成と利用が分離されていることで、作成も保守もしやすい体系的なコードを円滑に使用できます。

ランタイムフィールドの定義は、インデックスマッピング内で、または検索リクエスト内で行います。この独特な性能のおかげで、ランタイムフィールドとインデックス済みフィールドを併用する方法もフレキシブルに設計することができます。 

クエリ時にフィールド値を上書きする

「運用データ内にエラーを見つけたけれど、手遅れだった」というケースは少なくありません。  これからインジェストするドキュメントについては、簡単にインジェストの指示を修正できます。しかし既にインジェストされ、インデックス済みのデータを修正する作業となると、はるかに大変です。ランタイムフィールドを使うと、クエリ時の値を上書きして、インデックス済みデータのエラーを修正することができます。ランタイムフィールドは、インデックス済みフィールドと同じ名前でシャドーイングできるため、インデックス済みデータのエラーを修正することができます。  

以下は、具体的な修正の例です。メッセージフィールドと、アドレス(address)フィールドを持つインデックスがあるとします。

 PUT my_raw_index 
{
  "mappings": {
    "properties": {
      "raw_message": {
        "type": "keyword"
      },
      "address": {
        "type": "ip"
      }
    }
  }
}

ドキュメントを読み込みます。

 POST my_raw_index/_doc/1
{
  "raw_message":"199.72.81.55 - - [01/Jul/1995:00:00:01 -0400] GET /history/apollo/ HTTP/1.0 200 6245",
  "address":"1.2.3.4"
}

残念ながら、このドキュメントはアドレスフィールドに誤ったIPアドレスを含んでいます。正しいIPアドレスはメッセージ内に存在していますが、どういうわけか誤ったアドレスがドキュメントにパースされ、それがElasticsearchに送信されてインジェスト、およびインデックスされています。ドキュメントが1つなら大した問題ではありません。しかし、1か月経ってからドキュメントの10%に誤ったアドレスがあるとわかった場合はどうでしょうか。新規のドキュメント向けの修正は大した作業ではありませんが、インジェスト済みのドキュメントを再インデックスするとなれば、往々にして運用上複雑な状況を招くことになります。ランタイムフィールドを使うと、インデックスフィールドをランタイムフィールドでシャドーイングすることにより、すぐに修正することができます。以下に、これをクエリ内で実行する方法を示します。

GET my_raw_index/_search
{
  "runtime_mappings": {
    "address": {
      "type": "ip",
      "script":"Matcher m = /\\d+\\.\\d+\\.\\d+\\.\\d+/.matcher(doc[\"raw_message\"].value);if (m.find()) emit(m.group());"
    }
  },
  "fields": [ 
    "address"
  ]
}

すべてのクエリで使えるように、変更をマッピング内で実施することもできます。現在Painlessスクリプトでは、デフォルトでregexの使用が有効になっていることに注意してください。

パフォーマンスとフレキシビリティのバランスを取る

インデックス済みフィールドを使うと、インジェスト中にすべての準備が完了し、その洗練されたデータストラクチャーを維持することで最適なパフォーマンスが実現します。これに対し、ランタイムフィールドでクエリをかける場合は、インデックスフィールドでクエリをかけるよりも遅くなります。「ランタイムフィールドを使ってクエリをかけて、結果的に低速になった」という事態はできれば避けたいものです。

Elasticはランタイムフィールドでの検索に、非同期検索を使用することを推奨しています。非同期検索を使うと、クエリが指定された時間内に完了した場合、同期検索と同様に、結果の完全なセットが返されます。しかし、指定された時間内にクエリが完了しない場合は、部分的な結果のセットが返され、その後Elasticsearchは完全な結果セットが返されるまでポーリングを継続します。このメカニズムは、インデックスライフサイクルを管理している場合に特に有効です。一般的に、新しい結果が先に返され、またユーザーにとっては通常、新しい結果の方が重要であるためです。

最適なパフォーマンスを実現するため、Elasticはクエリの負荷の高い処理についてはインデックス済みフィールドに依存し、ランタイムフィールドはドキュメントのサブセットの値を求めるためだけに使用します。

ランタイムフィールドからインデックス済みフィールドに変更する

ランタイムフィールドを使うと、ユーザーはライブ環境のデータを操作して、マッピングやパースをフレキシブルに変更することができます。ランタイムフィールドはリソースを消費せず、またランタイムフィールドを定義するスクリプトは変更可能であるため、ユーザーは納得いくまで最適なマッピングを試すことができます。長期的にランタイムフィールドの利便性を高めるために、インデックス時にあらかじめフィールド値を求めることもできます。これは、インデックスフィールドとしてテンプレート内のフィールドをシンプルに定義しておき、インジェスト済みのドキュメントをそこに確実に含めることで達成できます。このフィールドは、次のインデックスロールオーバーからインデックスされて、よりすぐれたパフォーマンスを提供します。フィールドで使用するクエリは、一切変更する必要がありません。 

このシナリオは、動的マッピングを行う場合に特に便利です。対照的に、新規のドキュメントに新規のフィールドの生成を許可するアプローチは、そのデータをすぐに使うことができる点で非常に便利です(エントリのストラクチャーは、ログを生成するソフトウェアの更新などの原因で頻繁に変化します)。一方、動的マッピングは、インデックスに負荷をかけるリスクや、マッピングの爆発的増加を生じるリスクを伴います。たとえば、一部のドキュメントに予期されない2,000もの新規フィールドが出現する可能性は0ではないからです。ランタイムフィールドは、そのようなシナリオのソリューションとなります。つまり、新規のフィールドを自動的にランタイムフィールドとして作成することで、インデックスに負荷をかけず(ランタイムフィールドはインデックス内に存在しないため)、またindex.mapping.total_fields.limit.にカウントされないようにします。これらの自動生成されたランタイムフィールドは、低パフォーマンスにはなるものの、クエリをかけることが可能です。ユーザーはこのフィールドを使うことも、あるいは必要に応じて、次のロールオーバーでこのフィールドをインデックス済みフィールドに変更することもできます。   

Elasticは、まず既存のデータストラクチャーでランタイムフィールドを試してみることを推奨しています。既存のデータで作業した結果、ランタイムフィールドをインデックスして検索パフォーマンスを高める方がよい、という判断になるかもしれません。その場合は新規のインデックスを作成し、インデックスマッピングのフィールド定義を追加して、そのフィールドを_sourceに追加します。その後、この新規フィールドがインジェスト済みドキュメントに含まれていることを確認してください。データストリーミングを使用している場合は、インデックステンプレートをアップデートして、テンプレートからインデックスが作成される際、Elasticsearchでそのフィールドがインデックスされるようにします。Elasticは今後のリリースで、ランタイムフィールドをインデックス済みフィールドに変更するプロセスをシンプルにする機能の導入を予定しています。導入後は、フィールドをマッピングのランタイムセクションからプロパティセクションに移動させることで変更できます。 

以下に示すのは、タイムスタンプ(timestamp)フィールドを持つシンプルなインデックスマッピングを作成するリクエストです。"dynamic": "runtime"を含めることにより、Elasticsearchに対して、このインデックスに、追加のフィールドをランタイムフィールドとして動的に作成するよう指示しています。ランタイムフィールドにPainlessスクリプトが含まれる場合、フィールド値はPainlessスクリプトに基づいて計算されます。ランタイムフィールドがスクリプトを使わずに作成されている場合、以下のリクエストに示されるように、システムはランタイムフィールドと同じ名前を持つ_source内のフィールドを探し、その値をランタイムフィールド値として使用します。

PUT my_index-1
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

ドキュメントをインデックスして、この設定のメリットを確認しましょう。

POST my_index-1/_doc/1
{
  "timestamp":"2021-01-01",
  "message": "my message",
  "voltage":"12"
}

これでインデックス済みのタイムスタンプフィールドと、2つのランタイムフィールド(messageフィールドとvoltageフィールド)が作成されました。このインデックスマッピングを表示しましょう。

GET my_index-1/_mapping

ランタイムセクションにメッセージ(message)とボルテージ(voltage)が含まれています。この2つのフィールドはインデックスされていませんが、インデックス済みフィールドと同様にクエリをかけることができます。

{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "keyword"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}

メッセージフィールドにクエリをかけるシンプルな検索リクエストを作成してみましょう。

GET my_index-1/_search
{
  "query": {
    "match": {
      "message": "my message"
    }
  }
}

レスポンスは、以下のヒットを含んでいます。

...
"hits" : [
      {
        "_index" : "my_index-1",
        "_type" : "_doc",
        "_id" :"1",
        "_score" :1.0,
        "_source" : { 
          "timestamp" :"2021-01-01",
          "message" : "my message",
          "voltage" :"12" 
        } 
      } 
    ]
…

このレスポンスをみると、1つ問題があることに気づきます。ボルテージは数字であるという指定を行っていません。ボルテージはランタイムフィールドなので、マッピングのランタイムセクションで簡単にフィールド定義を修正することができます。

PUT my_index-1/_mapping
{
  "runtime":{
    "voltage":{
      "type": "long"
    }
  }
}

上のリクエストは、ボルテージのtypeをlongに変更しています。この変更は、インデックス済みのドキュメントにもすぐに反映されます。この挙動をテストするためにシンプルなクエリを構築し、すべてのドキュメントを11から13のボルテージで検索します。

GET my_index-1/_search
{
  "query": {
    "range": {
      "voltage": {
        "gt":11,
        "lt":13
      }
    }
  }
}

ボルテージは12なので、このクエリでmy_index-1のドキュメントが返される結果になりました。再びマッピングを見ると、マッピングのフィールドタイプをアップデートする前にElasticsearchにインジェストされたとドキュメントについても、ボルテージがtype longのランタイムフィールドになっていることを確認できます。

...
{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "long"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}
…

後々、ボルテージがアグリゲーションに便利だとわかり、データストリーミングで作成される次のインデックスにインデックスしようと考えるかもしれません。その場合は、このデータストリーム用に、インデックステンプレートに一致する新しいインデックス(my_index-2とします)を作成し、ボルテージをintegerに定義します(ランタイムフィールドで試行した結果、求めるデータタイプもわかったということにしましょう)。

理想的なのが、インデックステンプレート自体をアップデートして、次のロールオーバーに変更を反映させる方法です。この方法で反映させると、あるインデックスではボルテージフィールドがランタイムフィールド、別のインデックスではボルテージフィールドがインデックス済みフィールド、という状態であっても、my_index*に一致するすべてのインデックスのボルテージフィールドに対してクエリを実行できます。

PUT my_index-2
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      },
      "voltage":
      {
        "type": "integer"
      }
    }
  }
}

このような理由で、Elasticはランタイムフィールドに新しいフィールドライフサイクルワークフローを導入しています。このワークフローでは、フィールドが自動的にランタイムフィールドとして生成され、リソース消費に影響をあたえることも、マッピングの爆発的増加のリスクを生じることもありません。ユーザーはすぐにデータを扱いはじめることができます。具体的には、ランタイムフィールドのまま、フィールドのマッピングを実際のデータに応じて改良することができます。フレキシブルなランタイムフィールドのおかげで、この変更は既にElasticsearchにインジェストされたドキュメントにも適用されます。後にこのフィールドが便利だとわかった場合は、テンプレートを変更し、それ以降に(次のロールオーバーの後に)作成されるインデックスでフィールドをインデックスすることによって、パフォーマンスを最適化させることができます。

まとめ

大多数のケース、特に「データがどういうものか、そのデータで何をしたいか」がはっきりしている場合は、パフォーマンスにすぐれるインデックスフィールドを選んで間違いありません。逆に、ドキュメントのパースやスキーマのストラクチャーをフレキシブルに扱いたい場合、今後はランタイムフィールドを選ぶことが正解となります。

ランタイムフィールドとインデックス済みフィールドは相互補完的な機能であり、共生関係にあります。ランタイムフィールドはフレキシブルに使えますが、大規模な環境で、インデックスのサポートなしに良好なパフォーマンスを達成することは不可能です。インデックスのパワフルで厳格なストラクチャーが保護環境となることで、ランタイムフィールドは持ち味のフレキシビリティを発揮できます。言うなれば、サンゴ礁を隠れ家にする藻類のようなものかもしれません。この共生関係は、全員にメリットをもたらします。

今すぐ使いはじめる

ランタイムフィールドは、Elasticsearch Serviceでクラスターを立ち上げて使いはじめることも、Elastic Stackの最新バージョンをインストールして使いはじめることもできます。すでにElasticsearchを導入済みの方は、お使いのクラスターを7.11にアップグレードするだけで新機能をお試しいただくことができます。ランタイムフィールドについてより概念的な解説やメリットについては、ブログ記事「Runtime fields:Schema on read for Elastic」(ランタイムフィールド ― Elasticのschema on read)をご覧ください。この他に、ランタイムフィールドの導入に役立つ動画も4本あります。