Skip to content

필사 모드: 観測データをClickHouseに入れるということ — スキーマ、ロールアップ、TTL、そして役割分担

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 30日分のトレースを洗いざらい調べる質問が来たとき

「過去30日間に特定の決済代行会社へ送ったリクエストのうち、3秒を超えたものについて、テナントごとの分布を見たい」

こういう質問が来ると、たいていの観測スタックは詰まります。Prometheusは個々のリクエストを知りません。トレースバックエンドの保持期間は7日です。検索エンジンに入れてあっても、30日分を洗う間にクラスタが揺らぎます。

観測データが一日数TB規模になり、こうした質問が繰り返されるようになると、分析用データベースが必要になります。ClickHouseがこの場所によく登場する理由は、観測データの性質がカラム指向ストレージにうまく合うからです。

この記事では実際のスキーマを設計します。ClickHouse 26.5 stable基準で確認しており、長期安定版を使うなら26.3 LTS系が代替案です。25.8 LTSは2026年8月末でサポートが終わるため、新規構築には勧めません。ネイティブJSON型は25.3でproduction-readyになりました。

カラム指向ストレージが観測データに合う理由

三つが重なります。

第一に、クエリが狭い。 トレーステーブルにカラムが25個あっても、「サービスごとのp99」を求めるクエリが読むカラムはサービス名と処理時間の二つだけです。行指向ストレージは25カラム全部をディスクから読む必要がありますが、カラム指向ストレージは二つだけ読みます。

第二に、値が繰り返される。 サービス名は数十種類、スパン名は数百種類、ステータスコードは十種類程度です。同じ値が連続して並ぶと圧縮率が極端に良くなります。タイムスタンプはデルタエンコーディングで、低カーディナリティな文字列は辞書エンコーディングで縮みます。

第三に、書き込みがappend専用。 観測データは更新されません。MergeTreeが前提とするワークロードと正確に一致します。

圧縮率を体感するには自分で測ってみるのが早道です。

SELECT
    table,
    formatReadableSize(sum(data_uncompressed_bytes)) AS raw,
    formatReadableSize(sum(data_compressed_bytes))   AS compressed,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 1) AS ratio
FROM system.columns
WHERE database = 'otel'
GROUP BY table
ORDER BY sum(data_compressed_bytes) DESC;

-- カラム単位でも見られる。圧縮が効かないカラムがコストの大半を占める
SELECT
    name,
    type,
    formatReadableSize(data_compressed_bytes) AS compressed,
    round(data_uncompressed_bytes / data_compressed_bytes, 1) AS ratio
FROM system.columns
WHERE database = 'otel' AND table = 'otel_traces'
ORDER BY data_compressed_bytes DESC
LIMIT 15;

圧縮比が1に近いカラムがあれば、原因はたいてい二つです。ランダムな文字列(トレースID、UUID)か、自由形式テキストです。トレースIDはどうしようもありませんが、自由テキストは別カラムに分離して別のコーデックを適用する余地があります。

トレーススキーマ — ソートキーがすべて

MergeTreeでもっとも重要な決定はORDER BYです。データはディスク上でこの順序に並び、クエリはこの順序を利用して読むブロックを飛ばします。ソートキーを間違えると、他の何をもってしても取り返せません。

OpenTelemetry ClickHouseエクスポータが作るデフォルトテーブルは、トレースをサービス名、スパン名、時刻の順にソートします。これは「サービスとオペレーション単位で洗う」クエリに最適化された選択です。

