Elasticsearch Transforms入門ガイド|重い集計クエリを事前計算で解決する方法

BLOG

はじめに:この記事で解決できること

本記事では、ElasticsearchのTransforms機能を紹介します。これを使うことで、数十億件規模のデータに対して「検索のたびに重い計算を実行する」構造から、「あらかじめ計算した結果を参照する」構造へと設計を切り替えられます。

発想をひとつ変えるだけで、ダッシュボードのパフォーマンスは大きく改善します。


問題の本質:フルスキャン前提の設計

以前担当したプロジェクトでは、Kibanaダッシュボードを開くたびにタイムアウトが発生していました。最初はクエリチューニングやノード増強を検討しましたが、根本原因はruntime fieldを使ったフルスキャン前提の設計にありました。

実装していたES|QLクエリはこのようなものです。

FROM <巨大インデックス>
| EVAL metric = GREATEST(fieldA, fieldB)
| STATS MAX(metric) BY group_id

理論上は正しいクエリです。しかし数十億件に対してこれを毎回実行すると、全期間スキャン→全行で演算→高cardinalityフィールドで集約という処理をダッシュボード表示のたびに繰り返すことになります。

「これはクエリの問題ではなく、設計の問題だ」と気づいたことが解決の出発点でした。

解決策の発想:Elasticsearch Transformsとは何か

Elasticsearch Transformsは、生データを別インデックスに事前集計して保存する機能です。

生データインデックス Transform サマリーインデックス

ダッシュボードやアラートは軽量なサマリーインデックスだけを参照するため、検索のたびに重い計算を繰り返す必要がなくなります。

重要な点として、**Transformは「クエリを速くするツール」ではなく「データ構造を再設計するツール」**です。効果の大小は、group_byの設計——何をキーにまとめるか——に直接依存します。

Transformの構造を理解する

実験の前に、Transformがどういう構造を持つかを把握しておきましょう。

PUT _transform/<name>
{
  "source": { ... },
  "pivot": { ... },
  "dest": { ... },
  "settings": { ... }
}

source:読み込むインデックスを指定します。クエリフィルターを加えることで、不要なデータを事前に除外できます。

"source": {
  "index": "kibana_sample_data_logs",
  "query": {
    "range": { "@timestamp": { "gte": "now-90d" } }
  }
}

pivot:最も重要なブロックです。group_byでどの粒度にまとめるかを、aggregationsで何を計算するかを定義します。設計ミスはほぼここで起きます。

"pivot": {
  "group_by": {
    "timestamp_hour": {
      "date_histogram": {
        "field": "@timestamp",
        "calendar_interval": "1h"
      }
    },
    "response": {
      "terms": { "field": "response.keyword" }
    }
  },
  "aggregations": {
    "request_count": { "value_count": { "field": "bytes" } },
    "total_bytes": { "sum": { "field": "bytes" } }
  }
}

dest:出力先インデックスを指定します。以後のダッシュボードやアラートはこのインデックスだけを参照します。

settings:大規模環境では必須の安全装置です。max_page_search_sizeは一度に処理するドキュメント数を制御し、メモリ枯渇を防ぎます。値はクラスタのヒープメモリに応じて調整が必要です(デフォルトは500。余裕がなければ100〜200程度から始めるのが無難です)。

"settings": {
  "max_page_search_size": 100
}

サンプルデータで効果を検証する

ここからはElasticでデフォルトで入っているkibana_sample_data_logs(約14,000件、約8.4MB)を使って、group_by設計の違いがどれほど結果に影響するかを確認します。

実験①:高cardinalityキーでの集約(clientip × 1時間)

clientipはほぼリクエストごとに異なるIPアドレスです。これを1時間単位と組み合わせてgroup_byに使うと、グループがほとんど1件ずつになり、まとめる対象がありません。

PUT _transform/sample-ip-hourly-v1
{
  "source": {
    "index": "kibana_sample_data_logs"
  },
  "pivot": {
    "group_by": {
      "timestamp_hour": {
        "date_histogram": {
          "field": "@timestamp",
          "calendar_interval": "1h"
        }
      },
      "clientip": {
        "terms": {
          "field": "clientip"
        }
      }
    },
    "aggregations": {
      "total_bytes": {
        "sum": {
          "field": "bytes"
        }
      },
      "request_count": {
        "value_count": {
          "field": "bytes"
        }
      },
      "error_count": {
        "filter": {
          "term": {
            "response.keyword": "404"
          }
        }
      },
      "error_rate": {
        "bucket_script": {
          "buckets_path": {
            "errors": "error_count._count",
            "total": "request_count"
          },
          "script": "params.total > 0 ? (params.errors / params.total) * 100 : 0"
        }
      }
    }
  },
  "dest": {
    "index": "summary-ip-hourly"
  }
}
POST _transform/sample-ip-hourly-v1/_start

