Split View: 관측 데이터를 ClickHouse에 넣는다는 것 — 스키마, 롤업, TTL, 그리고 역할 분담
관측 데이터를 ClickHouse에 넣는다는 것 — 스키마, 롤업, TTL, 그리고 역할 분담
- 들어가며 — 30일치 트레이스를 훑어야 하는 질문이 생겼을 때
- 컬럼 저장이 관측 데이터에 맞는 이유
- 트레이스 스키마 — 정렬 키가 전부다
- 로그 스키마 — 속성을 Map 으로 둘 것인가 JSON 으로 둘 것인가
- 파티셔닝, TTL, 그리고 계층 저장
- 머티리얼라이즈드 뷰로 롤업
- 컬렉터에서 ClickHouse 로 넣을 때
- Prometheus, OpenSearch, ClickHouse 의 역할 나누기
- 마치며 — 스키마 결정은 나중에 되돌리기 어렵다
들어가며 — 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 의 역할 나누기
셋을 다 쓰는 것이 낭비처럼 보이지만, 각자 다른 질문에 답합니다. 하나로 통합하려는 시도는 대개 그중 하나의 강점을 잃는 것으로 끝납니다.
| 축 | Prometheus | OpenSearch | ClickHouse |
|---|---|---|---|
| 데이터 모델 | 시계열, 레이블 집합 | 역인덱스 문서 | 컬럼 테이블 |
| 가장 잘하는 것 | 초 단위 집계, 알림 평가 | 전문 검색, 임의 필드 조회 | 대량 스캔, 임의 집계, 조인 |
| 카디널리티 | 취약, 예산 관리 필수 | 필드 수에 취약 | 상대적으로 관대 |
| 보존 | 수개월 (다운샘플링 필요) | 수주 (비용 제약) | 수개월에서 수년 |
| 지연 | 초 단위 | 초 단위 | 초에서 분 단위 |
| 적합한 질문 | 지금 나쁜가, 알림을 울릴까 | 이 요청은 왜 실패했나 | 30일 동안 어떤 패턴이 있었나 |
현실적인 배치는 이렇습니다.
- 알림과 SLO 평가는 Prometheus입니다. 초 단위 평가와 저비용 질의가 필요하고, 이 영역에서 다른 도구로 대체할 이유가 거의 없습니다.
- 최근 로그의 전문 검색은 OpenSearch입니다. "이 오류 메시지가 포함된 로그"처럼 텍스트를 찾는 질의는 역인덱스가 압도적으로 유리합니다. 대신 보존을 짧게 갑니다.
- 장기 보존과 임의 분석은 ClickHouse입니다. 트레이스 원본, 로그 원본, 롤업이 모두 여기에 있고, 조인해서 답하는 질문을 담당합니다.
셋을 함께 쓸 때 반드시 지킬 것은 식별자의 일관성입니다. service.name, trace_id, deployment.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 변경, 머티리얼라이즈드 뷰 추가는 운영 중에 할 수 있습니다. 정렬 키 변경과 파티션 키 변경은 사실상 테이블 재생성입니다.
그래서 도입 순서는 이렇게 됩니다. 먼저 어떤 질의가 하루에 수천 번 실행될지를 적습니다. 그 질의의 필터 조건이 정렬 키의 앞부분이 되어야 합니다. 그다음 보존 기간을 원본과 롤업으로 나눠 정합니다. 마지막으로 속성 저장 방식을 고릅니다. 이 세 가지만 초기에 제대로 정해 두면 나머지는 나중에 고칠 수 있습니다.
지금 할 수 있는 점검은 컬럼별 압축비를 뽑아 보는 것입니다. 압축이 안 되는 컬럼이 저장 비용의 절반을 차지하고 있다면, 그 컬럼이 정말 필요한지부터 다시 묻습니다.
더 파고들 자료입니다.
Putting Observability Data Into ClickHouse — Schema, Rollups, TTL, and Splitting the Work
- Introduction — When You Need to Scan 30 Days of Traces to Answer a Question
- Why Columnar Storage Fits Observability Data
- Trace Schema — The Sort Key Is Everything
- Log Schema — Should Attributes Be a Map or a JSON Type?
- Partitioning, TTL, and Tiered Storage
- Rollups With Materialized Views
- Sending From the Collector Into ClickHouse
- Dividing Roles Among Prometheus, OpenSearch, and ClickHouse
- Closing — Schema Decisions Are Hard to Reverse Later
Introduction — When You Need to Scan 30 Days of Traces to Answer a Question
"I want to see the tenant distribution of requests to a specific payment processor over the last 30 days that took longer than 3 seconds."
When a question like this comes in, most observability stacks choke. Prometheus doesn't know about individual requests. The trace backend only retains 7 days. If you stored it in a search engine, the cluster wobbles while it scans 30 days' worth.
Once observability data reaches multiple terabytes a day and questions like this start recurring, you need an analytical database. ClickHouse shows up in this spot often because the nature of observability data suits columnar storage so well.
This post designs a real schema. Verified against ClickHouse 26.5 stable; if you want a long-term-stable line, the 26.3 LTS series is the alternative. 25.8 LTS support ends in late August 2026, so it isn't recommended for new builds. The native JSON type became production-ready in 25.3.
Why Columnar Storage Fits Observability Data
Three things line up.
First, queries are narrow. Even if a trace table has 25 columns, a query for "p99 by service" only reads two of them: service name and duration. Row storage has to read all 25 columns off disk; columnar storage reads only two.
Second, values repeat. There are dozens of service names, hundreds of span names, and around ten status codes. When the same value runs in a row, compression gets extremely good. Timestamps shrink via delta encoding, and low-cardinality strings shrink via dictionary encoding.
Third, writes are append-only. Observability data is never updated. This matches exactly the workload MergeTree assumes.
The fastest way to feel the compression ratio is to measure it yourself.
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;
-- You can also look at it column by column. Columns that don't compress well make up most of the cost
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;
If a column has a compression ratio close to 1, there are usually two causes: random strings (trace IDs, UUIDs), or free-form text. Trace IDs can't be helped, but free text can be split into a separate column where you have room to apply a different codec.
Trace Schema — The Sort Key Is Everything
The single most important decision in MergeTree is ORDER BY. Data is sorted on disk in this order, and queries use that order to skip blocks they don't need to read. Get the sort key wrong, and nothing else can make up for it.
The default table the OpenTelemetry ClickHouse exporter creates sorts traces by service name, span name, then timestamp. That's a choice optimized for queries that "scan by service and operation."
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;
Three things are worth watching closely.
Where to use LowCardinality(String). Use it only on strings with roughly fewer than 10,000 distinct values. A dictionary gets built, which speeds up both compression and filtering. Attach it to a column like TraceId, where values are essentially unique, and the dictionary grows without bound and makes things worse instead.
The role of the bloom filter index. Since the sort key is service and span name, a query that looks up a single trace ID can't use the sort order. The bloom filter quickly rules out "this trace ID isn't in this block," cutting down the blocks that need to be read. But this is a secondary aid — it can't substitute for the sort key.
ttl_only_drop_parts = 1. This processes TTL expiry at the part level, not the row level. Since partitions are per-day, an entire part for an expired day disappears at once. Without this setting, TTL cleanup triggers part rewrites, which drives disk I/O way up.
If you want to change the sort key, start by looking at which queries dominate.
| Dominant query | Recommended ORDER BY | Trade-off |
|---|---|---|
| Per-service latency analysis | (ServiceName, SpanName, Timestamp) | Single trace-ID lookups depend on the bloom filter |
| Single trace-ID lookups dominate | (TraceId) or a separate lookup table | Time-range scans become inefficient |
| Mostly per-tenant analysis | (TenantId, ServiceName, Timestamp) | The tenant column must be promoted to a physical column |
| Mostly recent-window exploration | (toStartOfHour(Timestamp), ServiceName) | Filtering older ranges is less efficient |
If both access patterns matter, create a second table. You pay double the storage cost, but both queries get fast. This is a common choice in ClickHouse.
Log Schema — Should Attributes Be a Map or a JSON Type?
The sort key for a log table is different. Since queries that narrow by time range and service dominate for logs, put time first, but don't slice it too finely.
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)),
-- Promote frequently filtered keys to physical columns
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;
The MATERIALIZED column is an important device here. At insert time, it pulls a value out of the Map and stores it as a separate column. A Map lookup has to search for the key every time, but a physical column reads directly, so frequently used filters get much faster. Storage cost goes up, but low-cardinality columns compress well, so the actual increase is small.
There are two options for how to store attributes.
| Item | Map(String, String) | JSON type |
|---|---|---|
| Type preservation | Everything flattened to strings | Original types preserved |
| Amount read per query | Reads the whole Map even for one key | Reads only the relevant sub-column |
| Schema change | Free-form | Free-form, sub-columns auto-created |
| Many sub-keys | Compression and queries both degrade | Controlled via a sub-column cap |
| Tooling compatibility | Works everywhere | Requires 25.3+ |
| Migration | Baseline | Existing tables need rewriting |
Map's biggest weakness is that it can't be partially read. Even to read a single LogAttributes['http.route'], you have to read the entire Map for that row off disk and decompress it. For a log with 40 attributes, that cost is significant. The JSON type stores each sub-key like its own separate column, so it doesn't have this problem.
-- Example JSON type usage — cap the number of sub-columns
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;
-- Accessing a sub-path with an explicit type lets indexes and statistics kick in
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 plays the same role as a field-count cap in a search engine. Paths beyond the cap get pushed into a separate shared store — queries get slower, but the cluster doesn't collapse. It structurally mitigates incidents like mapping explosions.
The decision rule is simple. If attribute keys are mostly predictable and few in number, a Map is enough. If the key set differs by service and keeps growing, the JSON type is better.
Partitioning, TTL, and Tiered Storage
A daily partition is the default. There's a temptation to slice it finer, but it's better to resist. More partitions means more parts, and more parts means heavier merge load and metadata overhead. If daily data runs into multiple terabytes, consider hourly partitions, but check first whether TTL and the sort key already solve the problem.
TTL is used for moving data, not just deleting it. Keep recent data on fast disks, and older data on slower, cheaper storage.
-- Define a storage policy (in config.xml or a separate config file)
-- hot: local NVMe, cold: object storage
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;
There's one thing you must confirm when using move TTL. Queries against data moved to object storage incur a network round trip and get much slower. You need to tell users up front that "90-day retention" doesn't mean "queryable at the same speed for 90 days." Otherwise, someday someone runs a full scan over data from 60 days ago and paralyzes the cluster.
Also verify that TTL is actually running.
-- Size per partition and the oldest data
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;
-- Merges pending and in progress
SELECT table, elapsed, progress, num_parts, formatReadableSize(memory_usage) AS mem
FROM system.merges
WHERE database = 'otel';
-- If the part count is high, inserts start getting rejected
SELECT table, count() AS parts
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table
ORDER BY parts DESC;
Part count is worth watching. Frequent small inserts make parts explode, and if merges can't keep up, inserts themselves get rejected. The standard response is to increase the exporter's batch size and turn on async inserts.
Rollups With Materialized Views
Retaining raw traces for 30 days is expensive. But most dashboard queries need aggregates, not raw data. If a materialized view builds the aggregate at insert time, you can keep the raw data short-lived and the aggregate long-lived.
-- 1) The table that will hold the aggregated results
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) Automatically aggregate as data is inserted into the source table
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) Merge the states together at query time
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;
There are three traps here.
First, a materialized view is an insert trigger. It doesn't process data already sitting in the source table. If you want to backfill historical data after creating the view, you have to run a separate INSERT SELECT.
Second, the source's and the view's TTLs are independent. If your goal was to shrink the source to 7 days while keeping the view at 400, that's exactly what happens. But if you change the view's definition, the meaning of the aggregate before and after that point diverges, so it's safer to create a new view and a new table when changing the definition.
Third, quantiles must be stored as state. If you store a per-minute p99 as a plain number and then average it later, that isn't a p99 anymore. You have to store the intermediate state with quantilesState and merge it at query time with quantilesMerge for the quantile across multiple buckets to hold approximately.
The combination of source and rollup retention determines cost.
| Data | Retention | Relative size | Questions it can answer |
|---|---|---|---|
| Raw spans | 7–14 days | 1.0 | Full path of an individual request, arbitrary attribute filters |
| 1-minute rollup | 90–400 days | 0.005 or less | Per-service trends, before/after deploy comparisons, SLO computation |
| Error spans retained separately | 90 days | 0.02 | Long-term patterns of rare errors |
Sending From the Collector Into ClickHouse
Letting the exporter auto-create the schema is recommended only for development. In production, manage the DDL yourself and turn off auto-creation. The sort key and TTL need to differ by organization, and changing an auto-created schema later means recreating the table.
# otel-collector.yaml
exporters:
clickhouse:
endpoint: tcp://clickhouse.observability.svc:9000?dial_timeout=10s
database: otel
username: otel_writer
password: ${env:CLICKHOUSE_PASSWORD}
# Manage the DDL yourself in production
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 likes large batches. Frequent small inserts make parts explode
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]
Batch size is the key setting. ClickHouse is designed on the assumption that it receives tens of thousands of rows at a time. Hundreds of small inserts per second mass-produce parts and come back as merge load.
Here's a collection of failures you'll actually run into.
| Symptom | Cause | Check | Response |
|---|---|---|---|
| Inserts get rejected | Active part count exceeded | Part count in system.parts | Increase batch size, enable async insert, revisit partitioning |
| One specific query is extremely slow | A filter that doesn't hit the sort key | Marks read in EXPLAIN | Redesign the sort key or add an auxiliary table |
| Disk fills up faster than expected | TTL isn't being processed at the part level | Per-partition size and earliest timestamp | Check ttl_only_drop_parts, review partitioning |
| Query fails from memory overrun | Excessive GROUP BY cardinality | Memory usage in the query log | Use a pre-aggregated view, cap memory per query |
| Querying old data is very slow | Moved to the object-storage tier | Storage policy and part location | Explain the tiering to users, steer them to the rollup table |
| Logs and traces don't correlate | TraceId format mismatch | Compare samples from both sides | Normalize the representation in the collector |
Check whether a query hits the sort key with EXPLAIN.
EXPLAIN indexes = 1
SELECT count()
FROM otel.otel_traces
WHERE ServiceName = 'checkout-api'
AND Timestamp >= now() - INTERVAL 1 HOUR;
-- The number of marks read should be a tiny fraction of the total
Dividing Roles Among Prometheus, OpenSearch, and ClickHouse
Running all three looks wasteful, but each answers a different question. Attempts to consolidate into one usually end up losing the strength of one of them.
| Axis | Prometheus | OpenSearch | ClickHouse |
|---|---|---|---|
| Data model | Time series, label sets | Inverted-index documents | Columnar tables |
| Best at | Second-level aggregation, alert evaluation | Full-text search, arbitrary field lookups | Large scans, arbitrary aggregation, joins |
| Cardinality | Fragile, budgeting required | Fragile to field count | Relatively forgiving |
| Retention | Months (needs downsampling) | Weeks (cost-constrained) | Months to years |
| Latency | Seconds | Seconds | Seconds to minutes |
| Right question for it | Is it bad right now, should we page | Why did this request fail | What patterns showed up over 30 days |
A realistic deployment looks like this.
- Alerting and SLO evaluation belong to Prometheus. It needs second-level evaluation and low-cost queries, and there's little reason to swap in another tool for this.
- Full-text search over recent logs belongs to OpenSearch. For queries that search text, like "logs containing this error message," an inverted index is overwhelmingly favorable. In exchange, keep retention short.
- Long-term retention and arbitrary analysis belong to ClickHouse. Raw traces, raw logs, and rollups all live here, and it handles questions answered by joining them.
When you run all three together, identifier consistency is non-negotiable. service.name, trace_id, and deployment.environment.name must hold the same value across all three systems, or you can't hop between tools. Normalize once in the collector, and have each exporter use that value as-is.
processors:
transform/normalize:
error_mode: ignore
trace_statements:
- context: resource
statements:
# Unify anything that came in under the old name to the current convention
- set(attributes["deployment.environment.name"], attributes["deployment.environment"])
where attributes["deployment.environment.name"] == nil
and attributes["deployment.environment"] != nil
- delete_key(attributes, "deployment.environment")
Closing — Schema Decisions Are Hard to Reverse Later
In ClickHouse, what's easy to reverse and what's hard splits cleanly. Adding an index, changing TTL, adding a materialized view — you can do these while running. Changing the sort key or the partition key is effectively recreating the table.
So the adoption order looks like this. First, write down which queries will run thousands of times a day. Those queries' filter conditions should become the front of the sort key. Next, split retention between raw data and rollups. Finally, choose how to store attributes. Get just these three right early on, and everything else can be fixed later.
A check you can run right now is pulling the per-column compression ratio. If a column that doesn't compress is eating up half your storage cost, start by asking again whether you really need that column.
Further reading.