ClickHouse Keeper는 큐가 아니다
ClickHouse에 이벤트를 보내는 중 서버가 꺼졌다고 가정해 봅니다.
Keeper 세 대가 모두 살아 있다면 수신 중이던 이벤트도 어딘가에 남아 있을까요?
남아 있지 않습니다.
Keeper는 ClickHouse의 복제와 분산 작업을 조정하지만, 클라이언트가 보낸 이벤트 본문을 저장하는 메시지 큐가 아닙니다.
이 차이를 놓치면 Keeper를 고가용성으로 만들고도 ingest 중단 때 데이터를 잃을 수 있습니다.
Keeper가 맡는 일
ClickHouse Keeper 는 ZooKeeper와 호환되는 클라이언트 프로토콜과 데이터 모델을 제공하는 분산 조정 서비스입니다.
Raft 합의를 사용해 복제 상태와 분산 작업의 순서를 맞춥니다.
ReplicatedMergeTree에서는 각 replica가 같은 테이블의 파트를 갖도록 조정합니다.
하지만 파트의 실제 바이트는 replica끼리 직접 주고받습니다.
Keeper에는 작은 znode 형태의 메타데이터가 들어갑니다.
| Keeper에 저장하는 것 | 역할 |
|---|---|
| replication log | 어떤 파트와 작업이 생겼는지 기록 |
| replica queue | 해당 replica가 아직 수행하지 않은 fetch·merge 작업 |
| part와 block 번호 | 파트 존재와 순서를 조정 |
| dedup checksum | 최근 INSERT 블록의 중복 여부 판단 |
| distributed DDL queue | ON CLUSTER DDL의 실행 순서와 완료 상태 |
| leader election과 lock | merge·mutation 같은 분산 작업의 소유권 조정 |
반대로 Keeper에 없는 것은 다음과 같습니다.
- INSERT로 보낸 원본 row
- 아직 flush되지 않은 비동기 INSERT 버퍼
- replica 디스크에 저장된 part 파일
- SELECT 결과와 query cache
- Collector가 전송하지 못한 telemetry batch
클라이언트는 ClickHouse replica에 데이터 바이트를 보냅니다.
첫 replica가 part를 만들고 Keeper에 등록하면 다른 replica는 해야 할 작업을 알게 됩니다.
다른 replica는 Keeper에서 데이터 본문을 읽는 것이 아니라 원본 replica에서 part를 fetch합니다.
Keeper의 복제 로그에 part 이름이 남아 있어도 그 part를 가진 replica가 모두 사라지면 본문을 복원할 수 없습니다.
메타데이터는 데이터의 위치를 가리킬 뿐, 데이터 자체를 대신하지 않습니다.
이름에 queue가 붙어도 메시지 큐는 아니다
Keeper에는 task_queue, replica의 queue, replication log가 있습니다.
이름만 보면 Kafka 같은 durable queue로 보이기 쉽습니다.
둘의 계약은 전혀 다릅니다.
| 축 | Kafka 같은 이벤트 로그 | ClickHouse Keeper |
|---|---|---|
| 저장 대상 | producer가 보낸 메시지 본문 | 파트 참조, 체크섬, 작업 지시 |
| 보존 | retention 동안 다시 읽을 수 있음 | 조정에 필요한 작은 상태 |
| replay | 소비자가 offset부터 재처리 | 이벤트 본문 replay 불가 |
| ClickHouse 경로의 위치 | ClickHouse 앞단 | ClickHouse 복제 조정 계층 |
| ClickHouse 중단 흡수 | retention 범위에서 가능 | 불가능 |
ClickHouse가 잠시 멈췄을 때 이벤트를 보관하려면 큐가 앞단에 있어야 합니다.
Kafka를 쓸 수도 있고, OTel Collector의 영속 sending_queue처럼 더 작은 장치를 쓸 수도 있습니다.
어느 쪽이든 성공적으로 ClickHouse에 전달하기 전까지 이벤트 본문을 소유해야 합니다.
Keeper 노드를 세 대에서 다섯 대로 늘리는 일은 이 문제를 해결하지 않습니다.
정족수를 잃으면 무엇이 멈추나
Keeper가 쓰기를 진행하려면 과반이 필요합니다.
필요한 수는 floor(N / 2) + 1입니다.
| Keeper 수 | 필요한 과반 | 견디는 동시 소실 |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
4대는 3대보다 한 대를 더 쓰면서도 견디는 소실 수가 같습니다.
그래서 보통 3대 또는 5대처럼 홀수로 구성합니다.
3대 중 2대를 잃으면 Keeper 정족수가 사라집니다.
이때 “ClickHouse 전체가 죽는다”라고 단정하면 실제 장애를 잘못 읽게 됩니다.
정족수 상실로 주로 멈추는 것은 새 조정 상태가 필요한 작업입니다.
| 멈추는 작업 | 계속될 수 있는 작업 |
|---|---|
| 신규 part 등록과 INSERT | 이미 로컬에 있는 part의 SELECT |
| replica 동기화와 복제 큐 진행 | Keeper가 필요하지 않은 진행 중 로컬 조회 |
ON CLUSTER DDL | 기존 데이터 파일 읽기 |
| mutation·merge 조정 일부 | 로컬 테이블과 상태에 따른 일부 읽기 |
SELECT가 항상 성공한다는 뜻은 아닙니다.
분산 쿼리가 접근해야 할 replica까지 함께 죽었거나, 쿼리 경로가 Keeper 상태를 새로 요구하거나, 읽으려는 part가 로컬에 없다면 조회도 실패할 수 있습니다.
정확한 표현은 Keeper 정족수를 잃어도 이미 로컬에 있는 part를 읽는 조회는 계속될 수 있다입니다.
장애를 어떻게 확인할까
애플리케이션의 TABLE_IS_READ_ONLY 오류만으로 원인을 확정하지 않습니다.
먼저 복제 테이블 상태를 봅니다.
SELECT
database,
table,
replica_name,
is_leader,
is_readonly,
is_session_expired,
queue_size,
absolute_delay,
total_replicas,
active_replicas,
zookeeper_exception
FROM system.replicas
ORDER BY database, table;is_readonly = 1과 is_session_expired = 1이 여러 replica에서 함께 보이면 Keeper 연결과 정족수를 의심할 수 있습니다.
zookeeper_exception은 세션 만료, 연결 실패, 경로 문제를 구분하는 단서입니다.
그다음 Keeper 멤버 상태를 확인합니다.
kubectl get pods -n clickhouse -o wide
kubectl get pvc -n clickhouse포트는 배포 방식에 따라 다릅니다.
독립형 ClickHouse Keeper 예제에서 흔히 보는 클라이언트 포트와 Altinity ClickHouseKeeperInstallation의 설정을 섞으면 진단 명령이 엉뚱한 곳을 봅니다.
CHK 구성에서는 클라이언트 포트 2181, Raft 포트 9444를 사용하는 예가 일반적이고, 독립형 Keeper 문서에서는 9181을 자주 봅니다.
실제 Service와 설정을 먼저 확인합니다.
kubectl get svc -n clickhouse
kubectl get endpointslice -n clickhouse포트 번호를 기억에 의존하지 않고 배포된 리소스에서 확인하는 편이 안전합니다.
Keeper 저장소도 영속적이어야 한다
Keeper가 이벤트 본문을 저장하지 않는다고 해서 Keeper 데이터가 중요하지 않은 것은 아닙니다.
Raft log와 snapshot에는 복제 상태와 조정 이력이 들어 있습니다.
데이터 노드가 로컬 NVMe를 쓰더라도 Keeper는 EBS gp3 같은 영속 볼륨에 두는 이유입니다.
Keeper 파드가 다른 노드로 옮겨도 기존 볼륨을 재부착할 수 있어야 정족수 복구가 단순해집니다.
세 멤버를 같은 노드나 같은 AZ에 몰아두면 숫자만 3일 뿐 장애 도메인은 하나입니다.
확인할 것은 세 가지입니다.
- 서로 다른 노드에 배치됐는가?
- AZ 장애를 견뎌야 한다면 서로 다른 AZ에 배치됐는가?
- 각 멤버의 Raft 데이터 볼륨이 삭제 정책에서 보호되는가?
Kubernetes PDB는 계획된 eviction을 제한합니다.
노드 급사나 AZ 장애를 막지는 못합니다.
Keeper의 배치와 볼륨 보존을 PDB 하나로 대신할 수 없습니다.
작은 INSERT가 Keeper에도 부담을 만든다
MergeTree는 INSERT마다 작은 part를 계속 만들면 파일 수와 merge 비용이 늘어납니다.
복제 구성에서는 part마다 Keeper 엔트리도 만들어집니다.
ClickHouse의 비동기 INSERT 설명 도 작은 part가 파일 작업, write amplification, Keeper 요청을 함께 늘린다고 설명합니다.
따라서 배칭은 데이터 디스크만을 위한 튜닝이 아닙니다.
Keeper의 조정 부하도 줄입니다.
다음 지표를 함께 봅니다.
SELECT
metric,
value
FROM system.metrics
WHERE metric ILIKE '%ZooKeeper%'
OR metric ILIKE '%Keeper%'
ORDER BY metric;SELECT
database,
table,
count() AS active_parts,
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS bytes
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY active_parts DESC;Keeper 요청이 늘고 active part도 빠르게 증가한다면 replica 수보다 INSERT shape를 먼저 확인할 수 있습니다.
경계를 한 줄로 정리하면
Keeper는 어떤 part가 있고 누가 무엇을 해야 하는지 기억합니다.
Kafka나 영속 Collector 큐는 아직 ClickHouse에 저장하지 못한 이벤트 본문을 기억합니다.
둘 다 장애 대응에 필요할 수 있지만 서로 대체하지 않습니다.
Keeper 정족수가 살아 있다는 사실은 ingest 데이터가 어딘가에 durable하게 대기한다는 뜻이 아닙니다.
반대로 Keeper 정족수를 잃었다고 이미 저장된 모든 SELECT가 즉시 사라지는 것도 아닙니다.
어떤 계층이 어떤 바이트를 소유하는지부터 그리면 장애의 경계가 꽤 선명해집니다.
자료 확인 기준: 2026-09-15. Keeper 포트와 시스템 테이블 컬럼은 실제 배포 버전과 operator가 렌더한 설정에서 다시 확인해야 합니다.