- Published on
ログを検索可能に、そして破産しないように — 構造化、マッピング爆発、保持、そして本当のコスト
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — ログのコストがコンピュートのコストを超えた日
- ログが答える質問、答えられない質問
- 構造化ログ — 何をフィールドにするか
- マッピング爆発 — インデックスが死ぬ実際の経路
- インデックスのライフサイクル — ロールオーバーと階層移動
- サンプリング — ログを原則をもって捨てる方法
- ログでメトリクスを真似したときの本当のコスト
- 失敗モードと診断
- おわりに — ログのコストは量ではなくフィールド設計が決める
はじめに — ログのコストがコンピュートのコストを超えた日
四半期のインフラコストを整理していて、ログの保存とインデックス作成がアプリケーションのコンピュートより高いという事実を発見することは珍しくありません。そしてそのログの大半は誰も検索したことがありません。
原因はたいてい圧縮率やストレージの単価ではありません。最初は「とりあえず全部入れよう」から始まり、フィールドが増え、インデックスが大きくなり、保持期間を減らそうにも監査要件に引っかかり、そのうちクラスタが不安定になる、という経路です。
この記事はその経路をたどり直し、それぞれの地点で何を決めるべきだったかを扱います。OpenSearch 3.5とOpenTelemetry Collector v0.157.0基準で確認しました。Elasticsearch系も概念は同じですが、ISMに相当する機能の名前はILMと異なります。
ログが答える質問、答えられない質問
まず境界線を引きます。ログは個々のイベントの記録なので「なぜ」に答えます。どんな値が入ってきてどの分岐を通り、どんな例外が出たかはログだけが知っています。
反対に、ログで答えるにはコストが高い質問があります。
| 質問 | 適した信号 | ログでやると |
|---|---|---|
| このリクエストはなぜ失敗したか | ログ | まさにこの用途 |
| 今のエラー率は何パーセントか | メトリクス | 毎回全体スキャン、コストがクエリ回数に比例 |
| 昨日と比べてどうか | メトリクス | 保持期間が短く比較対象がないことが多い |
| リクエストがどのサービスで遅くなったか | トレース | サービスごとのログを時刻で突き合わせる推測 |
| 30日前の特定注文の処理履歴 | ログ | これもログの用途 |
ログを減らす第一の方法は圧縮ではなく、他の信号に移せる質問を移すことです。
構造化ログ — 何をフィールドにするか
構造化ログは文字列の代わりにキーと値を使います。理由はパースのコストではなく検索可能性です。
# 悪い例 — 検索には正規表現が必要で、フォーマットが変わるとその正規表現が壊れる
log.info(f"order {order_id} for tenant {tenant} failed after {ms}ms: {err}")
# 良い例 — フィールドで検索と集計ができる
log.info(
"order.failed",
extra={
"order.id": order_id,
"tenant.id": tenant,
"duration_ms": ms,
"error.type": type(err).__name__,
"error.message": str(err)[:500],
"http.route": route,
},
)
イベント名をメッセージの位置に入れるのが核心です。order.failed のように値の種類が有限な名前を使えば、それだけでグルーピングと集計ができます。メッセージにIDが混じると、同じ出来事がすべて違う文字列になり集計が不可能になります。
フィールド名は最初から規約を決めます。OpenTelemetryのセマンティック規約をそのまま使えば、ログとトレースの属性名が一致して相関検索が容易になります。
{
"@timestamp": "2026-08-02T04:11:52.418Z",
"severity_text": "ERROR",
"severity_number": 17,
"body": "order.failed",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"service.name": "checkout-api",
"service.version": "2.7.1",
"deployment.environment.name": "prod",
"http.route": "/v1/orders/:id",
"http.response.status_code": 502,
"error.type": "UpstreamTimeout",
"order.id": "A-99183",
"tenant.id": "t-8871",
"duration_ms": 1421
}
trace_id が入っていることが重要です。これひとつでトレースからログへ、ログからトレースへと渡り歩けます。自動計測がログ相関をサポートしていれば、コード修正なしで付きます。
# Pythonの自動計測がログにtrace_idとspan_idを注入する
export OTEL_PYTHON_LOG_CORRELATION=true
export OTEL_PYTHON_LOG_LEVEL=info
フィールド設計の原則は三つです。
- 名前は固定、値は可変です。 フィールド名自体にIDや日付を入れません。このルールひとつでマッピング爆発の9割を防げます。
- ひとつのフィールドはひとつの型です。
durationがあるログでは数値、別のログでは"1.4s"という文字列だと、先に索引された型でマッピングが固定され、残りは索引失敗として捨てられます。 - 大きな塊はフィールドではなく本文です。 スタックトレース、リクエスト本文、レスポンスペイロードは検索可能なフィールドにせず、索引しない本文フィールドに入れます。
マッピング爆発 — インデックスが死ぬ実際の経路
動的マッピングは初めて見るフィールドを自動的に登録します。便利ですが、フィールド名がデータから生まれる瞬間に危険になります。
{
"message": "cache stats",
"cache": {
"user_8871_hits": 12,
"user_8871_misses": 3,
"user_9902_hits": 7
}
}
ユーザーごとに新しいフィールドが生まれます。ユーザーが10万人なら、フィールドは20万個です。マッピングはクラスタ状態に入り、クラスタ状態はすべてのノードが共有し、変更のたびに伝播します。結果は次の順序で現れます。
- 索引の遅延が増えます。新しいフィールドごとにマッピング更新が必要で、これはマスターノードを経由します
- マスターノードのCPUとヒープが上がります
- クラスタ状態の伝播が遅くなり、ノードが離脱し始めます
- シャードの再割り当てが始まり、索引と検索がともに遅くなります
- フィールド上限に達し、索引が拒否されます
もっとも悪いのは、3番まで進むと元に戻すのが難しいことです。マッピングは削除できず、インデックスを作り直すしかありません。
防御線を三重に張ります。
PUT _index_template/logs-app
{
"index_patterns": ["logs-app-*"],
"data_stream": {},
"priority": 200,
"template": {
"settings": {
"index.number_of_shards": 3,
"index.number_of_replicas": 1,
"index.refresh_interval": "30s",
"index.codec": "zstd_no_dict",
"index.mapping.total_fields.limit": 1500,
"index.mapping.depth.limit": 8,
"index.mapping.nested_fields.limit": 20
},
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": { "type": "date" },
"severity_text": { "type": "keyword" },
"body": { "type": "keyword" },
"trace_id": { "type": "keyword" },
"span_id": { "type": "keyword" },
"service.name": { "type": "keyword" },
"service.version": { "type": "keyword" },
"http.route": { "type": "keyword" },
"http.response.status_code": { "type": "short" },
"error.type": { "type": "keyword" },
"error.message": { "type": "text", "index": true, "norms": false },
"duration_ms": { "type": "integer" },
"tenant.id": { "type": "keyword" },
"order.id": { "type": "keyword", "doc_values": true, "index": true },
"stack_trace": { "type": "text", "index": false },
"attributes": { "type": "flat_object" }
}
}
}
}
三重の防御線それぞれが何を防ぐかを見ます。
第一に、dynamic: strict。 定義していないフィールドが来たら文書を拒否します。強引ですが正直です。ログが静かに消えるより、索引失敗が目に見えるほうがましです。拒否が負担なら false にして、保存はするが索引はしないようにできます。
第二に、flat_object フィールドをひとつ。 予測できないキーと値が必ず来る場所を用意しておきます。flat_objectはオブジェクト全体をひとつのフィールドとして扱うため、下位のキーがマッピングに登録されません。ドット記法での参照はできますが、下位キーごとのインデックスがないため検索は遅くなります。そのトレードオフこそ、私たちが望んでいるものです。
第三に、フィールド数の上限。 事故が起きてもクラスタ全体ではなく、そのインデックスだけが止まります。
| 防御線 | 防ぐもの | 代償 |
|---|---|---|
| dynamic strict | 想定外のフィールドの登録 | 新しいフィールドを追加するにはテンプレート変更が必要 |
| flat_object | 任意のキーのマッピング登録 | 下位キー検索が遅く、集計に制約 |
| total_fields.limit | 事故の爆発範囲 | 上限超過時に索引失敗 |
| index: false | 大きなテキストの索引コスト | そのフィールドで検索不可、参照のみ可能 |
| norms: false | テキストフィールドのスコアリングメタデータ | 関連度ランキングが粗くなる |
現状の診断はこう行います。
# インデックスごとのフィールド数 — 四桁が出たらすでに問題がある
curl -s 'https://opensearch:9200/logs-app-000042/_mapping?pretty' \
| jq '[paths(type=="object" and has("type")) | length] | length'
# クラスタ状態のサイズとマッピング更新のキュー
curl -s 'https://opensearch:9200/_cluster/stats?pretty' \
| jq '.indices.mappings'
curl -s 'https://opensearch:9200/_cluster/health?pretty' \
| jq '{status, number_of_pending_tasks, task_max_waiting_in_queue_millis}'
# どのインデックスがディスクを使っているか
curl -s 'https://opensearch:9200/_cat/indices/logs-*?v&s=store.size:desc&h=index,docs.count,store.size,pri,rep' \
| head -20
number_of_pending_tasks が常に0でないなら、マッピング更新が滞っているというサインです。この値にアラートを掛けておけば、マッピング爆発を3段階目に至る前に捕まえられます。
インデックスのライフサイクル — ロールオーバーと階層移動
ログのインデックスはひとつにせず、時間かサイズで分割します。理由は単純です。削除がインデックス単位なら即座で、文書単位だと高くつくからです。1日分のログを消そうとdelete-by-queryを回すと、セグメントの再書き込みが起きてクラスタが揺らぎます。
PUT _plugins/_ism/policies/logs-app-lifecycle
{
"policy": {
"description": "アプリケーションログの30日保持",
"default_state": "hot",
"ism_template": [
{
"index_patterns": ["logs-app-*"],
"priority": 200
}
],
"states": [
{
"name": "hot",
"actions": [
{
"rollover": {
"min_primary_shard_size": "30gb",
"min_index_age": "1d"
}
}
],
"transitions": [
{ "state_name": "warm", "conditions": { "min_index_age": "3d" } }
]
},
{
"name": "warm",
"actions": [
{ "replica_count": { "number_of_replicas": 0 } },
{ "force_merge": { "max_num_segments": 1 } }
],
"transitions": [
{ "state_name": "cold", "conditions": { "min_index_age": "10d" } }
]
},
{
"name": "cold",
"actions": [
{ "read_only": {} }
],
"transitions": [
{ "state_name": "delete", "conditions": { "min_index_age": "30d" } }
]
},
{
"name": "delete",
"actions": [{ "delete": {} }]
}
]
}
}
min_primary_shard_size でロールオーバーするのは、時間基準より安全です。トラフィックが跳ねる日に1日分のインデックスが300GBになると、シャードひとつが100GBを超えて検索が急激に遅くなります。サイズ基準なら、その日はインデックスが複数に分かれます。
warm 段階でレプリカを0に下げるのは、コストと耐久性の交換です。古いログがノード障害で失われても許容できるかを先に決めてから判断します。監査対象のログなら、この段階を入れず別のインデックスと別の保持ポリシーに分離します。
ポリシーが実際に回っているかも確認を忘れません。ISMは静かに失敗します。
# ポリシーが付いたインデックスと現在の状態
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
| jq 'to_entries[] | select(.value.index != null)
| {index: .value.index, state: .value."policy_id", step: .value.step.name, failed: .value.failed}'
# 失敗した管理対象インデックスだけ
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
| jq '[to_entries[] | select(.value.failed == true) | .key]'
サンプリング — ログを原則をもって捨てる方法
全量保持がコストに見合わないなら捨てるしかなく、問題は何を捨てるかです。原則はひとつです。価値が低いものではなく、重複が多いものを捨てます。
効果が大きい順に並べます。
- ヘルスチェックとプローブのログ — 全ログの20〜40%を占めることが珍しくありません。情報量はほぼゼロです
- 成功経路のDEBUGとINFO — エラーがなかったリクエストの詳細ログ
- 繰り返される同一イベント — 同じerror.typeが秒間数千件なら、上位N件とカウントだけ残します
- 静的アセットへのリクエスト — 画像、JS、CSSへのアクセスログ
コレクタで切り落とします。アプリを直さずにポリシーだけを変えられる場所だからです。
# otel-collector.yaml — ログパイプライン
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
# 1) ヘルスチェックはまるごと捨てる
filter/drop_health:
error_mode: ignore
logs:
log_record:
- 'attributes["http.route"] == "/healthz"'
- 'attributes["http.route"] == "/readyz"'
- 'attributes["http.route"] == "/metrics"'
# 2) 正常経路のDEBUGは10%だけ残す。エラーには手を付けない
probabilistic_sampler/debug:
sampling_percentage: 10
attribute_source: record
from_attribute: trace_id
# 3) 大きなフィールドは切り詰める
transform/trim:
error_mode: ignore
log_statements:
- context: log
statements:
- truncate_all(attributes, 4096)
- delete_key(attributes, "http.request.body")
- set(attributes["error.message"], Substring(attributes["error.message"], 0, 500))
where attributes["error.message"] != nil
batch:
timeout: 5s
send_batch_size: 8192
exporters:
opensearch:
http:
endpoint: https://opensearch.observability.svc:9200
logs_index: logs-app
sending_queue:
enabled: true
queue_size: 5000
retry_on_failure:
enabled: true
max_elapsed_time: 300s
service:
pipelines:
# エラーと警告は無条件で全量保持
logs/critical:
receivers: [otlp]
processors: [memory_limiter, transform/trim, batch]
exporters: [opensearch]
# 正常経路はサンプリングする
logs/routine:
receivers: [otlp]
processors: [memory_limiter, filter/drop_health, probabilistic_sampler/debug, transform/trim, batch]
exporters: [opensearch]
from_attribute: trace_id が重要です。同じトレースのログが一緒に残るか一緒に捨てられるため、残ったログが断片化しません。各ログレコードをランダムに判定すると、ひとつのリクエストのログのうち三行しか残らず調査に使えなくなります。
ルーティングで信号ごとにパイプラインを分けるのも方法です。重大度によって別のインデックスと別の保持期間に振り分ければ、ERRORは90日残しINFOは7日だけ残すというポリシーが可能になります。
| 階層 | 対象 | 保持期間 | 索引 | 相対コスト |
|---|---|---|---|---|
| 熱い | ERROR、WARN、監査イベント | 30〜90日 | 全フィールド | 基準 |
| ぬるい | INFOのうちビジネスイベント | 14〜30日 | 主要フィールドのみ | 0.4倍 |
| 冷たい | 成功経路のアクセスログ | 3〜7日 | 最小限 | 0.15倍 |
| 保管 | 規制対応用の原本 | 1〜7年 | なし、オブジェクトストレージ | 0.02倍 |
一番下の行がよく忘れられます。監査要件のせいで保持期間を減らせないなら、そのログを検索クラスタではなくオブジェクトストレージに原本として置き、必要なときだけ取り出す構造のほうがはるかに安上がりです。検索可能性と保持は別の要件です。
ログでメトリクスを真似したときの本当のコスト
「ログが全部あるのだから、そこからエラー率を計算すればいいのでは」という発想は自然で、小規模では実際に動きます。規模が大きくなるとコスト構造が逆転します。
数字で見てみます。
# 規模の想定
rps = 5_000 # 秒間リクエスト数
log_bytes = 800 # ログ1件の索引後サイズ (バイト)
seconds_of_day = 86_400
daily_gb = rps * log_bytes * seconds_of_day / 1024**3
print(f"1日の索引量: {daily_gb:.1f} GB")
# 1日の索引量: 321.8 GB
# 同じ情報をメトリクスで表現すると
routes, status_classes, pods = 120, 5, 40
series = routes * status_classes * pods
samples_per_day = series * (seconds_of_day / 15) # 15秒スクレイプ
metric_bytes = samples_per_day * 2 # 圧縮後サンプルあたり約2バイト
print(f"時系列 {series:,}本、1日 {metric_bytes/1024**3:.3f} GB")
# 時系列 24,000本、1日 0.257 GB
千倍以上の差が出ます。しかもこれはストレージコストだけを見たものです。実際の差はクエリのところでさらに広がります。
| 項目 | ログでエラー率を計算 | メトリクスでエラー率を計算 |
|---|---|---|
| 1日の保存量 | 数百GB | 数百MB |
| 30秒ごとに更新するダッシュボード | 毎回全区間スキャン | あらかじめ計算済みの時系列を参照 |
| 30日比較クエリ | 30日分の文書を洗う、数十秒 | 数百ms |
| 保持期間 | コストのため7〜14日が限界 | 1年以上が現実的 |
| アラート評価 | 1分ごとに重い集計クエリ | 安価なベクタ演算 |
| スパイク時の挙動 | ログが増えた分だけクエリも遅くなる | 時系列数はそのまま |
最後の行がもっとも危険です。障害が起きるとログが急増し、まさにその瞬間にログベースのアラートとダッシュボードがもっとも遅くなります。必要なときに機能しない観測システムです。
正しい方向はログを減らすことではなく、集計はメトリクスに移し、ログは調査用に残すことです。コレクタがこの変換を代わりにやってくれます。
# ログからカウントメトリクスを作りPrometheusへ送る
connectors:
count:
logs:
log.error.count:
description: 重大度別のログ発生数
conditions:
- 'severity_number >= 17'
attributes:
- key: service.name
- key: error.type
- key: http.route
service:
pipelines:
logs/in:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [opensearch, count]
metrics/from_logs:
receivers: [count]
processors: [batch]
exporters: [prometheusremotewrite]
attributes リストに何を入れるかがすべてです。ここに入れたものがそのままメトリクスのラベルになるため、order.id や tenant.id を入れると時系列が爆発します。ログでは問題なかった高カーディナリティのフィールドが、メトリクスに移った瞬間に問題になります。
失敗モードと診断
| 症状 | 原因 | 確認方法 | 対応 |
|---|---|---|---|
| ログが一部しか届かない | フィールド型の衝突による索引拒否 | 索引失敗レスポンス、デッドレター | フィールド型の固定、アプリ側で型を統一 |
| 索引遅延が増え続ける | マッピング更新キューの滞留 | クラスタの待機タスク数 | dynamic strict、flat_objectの導入 |
| 特定の時間帯だけ検索が急激に遅くなる | シャードひとつが大きすぎる | インデックスごとのシャードサイズ | サイズ基準のロールオーバー |
| 古いインデックスが消えない | ISMポリシー失敗後の放置 | ISM explainのfailedフィールド | 失敗インデックスの再適用、アラート追加 |
| ディスクが急に埋まる | force_merge中の一時領域 | マージ作業の状態 | warm移動前に空き容量を確保 |
| 検索はできるが集計ができない | flat_objectの下位キー | マッピングの確認 | 集計が必要なキーは明示フィールドに昇格 |
| 障害時にログが失われる | コレクタのキュー飽和 | コレクタのドロップカウンタ | キューサイズとバックプレッシャーポリシーの調整 |
最後の行は特に注意します。ログパイプラインのバックプレッシャーがアプリまで伝わると、観測システムがサービスを殺す状況になります。コレクタのキューが満杯になったらドロップさせておき、ドロップカウンタにアラートを掛けます。ログの欠落は悪いことですが、サービス停止よりはましです。
exporters:
opensearch:
sending_queue:
enabled: true
queue_size: 5000
# キューが満杯なら新しいデータを捨てる。アプリへバックプレッシャーを戻さない
block_on_overflow: false
おわりに — ログのコストは量ではなくフィールド設計が決める
同じトラフィックでログのコストが10倍違う二つの組織の違いは、圧縮アルゴリズムではありません。片方はフィールド名が固定されていて集計の質問をメトリクスに移してあり、もう片方は動的マッピングを有効にしたままダッシュボードがログを洗っています。
今すぐできる点検は三つです。第一に、もっとも大きなログインデックスのフィールド数を数えてみます。四桁ならこの四半期の課題が決まったということです。第二に、過去30日間に実際に検索されたインデックスの一覧を抜き出してみます。検索されたことのないインデックスが保存量の半分を占めていることがよくあります。第三に、ログをスキャンしているダッシュボードのパネルを探し、メトリクスに移せるか確認します。
さらに掘り下げるための資料です。