Skip to content
Published on

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

シェア
Authors

はじめに — ログのコストがコンピュートのコストを超えた日

四半期のインフラコストを整理していて、ログの保存とインデックス作成がアプリケーションのコンピュートより高いという事実を発見することは珍しくありません。そしてそのログの大半は誰も検索したことがありません。

原因はたいてい圧縮率やストレージの単価ではありません。最初は「とりあえず全部入れよう」から始まり、フィールドが増え、インデックスが大きくなり、保持期間を減らそうにも監査要件に引っかかり、そのうちクラスタが不安定になる、という経路です。

この記事はその経路をたどり直し、それぞれの地点で何を決めるべきだったかを扱います。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

フィールド設計の原則は三つです。

  1. 名前は固定、値は可変です。 フィールド名自体にIDや日付を入れません。このルールひとつでマッピング爆発の9割を防げます。
  2. ひとつのフィールドはひとつの型です。 duration があるログでは数値、別のログでは"1.4s"という文字列だと、先に索引された型でマッピングが固定され、残りは索引失敗として捨てられます。
  3. 大きな塊はフィールドではなく本文です。 スタックトレース、リクエスト本文、レスポンスペイロードは検索可能なフィールドにせず、索引しない本文フィールドに入れます。

マッピング爆発 — インデックスが死ぬ実際の経路

動的マッピングは初めて見るフィールドを自動的に登録します。便利ですが、フィールド名がデータから生まれる瞬間に危険になります。

{
  "message": "cache stats",
  "cache": {
    "user_8871_hits": 12,
    "user_8871_misses": 3,
    "user_9902_hits": 7
  }
}

ユーザーごとに新しいフィールドが生まれます。ユーザーが10万人なら、フィールドは20万個です。マッピングはクラスタ状態に入り、クラスタ状態はすべてのノードが共有し、変更のたびに伝播します。結果は次の順序で現れます。

  1. 索引の遅延が増えます。新しいフィールドごとにマッピング更新が必要で、これはマスターノードを経由します
  2. マスターノードのCPUとヒープが上がります
  3. クラスタ状態の伝播が遅くなり、ノードが離脱し始めます
  4. シャードの再割り当てが始まり、索引と検索がともに遅くなります
  5. フィールド上限に達し、索引が拒否されます

もっとも悪いのは、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]'

サンプリング — ログを原則をもって捨てる方法

全量保持がコストに見合わないなら捨てるしかなく、問題は何を捨てるかです。原則はひとつです。価値が低いものではなく、重複が多いものを捨てます。

効果が大きい順に並べます。

  1. ヘルスチェックとプローブのログ — 全ログの20〜40%を占めることが珍しくありません。情報量はほぼゼロです
  2. 成功経路のDEBUGとINFO — エラーがなかったリクエストの詳細ログ
  3. 繰り返される同一イベント — 同じerror.typeが秒間数千件なら、上位N件とカウントだけ残します
  4. 静的アセットへのリクエスト — 画像、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.idtenant.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日間に実際に検索されたインデックスの一覧を抜き出してみます。検索されたことのないインデックスが保存量の半分を占めていることがよくあります。第三に、ログをスキャンしているダッシュボードのパネルを探し、メトリクスに移せるか確認します。

さらに掘り下げるための資料です。