エンジニアリング

Elasticsearchでのイベントベースのデータ重複を効果的に防止

Elastic Stackは、多くのさまざまなユースケースに使われています。最も一般的なものの一つに、セキュリティイベント、ログ、メトリックといった、色々な種類のイベントや時系列データの格納と分析があります。これらのイベントは、イベント発生時または収集時を表す特定のタイムスタンプにリンクしたデータからなることが多く、往々にしてそのイベントを他と区別できる適切なキーがありません。

いくつかのユースケース、またおそらくユースケース内のデータのタイプにとっては、Elasticsearch内のデータが重複しないことは重要です。それは、ドキュメントの重複が分析の誤りや検索結果のエラーを導くからです。昨年、Logstashを用いた重複対策についてというブロク記事で本件を見ていきましたが、今回はもう少し詳しくよくある質問にお答えしていきたいと思います。

Elasticsearchへのインデックス

Elasticsearch内にデータをインデックスするときは、データが正常にインデックスできたと確認できるレスポンスを受け取ることが必要です。接続エラーやノードクラッシュなどのエラーがあるとそうしたレスポンスを受け取ることができず、データがインデックスできていないものがあるかどうかも分かりません。クライアントがこうした事態に遭遇したとき、確実な結果を得るためには再試行するのが標準的ですが、これでは同じドキュメントを2回以上インデックスすることになります。

重複対策のブログ記事で説明したように、これは、クライアント内の各ドキュメントにユニークなIDを定義しておくことで回避することが可能で、インデックス時にElasticsearchのID自動割り当てを利用するよりも有効です。重複するドキュメントを同じインデックスに書き込むと、2回ドキュメントを書き込む代わりに更新することになるので、重複を防ぐことになります。

UUIDとハッシュベースのドキュメントID

IDにどのタイプを使うか決めるにあたっては、主に2つのタイプから選ぶことができます。

Universally Unique Identifiers (UUID)は128ビット数に基づく識別子で、実用的な目的にかなってユニークでありながら、分散システム全体で横断的に生成可能です。このタイプの識別子は通常、イベントが関連付けられているコンテンツに依存しません。

UUIDを使用して重複を回避するには、イベントが正確に1回だけ発生していることを保証する境界をイベントが通過する前に、UUIDが生成され、イベントに割り当てられる必要があります。これは実際面では、UUIDをイベントの発生時に割り当てることを意味します。イベントが発生するシステムがUUIDを生成できないと、異なるタイプの識別子を使用する必要が生じる場合があります。

もう一つの主な識別子のタイプは、ハッシュ機能を用いてイベントのコンテンツをベースにした数値ハッシュを生成するものです。このハッシュ機能は特定のコンテンツに対して必ず同じ値を生成しますが、生成された値はユニークであるとは限りません。この2つの異なるイベントが同じハッシュ値になるというハッシュの衝突は、使用されるハッシュ関数のタイプと作成する値の長さだけでなく、インデックス内のイベント数に依存します。MD5やSHA1などの少なくとも128ビット長のハッシュは、多くのシナリオにおいて、一般的に長さと低い衝突率の間でバランスがとれたものです。さらに厳密にユニークであることを保証するためには、SHA256のような長いハッシュを使うことができます。

ハッシュベースの識別子はイベントのコンテンツに依存するので、どこで生成されても同じ値が算出されるため、後の処理段階で割り当てることが可能です。このことから、このタイプのIDはデータがElasticsearchにインデックスされる前ならいつでも割り当てることができ、投入パイプラインをデザインするときに柔軟性を与えます。

Logstashは、フィンガープリントフィルター プラグインにより、UUID算出とよく使われる一般的な幅広いハッシュ関数に対応しています。

効果的なドキュメントIDの選択

Elasticsearchがインデックス時に識別子の割り当てを許されている場合は、生成された識別子がインデックスに既存のものであってはならないことが分かっているので、最適化を行うことができます。これによりインデックス化のパフォーマンスが上がります。外部で生成されドキュメントとともに渡された識別子に対しては、Elasticsearchはこれが更新の可能性があるものとみなし、ドキュメントの識別子が既存のインデックスセグメントにあるかどうかチェックするので、余分な作業が必要となり処理が遅くなります。

外部ドキュメントの識別子は、すべて等しく作成されているわけではありません。並べ替え順序に基づいて時とともに徐々に増加する識別子は、完全にランダムな識別子よりもインデックス化のパフォーマンスが上です。その理由は、Elasticsearchが、最小および最大の識別値のみに基づく古いインデックスセグメントに識別子があるかどうかを、全体を検索しなければならないよりもすばやく判断できるからです。本件はこちらのブログ記事にて紹介しており、少し前のものですが今でも有効です。

ハッシュベースの識別子と多くの種類のUUIDは、一般的にランダムであるという性質があります。一つひとつタイムスタンプを定義するイベントのフローを扱うときは、このタイムスタンプを識別子のプレフィクスとして使い並べ替え可能にして、インデックス化のパフォーマンスを上げることが可能です。

