요약: 이 글은 카카오페이에서 실시간 OLAP인 Apache Pinot를 운영한 노하우를 다룹니다. 주요 내용으로는 Pinot 클러스터 구성, 장애 복구 아키텍처, 실시간 Upsert 테이블 운영 노하우가 있습니다.
💡 리뷰어 한줄평
coco.nut Apache Pinot 클러스터 구성부터 상용 서비스까지 어디에도 쉽게 찾을 수 없는 엔지니어의 실전 노하우를 담았습니다.
yol.yoli 카카오페이가 직접 부딪히며 얻은 Apache Pinot 운영 경험을 생생한 이야기로 들려드립니다.
시작하며
안녕하세요. 하둡플랫폼파트에서 하둡플랫폼을 운영하고 있는 sunny라고 합니다. 저희 파트에서는 다양한 목적에 맞춰 여러 클러스터를 구축하고 운영하며, 전사 사용자들에게 빅데이터 플랫폼을 제공하고 있습니다.
클러스터 | 용도 | 사용기술 |
---|---|---|
하둡 클러스터 | 대용량 데이터 분산저장, 배치 처리 | HDFS, Kudu, Impala, Spark, Hive, Iceberg 등 |
HBase/Phoenix 클러스터 | 컬럼 기반 데이터 분산저장, 통계 데이터 제공 | HBase, Phoenix, HDFS 등 |
Trino 클러스터 | 다양한 데이터 소스(하둡, Mysql, NoSQL 등) 데이터를 SQL 기반으로 분석 | Trino |
Pinot 클러스터 | 실시간 데이터를 SQL 기반으로 빠르게 분석 (실시간 OLAP) | Pinot |
이 글의 주제인 Apache Pinot는 대용량 데이터 스트림을 실시간으로 수집하고 분석할 수 있는 분산형 OLAP 데이터 저장소예요. 실시간 데이터뿐만 아니라 배치 데이터도 처리하고, 이 둘을 결합하여 분석할 수도 있습니다. Druid나 ClickHouse와 자주 비교되기도 합니다.
실시간 업데이트(Upsert) 기능을 활용하여 지속적으로 변경되는 데이터를 즉시 반영하는 용도로 Pinot를 도입했습니다.
Pinot 도입 초기에는 참고할만한 운영 사례가 부족하고 현재 버전에 비해 안정성이 다소 미흡해서 고생을 많이 했었는데요. 이 글에서는 오픈소스인 Apache Pinot를 도입하고 운영하면서 얻은 경험과 노하우를 공유하고자 합니다. Pinot 운영이 복잡할 수 있지만, 이 글에서 운영 노하우를 숙지하시면 실시간 데이터 분석을 활용하시는 데 도움이 될 거라 생각합니다. (본 내용은 Pinot 1.2.0 버전 기준으로 작성하였습니다.)
이 글은 이런 분들에게 추천해요.
- 실시간 OLAP에 관심 많은 엔지니어
- Pinot 도입을 고려 중이거나 운영 중인 엔지니어
이 글을 통해 다음과 같은 것들을 얻어가실 수 있어요.
- 운영 경험에서 얻은 이슈 해결 케이스
- 서비스 안정성을 높이는 클러스터 구성 Tip
- 실시간 업데이트 테이블 성능을 높이는 방법
Apache Pinot이란?
이 글은 Pinot를 운영하면서 얻은 유용한 노하우를 전달하는 게 목적이므로 Pinot 개념은 간단하게 짚고 넘어가겠습니다.
Pinot는 링크드인(LinkedIn)에서 공개한 실시간 OLAP 오픈소스 솔루션입니다. 링크드인 외에도 Uber, Stripe 등 다양한 기업에서도 Pinot를 활용하여 실시간 대시보드, 실시간 분석 애플리케이션 등 다양한 서비스를 제공하고 있습니다.
- Uber: 실시간으로 교통량 핫스팟 정보 제공
- Uber Eats: 레스토랑 운영을 위한 실시간 대시보드 제공
- Stripe: 실시간 결제 대시보드, 판매자를 위한 실시간/배치 데이터 집계데이터 제공
Pinot의 핵심적인 특징은 대용량 데이터를 빠르게 쿼리할 수 있도록 설계된 분산 시스템이라는 점입니다. 데이터를 세그먼트(Segment)로 분할하여 여러 서버에 분산 저장하고, 병렬 처리 방식으로 빠른 쿼리가 가능합니다. Pinot는 실시간 스트리밍 데이터와 배치 데이터를 수집하여 즉시 쿼리할 수 있습니다.
- 실시간 데이터 소스: Apache Kafka, Amazon Kinesis
- 배치 데이터 소스: Hadoop HDFS, Amazon S3, Azure ADLS, Google Cloud Storage
Pinot 클러스터는 다음과 같은 주요 컴포넌트로 구성됩니다.
- 브로커(Broker): 클라이언트로부터 쿼리 요청을 받아 서버로 라우팅하고, 여러 서버에서 받은 결과를 취합하여 최종 결과를 클라이언트에 전달함.
- 서버(Server): 세그먼트 데이터를 저장하고 쿼리 요청을 처리함.
- 컨트롤러(Controller): 클러스터의 메타데이터를 관리하고, 관리용 API를 제공함. 세그먼트 할당, 서버 및 브로커 상태 관리 등을 담당함.
Pinot에 관한 더 자세한 내용은 공식문서를 참고해 주세요.
1. Pinot 클러스터 구성
서비스 안정성을 최우선으로 고려하여 Pinot 클러스터를 구성했습니다. 클러스터를 구성하는 과정에서 다양한 설정들을 적용했지만, 그중에서도 서비스 안정성에 특히 도움이 되었던 3가지 내용을 공유하고자 합니다.
1-1) 딥스토어(Deep Store) 연동
Pinot는 기본적으로 서버의 로컬 파일 시스템을 데이터 저장소로 사용합니다. 저희는 데이터를 좀 더 안전하게 관리하고자 딥스토어(Deep Store)를 추가로 연결했어요.
딥스토어는 데이터를 영구적으로 저장할 수 있는 별도의 저장소입니다. 혹시라도 Pinot 서버에 문제가 생기더라도 딥스토어에 저장된 세그먼트를 다시 가져올 수 있어서, 데이터 손실을 막을 수 있습니다.
딥스토어로 HDFS, S3, GCS 등을 사용할 수 있고, 저희는 HDFS를 사용하고 있어요. 처음에 공식문서 가이드대로 HDFS를 연동했었으나 라이브러리 에러가 나서 고생을 했었는데요. 아래 설정파일을 참고하시면 고생 없이 성공하실 겁니다.
-- 컨트롤러/서버 start 스크립트
export HADOOP_JAR_DIR=/path/to/hadoop/jars
export HADOOP_VERSION=3.1.1
export HADOOP_GUAVA_VERSION=27.0.1
export HADOOP_GSON_VERSION=2.2
# 딥스토어 HDFS 연동 라이브러리
export CLASSPATH_PREFIX="${HADOOP_JAR_DIR}/hadoop-hdfs-${HADOOP_VERSION}.jar
:${HADOOP_JAR_DIR}/hadoop-annotations-${HADOOP_VERSION}.jar
:${HADOOP_JAR_DIR}/hadoop-auth-${HADOOP_VERSION}.jar
:${HADOOP_JAR_DIR}/gson-${HADOOP_GSON_VERSION}.jar
:${HADOOP_JAR_DIR}/guava-${HADOOP_GUAVA_VERSION}-jre.jar
:${HADOOP_JAR_DIR}/commons-configuration2-2.1.1.jar
:${HADOOP_JAR_DIR}/hadoop-hdfs-client-${HADOOP_VERSION}.jar
:${HADOOP_JAR_DIR}/hadoop-hdfs-httpfs-${HADOOP_VERSION}.jar
:${HADOOP_JAR_DIR}/hadoop-hdfs-native-client-${HADOOP_VERSION}.jar
:${HADOOP_JAR_DIR}/hadoop-common-${HADOOP_VERSION}.jar
:${HADOOP_JAR_DIR}/failureaccess-1.0.1.jar
:${HADOOP_JAR_DIR}/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar
:${HADOOP_JAR_DIR}/commons-lang-2.6.jar"
이렇게 딥스토어를 연동하여 데이터 안정성을 확보하는 것 외에도, 클러스터 운영 환경에서 발생할 수 있는 다양한 장애에 대비하는 것이 중요합니다. 다음으로, 쿼리 및 데이터 저장 관련 장애를 예방하기 위한 클러스터 설정을 알아보겠습니다.
1-2) 장애 예방을 위한 클러스터 설정
쿼리 장애 예방: Server Failure Detector 설정
첫 번째, Server Failure Detector 설정입니다. 서버가 비정상 종료됐을 때 쿼리 실패를 줄일 수 있는 설정입니다.
Pinot에 쿼리를 실행하면 브로커가 쿼리를 여러 서버에 라우팅합니다. 이때 서버가 비정상 종료되면 약 30초 동안 쿼리 실패가 발생할 수 있습니다. 브로커가 Heartbeat 실패를 감지하기 전까지 장애가 발생한 서버로 쿼리 요청을 계속 보내기 때문입니다. 브로커가 Heartbeat 실패 인지 후 라우팅 테이블에서 장애 서버를 제거하면 쿼리 장애는 해결되지만, 그 짧은 시간 동안 발생하는 장애는 서비스에 영향을 줄 수 있습니다.
Server Failure Detector 설정을 사용하면 브로커가 장애 서버를 빠르게 감지하여 쿼리 라우팅에서 제외하기 때문에 쿼리 실패를 줄일 수 있습니다. Pinot 0.11.0 버전부터 사용 가능한 설정이고, 브로커 설정에 다음과 같이 적용할 수 있습니다.
-- 브로커 설정
# FailureDetector
pinot.broker.failure.detector.type=CONNECTION
pinot.broker.failure.detector.retry.initial.delay.ms=5000
pinot.broker.failure.detector.retry.delay.factor=2
pinot.broker.failure.detector.max.retries=10
데이터 저장 장애 예방: Peer download 설정
두 번째, Peer download 설정입니다. 딥스토어 장애가 발생했을 때 세그먼트를 다른 서버에서 다운로드할 수 있는 설정입니다.
Pinot 실시간 테이블에서 세그먼트가 flush 될 때, 정상적인 상황에서는 다음과 같은 프로세스가 진행됩니다.
- Winner 서버가 세그먼트를 딥스토어에 업로드하고 로컬에도 저장함.
- Non-Winner 서버들은 Winner 서버의 offset을 따라잡거나, 딥스토어에서 세그먼트를 다운로드함.
그러나 딥스토어에 장애가 발생하면 Non-Winner 서버는 세그먼트 다운로드를 할 수 없어 flush 처리에 실패합니다. 이렇게 되면 새로운 Consuming 세그먼트 생성이 중단되어 실시간 데이터를 조회할 수 없게 되며, 데이터 누락으로 이어질 수 있습니다.
이러한 장애 상황을 예방하기 위해 peer download 설정을 활용하고 있습니다. 이 설정을 사용하면 딥스토어 다운로드에 실패하더라도 HTTP/HTTPS 통신을 통해 다른 서버에서 세그먼트를 다운로드할 수 있습니다. 그러면 세그먼트 flush가 정상적으로 실행되어, 실시간 데이터 유실 가능성을 최소화할 수 있어요. peer download 설정은 컨트롤러, 서버, 테이블 설정에 모두 반영해야 합니다.
-- 컨트롤러 설정
controller.allow.hlc.tables=false
controller.enable.split.commit=true
-- 서버 설정
pinot.server.instance.segment.store.uri=hdfs:///pinot/peer-server/segments
pinot.server.instance.enable.split.commit=true
pinot.server.storage.factory.class.hdfs=org.apache.pinot.plugin.filesystem.HadoopPinotFS
-- 테이블 설정
"segmentsConfig": {
"peerSegmentDownloadScheme": "http"
}
이처럼 클러스터 설정으로 서비스 안정성을 확보하는 것 외에도, 데이터를 안전하게 보호하기 위한 보안 강화도 중요합니다. 다음은 Pinot에서 제공하는 인증 방식과 보안 설정 방법을 살펴보겠습니다.
1-3) 보안 강화
데이터를 처리하는 시스템이라면 인증받은 사용자들이 권한을 받은 데이터에만 접근해야 합니다. Pinot를 사용하는 실시간 통계 시스템에서도 계정과 테이블 권한을 관리하고 있습니다.
Pinot는 2가지 인증방식을 제공하고 있어요.
-
- HTTP Basic Auth 방식: 전통적인 HTTP 기본 인증 방식이며, 설정 파일로 관리하므로 ACL을 적용하려면 재시작이 필요함.
-
- ZooKeeper 기반 HTTP Basic Auth 방식: ZooKeeper를 활용하여 인증 설정을 관리하여 재시작 없이 동적으로 ACL 설정을 적용할 수 있음.
ZooKeeper 기반 인증 방식을 사용하면 Pinot 콘솔에서 사용자 생성, 비밀번호 설정, 역할 설정, 테이블별 권한 설정 등을 할 수 있습니다. 저희는 운영 효율성을 위해 ZooKeeper 기반 방식을 사용하고 있습니다. 이 방식은 Pinot 0.10.0 버전부터 지원되었지만, 1.0.0 버전에서 쿼리 지연 이슈가 있었습니다. 저희가 이슈를 발견하여 Apache Pinot 커뮤니티에 보고했었고, 1.1.0 버전부터 버그(#11904)가 수정되어 안정적으로 사용하고 있습니다.
또한, 보안 정책을 준수하기 위해 하둡플랫폼파트에서 Pinot 1.2.0 버전 소스 코드를 직접 수정하였습니다. Pinot에서 기본으로 제공해 주는 콘솔에 아래와 같은 기능을 추가했습니다.
-
- 계정별 로그인 제한: 공용 계정은 누가 사용했는지 식별이 어렵기 때문에 개인 계정만 콘솔에 로그인 가능하도록 제한하였습니다.
-
- 어드민 기능 접근 제한: Pinot 콘솔에서는 메뉴별 권한 제어가 불가능합니다. 그래서 일반 사용자 계정의 ZooKeeper Browser 메뉴 접근을 차단하도록 해서 메타 정보를 임의로 수정하는 것을 방지하였습니다.
-
- 쿼리 에디터 제어: 민감 데이터를 조회할 때는 조회사유와 승인절차가 필요한 정책이 있어 별도의 인터페이스를 사용해야 하는데요. 그래서 일반 사용자는 Pinot 콘솔에서 쿼리를 실행할 수 없도록 차단하고, Explain 쿼리만 허용하였습니다.
이렇게 Pinot 콘솔의 보안을 강화하여 사용하고 있습니다.
2. DR(Disaster Recovery) 아키텍처 구축
안정적인 클러스터 운영을 넘어, 데이터 센터 레벨의 대규모 장애나 예기치 않은 오류, 운영성 작업으로 인한 장애까지 대비하고자 자체적으로 DR(Disaster Recovery) 아키텍처를 구축했습니다. Pinot는 HA(High Availability)를 지원하지만, 데이터 센터 레벨의 DR은 별도로 제공하지 않기 때문입니다.
저희가 구현한 DR 아키텍처는 Active-Standby 클러스터 구성 기반으로 동작합니다. 장애 발생 시, Active 클러스터에서 Standby 클러스터로 5분 이내에 전환되어 서비스 중단을 최소화하고 있어요.
이러한 DR 아키텍처를 구현하기까지, 몇 가지 중요한 고민을 거쳤습니다.
-
- 클러스터 장애는 어떤 기준으로 판단해야 할까?
-
- 클러스터 장애를 어떻게 자동으로 감지할 수 있을까?
-
- 장애가 감지되면 Active 클러스터를 Standby 클러스터로 어떻게 전환할까?
지금부터 이 고민들을 어떻게 풀어나갔는지 말씀드리겠습니다.
2-1) 클러스터 장애 판단 기준
우선, 어떤 상황을 클러스터 장애로 볼 것인지 정의하는 게 중요했습니다. PoC를 진행하면서 여러 장애 시나리오를 테스트한 결과를 바탕으로, Pinot의 컴포넌트별 장애 판단 기준을 마련했는데요. 기준은 다음과 같습니다. 이 중 하나라도 해당되면 클러스터 장애로 판단했습니다.
Pinot 컴포넌트 | 장애 판단 기준 | 장애 영향도 |
---|---|---|
컨트롤러 | 모든 컨트롤러가 장애일 때 | 배치 작업 장애, 어드민 API 사용 불가 |
브로커 | 모든 브로커가 장애일 때 | 쿼리 불가 |
서버 | 3대 이상 장애일 때 | 데이터 누락 가능 (세그먼트 복제본 서버 모두 다운된 경우) |
2-2) 클러스터 장애 자동 감지
다음으로, 클러스터 상태를 주기적으로 확인해서 장애를 감지하기 위해 헬스체크 모듈을 개발했습니다. 이 모듈은 주기적으로 컨트롤러, 브로커, 서버의 상태를 확인하고, 위에서 정의한 장애 판단 기준에 따라 결과를 ‘GOOD’(정상) 또는 ‘BAD’(장애)로 반환합니다. 구체적으로는 다음 3가지를 주기적으로 수행합니다.
-
- 컨트롤러를 통해 메타정보 조회
-
- 브로커를 통해 헬스체크 쿼리 수행
-
- 헬스체크 쿼리 결과값으로 healthy 서버대수 체크
2-3) Active-Standby 클러스터 전환
마지막으로, 장애 발생 시 Active 클러스터를 Standby 클러스터로 자동으로 전환하는 구조를 만들었어요. 이 과정에서 로드 밸런서(LB)와 GSLB(Global Server Load Balancing)를 활용했는데요. 헬스체크 모듈은 로드 밸런서에 연결되어 있고, 각 브로커에 하나씩 배포됩니다.
- 헬스체크 모듈이 장애를 감지하면, 로드 밸런서는 해당 모듈이 배포된 브로커에 더 이상 트래픽을 보내지 않습니다.
- 만약 모든 헬스체크 모듈이 ‘BAD’ 상태를 반환하면, 로드 밸런서는 Active 클러스터에 있는 모든 브로커가 정상 작동하지 않는다고 판단합니다.
- 이 정보를 받은 GSLB는 Active 클러스터에 장애가 발생한 것으로 판단하고, 클라이언트 트래픽을 Standby 클러스터로 자동으로 전환합니다.
- 결과적으로 클라이언트는 Standby 클러스터에 접속하여 정상적으로 쿼리를 수행할 수 있어 서비스 중단을 최소화할 수 있습니다.
이렇게 DR 아키텍처를 통해 예기치 못한 장애 상황에서도 안정적인 서비스를 제공하고 있습니다.
3. 실시간 Upsert 테이블 운영
실시간 Upsert 기능은 Pinot의 핵심 기능이라고 할 수 있습니다. 레코드 단위로 데이터를 준실시간으로 변경할 때 매우 유용해요. 실시간 통계에서 변경된 데이터를 바로 반영하기 위해 이 기능을 사용하고 있습니다.
실시간 Upsert 테이블을 운영하면서 얻은 3가지 노하우를 말씀드리고자 합니다. 클러스터 성능에 영향을 줄 수 있는 부분이니 도움을 얻어 가셨으면 좋겠습니다.
3-1) PK(Primary Key)로 인한 서버 메모리 관리
Upsert 테이블의 PK 관련 메타데이터는 Pinot 서버의 힙 메모리에 저장됩니다. 그래서 PK 관리를 어떻게 하느냐에 따라 서버 메모리 부담을 크게 줄일 수 있습니다. 저희가 활용하는 방법은 크게 3가지입니다.
첫 번째, PK 개수를 관리해요. PK 개수가 많아지면 메모리 사용량도 늘어나서 서버에 부담이 갈 수 있어요. 저희 환경에서는 PK가 1억 2천만 건을 넘었을 때, OOM이 발생하거나 GC가 과도하게 일어나는 문제가 있었습니다. 심지어 PK가 늘어나면 Zookeeper에 저장되는 정보량도 늘어나서 Zookeeper와의 통신에도 문제가 생길 수 있습니다. 그래서 실시간 Upsert 테이블을 운영하면서 이런 문제를 만나게 된다면, PK 개수를 확인해 보세요.
두 번째, PK 길이를 관리해요. PK 길이는 짧게 가져갈수록 서버 메모리 사용량을 아낄 수 있습니다. 복합키는 되도록 피하고, Upsert 테이블 설정에서 hashFunction을 사용하면 PK 길이와 상관없이 해시 코드 값이 저장되어서 서버 부담을 줄일 수 있습니다.
-- 테이블 설정
"upsertConfig": {
"hashFunction": "MURMUR3",
}
세 번째, PK의 보관 기간을 관리해요. PK의 TTL(Time To Live)을 설정하면 서버 메모리 사용량을 줄일 수 있습니다. TTL이 지난 PK의 메타데이터는 메모리에서 자동으로 삭제되거든요. TTL은 카디널리티가 높거나, Update가 빈번한 테이블에서 더 유용합니다.
-- 테이블 설정
"upsertConfig": {
"enableSnapshot": true,
"metadataTTL": 86400,
}
다만, 1.0.0 버전에서는 PK TTL 설정 시 새로운 세그먼트가 생성되지 않는 버그가 있었는데요. Upsert 테이블은 데이터 정합성을 위한 Snapshot 기능을 필수로 사용해야 하는데, TTL이 지난 세그먼트도 Snapshot을 생성하려고 시도하면서 에러가 발생하고, 이로 인해 새로운 세그먼트 생성에도 실패하는 문제였습니다. 이 버그는 저희가 운영 중에 발견하여 Pinot 커뮤니티에 보고했고, 1.2 버전에서 수정되었으니 참고하시길 바랍니다. (#12449)
3-2) 세그먼트 Compaction
서비스 요건상 실시간 Upsert 테이블을 리텐션 없이 운영하고 있습니다. 시간이 지나면서 테이블 크기가 커지고, 세그먼트 개수도 늘어나서 고민이 많았습니다. Upsert를 하더라도 세그먼트가 계속 많아지는 이유는 Pinot의 세그먼트는 기본적으로 불변(immutable) 상태이기 때문에 기존에 생성된 세그먼트들이 그대로 남아있기 때문입니다.
세그먼트가 많아지면 어떤 문제가 생길까요? 쿼리 할 때 필요 없는 데이터까지 조회해서 쿼리가 느려지고, 세그먼트 메타데이터도 많아져서 ZooKeeper나 서버 메모리에도 부담이 됩니다.
그래서 세그먼트 컴팩션 작업을 주기적으로 돌려서 이 문제를 해결하고 있습니다. 실시간 테이블에서 이미 flush가 된 온라인 세그먼트 중에서, 쿼리에 활용되지 않는 레코드를 포함하고 있는 세그먼트들이 컴팩션 대상이 됩니다.
세그먼트 컴팩션 작업을 수행하면 2가지 효과를 볼 수 있어요. 덕분에 서버 메모리와 저장 공간을 확보할 수 있고 쿼리 성능도 빨라질 수 있습니다.
-
- 쿼리 되지 않는 레코드를 정리해서 세그먼트 사이즈를 줄일 수 있습니다.
-
- 세그먼트의 데이터가 모두 쿼리 되지 않는 레코드이면 세그먼트가 삭제되기 때문에 세그먼트 개수도 줄일 수 있어요.
다만, 세그먼트에 유효한 데이터가 하나라도 남아있으면 세그먼트가 삭제되지 않기 때문에, 컴팩션 후에도 작은 크기의 세그먼트가 많이 남을 수 있어요. 이럴 땐 Pinot 1.3 버전에서 추가된 세그먼트 병합 기능을 참고해 보시길 바랍니다. (#14477)
3-3) 모니터링
실시간 Upsert 테이블을 운영하면서 Pinot이 제공하는 다양한 메트릭을 활용하고 있습니다. JMX 메트릭을 프로메테우스(Prometheus)로 보내서 그라파나(Grafana) 대시보드를 구축해 놓고 모니터링하고 있답니다. JMX export 관련 설정은 이 문서를 참고해 주세요.
지금부터 저희가 PK, 세그먼트 현황 등을 확인할 때 유용하게 사용 중인 메트릭들을 소개해 드릴게요.
pinot_server_upsertPrimaryKeysCount_Value
: Upsert 테이블의 PK(Primary Key) 개수pinot_controller_segmentCount_Value
: Pinot 클러스터 전체 혹은 테이블별 세그먼트 개수pinot_server_documentCount_Value
: 각 서버에서 저장하고 있는 데이터(document)의 총 개수
뿐만 아니라, Pinot에서 쿼리 실행 관련 정보를 얻을 수 있는 메트릭도 자주 사용합니다.
pinot_broker_queries_Count
: 브로커로 유입된 총 쿼리 수pinot_server_numDocsScanned_Count
: 서버에서 쿼리 처리 과정에서 스캔한 document 수pinot_server_grpcQueryExecutionMs_Count
: gRPC 쿼리 실행 시간(ms)
그리고, Pinot의 안정적인 운영에 필요한 정보도 확인할 수 있어요.
pinot_controller_percentOfReplicas_Value
: 세그먼트 복제본 비율pinot_server_realtime_rowsConsumed_Count
: 실시간 데이터 컨슈밍 속도 체크
이 외에도 다양한 메트릭이 제공되니, 필요한 정보에 따라 대시보드를 구축하여 활용하시면 Pinot 운영에 큰 도움이 되실 거예요.
4. Pinot 운영해 보니 알게 된 점
Pinot를 운영하면서 많은 것을 배웠습니다. 그중에서 당연히 A처럼 동작할 거라 예상했지만 B처럼 동작하는 경우들이 있었습니다. 이 내용을 미리 알고 있으면 Pinot를 운영할 때 시행착오를 줄이는데 도움이 될 것 같아 공유드립니다.
4-1) 언더 리플리카 (Under Replica) 상태
Pinot는 데이터 유실을 막기 위해 데이터를 여러 서버에 복제해서 저장할 수 있습니다. 이 복제본을 리플리카(Replica)라고 부르는데요. 데이터 안정성을 높이기 위해 리플리카 수를 3으로 설정하여 사용하고 있습니다.
그런데 서버 장애 등으로 일부 리플리카가 사라져서 언더 리플리카(Under Replica) 상태가 될 때가 있습니다. 이때, HDFS처럼 알아서 복구해 줄 거라고 예상했었는데, Pinot는 아쉽게도 언더 리플리카를 자동으로 복구해주지 않습니다. 물론 언더 리플리카 상태라고 해서 당장 쿼리하는 데 문제가 생기는 건 아니지만, 추가적인 서버 장애 발생 시 데이터 누락 가능성을 높이므로 복구해 주는 것이 좋습니다.
언더 리플리카가 발생하는 상황에 따라 복구 방법이 조금씩 다른데요. 몇 가지 대표적인 케이스를 정리해 보겠습니다.
- 서버 장애가 일시적인 경우
- 서버 장애 때문에 Pinot 서버 프로세스가 잠깐 다운된 경우입니다.
- 이런 경우엔 Pinot 서버를 재시작하기만 하면 됩니다.
- 서버가 재시작되면서 로컬에 있는 세그먼트를 로딩하거나 딥스토어에서 다운로드하므로, 언더 리플리카 상태가 해소됩니다.
- 서버 장애가 장기적으로 지속되는 경우
- 서버를 다시 시작할 수 없는 상황, 예를 들어 서버 장애가 장기화되어 해당 서버를 아예 클러스터에서 제외해야 할 수도 있습니다.
- 이런 경우엔 약간 더 복잡한 과정을 거쳐야 합니다. 먼저 장애가 발생한 서버의 테넌트를 변경하고, 해당 서버를 disable 처리하여 클러스터에서 제외합니다.
- 그다음 ‘Reassign Instances’ 옵션을 활성화하여 서버 리밸런싱을 수행합니다. 이렇게 하면 장애 서버를 제외한 나머지 서버들로 리밸런싱이 되면서 언더 리플리카 상태가 해소됩니다.
이렇게 하면 언더 리플리카를 수동으로 처리해 줄 수 있습니다.
추가로, 서버 로컬에서 세그먼트 데이터가 유실된 경우는 어떻게 될까요? 이 경우엔 언더 리플리카가 발생하지 않습니다. Pinot는 기본적으로 세그먼트를 메모리에 매핑하여 사용하는 구조이기 때문에 디스크에서 파일이 삭제되더라도 이미 메모리에 로딩된 데이터를 기반으로 쿼리를 처리할 수 있습니다.
그럼에도 서버 로컬 디스크는 여전히 성능 및 안정성 측면에서 중요한 역할을 합니다. 예를 들어, 서버를 재시작하는 경우 로컬에 세그먼트가 있으면 다시 다운로드할 필요 없이 빠르게 세그먼트를 로드하고 서비스를 재개할 수 있습니다.
아래 3가지 방법을 사용하면 딥스토에서 세그먼트를 다운로드하기 때문에 로컬에서 유실된 세그먼트를 복구할 수 있습니다.
-
- 세그먼트 reset 수행
-
- 세그먼트 reload 수행 (forceDownload 옵션을 true로 설정)
-
- 서버 재시작
4-2) 리플리카 서버 전체 다운
다수의 서버 장애가 발생하면 리플리카를 저장하고 있던 서버들이 모두 다운되는 경우도 있을 텐데요. 이때 해당 세그먼트를 조회하는 쿼리는 실패할 거라 예상했었습니다. 결과적으로 쿼리는 성공하나, 해당 세그먼트의 데이터만 누락되어 조회됩니다.
리플리카 서버가 전체 다운된 상황에서 브로커로 REST API로 쿼리 요청을 보내면 HTTP Status Code는 200으로 정상 리턴되나, 응답 포맷에서 exceptions 항목에 Pinot 에러 코드가 포함되는 형태입니다. 그래서 쿼리 요청은 성공합니다.
Pinot 1.2 버전부터는 이러한 데이터 누락을 감지할 수 있도록 다음과 같은 기능이 추가되었습니다.
X-Pinot-Error-Code
: 응답 헤더에 Pinot 에러 코드가 포함됨 (정상적인 경우 -1).partialResult
: 응답 포맷에 일부 데이터가 누락되었음을 나타내는 플래그가 포함됨 (정상적인 경우 false).
따라서 데이터 누락에 민감한 서비스를 운영한다면 Pinot 쿼리 응답을 처리할 때 HTTP 헤더의 오류 코드 또는 응답 포맷에서 partialResult 플래그를 확인하여 예외 처리 로직을 구현해야 합니다.
4-3) 스냅샷 테이블
통계정보 중에서 매일 갱신되는 케이스에서는 전체 데이터를 매일 교체하여 저장하는 스냅샷 테이블을 사용하고 있습니다.
Pinot 테이블 설정에서 segmentPushType
을 ‘REFRESH’로 설정하면 배치 작업 실행 시 기존 데이터가 모두 교체됩니다.
이 기능은 실시간 테이블이 아닌 오프라인 테이블에서만 사용할 수 있습니다.
-- table.json
{
"OFFLINE": {
"tableName": "table_base_OFFLINE",
"tableType": "OFFLINE",
"segmentsConfig": {
"schemaName": "table_base",
"replication": "3",
"replicasPerPartition": "3",
"segmentPushType": "REFRESH",
"segmentPushFrequency": "DAILY",
"minimizeDataMovement": false
}
...
이렇게 설정만 해주면 모든 데이터가 교체될 거라 생각했었는데요. 완벽한 데이터 교체를 위해서는 다음 2가지 사항을 기억하셔야 합니다.
-
- 세그먼트 이름 통일: 새로 생성하는 세그먼트의 이름이 이전 세그먼트와 동일해야, 기존 세그먼트가 교체됨.
-
- 세그먼트 개수 일치: 이전 배치에서 적재된 세그먼트 개수가 현재 배치에서 생성될 세그먼트 개수보다 많을 경우, 이전 배치의 데이터가 지워지지 않고 남아 있음.
1번 항목을 고려해서 세그먼트 이름을 고정해 줍니다. 배치 작업 설정에서 세그먼트 이름의 prefix를 설정하여, 세그먼트 이름이 동일하게 유지되도록 관리합니다.
-- ingestion-job-table_base.yaml
segmentNameGeneratorSpec:
type: simple
configs:
segment.name.postfix: 'v1'
exclude.sequence.id: false
2번 항목을 고려해서 이전 배치와 현재 배치의 세그먼트 개수를 맞춰주고 있습니다. 배치 실행 전에 아래처럼 전처리 작업을 해주고 있어요. 참고로 Pinot 배치 작업에서는 보통 하나의 파일이 하나의 세그먼트로 생성됩니다.
- Pinot 어드민 API를 조회해 현재 테이블의 세그먼트 개수를 확인한다.
- 이전 배치의 세그먼트 개수가 현재 ETL 할 파일 수보다 많을 경우, 빈 파일을 만들어 이전 배치의 세그먼트 개수와 현재 ETL 할 파일 개수를 같게 만들어 준다.
이렇게 하면 스냅샷 테이블의 모든 데이터를 완전하게 교체할 수 있습니다.
4-4) Kafka 실시간 컨슈밍
Pinot를 통해 Kafka에서 실시간으로 데이터를 컨슈밍하고 있습니다. 실시간 서비스 특성상 Kafka 클러스터의 변경이나 Kafka 토픽 재생성과 같은 예상치 못한 상황을 대응하는 것이 중요합니다. 이때 Pinot의 컨슈밍 중단(pause)/재개(resume) 기능을 사용하면 테이블 수정이나 서버 재시작 없이 대응할 수 있습니다.
Kafka 클러스터가 변경된 후 토픽이 초기화되는 상황에서 Pinot이 어떻게 동작하고, 어떻게 대응해야 하는지 경험을 바탕으로 공유하고자 합니다.
보통 Pinot 서버는 Kafka 토픽에서 데이터를 읽어오고, 데이터의 위치 정보인 오프셋(offset)을 내부 메타데이터에 기록합니다. 이때, 실시간 테이블의 컨슈밍 오프셋은 Kafka 토픽의 현재 오프셋보다 작거나 같아야 합니다.
하지만 Kafka 클러스터가 변경된 후 토픽이 초기화된 과정에서 예상치 못한 문제가 발생했습니다. Kafka 클러스터 변경 작업 때문에 잠시 컨슈밍을 멈췄다가 (pause) 변경 후 다시 시작 (resume) 했는데, 컨슈밍이 실패했었어요.
Pinot는 새로운 Kafka 토픽에서 컨슈밍을 시도했지만, Pinot가 이미 저장하고 있던 컨슈밍 오프셋이 초기화된 Kafka 토픽의 오프셋 범위를 벗어났기 때문이었습니다. 한마디로, Pinot가 과거에 처리하던 오프셋 정보가 새 토픽에서는 더 이상 유효하지 않은 정보가 된 것입니다.
처음에는 테이블 설정 중에 stream.kafka.consumer.prop.auto.offset.reset
이라는 속성이 smallest로 설정되어 있어 새로운 토픽의 첫 번째부터 알아서 컨슈밍을 할 거라 생각했었는데요.
알고 보니 이 설정은 어느 오프셋부터 수집을 시작할지 결정하는 데 사용되지만, 컨슈밍 재시작(resume) 시에는 적용이 되지 않았습니다.
이런 상황에서는 컨슈밍을 다시 시작(resume)할 때 consumeFrom
파라미터 값을 smallest 혹은 largest로 설정하여 Kafka 토픽의 어느 위치부터 데이터를 가져올지 명시적으로 지정해야 합니다.
이렇게 하면 Pinot가 기존에 가지고 있던 오프셋 정보를 무시하고 새롭게 지정된 오프셋부터 컨슈밍을 재개할 수 있습니다.
아래 명령어는 consumeFrom 파라미터를 사용하여 컨슈밍을 재개하는 코드입니다.
curl -u $authorization -H 'accept: application/json' -X POST "http://${CONTROLLER_ADDR}:${CONTROLLER_PORT}/tables/${TABLE_NAME}_REALTIME/resumeConsumption?consumeFrom=smallest"
consumeFrom=smallest는 Kafka 토픽에서 가장 오래된 offset부터 컨슈밍을 시작하라는 의미입니다. 즉, 새로운 토픽의 처음부터(offset=0) 데이터를 다시 가져오도록 하는 것입니다.
이 사례가 Pinot와 Kafka를 함께 사용하시는 분들께 작게나마 도움이 되었으면 좋겠습니다.
4-5) Trino gRPC 사용
Pinot 쿼리를 위해 Trino를 클라이언트 도구로 사용하고 있습니다. Trino는 Pinot 데이터를 읽어올 때 크게 2가지 방식을 지원합니다.
-
- Broker를 통한 쿼리 방식 (Pushdown)
-
- Pinot 서버와 gRPC 통신하는 방식
Trino를 통해 Pinot 데이터를 대량 조회하는 케이스에서 gRPC 통신 방식을 사용하고 있습니다. 스트림 형태로 Pinot 데이터를 가져오기 때문에, 대용량 데이터를 효율적으로 처리할 수 있다는 장점 때문입니다.
그런데 최근 Trino를 사용하여 Pinot의 데이터를 조회하던 중, 예기치 못한 상황을 경험했습니다. Pinot 서버에서 타임아웃이 발생했음에도 불구하고 Trino에서는 쿼리 오류가 발생하지 않아 정상적으로 실행된 것처럼 보였지만, 실제로는 일부 데이터가 누락되는 현상이었습니다.
Pinot 서버 로그에서 아래와 같은 타임아웃 오류가 발생한다면, Trino의 쿼리 결과에 데이터 누락이 있을 수 있다는 점을 인지해야 합니다.
-- pinot 서버 로그
25/02/12 11:00:03.773 ERROR [grpc-default-executor-321] org.apache.pinot.core.operator.streaming.BaseStreamingCombineOperator:getNextBlock:84 Timed out while polling results block (query: QueryContext{_tableName='table_base_OFFLINE', _subquery=null, _selectExpressions=[column], _distinct=false, _aliasList=[null], _filter=column != 'null', _groupByExpressions=null, _havingFilter=null, _orderByExpressions=null, _limit=2147483647, _offset=0, _queryOptions={}, _expressionOverrideHints={}, _explain=false})
이러한 문제를 방지하기 위한 한 가지 방법은 Pinot 서버의 타임아웃 설정을 적절히 조정하는 것입니다. 하지만, 근본적인 해결책을 찾기 위해 Trino의 Pinot 커넥터에서 gRPC 서버 타임아웃으로 인한 데이터 누락을 감지하거나 예방할 수 있는 방법을 지속적으로 검토하고 있습니다.
마치며
지금까지 저희 팀이 Apache Pinot를 도입하고 운영하면서 얻은 다양한 노하우를 담아보았습니다.
Apache Pinot를 도입하고 운영하는 과정은 실시간 데이터 분석의 활용도와 서비스 안정성을 높이기 위한 여정이었어요. 오픈소스 커뮤니티에 직접 이슈를 공유하고 DR 아키텍처를 설계하면서, 플랫폼 엔지니어로서도 한층 더 성장할 수 있었던 시간이었습니다.
이렇게 하둡플랫폼파트에서는 하둡 에코시스템뿐만 아니라 새로운 오픈소스도 도입하면서 변화하는 데이터 환경에 발맞춰 가고 있습니다. 앞으로도 카카오페이의 데이터 활용 가치를 더 높이기 위해 노력하고자 합니다.
빅데이터 플랫폼을 운영하다 보면 누군가의 사례가 한줄기의 빛처럼 도움이 될 때가 많은데요. 이 글도 Pinot를 도입하거나 운영하는 데 어려움을 겪는 분들에게 도움이 되기를 바랍니다.
긴 글 읽어주셔서 감사합니다!