CREATE TABLE otel.otel_traces
(
    Timestamp          DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    TraceId            String CODEC(ZSTD(1)),
    SpanId             String CODEC(ZSTD(1)),
    ParentSpanId       String CODEC(ZSTD(1)),
    TraceState         String CODEC(ZSTD(1)),
    SpanName           LowCardinality(String) CODEC(ZSTD(1)),
    SpanKind           LowCardinality(String) CODEC(ZSTD(1)),
    ServiceName        LowCardinality(String) CODEC(ZSTD(1)),
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    ScopeName          String CODEC(ZSTD(1)),
    ScopeVersion       String CODEC(ZSTD(1)),
    SpanAttributes     Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    Duration           UInt64 CODEC(ZSTD(1)),
    StatusCode         LowCardinality(String) CODEC(ZSTD(1)),
    StatusMessage      String CODEC(ZSTD(1)),

    Events Nested (
        Timestamp  DateTime64(9),
        Name       LowCardinality(String),
        Attributes Map(LowCardinality(String), String)
    ) CODEC(ZSTD(1)),

    Links Nested (
        TraceId    String,
        SpanId     String,
        TraceState String,
        Attributes Map(LowCardinality(String), String)
    ) CODEC(ZSTD(1)),

    INDEX idx_trace_id       TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_attr_key   mapKeys(ResourceAttributes)   TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_span_attr_key  mapKeys(SpanAttributes)       TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_duration       Duration TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

三点、注目してください。

LowCardinality(String)の使いどころ。 値の種類がおおむね1万個未満の文字列にだけ使います。辞書が作られて圧縮とフィルタリングが速くなります。TraceIdのように値がほぼ一意なカラムに付けると、辞書が際限なく大きくなってかえって悪化します。

ブルームフィルタインデックスの役割。 ソートキーがサービス名とスパン名なので、トレースIDひとつで検索するクエリはソート順を使えません。ブルームフィルタが「このブロックにこのトレースIDはない」を素早く判定し、読むブロックを減らしてくれます。ただしこれは補助手段であり、ソートキーの代わりにはなりません。

ttl_only_drop_parts = 1 TTL失効を行単位ではなくパート単位で処理します。パーティションが日単位なので、失効した日のパートがまるごと消えます。この設定がないと、TTLのクリーンアップがパートの再書き込みを引き起こし、ディスクI/Oが大きく増えます。

ソートキーを変えたいなら、まずどのクエリが大多数かを見ます。

主なクエリ推奨ORDER BY代償
サービスごとの遅延分析(ServiceName, SpanName, Timestamp)トレースID検索はブルームフィルタ頼み
トレースID単体の検索が圧倒的(TraceId)または別の検索用テーブル時間範囲スキャンが非効率
テナントごとの分析がほとんど(TenantId, ServiceName, Timestamp)テナントカラムを物理カラムに昇格させる必要
直近時間帯の探索が中心(toStartOfHour(Timestamp), ServiceName)古い区間のフィルタリングが非効率になる

両方のアクセスパターンがどちらも重要なら、テーブルをもうひとつ作ります。ストレージコストを二倍払う代わりに、両方のクエリが速くなります。ClickHouseではこれはよくある選択です。

ログスキーマ — 属性をMapで持つかJSONで持つか

ログテーブルのソートキーは違います。ログは時間範囲とサービスで絞るクエリが圧倒的なので、時間を前に置きつつ、細かく切りすぎないようにします。

CREATE TABLE otel.otel_logs
(
    Timestamp          DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    TraceId            String CODEC(ZSTD(1)),
    SpanId             String CODEC(ZSTD(1)),
    TraceFlags         UInt8,
    SeverityText       LowCardinality(String) CODEC(ZSTD(1)),
    SeverityNumber     UInt8,
    ServiceName        LowCardinality(String) CODEC(ZSTD(1)),
    Body               String CODEC(ZSTD(1)),
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    LogAttributes      Map(LowCardinality(String), String) CODEC(ZSTD(1)),

    -- よくフィルタリングするキーは物理カラムに昇格させる
    HttpRoute    LowCardinality(String) MATERIALIZED LogAttributes['http.route'],
    HttpStatus   UInt16                 MATERIALIZED toUInt16OrZero(LogAttributes['http.response.status_code']),
    ErrorType    LowCardinality(String) MATERIALIZED LogAttributes['error.type'],

    INDEX idx_trace_id  TraceId    TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_body      Body       TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1,
    INDEX idx_severity  SeverityNumber TYPE set(16) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, toStartOfFiveMinutes(Timestamp), Timestamp)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

MATERIALIZEDカラムが重要な装置です。挿入時点でMapから値を取り出し、別カラムとして保存します。Map参照は毎回キーを探す必要がありますが、物理カラムは直接読めるので、よく使うフィルタが大幅に速くなります。ストレージコストは増えますが、低カーディナリティなカラムは圧縮がよく効くので実際の増加幅は小さくなります。

属性の保存方式には二つの選択肢があります。

項目Map(String, String)JSON型
型の保持すべて文字列に平坦化元の型を維持
クエリ時に読む量キーひとつだけ必要でもMap全体を読む該当するサブカラムだけ読む
スキーマの変化自由自由、サブカラムが自動生成される
サブキー数が多いとき圧縮とクエリが両方悪化サブカラム上限の設定で制御
ツール互換性どこでも動く25.3以降が必要
移行基準既存テーブルは再作成が必要

Mapの最大の弱点は部分読み込みができないことです。LogAttributes['http.route']ひとつを読もうとしても、その行のMap全体をディスクから読んで解凍する必要があります。属性が40個あるログではこのコストが大きくなります。JSON型はサブキーをそれぞれ別カラムのように保存するため、この問題がありません。

-- JSON型の使用例 — サブカラム数に上限を設ける
CREATE TABLE otel.otel_logs_json
(
    Timestamp    DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    ServiceName  LowCardinality(String) CODEC(ZSTD(1)),
    SeverityText LowCardinality(String) CODEC(ZSTD(1)),
    TraceId      String CODEC(ZSTD(1)),
    Body         String CODEC(ZSTD(1)),
    Attributes   JSON(max_dynamic_paths = 512) CODEC(ZSTD(1))
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, toStartOfFiveMinutes(Timestamp), Timestamp)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS ttl_only_drop_parts = 1;

-- サブパスに型を明示してアクセスすると、インデックスと統計情報が活用される
SELECT
    ServiceName,
    Attributes.http.route::LowCardinality(String) AS route,
    count() AS c,
    quantile(0.99)(Attributes.duration_ms::Float64) AS p99
FROM otel.otel_logs_json
WHERE Timestamp >= now() - INTERVAL 1 HOUR
  AND SeverityText = 'ERROR'
GROUP BY ServiceName, route
ORDER BY c DESC
LIMIT 20;

max_dynamic_pathsは検索エンジンにおけるフィールド数上限と同じ役割を果たします。上限を超えたパスは別の共有ストレージに追いやられてクエリが遅くなりますが、クラスタが崩壊することはありません。マッピング爆発のような事故を構造的に緩和します。

判断基準は単純です。属性キーがおおむね予測可能で数が少ないならMapで十分です。キー集合がサービスごとに違い、増え続けるならJSON型のほうが優れています。

パーティショニング、TTL、そして階層ストレージ

パーティションは日単位が基本です。もっと細かく分けたい誘惑がありますが、我慢したほうがよいです。パーティションが増えるとパート数が増え、パートが多いとマージ負荷とメタデータの負担が大きくなります。一日のデータが数TBあるなら時間単位のパーティションを検討しますが、その前にTTLとソートキーで解決できないかを見ます。

TTLは削除だけでなく移動にも使います。直近のデータは速いディスクに、古いデータは遅くて安いストレージに置きます。

-- ストレージポリシーの定義 (config.xml または別の設定ファイル)
-- hot: ローカルNVMe、cold: オブジェクトストレージ

ALTER TABLE otel.otel_traces
MODIFY TTL
    toDateTime(Timestamp) + INTERVAL 3 DAY  TO VOLUME 'hot',
    toDateTime(Timestamp) + INTERVAL 14 DAY TO VOLUME 'cold',
    toDateTime(Timestamp) + INTERVAL 90 DAY DELETE;

移動TTLを使うとき、必ず確認すべきことがあります。オブジェクトストレージに移されたデータへのクエリはネットワーク往復が発生し、はるかに遅くなります。「90日保持」が「90日間同じ速度で検索できる」ことを意味しないと、あらかじめユーザーに伝えておく必要があります。そうしないと、ある日誰かが60日前のデータを全スキャンしてクラスタを麻痺させます。

TTLが実際に動いているかも確認します。

-- パーティションごとのサイズと最も古いデータ
SELECT
    table,
    partition,
    formatReadableSize(sum(bytes_on_disk)) AS size,
    sum(rows) AS rows,
    min(min_time) AS oldest
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table, partition
ORDER BY partition
LIMIT 10;

-- マージの待機と進行中のもの
SELECT table, elapsed, progress, num_parts, formatReadableSize(memory_usage) AS mem
FROM system.merges
WHERE database = 'otel';

-- パート数が多いと挿入が拒否され始める
SELECT table, count() AS parts
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table
ORDER BY parts DESC;

パート数は注視する価値があります。小さな挿入が頻繁だとパートが急増し、マージが追いつかなくなると挿入自体が拒否されます。エクスポータのバッチサイズを大きくし、非同期挿入を有効にするのが基本的な対応です。

マテリアライズドビューでロールアップする

生のトレースを30日保持するのはコストがかかります。ところが、ほとんどのダッシュボードクエリは生データではなく集計値を必要とします。マテリアライズドビューが挿入時点で集計を作ってくれれば、生データは短く、集計は長く保持できます。

-- 1) 集計結果を格納するテーブル
CREATE TABLE otel.trace_rollup_1m
(
    Bucket      DateTime,
    ServiceName LowCardinality(String),
    SpanName    LowCardinality(String),
    SpanKind    LowCardinality(String),
    Calls       AggregateFunction(count),
    Errors      AggregateFunction(countIf, UInt8),
    DurationQ   AggregateFunction(quantiles(0.5, 0.9, 0.99), Float64),
    DurationSum AggregateFunction(sum, Float64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(Bucket)
ORDER BY (ServiceName, SpanName, SpanKind, Bucket)
TTL Bucket + toIntervalDay(400);

-- 2) 生テーブルに挿入されたタイミングで自動的に集計する
CREATE MATERIALIZED VIEW otel.trace_rollup_1m_mv TO otel.trace_rollup_1m AS
SELECT
    toStartOfMinute(Timestamp) AS Bucket,
    ServiceName,
    SpanName,
    SpanKind,
    countState()                                   AS Calls,
    countIfState(StatusCode = 'Error')             AS Errors,
    quantilesState(0.5, 0.9, 0.99)(Duration / 1e6) AS DurationQ,
    sumState(Duration / 1e6)                       AS DurationSum
FROM otel.otel_traces
GROUP BY Bucket, ServiceName, SpanName, SpanKind;

-- 3) クエリ時に状態をマージする
SELECT
    ServiceName,
    SpanName,
    countMerge(Calls)                          AS calls,
    countIfMerge(Errors)                       AS errors,
    round(countIfMerge(Errors) / countMerge(Calls), 4) AS error_ratio,
    arrayElement(quantilesMerge(0.5, 0.9, 0.99)(DurationQ), 3) AS p99_ms