タイムスタンプをプレフィクスにして識別子を作成することは、ハッシュ値がタイムスタンプごとにユニークでありさえすれば良いため、ハッシュの衝突可能性を減らすメリットもあります。これにより、投入量の多いシナリオにおいてもより短いハッシュ値を使うことが可能になります。

Logstashでは、UUIDあるいはハッシュを生成するためにフィンガープリントフィルター プラグインを使ったり、タイムスタンプを16進の文字列表現をRubyフィルターを使うことで、この種の識別子を作成することができます。ハッシュ化できるメッセージフィールドがあり、イベントのタイムスタンプがすでに@timestampフィールドへパースされていることが推測される場合は、その識別子のコンポーネントを作成し、メタデータに次のように格納することが可能です。

fingerprint {
  source => "message"
  target => "[@metadata][fingerprint]"
  method => "MD5"
  key => "test"
}
ruby {
  code => "event.set('@metadata[tsprefix]', event.get('@timestamp').to_i.to_s(16))"
}

すると、ドキュメントIDをElasticsearch出力プラグイン内に作成する際に、次の2つのフィールドが使用されます。

elasticsearch {
  document_id => "%{[@metadata][tsprefix]}%{[@metadata][fingerprint]}"
}

これは、16進法で長さ40文字のドキュメントIDとなり、例えば4dad050215ca59aa1e3a26a222a9bbcaced23039のようになります。完全な設定例は、こちらのGistでご覧いただけます。

インデックスのパフォーマンスへの影響

さまざまなタイプの識別子を使うことの影響は、データ、ハードウェア、ユースケースによって異なります。一般的なガイドラインは提供できますが、ベンチマークを実行して自分のユースケースには正確にどんな影響があるのか判断することが重要です。

最適なインデックスのスループットのためには、Elasticsearchで自動生成された識別子を使用することがいつでも一番効果的な選択肢でしょう。更新チェックが不要なため、インデックスのパフォーマンスは、インデックスやシャードのサイズが拡大するほどには変わりません。ゆえに、可能であればいつでも使用することをおすすめします。

外部ID使用が理由で生じる更新チェックは、余分なディスクアクセスを必要とします。これによる影響は、オペレーティングシステムが必要なデータをどの程度効果的にキャッシュできるか、またストレージの速度や、ランダムな読み取りがどの程度うまく処理されるかによって異なります。インデックスのスピードはまた、インデックスとシャードが拡大しチェックするセグメントが増えるほど低下することもよくあります。

ロールオーバーAPIの使用

従来の時間ベースのインデックスは、特定の設定期間をカバーするインデックス一つひとつに依存しています。つまり、データ量が時間とともに変動する場合は、このインデックスとシャードのサイズがかなり大きく変化するということです。不規則なサイズのシャードは好ましくなく、パフォーマンス上の問題を引き起こします。

ロールオーバーインデックスAPIは、時間だけでなく複数の基準に基づく時間ベースのインデックスを管理する、柔軟な方法を提供するために導入されました。これは、既存のインデックスが一定のサイズ、ドキュメント数/古さに達したときに新しいインデックスにロールオーバーすることを可能にし、シャードとインデックスのサイズがより予測可能になっています。

しかしながらこれによって、イベントのタイムスタンプとそれが属するインデックスとのつながりは絶たれてしまいます。インデックスが厳密に時間を元にしたものであれば、到着がどれだけ遅くなっても、イベントはかならず同じインデックスになります。この考え方が、外部識別子を使った重複防止を可能にしています。ゆえに、ロールオーバーAPIを用いると、重複の確率が低くなったとしても、完全に重複を防ぐことはもう不可能です。 2つの重複したイベントがロールオーバーしたどちらかに到着して、そのため同じタイムスタンプを持っていても別々のインデックスになってしまう可能性があり、これが更新されることにはなりません。

ですから、重複防止が厳密な要件の場合には、ロールオーバーAPIの使用は推奨しません。

予測できないトラフィック量への対策

ロールオーバーAPIを使うことはできなくとも、トラフィック量が上下し時間ベースのインデックスが過剰に小さく、あるいは大きくなってしまった場合に、シャードサイズを適応させ変更する方法がまだあります。

トラフィックの急増などによりシャードが過剰に長くなってしまった場合は、分割インデックスAPIを使用してインデックスを複数のシャードに分けることが可能です。このAPIは、インデックス作成時に適用するよう設定する必要があるので、インデックステンプレートによって追加する必要があります。

反対にトラフィック量が少なすぎて、異常に小さいシャードができてしまった場合は、圧縮インデックスAPIを使ってインデックス内のシャード数を減らすことができます。

まとめ

このブログ記事でご覧になったように、Elasticsearch内での重複を防ぐことは、Elasticsearchにデータをインデックスする前に外部でドキュメントの識別子を特定することで可能になります。識別子のタイプと構造は、インデックス化のパフォーマンスに著しく影響することがあります。ただし、これはユースケースごとに異なるため、自分の特定のシナリオにはどれが最適か、ベンチマークによって見極めることをおすすめします。