ClickHouse on EKS 실측 벤치마크
기존 측정 환경: ClickHouse 24.8 (측정 당시 버전 — 24.x 계열은 현재 지원 종료, 테스트 환경의 버전 안내 참고), Kubernetes 1.36 (Amazon EKS) 마지막 업데이트: 2026년 9월 11일
"ClickHouse는 빠르다"는 말은 벤치마크 보고서마다 나오지만, EKS의 평범한 노드 하나와 기본 gp3 볼륨에서 어느 정도인지 직접 잰 숫자는 찾기 어렵습니다. 이 문서는 4 vCPU 노드 + 기본 설정 gp3 100 GiB라는 의도적으로 소박한 환경에 Kubernetes 로그 1억 행을 넣고 측정한 결과입니다. 수치는 기존 실행의 보고값입니다. 원시 query_log와 당시의 모든 쿼리·캐시 상태가 이 문서에 포함된 것은 아니므로 동일한 숫자의 재현을 보장하지 않습니다. 아래 새 실행 예제에서는 실제 SQL·설정·버전·원시 결과를 함께 보관합니다.
TL;DR — 측정 결과 요약
| 측정 항목 | 결과 |
|---|---|
| Ingest (서버 내부 생성·삽입) | 1억 행 / 106.7초 = 약 94만 행/초 |
| 저장 크기 (LZ4 기본) | 15.37 GiB → 7.82 GiB (1.97×) |
| 저장 크기 (ZSTD(3)) | 15.37 GiB → 4.16 GiB (3.7×), LZ4 대비 47% 절감 |
| ORDER BY 키 범위 카운트 (1시간 창) | 4 ms — 59,916행을 세면서 실제로 읽은 행은 1억 중 16,385행 |
| 파드별 ERROR 건수 GROUP BY (2일 창) | 0.36 초 (2,800만 행 스캔) |
LIKE '%timeout%' 풀스캔 | 캐시 warm 2.63 초 / Direct I/O 요청 31.5 초 (12배) |
| trace_id 점 조회 | 풀스캔 1.13초 → bloom filter index 후 0.036초 (31배) |
테스트 환경
| 항목 | 값 |
|---|---|
| 클러스터 | Amazon EKS, Kubernetes 1.36, ap-northeast-2 |
| 노드 | m5.xlarge (4 vCPU, 16 GiB) — Karpenter가 프로비저닝한 전용 노드 1대 (벤치마크 파드 단독 배치) |
| 파드 리소스 | requests 2.5 vCPU / 9 Gi, limits 3.5 vCPU / 12 Gi |
| 스토리지 | EBS gp3 100 GiB 기본 설정 (3,000 IOPS / 125 MiB/s 베이스라인), EBS CSI 드라이버 |
| ClickHouse | 공식 이미지 clickhouse/clickhouse-server:24.8 (24.8.14.39), 설정 기본값 |
| 시간당 비용 | m5.xlarge 온디맨드 $0.236/h + gp3 100 GiB $0.0912/GB-월 (서울 리전, 2026-09 Pricing API 조회) |
버전 안내. 측정은 당시 LTS였던 24.8에서 했지만, ClickHouse 보안 정책의 지원 버전 목록에는 이제 24.x가 없습니다(2026년 9월 기준 지원 라인은 26.8 LTS, 26.7, 26.6, 26.3 LTS). 새로 배포한다면 현행 LTS 태그를 쓰세요. 아래에서 측정한 메커니즘 — primary key 프루닝, LZ4/ZSTD 코덱, bloom filter skip index — 은 현행 릴리스에도 모두 있지만, 용량 산정에 쓰려면 실제 배포할 버전에서 다시 재보는 것이 맞습니다.
일부러 "화려하지 않은" 환경입니다. 전용 i-계열 NVMe 인스턴스가 아니라, 여러분 클러스터에 이미 있을 법한 범용 노드와 기본 gp3에서 어디까지 되는지가 이 벤치마크의 질문입니다.
배포 매니페스트
아래는 재실행용 예제이며 검토 시점 지원 LTS인 26.3.33.24를 사용합니다. 원래 24.8 측정값은 유지합니다. 일반 EC2 노드의 EBS CSI 드라이버·IAM 권한과 PVC 프로비저닝이 필요합니다. 기본 사용자의 외부 접근 제한을 유지하고 kubectl exec 안의 localhost 클라이언트로 측정합니다. 이미지 digest·fio/EBS 설정·실제 CPU/메모리 사용도 기록합니다.
apiVersion: v1
kind: Namespace
metadata:
name: bench-database
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: bench-clickhouse-gp3
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
iops: "3000"
throughput: "125"
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: clickhouse-data
namespace: bench-database
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: bench-clickhouse-gp3
resources:
requests:
storage: 100Gi
---
apiVersion: v1
kind: Pod
metadata:
name: clickhouse
namespace: bench-database
annotations:
karpenter.sh/do-not-disrupt: "true"
spec:
nodeSelector:
node.kubernetes.io/instance-type: m5.xlarge
containers:
- name: clickhouse
image: clickhouse/clickhouse-server:26.3.33.24
resources:
requests: { cpu: "2500m", memory: 9Gi }
limits: { cpu: "3500m", memory: 12Gi }
readinessProbe:
exec:
command: ["clickhouse-client", "--host", "127.0.0.1", "--query", "SELECT 1"]
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/clickhouse
volumes:
- name: data
persistentVolumeClaim:
claimName: clickhouse-data프로덕션에서는 수명주기·복구 자동화가 필요하며, 예를 들어 Altinity clickhouse-operator의
ClickHouseInstallation을 사용할 수 있습니다. 여기서는 측정 대상을 단순하게 유지하기 위해 Pod를 직접 사용했습니다.
데이터셋 — 현실적인 Kubernetes 로그 1억 행
균등 난수(generateRandom)는 압축률을 왜곡하므로, 실제 로그처럼 반복되는 템플릿 + 가변 필드 구조로 생성했습니다. 10개 네임스페이스, 네임스페이스당 파드 이름 1개(파드 접미사를 네임스페이스를 고르는 것과 같은 해시 버킷에서 만들기 때문에 파드 값은 총 10종뿐입니다 — 압축 수치에 영향을 주는 단순화이며, 측정 2에서 다시 다룹니다), 0.8% ERROR 비율, 7일치 타임스탬프입니다.
CREATE TABLE logs
(
timestamp DateTime64(3),
namespace LowCardinality(String),
pod String,
container LowCardinality(String),
level LowCardinality(String),
message String,
trace_id String,
duration_ms Float32
)
ENGINE = MergeTree
PARTITION BY toDate(timestamp)
ORDER BY (namespace, timestamp);INSERT INTO logs (timestamp, namespace, pod, container, level, trace_id, duration_ms, message)
WITH
['payment','order','user','search','catalog','cart','shipping','auth','gateway','recommend'] AS nss,
['GET /api/v1/orders','POST /api/v1/payments','GET /api/v1/users','GET /api/v1/search',
'POST /api/v1/cart/items','GET /api/v1/products','POST /api/v1/shipments','POST /oauth/token',
'GET /healthz','GET /api/v1/recommendations'] AS eps
SELECT
toDateTime64('2026-08-25 00:00:00', 3) + toIntervalMillisecond(number * 6) AS timestamp,
nss[(cityHash64(number) % 10) + 1] AS namespace,
concat(namespace, '-7c7dd4f9c-', substring(lower(hex(sipHash64(cityHash64(number) % 10))), 1, 5)) AS pod,
if(cityHash64(number + 2) % 10 < 8, 'app', 'istio-proxy') AS container,
multiIf(cityHash64(number + 3) % 1000 < 8, 'ERROR',
cityHash64(number + 3) % 1000 < 50, 'WARN',
cityHash64(number + 3) % 1000 < 300, 'DEBUG', 'INFO') AS level,
lower(hex(sipHash128(number))) AS trace_id,
round(if(level = 'ERROR', 2000 + (cityHash64(number + 4) % 30000) / 10,
(cityHash64(number + 4) % 20000) / 100), 1) AS duration_ms,
multiIf(
level = 'ERROR', concat('upstream request timeout after ', toString(round(duration_ms)),
'ms endpoint=', eps[(cityHash64(number + 5) % 10) + 1],
' status=503 trace_id=', trace_id),
concat(eps[(cityHash64(number + 5) % 10) + 1], ' completed status=200 in ',
toString(duration_ms), 'ms trace_id=', trace_id)
) AS message
FROM numbers_mt(100000000)
SETTINGS max_threads = 3, max_insert_threads = 2, max_memory_usage = 9000000000;측정 1 — Ingest: 1억 행 / 106.7초
Elapsed: 106.747 sec → 약 936,800 행/초, 파티션 7개(일 단위), active parts 36개이 수치의 의미를 정확히 읽어야 합니다. 서버 내부에서 생성해 바로 삽입(INSERT…SELECT)한 값이므로 네트워크 전송과 텍스트 파싱 비용이 빠진 서버 내부 경로의 측정값입니다. 데이터 생성 CPU 비용도 포함하므로 외부 ingest의 수학적 상한은 아닙니다. 외부 성능은 클라이언트·포맷·네트워크·배치·동시성에 따라 달라집니다. 그럼에도 3.5 vCPU 제한 안에서 초당 약 94만 행을 정렬·압축·기록했다는 점, 그것도 125 MiB/s짜리 기본 gp3에서 해냈다는 점이 핵심입니다 — 최종 압축 크기/경과 시간은 약 75 MiB/s지만, 이는 실제 EBS 쓰기 처리량의 계측값이 아닙니다. 페이지 캐시·writeback·merge 비용과 durable flush 조건을 별도로 확인해야 합니다.
측정 2 — 압축: 어떤 컬럼이 돈을 쓰는가
전체: 15.37 GiB → 7.82 GiB (1.97×, LZ4 기본). 컬럼별 내역이 훨씬 흥미롭습니다:
| 컬럼 | 압축 후 | 압축 전 | 비율 |
|---|---|---|---|
| message | 3.97 GiB | 8.75 GiB | 2.2× |
| trace_id | 3.08 GiB | 3.07 GiB | 1.0× (압축 불가) |
| timestamp | 404.07 MiB | 762.94 MiB | 1.89× |
| duration_ms | 289.17 MiB | 381.47 MiB | 1.32× |
| level | 45.88 MiB | 95.72 MiB | 2.09× |
| container | 39.27 MiB | 95.72 MiB | 2.44× |
| pod | 9.32 MiB | 2.15 GiB | 236× |
| namespace | 488.63 KiB | 95.72 MiB | 201× |
크기와 비율은 system.parts_columns가 보고한 값 그대로입니다(압축 전/후 바이트 합계의 formatReadableSize). 비율은 반올림된 크기가 아니라 원래 바이트 수로 계산된 값입니다.
두 가지 교훈이 바로 보입니다:
- LowCardinality + ORDER BY 정렬의 위력 — namespace는 ORDER BY 첫 키라서 같은 값이 길게 이어지고, 95.7 MiB가 489 KiB로 사라집니다. pod가 236×인 것도 같은 이유인데, 한 가지 주의가 필요합니다. 이 생성기는 네임스페이스당 파드 이름을 정확히 1개(총 10종)만 만들기 때문에 pod 컬럼이 namespace의 복사본처럼 동작합니다. 네임스페이스당 파드가 수십 개이고 재시작마다 이름이 바뀌는 실제 클러스터에서는 pod 압축률이 눈에 띄게 낮아집니다.
- 고엔트로피 ID가 스토리지의 약 40%를 먹습니다 — 이 실행의 LZ4에서는 32자 hex trace_id가 거의 줄지 않아(1.0×) 전체 7.82 GiB 중 3.08 GiB(39.4%)를 차지합니다. 로그 스키마를 설계할 때 "ID를 문자열로 넣을 것인가"가 저장 비용의 최대 변수라는 뜻입니다. hex 문자열에는 4비트 정보가 1바이트 문자로 표현되므로 다른 코덱으로도 압축 여지가 있습니다. UUID/FixedString(16) 바이너리 표현은 원시 표현 폭을 줄이지만 최종 저장 절감은 코덱·인덱스·쿼리 호환성을 포함해 측정합니다.
LZ4 vs ZSTD(3) — 저장 47% vs 스캔 1.9×
같은 데이터를 CODEC(ZSTD(3)) 테이블에 다시 삽입해 비교했습니다:
| LZ4 (기본) | ZSTD(3) | |
|---|---|---|
| 압축 후 크기 | 7.82 GiB (1.97×) | 4.16 GiB (3.7×) |
| 재압축 삽입 (1억 행) | — | 120.0초 |
LIKE '%timeout%' 풀스캔 (warm) | 2.63초 | 4.9초 |
저장은 47% 줄지만 CPU-bound 풀스캔은 1.9배 느려집니다. 최근 데이터 LZ4 + 오래된 파티션 TTL ZSTD 재압축은 검토할 수 있는 조합입니다. 최적 코덱은 실제 데이터·CPU·I/O 비중에 따라 달라집니다.
측정 3 — 쿼리: 어떤 쿼리가 왜 빠른가/느린가
각 쿼리는 mark/uncompressed 캐시를 비운 뒤 ① min_bytes_to_use_direct_io=1로 페이지 캐시를 우회한 Direct I/O 요청 1회, ② warm 3회(최솟값 기록)를 측정했습니다.
| # | 쿼리 패턴 | Direct I/O 요청 | warm | 읽은 행 수 (read_rows) |
|---|---|---|---|---|
| Q1 | WHERE namespace='payment' AND timestamp BETWEEN … (1시간 창 count) | 13 ms | 4 ms | 16,385 (0.016%) — 결과 59,916 |
| Q2 | 파드별 ERROR 건수, 2일 창 GROUP BY (데이터셋에 파드가 10개뿐이라 LIMIT 10은 사실상 전체 반환) | 0.57 s | 0.36 s | 2,800만 |
| Q3 | message LIKE '%timeout%' 전 기간 풀스캔 | 31.5 s | 2.63 s | 1억 |
| Q4 | namespace별 duration p50/p99 전 기간 | 1.34 s | 1.03 s | 1억 (필터 없음) |
| Q5 | trace_id = '…' 점 조회 (인덱스 없음) | 24.3 s | 1.13 s | 1억 |
읽는 법:
- Q1이 4ms인 이유: PARTITION BY(일)와 ORDER BY(namespace, timestamp)가 겹치면서
payment의 1시간 창이 하나의 연속된 키 범위가 됩니다. 결과는 59,916행인데system.query_log의 read_rows는 16,385행뿐입니다. 24.6부터 ClickHouse는 primary key 범위 안에 완전히 들어가는 granule은 인덱스만으로 개수를 세고, 범위 양끝에 걸친 granule(약 두 개, 8,192행씩)만 실제로 압축을 풀어 읽기 때문입니다. 이는 해당 조건에서 읽을 데이터를 줄이는 최적화입니다. 적용 여부는EXPLAIN indexes = 1과 실제 read_rows로 확인합니다. - Q3의 31.5초(Direct I/O 요청) vs 2.63초(warm): message 컬럼 압축본 약 4 GiB를 디스크에서 읽으면 4 GiB ÷ 31.5초 ≈ 130 MiB/s — gp3 볼륨 상한(125 MiB/s)과 이 m5.xlarge의 인스턴스 EBS 베이스라인(1,150 Mbps ≈ 137 MiB/s)이 겹쳐 있는 좁은 구간에 막힌 수치입니다. 두 한계가 너무 가까워 이 측정만으로는 어느 쪽이 먼저 걸렸는지 가릴 수 없습니다. 같은 쿼리가 페이지 캐시에서는 CPU-bound(초당 3,800만 행)로 바뀝니다. 풀스캔 성능은 데이터베이스가 아니라 볼륨 처리량 설정의 문제일 수 있다는 실측 증거입니다. (EBS gp2 vs gp3 실측 참고) 인덱스 없는 Q5도 같은 이야기입니다: trace_id 3.08 GiB를 24.3초에 읽어 ≈ 130 MiB/s.
- Q4가 풀스캔인데 1초인 이유: 컬럼 지향의 본질입니다. 컬럼 크기로 보면 duration_ms(289 MiB)와 namespace(0.5 MiB)만 건드리고 7.8 GiB를 읽지 않으며, warm 실행은 Float32 1억 개의 분위수 계산에 CPU-bound입니다.
- 짧은 Direct I/O 요청 수치(Q2, Q4)만으로 물리 디스크 처리량을 계산할 수 없습니다. 최종 압축 크기와 쿼리 시간의 비율이 125 MiB/s를 넘는다는 이유만으로 페이지 캐시 사용을 증명하지는 못합니다. mark/uncompressed cache 삭제는 OS 페이지 캐시 삭제와도 다릅니다. 원래 실행은 추가 추적 전에 종료되었으므로 캐시·read method·짧은 구간의 서비스 동작을 분리하지 못했습니다. 새 실행에서는 query_log의 ProfileEvents와 노드/EBS I/O 지표를 함께 저장합니다.
측정 4 — bloom filter skip index: 1.13초 → 0.036초
trace_id 점 조회는 ORDER BY 키가 아니므로 기본적으로 풀스캔(1.13초)입니다. skip index를 추가하면:
ALTER TABLE logs ADD INDEX trace_bf trace_id TYPE bloom_filter(0.01) GRANULARITY 4;
ALTER TABLE logs MATERIALIZE INDEX trace_bf; -- asynchronous; wait for system.mutations.is_done| 인덱스 없음 | bloom_filter(0.01) | |
|---|---|---|
| warm 조회 시간 | 1.13 s | 0.036 s (31×) |
| 읽은 행 수 | 1억 | 108만 (98.9% 스킵) |
| 읽은 데이터 | 3.82 GiB | 42.6 MiB |
| 인덱스 크기 | — | 119.7 MiB (테이블의 1.5%) |
관측용 로그 저장소에서 "trace ID로 점프"는 가장 흔한 쿼리인데, 인덱스 크기 1.5%와 materialize 20초로 31배를 얻습니다. Grafana + ClickHouse 로그 백엔드를 구성한다면 후보 인덱스입니다. 실제 선택도·false positive·쓰기/merge 비용과 인덱스 사용 여부를 확인해야 합니다.
비용으로 환산하면
2026-09-11 Pricing API에서 서울 리전 m5.xlarge Linux 온디맨드 $0.236/h, gp3 $0.0912/GB-month를 확인했습니다. EBS는 프로비저닝한 용량으로 과금됩니다. 데이터가 7.82 GiB 또는 4.16 GiB여도 이 예제의 100 GiB 볼륨은 월 $9.12이며, 사용 바이트에 단가를 곱한 $0.71/$0.38을 실제 청구액으로 보면 안 됩니다.
하루 1억 행을 30일 보관하면 이 합성 데이터의 LZ4 비율상 데이터만 약 235 GiB입니다. 기존 100 GiB 볼륨에는 들어가지 않으며, merge 작업 공간·인덱스·복제·백업·여유 용량을 더해 프로비저닝해야 합니다. 235 GiB만 잡아도 약 $21.43/월이고 노드·EKS·네트워크·운영 비용은 별도입니다. CloudWatch Logs의 ingest 요금과 EBS 저장 비용 한 항목만 비교해 전체 비용 우위를 결론 내리지 않습니다.
재현 방법
아래는 패턴을 비교하기 위한 새 실행 예제입니다. Q1/Q2의 원래 정확한 시간 창과 Q5의 원래 ID는 보존되지 않았으므로 기존 결과 행 수·밀리초와 일치한다고 주장하지 않습니다. 재실행에는 각각의 SQL, 버전, timezone, read settings, 원시 결과를 함께 저장합니다.
-- Q1: explicit example window for a new run, not recovered historical SQL.
SELECT count() FROM logs
WHERE namespace = 'payment'
AND timestamp >= toDateTime64('2026-08-26 00:00:00', 3)
AND timestamp < toDateTime64('2026-08-26 01:00:00', 3);
-- Q2: a two-day example window.
SELECT namespace, pod, count() AS errors FROM logs
WHERE level = 'ERROR'
AND timestamp >= toDateTime64('2026-08-26 00:00:00', 3)
AND timestamp < toDateTime64('2026-08-28 00:00:00', 3)
GROUP BY namespace, pod ORDER BY errors DESC LIMIT 10;
-- Q3: full-range message scan.
SELECT count() FROM logs WHERE message LIKE '%timeout%';
-- Q4: approximate quantiles; all candidate rows are still processed.
SELECT namespace, quantiles(0.5, 0.99)(duration_ms) AS p50_p99
FROM logs GROUP BY namespace;
-- Q5: a trace ID that the generator creates for number=42.
SELECT * FROM logs
WHERE trace_id = lower(hex(sipHash128(toUInt64(42))));kubectl apply -f clickhouse.yaml
kubectl wait -n bench-database pod/clickhouse --for=condition=Ready --timeout=300s
kubectl exec -n bench-database clickhouse -- \
clickhouse-client --host 127.0.0.1 --query 'SELECT version(), timezone()'
# Save the CREATE TABLE block as schema.sql and INSERT block as insert.sql.
kubectl exec -i -n bench-database clickhouse -- \
clickhouse-client --host 127.0.0.1 --multiquery < schema.sql
kubectl exec -i -n bench-database clickhouse -- \
clickhouse-client --host 127.0.0.1 --time --multiquery < insert.sql
# Run the selected query with a unique ID; store its exact SQL with the results.
kubectl exec -i -n bench-database clickhouse -- \
clickhouse-client --host 127.0.0.1 --query_id benchmark-q3-run1 \
--time --multiquery < q3.sql각 쿼리의 Direct I/O 요청은 해당 SELECT 끝에 SETTINGS min_bytes_to_use_direct_io=1을 넣어 별도로 실행합니다. 이후 기본 read 설정으로 반복하고 최솟값뿐 아니라 전체 표본과 중앙값을 저장합니다. SYSTEM FLUSH LOGS 후 해당 query_id의 query_log를 보관하고, 인덱스 적용은 system.mutations 완료를 확인한 뒤 비교합니다. 쿼리 캐시·filesystem cache·OS 캐시와 background merge도 기록합니다.
모든 결과를 클러스터 밖으로 복사한 뒤 전용 bench-database namespace와 bench-clickhouse-gp3 StorageClass를 정리합니다. 이 예제의 Delete reclaimPolicy는 PVC 삭제 시 측정 데이터도 삭제합니다.
해석 시 주의사항
- 단일 노드, 단일 실행 환경입니다. 복제/샤딩 구성이나 다른 인스턴스 타입에서는 절대값이 달라집니다. 상대적 패턴(프루닝, 컬럼 지향, 캐시, 인덱스 효과)이 이 문서의 본론입니다.
- ingest 수치는 서버 내부 생성·삽입 경로의 측정값이며 외부 수집 처리량은 아닙니다(측정 1 참고).
- 합성 데이터의 압축률은 필드 구성에 민감합니다. 고엔트로피 trace_id를 message에도 포함시켜 보수적으로 만들었지만, pod 컬럼(이름이 10종뿐, 측정 2 참고)은 반대로 낙관적입니다. 실제 로그의 압축률은 스키마에 따라 이보다 좋을 수도 나쁠 수도 있습니다.
- warm 첫 회는 캐시 적재 때문에 느립니다(Q3 첫 회 8.7초 → 이후 2.6초). 표의 warm 값은 3회 중 최솟값입니다.
함께 읽기
- ClickHouse — 로그 백엔드 관점 — 수집 파이프라인(Fluent Bit/Vector)과의 통합
- EBS gp2 vs gp3 실측 벤치마크 — Q3에서 확인한 볼륨 처리량 병목의 근거
- Database on Kubernetes 개요 — Operator 지형과 관리형 vs self-hosted 판단 기준