FROM otel.trace_rollup_1m
WHERE Bucket >= now() - INTERVAL 30 DAY
GROUP BY ServiceName, SpanName
ORDER BY calls DESC
LIMIT 30;

三つの落とし穴があります。

第一に、マテリアライズドビューは挿入トリガーです。生テーブルにすでに入っているデータは処理しません。ビューを作った後に過去データを埋めたければ、別途INSERT SELECTを実行する必要があります。

第二に、生テーブルとビューのTTLは独立しています。生テーブルを7日に短縮しつつビューを400日にするのが目的だったなら、まさにそのとおりに動作します。ただしビューの定義を変えると、その時点の前と後で集計の意味が変わってしまうため、定義変更は新しいビューと新しいテーブルを作るほうが安全です。

第三に、分位数は状態として保存する必要があります。1分単位のp99をただの数値として保存し、あとで平均を取ると、それはp99ではなくなります。quantilesStateで中間状態を保存し、クエリ時にquantilesMergeで合成してはじめて、複数区間にまたがる分位数が近似的に成立します。

生データとロールアップの保持期間の組み合わせがコストを決めます。

データ保持期間相対サイズ答えられる質問
生スパン7〜14日1.0個々のリクエストの全経路、任意の属性フィルタ
1分ロールアップ90〜400日0.005以下サービスごとの傾向、デプロイ前後の比較、SLO計算
エラースパンのみ別途保持90日0.02まれなエラーの長期パターン