結果:

  • ドキュメント数:13,844件(元データとほぼ同数)
  • サイズ:約2.7MB

Transformは実行されていますが、圧縮効果はほぼゼロです。cardinalityが高いキーは、まとめるべき単位として機能しません。

実験②:低cardinalityキーでの集約(response × 1時間)

response(HTTPステータスコード)は200、404、503など数種類しかありません。これを1時間単位でまとめると、同じ時間帯の同じレスポンスコードが大量にひとつのドキュメントに集約されます。

PUT _transform/sample-response-hourly-v1
{
  "source": {
    "index": "kibana_sample_data_logs"
  },
  "pivot": {
    "group_by": {
      "timestamp_hour": {
        "date_histogram": {
          "field": "@timestamp",
          "calendar_interval": "1h"
        }
      },
      "response": {
        "terms": {
          "field": "response.keyword"
        }
      }
    },
    "aggregations": {
      "request_count": {
        "value_count": {
          "field": "bytes"
        }
      },
      "total_bytes": {
        "sum": {
          "field": "bytes"
        }
      },
      "error_flag_count": {
        "filter": {
          "range": {
            "response.keyword": {
              "gte": "400"
            }
          }
        }
      },
      "error_rate": {
        "bucket_script": {
          "buckets_path": {
            "errors": "error_flag_count._count",
            "total": "request_count"
          },
          "script": "params.total > 0 ? (params.errors / params.total) * 100 : 0"
        }
      }
    }
  },
  "dest": {
    "index": "summary-response-hourly"
  }
}
POST _transform/sample-response-hourly-v1/_start

結果:

  • ドキュメント数:2,005件(約85%削減
  • サイズ:約405KB(約95%削減

サイズを確認するには、summary-response-hourly/*stats を実行し、レスポンス内の “store” フィールドにある “size_in_bytes” の値を確認できます。

この劇的な差はどこから来るのでしょうか。

Transformが生む2種類の圧縮

Transformが実現する圧縮は、実は2方向で同時にやります。

① 縦方向の圧縮(行を減らす)

元ログ:

時刻responsebytes
10:012001000
10:052002000
10:102001500
10:15404300
10:20404500

5行あります。

これを

1時間 × response

でまとめると:

時間responsetotal_bytes
10:002004500
10:00404800

5行 → 2行

これが縦方向の圧縮です。

同じカテゴリが繰り返し出るほど効果は大きくなります。

② 横方向の圧縮(列を減らす)

元ログには多くのフィールドがあります。

  • message
  • agent
  • geo
  • request
  • referer
  • tags
  • ……

でも、集約に必要なのは:

  • 時間
  • response
  • bytes

だけ。

Transform後のインデックスには、不要なフィールドは存在しません。

つまり、列が消える。

今回サイズが95%削減された理由はここにあります。

実行手順

# 1. Transformを作成
PUT _transform/summary-response-hourly
{ ... }

# 2. 実行前にプレビューで確認(大規模環境では必須)
POST _transform/summary-response-hourly/_preview

# 3. 起動
POST _transform/summary-response-hourly/_start

# 4. 状態確認
GET _transform/summary-response-hourly/_stats

# 5. 必要に応じて停止・削除
POST _transform/summary-response-hourly/_stop
DELETE _transform/summary-response-hourly

まとめと設計指針

Transformsを導入する前に、以下の問いに答えてみてください。

この計算は、本当に検索時にやる必要がありますか?

答えがNoであれば、Transformは有効な選択肢です。設計時には以下の点に注意してください。

  • group_byのcardinalityを下げることが効果の前提条件。高cardinalityキーは原則として避ける
  • 必要なフィールドだけを残す設計にすることで、横方向の圧縮も最大化できる
  • _previewで必ず事前検証してから本番適用する
  • max_page_search_sizeはクラスタのリソースに合わせて調整する

スケールが大きくなるほどTransformの効果は大きくなります。数億・数十億件規模の時系列データを扱っているなら、「事前計算+サマリーインデックス」という設計パターンは真っ先に検討すべき選択肢です。