はじめに:この記事で解決できること
本記事では、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方向で同時にやります。
① 縦方向の圧縮(行を減らす)
元ログ:
| 時刻 | response | bytes |
| 10:01 | 200 | 1000 |
| 10:05 | 200 | 2000 |
| 10:10 | 200 | 1500 |
| 10:15 | 404 | 300 |
| 10:20 | 404 | 500 |
5行あります。
これを
1時間 × response
でまとめると:
| 時間 | response | total_bytes |
| 10:00 | 200 | 4500 |
| 10:00 | 404 | 800 |
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の効果は大きくなります。数億・数十億件規模の時系列データを扱っているなら、「事前計算+サマリーインデックス」という設計パターンは真っ先に検討すべき選択肢です。