コレクタからClickHouseへ送るとき

エクスポータにスキーマを自動生成させるのは開発環境限定で勧めます。本番ではDDLを自分で管理し、自動生成をオフにします。ソートキーとTTLは組織ごとに異なるべきものですが、自動生成されたスキーマを後から変えるにはテーブルを作り直す必要があるからです。

# otel-collector.yaml
exporters:
  clickhouse:
    endpoint: tcp://clickhouse.observability.svc:9000?dial_timeout=10s
    database: otel
    username: otel_writer
    password: ${env:CLICKHOUSE_PASSWORD}
    # 本番ではDDLを自分で管理する
    create_schema: false
    logs_table_name: otel_logs
    traces_table_name: otel_traces
    compress: lz4
    async_insert: true
    timeout: 10s
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 10000
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_elapsed_time: 300s

processors:
  # ClickHouseは大きなバッチを好む。小さな挿入が頻繁だとパートが急増する
  batch:
    timeout: 10s
    send_batch_size: 20000
    send_batch_max_size: 50000

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [clickhouse]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [clickhouse]

バッチサイズが核心の設定です。ClickHouseは一度に数万行を受け取ることを前提に設計されています。毎秒数百回の小さな挿入はパートを量産し、マージ負荷として跳ね返ってきます。

実際に遭遇する障害をまとめておきます。

症状原因確認方法対応
挿入が拒否されるアクティブなパート数の超過system.partsのパート数バッチサイズ拡大、非同期挿入、パーティション単位の見直し
特定のクエリだけ極端に遅いソートキーに乗らないフィルタEXPLAINの読み込みマーク数ソートキーの再設計、または補助テーブル
ディスクが想定より早く埋まるTTLがパート単位で処理されていないパーティションごとのサイズと最古時刻ttl_only_drop_partsの確認、パーティション単位の点検
メモリ超過でクエリが失敗GROUP BYのカーディナリティ過多クエリログのメモリ使用量事前集計ビューの利用、クエリごとのメモリ上限
古いデータの検索が非常に遅いオブジェクトストレージ階層に移動済みストレージポリシーとパートの位置ユーザーに階層構造を案内、ロールアップテーブルへ誘導
ログとトレースの相関が取れないTraceIdのフォーマット不一致両者のサンプルを比較コレクタで表記を統一

クエリがソートキーに乗っているかはEXPLAINで確認します。

EXPLAIN indexes = 1
SELECT count()
FROM otel.otel_traces
WHERE ServiceName = 'checkout-api'
  AND Timestamp >= now() - INTERVAL 1 HOUR;
-- 読み込んだマーク数が全体のごく一部であるべき

Prometheus、OpenSearch、ClickHouseの役割分担

三つとも使うのは無駄に見えますが、それぞれ別の質問に答えます。ひとつに統合しようとする試みは、たいていそのうちのどれかの強みを失って終わります。

PrometheusOpenSearchClickHouse
データモデル時系列、ラベル集合転置インデックス文書カラムテーブル
もっとも得意なこと秒単位の集計、アラート評価全文検索、任意フィールドの検索大量スキャン、任意の集計、結合
カーディナリティ弱く、予算管理が必須フィールド数に弱い相対的に寛容
保持期間数か月(ダウンサンプリングが必要)数週間(コスト制約)数か月から数年
遅延秒単位秒単位秒から分単位
適した質問今悪いか、アラートを鳴らすべきかこのリクエストはなぜ失敗したか30日間でどんなパターンがあったか

現実的な配置はこうなります。

  • アラートとSLO評価はPrometheusです。秒単位の評価と低コストなクエリが必要で、この領域を他のツールに置き換える理由はほとんどありません。
  • 直近ログの全文検索はOpenSearchです。「このエラーメッセージを含むログ」のようにテキストを探すクエリは、転置インデックスが圧倒的に有利です。その代わり保持期間は短く取ります。
  • 長期保持と任意の分析はClickHouseです。生のトレース、生のログ、ロールアップがすべてここにあり、結合して答える質問を担当します。

三つを一緒に使うとき、必ず守るべきなのは識別子の一貫性です。service.nametrace_iddeployment.environment.nameがこの三つのシステムで同じ値でなければ、ツール間を行き来できません。コレクタで一度正規化し、各エクスポータがその値をそのまま使うようにします。

processors:
  transform/normalize:
    error_mode: ignore
    trace_statements:
      - context: resource
        statements:
          # 古い名前で入ってきたものを現在の規約に統一する
          - set(attributes["deployment.environment.name"], attributes["deployment.environment"])
            where attributes["deployment.environment.name"] == nil
                  and attributes["deployment.environment"] != nil
          - delete_key(attributes, "deployment.environment")

おわりに — スキーマの決定は後から覆しにくい

ClickHouseでは、後から覆しやすいものと難しいものがはっきり分かれます。インデックスの追加、TTLの変更、マテリアライズドビューの追加は運用中にできます。ソートキーの変更とパーティションキーの変更は事実上テーブルの再作成です。

そのため、導入の順番はこうなります。まず、どのクエリが一日に何千回も実行されるかを書き出します。そのクエリのフィルタ条件がソートキーの前方になるべきです。次に、保持期間を生データとロールアップに分けて決めます。最後に、属性の保存方式を選びます。この三つさえ最初にきちんと決めておけば、残りは後から直せます。

今すぐできる点検は、カラムごとの圧縮比を抜き出してみることです。圧縮が効かないカラムがストレージコストの半分を占めているなら、そのカラムが本当に必要かどうかから問い直します。

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

현재 단락 (1/321)

「過去30日間に特定の決済代行会社へ送ったリクエストのうち、3秒を超えたものについて、テナントごとの分布を見たい」

작성 글자: 0원문 글자: 13,709작성 단락: 0/321