아파치 카프카 공식 레퍼런스를 한글로 번역한 문서입니다.
전체 목차는 여기에 있습니다.
목차
- 4.1 Motivation
- 4.2 Persistence
- 4.3 Efficiency
- 4.4 The Producer
- 4.5 The Consumer
- 4.6 Message Delivery Semantics
- 4.7 Replication
- 4.8 Log Compaction
- 4.9 Quotas
4.1 Motivation
카프카는 대기업에 있을 만한 모든 실시간 데이터 피드를 처리할 수 있는 통합 플랫폼이 되도록 설계했다. 그렇기 때문에 상당히 광범위한 사용 사례를 고려해서 카프카를 설계해야 했다.
실시간 로그 집계같은 대용량 이벤트 스트림을 지원하려면 시간당 처리량이 높아야 했다.
오프라인 시스템에서 주기적인 데이터를 로드할 수 있으려면 대용량 데이터 백로그를 적절하게 처리할 수 있어야 했다.
이 말은, 기존 관행대로 메세지를 처리하는 서비스를 위해 짧은 지연 시간 내로 데이터를 전달할 수 있어야 한다는 뜻이기도 하다.
이런 데이터 피드를 파티셔닝하고, 실시간으로 분산 처리해서 새로운 피드를 만들고 싶었다. 그렇다보니 파티셔닝과 컨슈머라는 모델이 생겨났다.
마지막으로, 서비스에 필요한 다른 데이터 시스템에 스트림을 전송하는 케이스에선 시스템 장애가 있더라도 내결함성을 보장할 수 있어야 한다는 것을 알게됐다.
이런 사용 사례를 지원하려다 보니, 기존 메세징 시스템에 비해 데이터베이스 로그에 더 가까운, 여러 가지 독특한 요소를 설계하게 됐다. 다음 섹션에 이어서 몇 가지 설계 요소를 간단히 짚어보겠다.
4.2 Persistence
Don’t fear the filesystem!
카프카는 메세지를 저장하고 캐싱하는 데 파일 시스템에 크게 의존한다. “디스크는 느리다”는 일반적인 인식때문에 persistent 구조도 뒤지지 않는 성능을 제공할 수 있다는 것에 회의적인 사람들이 많다. 하지만 실제로는 디스크는 어떻게 사용하느냐에 따라 기대하는 것보다 훨씬 느릴 수도 있고 빠를 수도 있다. 디스크 구조를 잘만 설계하면 네트워크만큼 빠르게 동작하기도 한다.
디스크 성능에 관해 주목할만한 사실은, 지난 10년 동안 하드 드라이브의 처리량이 디스크 검색의 지연 시간의 한계를 벗어났다는 거다. 결과적으로 6 x 7200rpm SATA RAID-5 어레이를 사용한 JBOD 구성에선 랜덤 쓰기 성능은 약 100k/sec에 불과하지만, 선형 쓰기 성능은 약 600MB/sec에 달한다 — 6000배 이상이나 차이난다. 선형 읽기/쓰기는 모든 사용 패턴 중에서도 가장 예측이 가능한 패턴이며, 운영 체제의 최적화에 크게 좌우된다. 최신 운영 체제는 데이터를 큰 블록 배수로 프리페치하고 작은 논리적 쓰기를 큰 물리적 쓰기로 그룹화하는 read-ahead, write-behind 기술을 제공한다. 이 이슈에 대한 추가 논의는 ACM 큐 문서에서 찾을 수 있다. 이들은 실제로 어떤 경우엔 순차 디스크 액세스가 랜덤 메모리 액세스보다 빠를 수 있다는 사실을 발견했다!
이런 성능 차이를 보완하기 위해 최신 운영 체제는 점점 더 과감하게 디스크 캐싱에 메인 메모리를 사용하고 있다. 최신 OS는 메모리를 회수(reclaim)할 때 성능 저하는 거의 없이 모든 여유(free) 메모리를 디스크 캐싱으로 전환한다. 모든 디스크 읽기/쓰기는 이 통합 캐시를 거친다. 이 기능은 직접 I/O를 사용하려는게 아니고서는 쉽게 끌 수 없는데, 그렇다보니 프로세스 내에 캐시 데이터를 유지하더라도, 데이터는 OS 페이지 캐시에 복제될 공산이 크기 때문에, 사실상 모든 데이터를 두 번 저장하는 거나 마찬가지다.
게다가 카프카는 JVM 위에서 동작하며, 자바 메모리를 어느정도 사용해본 사람이라면 누구나 다음과 같은 사실을 알고 있다:
- 객체를 메모리에 올릴 땐 오버 헤드가 매우 높은 편이며, 저장된 데이터 크기의 두 배를 차지할 때도 종종 있다 (혹은 그 이상).
- 자바 가비지 컬렉션은 힙 데이터가 늘어날수록, 점점 더 느려져 성가시다.
이런 요인들을 감안하면, 인 메모리 캐시나 다른 기타 구조를 유지하는 것보단, 파일 시스템을 사용하고 페이지 캐시에 의존하는 게 낫다 — 모든 여유 메모리에 자동 액세스하면 사용할 수 있는 캐시는 최소한 두 배가 되며, 각 객체를 그대로 저장하는 대신 컴팩트한 바이트 구조를 저장하면 또다시 두 배로 늘릴 수 있다. 이렇게하면 32GB 시스템에선, GC 페널티 없이 최대 28-30GB까지 캐시를 확보할 수 있다. 게다가, 이 캐시는 서비스를 다시 시작해도 웜 상태를 유지하는데, 프로세스 내에 저장한 캐시는 메모리에서 다시 구성해야 한다 (10GB 정도의 캐시라면 10분이 소요될 수도 있다). 그렇지 않으면 완전한 콜드 캐시로 시작해야 한다 (초기 성능이 심각하게 저하될 수 있다). 더불어, 캐시와 파일 시스템 간의 일관성을 유지하기 위한 모든 로직은 이제 OS에 들어가 있기 때문에, 코드는 매우 간단해진다. 프로세스 내에서 단발성으로 캐시를 구성할 때보다 더 효율적이고 정확하게 동작하는 편이기도 하다. 선형 읽기를 중심으로 디스크를 사용한다면, 디스크 읽기 연산마다 read-ahead가 이 캐시를 유용한 데이터로 미리 채울 거다.
여기서 제안하는 설계는 매우 단순하다. 데이터를 가능한 한 많이 인 메모리에 유지하고 공간이 부족할 때 허둥지둥 파일 시스템으로 전부 플러시하기보단, 오히려 정반대다. 모든 데이터는 디스크로 플러시할 필요 없이 파일 시스템에 있는 persistent 로그에 즉시 쓰여진다. 사실상 커널의 페이지 캐시로 전송된다는 걸 의미한다.
이 페이지 캐시 중심 설계 스타일은 여기 Varnish 설계 아티클에서 설명하고 있다 (건전한 거만함이 돗보인다).
Constant Time Suffices
보통 메세징 시스템에선 컨슈머 당 대기열을 persistent 데이터 구조로 사용하는데, 관련 BTree나 기타 범용 랜덤 액세스 데이터 구조로 메세지에 관한 메타데이터를 유지한다. BTree는 데이터 구조 중에서 가장 다방면으로 활용할 수 있는 구조며, 메세징 시스템에서 필요한 광범위한 트랜잭션/비트랜잭션 시맨틱스를 지원할 수 있다. 하지만 비용은 상당히 많이 든다: Btree 연산은 O(log N)이다. 보통 O(log N)은 본질적으로 상수 시간(constant time)과 동일하다고 보지만, 디스크 연산에서는 이야기가 다르다. 디스크 탐색은 pop당 10ms로 이루어지며, 각 디스크는 한 번에 하나씩만 탐색할 수 있으므로 병렬 처리는 제한된다. 따라서 적은 수의 디스크 검색도 매우 높은 오버 헤드로 이어진다. 스토리지 시스템은 매우 빠른 캐시 연산과 매우 느린 물리적 디스크 연산을 함께 쓰며, 캐시는 고정돼 있다. 그렇기 때문에 데이터가 늘어날 때 트리 구조에서 보이는 성능은 보통 초선형적이다. 데이터를 두 배로 늘리면 속도는 두 배 이상으로 훨씬 더 느려진다.
직관적으로 봐도, persistent 대기열은 일반적인 로깅 솔루션처럼 간단한 읽기 연산과 파일 append 연산을 기반으로 구성할 수 있다. 이 구조에선 모든 연산이 O(1)이며, 읽기/쓰기 연산이 서로를 블로킹하지 않는다는 장점이 있다. 이때 성능은 데이터 크기와는 전혀 상관 없기 때문에 성능에서 얻는 이점이 분명히 존재한다 — 이제 서버 한 대로 저렴하고 회전 속도가 느린 1+TB SATA 드라이브 여러 개를 최대로 활용할 수 있다. 이런 드라이브는 검색 성능은 좋지 않더라도, 대용량 읽기/쓰기에 적합한 성능을 보여주며, 가격은 1/3, 용량은 3배다.
성능 저하 없이 디스크 공간에 거의 무제한으로 액세스할 수 있다는 것은 일반적인 메세징 시스템에선 찾아볼 수 없는 다른 기능을 제공할 수 있다는 걸 의미한다. 예를 들어 카프카에서는 메세지를 컨슘하는 즉시 삭제하는 대신, 상대적으로 긴 기간 동안 (일주일이라고 치자) 메세지를 보관할 수 있다. 뒤에서도 설명하겠지만, 덕분에 컨슈머를 상당히 유연하게 활용할 수 있다.
4.3 Efficiency
카프카를 만들면서 효율성 방면에서도 상당히 공을 들였다. 주요 유스 케이스 중 하나는 양이 매우 방대한 웹 활동 데이터를 처리하는 거다: 웹에선 각 페이지를 조회할 때마다 수십 개의 쓰기 연산을 실행할 수 있다. 게다가, 발행한 이 모든 메세지를 최소한 컨슈머 하나에선 읽어간다고 가정하고 (보통 다수가 읽어간다), 가능한 한 컨슘 동작의 비용을 줄이려고 다분히 애를 썼다.
여러 가지 유사한 시스템을 구축하고 실행해보면서, 효율성은 멀티 테넌트를 효과적으로 운영하려면 생명과도 같다는 사실을 깨달았다. 어플리케이션 사용량이 조금 늘어난다고 해서 다운스트림에 있는 인프라 서비스가 쉽게 병목이 돼버린다면, 작은 변경 사항으로도 문제를 일으키기 쉽다. 카프카는 매우 빠르기 때문에 인프라까지 오기 전에 어플리케이션이 먼저 부하로 실패할 거다. 사용 패턴은 거의 매일 매일 변화한다. 중앙 클러스터에서 수십 또는 수백 개의 어플리케이션을 지원하는, 중앙 집중식 서비스를 실행한다면 인프라의 효율성은 특히 더 중요하다.
이전 섹션에선 디스크 효율성에 대해 논했다. 비효율적인 디스크 액세스 패턴을 제외하면, 이런 시스템 타입에선 흔히 두 가지가 비효율성의 원인이 되곤한다: 작은 I/O 연산이 너무 많거나, 바이트 복사가 과도하게 이루어 지거나.
작은 I/O 문제는 클라이언트와 서버 간에, 그리고 서버 자체의 persistent 연산에서 발생한다.
카프카는 이를 방지하고자 메세지를 자연스레 그룹으로 묶어 추상화한 “메세지 셋”을 중심으로 프로토콜을 설계했다. 네트워크 요청에선 메세지를 한 번에 하나씩 전송하는 대신, 이 프로토콜을 통해 메세지를 그룹으로 묶어 네트워크 왕복의 오버 헤드를 분할한다. 그에 따라 서버는 서버 로그에 메세지 청크를 한꺼번에 추가하고, 컨슈머는 큰 선형 청크를 한 번에 가져온다.
간단한 최적화지만, 속도 면에선 단위가 달라진다. 배치 처리에선 더 큰 네트워크 패킷과, 더 큰 순차 디스크 연산, 연속 메모리 블록 등이 가능해진다. 카프카는 이를 통해 산발적인 랜덤 메세지 쓰기 스트림을 선형 쓰기로 전환해서 컨슈머에 전달한다.
또 다른 비효율성은 바이트 복사에서 비롯된다. 메세지 비율이 낮을 땐 문제 없더라도, 부하 속에서는 상당한 영향을 끼친다. 카프카는 이를 방지하고자 프로듀서와, 브로커, 컨슈머 모두 표준화된 바이너리 메세지 포맷을 사용한다 (따라서 데이터 청크를 수정 없이 전송할 수 있다).
브로커가 관리하는 메세지 로그 자체는 단순한 파일의 디렉토리일 뿐이며, 각 파일은 프로듀서와 컨슈머가 공통으로 사용하는 포맷으로 디스크에 쓰여진 일련의 메세지 셋으로 채워진다. 이렇게 공통 포맷을 유지하면, persistent 로그 청크를 네트워크로 전송하는 핵심 연산을 최적화할 수 있다. 최신 유닉스 운영 체제는 페이지 캐시에서 소켓으로의 데이터 전송을 고도로 최적화한 코드를 제공한다. 리눅스에서는 sendfile 시스템 호출로 최적화한다.
sendfile의 효과를 이해하려면 먼저, 파일에서 소켓으로 데이터를 전송하는 일반적인 데이터 경로를 이해해야 한다:
- 운영 체제가 디스크에 있는 데이터를 커널 공간의 페이지 캐시로 읽어간다
- 어플리케이션은 커널 공간에 있는 데이터를 사용자 공간의 버퍼로 읽어간다
- 어플리케이션은 데이터를 커널 공간에 있는 소켓 버퍼로 다시 쓴다
- 운영 체제는 소켓 버퍼의 데이터를 네트워크를 통해 전송할 NIC 버퍼로 복사한다
여기선 복사본을 4개 사용하며, 시스템 호출을 두 번 수행한다. 명백하게 비효율적이다. sendfile을 사용하면 OS가 페이지 캐시에서 네트워크로 직접 데이터를 보낼 수 있어 이렇게 반복해서 복사하지 않아도 된다. 최적화된 코드에서는 NIC 버퍼로 복사한 최종 복사본만 있으면 된다.
보통은 토픽 하나에도 멀티 컨슈머가 있을 거다. 위에서 설명한 zero-copy 최적화를 사용하면, 메모리에 저장해 뒀다가 데이터를 읽을 때마다 사용자 공간에 복사하는 대신, 정확히 한 번만 페이지 캐시에 데이터를 복사해서 컨슘할 때마다 재사용한다. 덕분에 네트워크 연결 속도와 거의 근접한 속도로 메세지를 컨슘할 수 있다.
이렇게 pagecache와 sendfile을 조합했을 때, 카프카 클러스터의 메세지 생산 속도를 컨슈머 대부분이 따라잡고 있다면, 완전히 캐시로만 데이터를 서빙할 수 있어 디스크 읽기 연산은 전혀 볼 수 없을 거다.
자바에서의 sendfile, zero-copy 지원 배경은 이 문서를 참고해라.
End-to-end Batch Compression
간혹 CPU나 디스크가 아닌, 네트워크 대역폭이 병목이 되기도 한다. 메세지를 광역 통신망을 통해 여러 데이터센터로 전송해야 하는 데이터 파이프라인에서 특히 더 두드러진다. 물론, 꼭 카프카를 쓰지 않더라도, 메세지를 받을 때마다 하나씩 압축하면 되지만, 같은 유형의 메세지가 반복되면 (같은 JSON 필드명이나, 웹 로그의 user agent나 다른 공통 문자열이 많은 경우 등) 중복되는 만큼 압축률이 크게 떨어질 수 있다. 압축을 효율적으로 하려면, 각 메세지를 개별적으로 압축하기 보단, 여러 메세지를 한꺼번에 압축해야 한다.
카프카는 효율적인 배치 처리 포맷으로 압축을 지원한다. 메세지 배치를 모아놓고 함께 압축해서 이 포맷 그대로 서버에 전송할 수 있다. 이 메세지 배치는 압축된 포맷으로 쓰여져, 로그에도 압축된 상태로 보관하고, 컨슈머에서만 압축을 푼다.
카프카는 GZIP, Snappy, LZ4, ZStandard 압축 프로토콜을 지원한다. 압축에 관한 자세한 정보는 여기에서 확인할 수 있다.
4.4 The Producer
Load balancing
프로듀서는 중간 라우팅 계층 없이 파티션의 리더인 브로커에 데이터를 직접 전송한다. 프로듀서가 이 작업을 수행할 수 있으려면, 모든 카프카 노드는 언제든지 어떤 서버가 활성 상태이고 토픽 파티션의 리더는 누구인지에 대한 메타데이터 요청에 응답할 수 있어야 한다. 그래야만 프로듀서에서 이 요청을 적절하게 지시할 수 있다.
메세지를 발행할 파티션은 클라이언트에서 제어한다. 파티션은 일종의 랜덤 로드 밸런싱을 구현해 무작위로도 결정할 수 있고, 파티션 함수에 어떤 의미 체계를 부여할 수도 있다. 카프카는 의미를 부여해 파티셔닝할 수 있는 인터페이스를 제공한다. 이땐 사용자가 직접 파티션할 키를 지정하고, 이 키의 해쉬값으로 파티션을 정하게 된다 (필요하면 파티션 함수를 재정의할 수도 있다). 예를 들어, 사용자 id를 키로 선택했다면, 해당 사용자의 모든 데이터는 동일한 파티션으로 전송된다. 이렇게 하면 컨슈머는 컨슘해갈 데이터에 대한 지역성(locality)을 고려할 수 있다. 이 파티셔닝 기법은 컨슈머가 지역성이 중요한 데이터를 처리할 수 있도록 의도한 설계다.
Asynchronous send
카프카의 효율성은 배치 처리에서 기인하는 바가 크다. 카프카 프로듀서는 배치 처리를 위해 메모리에 데이터를 쌓아뒀다가, 더 큰 배치를 단일 요청으로 전송한다. 이때는 메세지를 고정된 수만큼만 누적하고, 고정된 지연 시간 이상으론 대기하지 않도록 설정할 수 있다 (예를 들어 64k 또는 10ms). 그덕에 전송할 바이트는 좀 더 누적하고, 서버에선 더 큰 I/O 연산을 적은 횟수로 실행한다. 버퍼링은 설정으로 조절할 수 있으며, 버퍼링 메커니즘은 약간의 대기 시간을 희생해, 처리량을 더 끌어올린다.
프로듀서 전용 설정과 api에 대한 자세한 내용은 이 문서 내에서 확인할 수 있다.
4.5 The Consumer
카프카 컨슈머는, 컨슘하려는 파티션을 리딩하는 브로커에 “fetch” 요청을 발행하는 식으로 동작한다. 컨슈머는 요청마다 로그의 오프셋을 지정하며, 이 위치부터 시작하는 로그 청크를 돌려받는다. 그렇기 때문에 로그 위치에 대한 제어권은 상당 부분이 컨슈머에 있으며, 필요하다면 컨슈머에서 오프셋을 뒤로 되돌려 데이터를 다시 컨슘할 수도 있다.
Push vs. pull
개발 초기에는 컨슈머가 브로커에서 데이터를 pull해올지, 아니면 브로커가 컨슈머에 push해줄지를 고민했었다. 카프카는 이 관점에선 다른 메세징 시스템 대부분에서 채용한 전통적인 설계를 따른다. 즉, 프로듀서는 브로커로 데이터를 push하고, 컨슈머는 브로커에서 pull해 간다. Scribe나 Apache Flume같은 일부 로깅 중심 시스템에선 양상이 좀 다른데, 데이터를 다운스트림으로 push한다. 이 두 가지 접근 방식에는 장단점이 있다. 하지만 push 기반 시스템에선 브로커가 데이터 전송 속도를 제어하기 때문에, 제각각인 컨슈머를 모두 대응하긴 어렵다. 일반적으로는, 컨슈머가 가능한 한 빠른 속도로 데이터를 컨슘해가는 게 좋다. 하지만, 안타깝게도 push 시스템에선 컨슘 속도가 데이터 생산 속도를 따라가지 못하면 컨슈머가 마비될 수도 있다는 뜻이기도 하다 (실질적인 서비스 거부 공격). pull 기반 시스템은 단순히 컨슈머가 뒤쳐지더라도 가능할 때 따라 잡을 수 있다는 특징이 있다. push 시스템의 단점은 컨슈머가 뒤쳐져있다는 것을 알릴 수 있는 일종의 백 오프 프로토콜로 어느정도는 보완할 수 있지만, 컨슈머를 최대로 활용할 수 있는 (그러면서도 과도하진 않은) 전송 속도를 유지하는 건 보기보다 까다로운 일이다. 과거에는 이 방식으로 시스템을 구축해보려 했으나, 결국 카프카에선 보다 전통적인 pull 모델을 사용하기로 했다.
pull 기반 시스템은 장점이 한 가지 더 있는데, 컨슈머가 가져갈 데이터를 좀 더 과감하게 배치로 처리할 수 있다는 거다. push 기반 시스템에선 즉시 요청을 보내거나, 아니면 데이터를 쌓아뒀다가 다운스트림 컨슈머가 바로 바로 처리할 수 있을지는 모르는 채로 나중에 보내거나, 둘 중에 하나다. 지연 시간을 낮추더라도, 결국 메세지 하나만 버퍼에 담은 채로 전송하게 돼 낭비다. pull 기반 설계에서 컨슈머는 언제나 로그의 현재 위치 이후부터 모든 메세지를 pull해가기 때문에 (혹은 설정할 수 있는 최대 크기까지) 이 문제는 해소된다. 따라서 불필요한 지연 없이 최적의 배치 처리를 구현할 수 있다.
단순히 pull만 하는 시스템의 문제는, 브로커에 데이터가 없으면 컨슈머는 루프 polling으로 리소스를 차지한채, 사실상 데이터가 도착하기만을 기다린다는 거다. 카프카에선 pull 요청에 파라미터를 넘겨 이를 방지한다. 이땐 데이터가 도착할 때까지 기다리는 (원한다면 주어진 바이트 수만큼 쌓이길 기다릴 수도 있는) “long poll”로 컨슈머 요청을 블로킹한다.
처음부터 끝까지 전부 pull방식만 사용하는 어떤 다른 설계를 상상했을 수도 있다. 프로듀서는 저마다 메세지를 로컬 로그에 기록하고, 브로커에선 이 로그를 pull해가며, 컨슈머에서도 브로커 로그를 pull해가는 방법도 있다. 유사한 “store-and-forward” 타입 프로듀서도 종종 거론된다. 신박한 방법이긴 하지만, 프로듀서가 수천 개 있는 유스 케이스를 대상으론 적합하지 않다고 느꼈다. persistent 데이터 시스템을 대규모로 실행해 봤을 때, 많은 어플리케이션에 걸쳐 사용하는 시스템에 디스크를 수천 개 쓰는 건 실제로 더 안정적인 구조를 만들지 못하며, 운영도 끔찍할 거라 판단했다. 그리고 실무에서, 프로듀서 persistence 없이도, 강력한 SLA가 있다면 대규모 파이프라인을 실행할 수 있음을 밝혀냈다.
Consumer Position
의외로 메세징 시스템의 성능은 무엇을 컨슘했는지 추적하는 방법으로도 크게 달라진다.
메세징 시스템은 대부분 어떤 메세지를 컨슘했는지에 대한 메타데이터를 브로커에 유지한다. 즉, 메세지를 컨슈머에 전달하면 브로커는 이 사실을 즉시 로컬에 기록하거나, 컨슈머의 승인(acknowledgement)을 기다린다. 상당히 직관적인 방법으로, 사실 단일 머신 서버라면 이 상태 정보를 딱히 보낼 만한 데도 없다. 메세징 시스템에서 많이 사용하는 스토리지의 데이터 구조는 확장성이 좋지 않기 때문에, 실용적인 선택이기도 하다. 브로커는 어떤 메세지를 컨슘했는지 알고 있으므로, 데이터를 즉시 삭제해 데이터 크기를 작게 유지할 수 있다.
아직 브로커와 컨슈머가 무엇을 컨슘했는지 합의할 방법이 명확하지 않다는 건 짚고 넘어가야 할 문제다. 브로커가 메세지를 네트워크를 통해 전달할 때마다 즉시 컨슘한 것으로 기록한다면, 혹여나 컨슈머에서 메세지 처리에 실패하면 (크래시가 나거나 요청 타임 아웃이 발생하는 등 어떤 이유에서라도) 이 메세지는 유실된다. 메세징 시스템에선 이 문제 해결을 위해 많이들 acknowledgement 기능을 추가한다. 즉, 메세지를 전송하면 이 메세지를 컨슘했음이 아니라 전송했음으로만 마킹한다. 브로커는 메세지를 컨슘한 것으로 기록하기 전에 먼저 컨슈머가 해당 메세지를 승인(acknowledgement)하길 기다린다. 이 전략으론 메세지 유실 문제는 해결되지만, 또 다른 문제가 생긴다. 먼저, 컨슈머가 메세지를 처리했지만 승인을 보내기 전에 실패하면 이 메세지는 두 번 컨슘하게 된다. 두 번째 문제는 성능과도 연관이 있는데, 이제 브로커는 모든 메세지마다 상태값을 여러벌 유지해야 한다 (다음 번엔 전달하지 않도록 잠가둔 상태 하나와, 차후 삭제할 수 있도록 컨슘한 것으로 박제한 상태). 전송은 했지만 승인은 되지 않은 메세지를 처리하는 것같이 까다로운 문제도 아직 남아 있다.
카프카에선 컨슘한 메세지 정보를 좀 다르게 처리한다. 카프카의 토픽은 정렬을 마친 파티션 셋으로 나뉘며, 언제라도 파티션을 구독하면, 각 파티션은 구독 컨슈머 그룹 내에선 정확히 컨슈머 한 개가 컨슘한다. 따라서 각 파티션마다의 컨슈머의 위치는 정수 값 하나만으로 표현된다. 컨슘할 다음 메세지의 오프셋을 나타내는 숫자가 전부다. 이렇게하면 각 파티션마다 숫자가 하나씩만 있으면 되기 때문에, 무엇을 컨슘했는지에 대한 상태 정보는 굉장히 작아진다. 이 상태는 주기적으로 체크포인트해갈 수 있다. 덕분에 메세지 승인에 들어가는 비용을 크게 아낄 수 있다.
이렇게 설계하면 좋은 점이 하나 더 있다. 필요하면 컨슈머가 직접 이전 오프셋으로 되돌아가 데이터를 다시 컨슘할 수 있다. 큐를 역행하는 건 대기큐의 일반적인 규칙에 위배되지만, 많은 컨슈머에서 꼭 필요한 기능으로 밝혀졌다. 예를 들어, 컨슈머 코드에 버그가 있었고 일부 메세지를 컨슘한 후에 알아냈다면, 버그를 수정하고 나서 해당 메세지를 다시 컨슘하면 된다.
Offline Data Load
확장 가능한 persistence 덕분에, 컨슈머로 배치 데이터를 주기적으로만 컨슘해가는 것도 가능하다. 주기적으로 컨슘한 배치 데이터는, 하둡이나 관계형 데이터 웨어하우스같은 오프라인 시스템에 벌크로 데이터를 로드하는 식으로 활용할 수 있다.
하둡에서는 노드/토픽/파티션 조합 당 개별 map 태스크를 하나씩 할당해 부하를 분산시킨다. 덕분에 데이터 로드를 완전히 병렬로 처리할 수 있다. 하둡에선 태스크를 관리할 수 있으며, 실패한 태스크는 단순히 기존 위치부터 다시 시작하기 때문에 데이터가 중복될 염려 없이 재시작할 수 있다.
Static Membership
스태틱 멤버십은 그룹 리밸런스 프로토콜 위에 구축하는 스트림 어플리케이션과, 컨슈머 그룹, 기타 어플리케이션의 가용성을 개선하기 위해 등장했다. 리밸런스 프로토콜에선 그룹 코디네이터를 사용해 그룹 구성원에게 엔티티 id를 할당한다. 이렇게 생성한 id는 단기간 동안만 유지되며, 구성원이 재시작돼 다시 조인하게 되면 변경된다. 이렇게 “다이나믹 멤버십”을 사용하면, 컨슈머 앱에선 코드 배포나, 설정 업데이트, 주기적인 재시작같은 관리 작업 중에 많은 태스크가 다른 인스턴스에 재할당될 수 있다. 대규모의 상태를 유지하는 어플리케이션에선, 태스크가 셔플링되면 로컬 상태를 복구하고 태스크를 시작하기까지 오랜 시간이 걸리고, 어플리케이션 일부를 사용할 수 없거나 완전히 서비스가 불가능해지기도 한다. 카프카에선 이대신 그룹 관리 프로토콜로 그룹 구성원에 영구적인 엔티티 id를 부여할 수 있다. 이때는 이 id를 기반으로 그룹 멤버십을 불변 상태로 유지하기 때문에, 리밸런스를 트리거하지 않는다.
스태틱 멤버십을 사용하고 싶다면,
- 브로커 클러스터와 클라이언트 앱 모두 2.3 이상으로 올리고, 브로커가
inter.broker.protocol.version
을 2.3 이상으로 사용하고 있는지도 확인해봐라. - 한 그룹 내에 속한 모든 컨슈머 인스턴스에서
ConsumerConfig#GROUP_INSTANCE_ID_CONFIG
를 유니크한 값으로 설정해라. - 카프카 스트림즈 어플리케이션에선, 인스턴스에 사용하는 스레드 수와는 상관없이 KafkaStreams 인스턴스별로만 유니크한
ConsumerConfig#GROUP_INSTANCE_ID_CONFIG
를 설정하면 된다.
브로커 버전은 2.3 이전인데 클라이언트 측에서 ConsumerConfig#GROUP_INSTANCE_ID_CONFIG
를 설정하면, 컨슈머 어플리케이션이 브로커 버전을 감지해 UnsupportedException
을 던진다. 의도치않게 인스턴스에 중복된 id를 설정하면, 브로커에서 차단 메커니즘이 org.apache.kafka.common.errors.FencedInstanceIdException을 유발시켜 중복 클라이언트를 즉시 종료할 수 있도록 알려준다. 자세한 내용은 KIP-345를 참고해라.
4.6 Message Delivery Semantics
이제 프로듀서와 컨슈머의 동작 방식을 어느정도 이해했으므로, 카프카가 제공하는 프로듀서와 컨슈머 간 시맨틱스 보장에 대해 논하겠다. 메세지 전달 보장은 분명 여러 가지 형태로 제공할 수 있다:
- At most once—메세지를 유실할 순 있어도 절대 다시 전달하지 않는다.
- At least once—메세지를 절대 유실하진 않지만 재전달할 순 있다.
- Exactly once—실제로 사람들이 원하는대로, 모든 메세지를 딱 한 번만 전달한다.
이 문제는 두 가지로 나뉜다는 점에 주목할 필요가 있다: 메세지를 발행하는 쪽의 내구성 보장과, 메세지를 컨슘하는 쪽의 내구성 보장.
많은 시스템이 “exactly once” 딜리버리 시맨틱스를 제공한다고 주장하지만, 주의 사항도 반드시 확인해야 한다. 이런 주장은 대부분 오해의 소지가 있다 (예를 들어, 컨슈머나 프로듀서가 실패하는 경우, 컨슈머가 멀티 프로세스거나 디스크에 기록한 데이터가 손실되는 경우는 고려하지 않는다).
카프카의 시맨틱스는 직관적이다. 메세지를 발행할 땐 메세지를 로그에 “커밋”한다는 개념이 있다. 발행한 메세지를 커밋하고 나면, 이 메세지를 기록한 파티션을 복제하는 브로커 하나라도 “활성” 상태로 유지되는 한 이 메세지는 유실되지 않는다. 커밋한 메세지와 활성 파티션의 정의와, 카프카에서 처리하고자 하는 실패 타입은 다음 섹션에서 자세히 설명한다. 지금은 데이터 유실 없는 완벽한 브로커라고 가정하고 프로듀서와 컨슈머의 메세지 보장을 이해하는 데 집중해보자. 프로듀서가 메세지를 발행하려는 찰나에 네트워크 오류가 발생하면, 이 오류가 메세지를 커밋하기 전에 발생했는지, 이후에 발생했는지는 확인할 수 없다. 데이터베이스 테이블에 자동 생성된 키로 데이터를 insert하는 것과 시맨틱스가 비슷하다.
0.11.0.0 이전에는 프로듀서가 메세지를 커밋했음을 알리는 응답을 받지 못하면 메세지를 다시 보내는 것밖에 달리 방법이 없었다. 이 방식에서는, 기존 요청이 실제로는 성공했다면 재전송 중에 메세지를 다시 로그에 기록할 수도 있기 때문에, at-least-once 딜리버리 시맨틱스를 제공한다. 0.11.0.0부터 카프카 프로듀서는 재전송해도 로그에는 중복되지 않음을 보장하는 멱등성 옵션을 함께 지원한다. 이 옵션에선 브로커는 각 프로듀서에 ID를 할당하고, 프로듀서는 모든 메세지를 시퀀스 번호를 함께 전송한다. 브로커는 이 번호를 사용해 중복 메세지를 제거한다. 이와 더불어 0.11.0.0부터 프로듀서는 트랜잭션과 유사한 시맨틱스를 사용해 여러 토픽 파티션에 메세지를 전송하는 기능을 지원한다. 즉, 모든 메세지를 기록하는 데 성공하거나, 그게 아니면 아무것도 기록하지 않는다. 이 기능이 주로 필요한 곳은 카프카의 여러 토픽에 걸친 처리를 정확히 한 번 실행해야 하는 곳이다 (아래에서 설명).
모든 유스 케이스에 이 정도로 강력한 보장이 필요한 건 아니다. 지연 시간이 중요한 서비스라면, 프로듀서에서 원하는 내구성 수준을 지정할 수 있다. 프로듀서가 메세지 커밋을 기다리도록 지정하면 약 10ms 정도가 소요될 수 있다. 물론, 프로듀서가 메세지를 완전히 비동기로 전송하거나, 리더(팔로워까지 기다릴 필요는 없이)가 메세지를 받을 때까지만 기다리도록 지정할 수도 있다.
이제 컨슈머 관점에서 본 시맨틱스를 설명하겠다. 모든 복제본에는 같은 오프셋을 사용하는 정확히 동일한 로그가 저장된다. 컨슈머는 이 로그 상의 자신의 위치를 제어한다. 컨슈머가 절대 크래시나지 않는다면, 이 위치를 단순히 메모리에 저장할 수도 있지만, 컨슈머가 실패했을 때 그 토픽 파티션을 다른 프로세스에 할당하려면, 프로세스가 새로 떠도 처리를 시작할 적절한 위치를 선택할 수 있어야 한다. 컨슈머가 메세지를 읽어가는 경우를 생각해보자. 메세지를 처리하고 위치를 업데이트하는 옵션은 여러 가지가 있다.
- 메세지를 읽으면 일단 로그에 위치를 저장하고, 그 다음 메세지를 처리한다. 이땐 컨슈머 프로세스가 위치를 저장하고 나서 메세지를 처리한 결과를 저장하기 전에 크래시날 가능성이 있다. 이런 경우엔 처리를 이어받은 프로세스는, 저장된 위치보다 앞에 있는 일부 메세지를 처리하지 못했더라도, 그 위치에서부터 시작할 거다. 이 옵션은 컨슈머가 실패하면 메세지를 처리하지 않을 수 있는 “at-most-once” 시맨틱스에 해당한다.
- 메세지를 읽고, 처리까지 마친 다음 위치를 저장한다. 이땐 컨슈머 프로세스가 메세지를 처리하고 나서 위치를 저장하기 전에 크래시날 가능성이 있다. 이런 경우엔 새 프로세스에서 넘겨받은 맨 앞에 있는 일부 메세지는 이미 처리된 메세지다. 이 옵션은 컨슈머가 실패하면 “at-least-once” 시맨틱스를 제공한다. 메세지는 대부분 기본 키를 가지고 있으므로, 업데이트 연산은 멱등성을 유지할 수 있다 (동일한 메세지를 두 번 받더라도 같은 레코드 복사본으로 덮어쓴다).
그렇다면 실제로 바라던 exactly once 시맨틱스는 어떨까? 카프카 토픽을 컨슘해서 다른 토픽으로 생산한다면 (카프카 스트림즈 어플리케이션에서 같이), 위에서 언급했던 0.11.0.0 버전에 추가된 프로듀서의 트랜잭션 기능을 활용할 수 있다. 컨슈머의 위치는 토픽 메세지에 저장하기 때문에, 카프카 오프셋을, 처리를 마친 데이터를 저장하는 출력 토픽과 동일한 트랜잭션에서 기록할 수 있다. 트랜잭션이 중단되면 컨슈머의 위치는 이전 값으로 되돌아가고, 출력 토픽에 생성한 데이터는 “격리 수준”에 따라 다른 컨슈머에서는 보이지 않을 거다. 디폴트 “read_uncommitted” 격리 수준에선, 컨슈머는 중단된 트랜잭션에 속해있던 메세지도 모두 볼 수 있지만, “read_committed”에선 컨슈머는 트랜잭션 내에서 커밋된 메세지만 반환한다 (트랜잭션과 상관 없는 메세지는 당연히 모두 반환한다).
데이터를 외부 시스템에 저장할 때는, 컨슈머의 위치를 실제로 저장을 마친 데이터와 묶어서 처리하기가 좀 어렵다. 보통은 컨슈머 위치 저장과 컨슈머 출력 저장 사이에 2단계 커밋을 도입해 해결하곤 한다. 하지만 이 문제는 컨슈머가 출력 데이터를 저장하는 곳에 오프셋을 함께 저장하도록 하면 더 간단하게 해결되며, 범용적으로 활용할 수 있다. 컨슘한 데이터를 저장하는 출력 시스템은 2단계 커밋을 지원하지 않는 시스템이 많다는 점도 고려했다. 이에 대한 좋은 예시는 카프카 커넥트 커넥터다. 커넥터는 HDFS에 데이터를 보낼 때, 읽어온 데이터와 데이터의 오프셋을 함께 저장해, 데이터와 오프셋이 모두 업데이트되거나 둘 다 업데이트되지 않도록 보장한다. 카프카는 이 정도 강력한 시맨틱스가 필요한 다른 많은 데이터 시스템에서도, 메세지에 중복 제거를 위한 기본 키가 없을 때에도, 유사한 패턴을 사용한다.
실제로 카프카는 이렇게 카프카 스트림즈에서 exactly-once 전달을 지원하며, 프로듀서/컨슈머는 여러 카프카 토픽간에 데이터를 전송하고 처리하면서 범용적인 트랜잭션을 활용해 exactly-once를 구현할 수 있다. 다른 외부 시스템으로 데이터를 정확히 한 번 전달하려면 보통은 그 시스템과의 협력이 필요하지만, 카프카에선 오프셋으로 이를 실현할 수 있다 (카프카 커넥트 참고). 그 외에 카프카는 기본적으로 at-least-once 전송을 보장하며, 사용자가 원하면 프로듀서의 재시도 정책을 비활성화하고, 메세지 배치를 처리하기 전에 컨슈머에서 오프셋을 커밋해 at-most-once 전송을 구현할 수 있다.
4.7 Replication
카프카는 각 토픽의 파티션 로그를, 설정한 수만큼의 서버에 복제한다 (replication factor는 토픽별로 설정할 수 있다). 클러스터에 있는 서버에 장애가 발생하면, 이 복제본으로 자동으로 패일오버할 수 있으므로, 장애가 있더라도 메세지는 계속 유지된다.
다른 메세징 시스템도 복제와 관련된 기능을 몇 가지 제공하지만, 우리 생각은 (완전 편파적인 의견이긴 하지만) 이 기능은 많이 사용하지 않는데도 굳이 넣어, 큰 단점이 따르는 것으로 보인다: 복제본은 비활성 상태고, 시간당 처리량에 끼치는 영향이 크며, 수동 설정이 까다롭다는 점 등. 카프카는 기본적으로 복제본을 함께 사용하도록 되어 있다. 사실 복제하지 않는 토픽은 replication factor가 1인 복제 토픽으로 구현한다.
복제 단위는 토픽 파티션이다. 서버 장애가 없는 상태라면, 카프카의 모든 파티션은 리더 하나와 0개 이상의 팔로워를 가진다. 리더를 포함한 총 레플리카 수가 replication factor를 구성한다. 모든 읽기와 쓰기는 파티션의 리더로 전달된다. 보통 브로커보단 파티션이 더 많으며, 리더는 브로커에 고르게 분산된다. 팔로워의 로그는 리더의 로그와 동일하다 — 모두 같은 오프셋과 메세지를 동일한 순서로 가지고 있다 (물론 리더는 언제라도 로그 끝에 아직 복제되지 않은 메세지를 몇 개 더 가지고 있을 순 있다).
팔로워는 평범한 카프카 컨슈머처럼 리더의 메세지를 컨슘해서 자기 로그에 적용한다. 팔로워가 리더에서 데이터를 pull해가는 방식에선, 팔로워가 로그에 적용할 항목들을 자연스레 배치로 처리할 수 있다는 특성도 있다.
대부분의 분산 시스템과 마찬가지로 자동으로 장애를 처리하려면 노드가 “살아있다”는 의미를 정확하게 정의해야 한다. 카프카에선 두 가지 조건으로 노드가 살아있는지를 판단한다.
- 노드는 주키퍼와의 세션을 유지할 수 있어야 한다 (주키퍼의 하트 비트 메커니즘을 통해)
- 노드가 팔로워라면 리더에서 발생하는 쓰기를 복제해야 하며, “너무 멀리” 뒤쳐지지 않아야 한다
카프카에선 “살아 있다”나 “실패했다”같은 모호한 표현 대신, 이 두 조건을 충족하는 노드를 “in sync” 상태라고 표현한다. 리더는 “in sync” 노드 셋을 추적한다. 팔로워가 죽거나, 멈춰 있거나, 뒤처지면, 리더는 in sync 레플리카에서 이 노드를 제외한다. 멈춰있거나 뒤쳐진 레플리카는 replica.lag.time.max.ms 설정에 따라 결정한다.
카프카에선 분산 시스템에 등장하는 모델 중, 노드가 갑자기 작동을 멈춘 후에 나중에 복구하는 “실패/복구” 모델만 처리하고자 한다 (아마 노드가 죽었다는 사실은 알지 못함). 카프카는 노드가 독단적인 응답이나 악의적인 응답을 생성하는, 소위말해 “비잔틴(Byzantine)” 오류는 처리하지 않는다 (대부분 버그나 부정한 행위로 인한 오류다).
이제 메세지가 커밋되었다는 것은 해당 파티션의 모든 in sync 레플리카가 이 메세지를 로그에 적용했을 때로 좀 더 정확하게 정의할 수 있다. 컨슈머에게는 커밋된 메세지만 전달된다. 그렇기 때문에 컨슈머에선 리더가 실패하면 유실될 여지가 있는 메세지를 받을까봐 걱정할 필요가 없다. 반면 프로듀서는 지연 시간과 내구성 중 원하는 균형점에 따라 메세지를 커밋할 때까지 대기할지 말지를 선택할 수 있다. 프로듀서의 acks 설정으로 제어하면 된다. 프로듀서가 전체 in-sync 레플리카 셋에 메세지를 기록했다는 승인(acknowledgment)을 요청했을 때, 확인할 in-sync 레플리카의 “최소 수”를 토픽에도 설정할 수 있다. 프로듀서가 덜 엄격한 승인을 요청하면 in-sync 레플리카 수가 이 최소값보다 적더라도 메세지를 커밋하고 컨슘할 수 있다 (리더만 확인할 수도 있음).
카프카가 제공하는 보장은 in sync 레플리카가 최소 하나만 살아 있으면, 커밋한 메세지는 절대 유실되지 않는다는 거다.
카프카는 노드에 장애가 있더라도 잠시동안 패일오버 하고난 뒤에 계속 서비스할 수 있지만, 네트워크 파티션이 일어난다면 그렇지 않을 수도 있다.
Replicated Logs: Quorums, ISRs, and State Machines (Oh my!)
카프카 파티션의 본질은 복제된 로그다. 로그 복제는 분산 데이터 시스템에 필요한 가장 기본적인 요소 중 하나며, 복제를 구현하는 방법은 여러 가지다. 로그 복제는 state-machine 스타일 분산 시스템 구현을 위한 기본 요소로써, 다른 시스템에서도 사용하곤 한다.
로그를 복제할 땐 값들의 순서를 합의하는 프로세스를 모델링한다 (보통 로그 엔트리에 0, 1, 2, …등의 숫자를 매긴다). 구현 방법은 여러 가지가 있지만, 가장 빠르고 간단한 방법은 전달받은 값들의 순서를 결정하는 리더를 이용하는 거다. 리더가 살아있기만 하면, 모든 팔로워는 리더가 선택한 값과 순서를 그대로 복사해가면 된다.
물론 리더가 실패하지 않는다면 팔로워는 필요 없었을 거다! 리더가 죽으면 팔로워 중에서 새 리더를 골라야 한다. 하지만 팔로워 자체도 뒤쳐지거나 크래시날 수 있기 때문에, 반드시 최신 상태의 팔로워를 선택해야 한다. 로그 복제 알고리즘에서 반드시 보장해야 하는 건, 클라이언트에게 메세지가 커밋됐다고 알렸다면, 리더가 실패해서 새로 선출한 리더에도 이 메세지가 반드시 있어야 한다는 거다. 여기서는 트레이드 오프가 따른다. 리더가 메세지 커밋을 선언하기 전에 더 많은 팔로워들이 메세지를 승인(acknowledge)하기를 기다린다면, 리더로 선출할 수 있을만한 후보가 더 많이 있게 될 거다.
중복 보장을 위해 필요한 승인(acknowledgement) 횟수와 리더를 선출할 때 비교해볼 로그 수를 선택했다면, 이를 정족수(Quorum)라고 부른다.
보통은 커밋을 결정할 때와 리더를 선출할 때 모두 과반수 투표를 이용하는 식으로 트레이드 오프에 접근한다. 카프카에선 다수결 투표를 쓰진 않지만, 어쨌든 트레이드 오프 이해를 위해 다수결 투표를 살펴보도록 하자. 레플리카가 2f+1만큼 있다고 해보자. 리더가 커밋을 선언하려면 f+1만큼의 레플리카가 메세지를 받아야 한다. 최소 f+1만큼의 레플리카 중에서 가장 완전한 로그를 가진 팔로워를 새 리더로 선출한다면, f 이하의 실패에선 새 리더는 커밋된 모든 메세지를 가지고 있다는 걸 보장할 수 있다. f+1 레플리카 중에는 커밋된 모든 메세지를 가진 레플리카가 무조건 하나는 있기 때문이다. 모든 메세지를 가진 레플리카의 로그가 가장 완전하기 때문에, 이 레플리카를 가진 팔로워가 새 리더로 선출된다. 알고리즘마다 다른 세부 사항도 많이 처리해야 하지만 (로그가 더 완전하다는 게 뭔지 정확하게 정의해, 리더가 실패한 상황에서도 로그 일관성을 보장하거나 레플리카 셋에 있는 서버 셋을 변경하는 등), 여기선 무시하고 넘어가겠다.
과반수 투표 방식엔 아주 좋은 특징이 하나 있다: 대기 시간은 가장 빠른 서버로 결정된다. 즉, replication factor가 3이라면, 지연 시간은 두 팔로워 중 더 빠른 팔로워로 결정된다.
이 알고리즘 계열은 주키퍼의 Zab, Raft, Viewstamped Replication 등 아주 다양하다. 우리가 알고 있는 카프카의 실제 구현과 가장 유사한 학술 논문은 마이크로스프트의 PacificA다.
과반수 투표의 단점은 실패한 노드가 많아지면 리더를 선출할 수 없다는 거다. 노드 하나의 실패를 견디려면 데이터 복제본 3개가 필요하고, 노드 둘의 실패를 견디려면 데이터 복제본 5개가 필요하다. 경험상 실제 시스템에선, 장애가 발생한 노드가 가진 데이터를 중복으로 저장한 것만으로는 장애를 견디기 힘든데도, 굳이 디스크 공간을 5배 차지하고 시간당 처리량을 1/5로 줄여가면서 모든 쓰기를 5번 수행하는 건 대용량 데이터 처리에 있어 그다지 실용적이지 않다. 그렇기 때문에 정족수(quorum) 알고리즘은 주키퍼같은 공유 클러스터 구성에는 흔히 쓰여도, 일차적인 데이터 스토리지에선 그만큼 사용하지 않는다. 예를 들어 HDFS의 네임 노드는 과반수 투표 기반 저널링으로 고가용성을 제공하지만, 데이터 자체에는 비용이 더 들기때문에 같은 방법을 사용하지 않는다.
카프카는 정족수 셋에 약간 다르게 접근한다. 카프카는 과반수 투표 대신, 리더를 따라 잡는 in-sync 레플리카 (ISR) 셋을 동적으로 유지한다. 이 셋에 있는 구성원만 리더로 선출할 수 있다. 카프카 파티션에 대한 쓰기는 모든 in-sync 레플리카가 쓰기를 수신할 때까지 커밋한 것으로 간주하지 않는다. 이 ISR 셋에 변경 사항이 생길 때마다 주키퍼에 저장된다. 덕분에 ISR에 있는 모든 레플리카는 리더로 선출될 수 있다. 카프카 파티션을 많이 설계해 리더십 밸런스가 중요해질수록 ISR이 핵심이다. ISR 모델과 f+1 레플리카를 사용하면 카프카 토픽은 노드 f개가 실패해도 견딜 수 있으며, 커밋된 메세지를 유실하지 않는다.
이런 트레이드 오프라면, 카프카로 처리하고자 하는 유스 케이스엔 대부분 합리적일 거다. 실제로 f개의 실패를 견디려면 다수결 투표와 ISR 접근 방식 모두 메세지를 커밋하기 전에 기다리는 레플리카 승인 수는 동일하다 (예를 들어, 노드 하나가 실패했을 때 살아남으려면, 과반수 정족수는 레플리카 3개와 승인 1개가, ISR에선 레플리카 2개와 승인 1개가 필요하다). 다수결 투표에서 좋은 점은 제일 느린 서버는 기다리지 않고 커밋할 수 있다는 점이다. 하지만 카프카에선 클라이언트가 메세지 커밋을 블로킹할지 말지를 선택할 수 있기 때문에 더 낫다고 생각한다. 오히려 필요한 replication factor는 더 적기 때문에 처리량이 더 늘어나고 디스크 공간을 아낄 수 있다.
설계 상 중요한 차별점이 하나 더 있는데, 카프카에선 크래시난 노드에서 데이터 손상 없이 모든 데이터를 복구할 필요가 없다는 점이다. 이 분야에서 복제 알고리즘이, 어떤 장애-복구 시나리오에서도 일관성이 깨질 가능성 없이 데이터를 유실하지 않는 “안정적인 스토리지”의 존재에 의존하는 건 그리 드문 일이 아니다. 스토리지에 의존하려는 생각은 크게 두 가지가 잘못됐다. 먼저, 디스크 오류는 persistent 데이터 시스템을 운영하다보면 실제로 겪는 가장 흔한 문제이며, 데이터가 손상되는 경우도 많다. 둘째, 데이터 손상이 문제 되지 않더라도, 일관성 보장을 위해 모든 쓰기 연산마다 fsync를 사용하고 싶진 않다. 이렇게하면 성능이 100~1000배는 감소할 수 있기 때문이다. 카프카는 레플리카가 다시 ISR에 조인할 수 있는 프로토콜을 사용하며, 다시 ISR에 들어오기 전에 크래시로 인해 플러시하지 못한 데이터마저, 전부 다시 re-sync했음을 보장한다.
Unclean leader election: What if they all die?
데이터 유실에 관한 카프카의 보장은 in sync 상태로 남아있는 최소 한 개의 레플리카에 근거한다. 파티션을 복제한 노드가 모두 죽으면, 이 보장은 더 이상 유효하지 않다.
하지만 실제 시스템은 모든 레플리카가 죽었을 때도 어떤 합리적인 조치를 취할 필요가 있다. 이런 일이 일어날만큼 운이 좋지 않았다면, 어떤 일이 일어날지부터 고려해봐야 한다. 구현할 수 있는 동작은 두 가지가 있다:
- ISR에 있는 레플리카 중 하나가 다시 살아나길 기다렸다가 살아난 레플리카를 리더로 선출한다 (모든 데이터가 남아있기를 바라며).
- 첫 번째로 살아난 레플리카를 (꼭 ISR에 속할 필욘 없음) 리더로 선출한다.
두 동작은 단순히 가용성과 일관성 사이를 절충하는 문제다. ISR에서 레플리카를 기다리면, IRS 내 레플리카들이 다운돼 있는 동안은 카프카를 사용할 수 없다. 레플리카가 없어졌거나 레플리카에 있는 데이터가 손실됐다면 영구적으로 다운된다. 반면 살아난 non-in-sync 레플리카를 리더로 선출하도록 허용하면, 이 레플리카의 로그엔 커밋된 모든 메세지가 있다고는 보장할 순 없지만, 이 로그를 실제 데이터로 삼는다. 0.11.0.0 버전부터 카프카는 기본적으로 첫 번째 전략을 통해 일관성있는 레플리카를 기다린다. 일관성보단 가용성이 중요하다면, 이 동작은 설정 프로퍼티 unclean.leader.election.enable로 변경할 수 있다.
이 딜레마는 카프카에만 국한돼있진 않다. 정족수 기반 체계라면 항상 존재한다. 예를 들어, 과반수 투표 체계에선, 서버 과반수 이상이 복구 불가능한 오류 상황이라면, 데이터를 100% 잃거나, 일관성을 위반해서라도 다른 서버에 남아있는 데이터를 새로운 실 데이터로 삼아야 한다.
Availability and Durability Guarantees
프로듀서가 카프카에 메세지를 쓸때는, 0,1 또는 모든(-1) 레플리카가 승인(acknowledge)할 때까지 기다릴지를 선택할 수 있다. “모든 레플리카에 의한 승인”은 할당된 레플리카 셋 전체가 메세지를 받은걸 보장하는 게 아니라는 점에 주의해라. 기본적으로 acks=all에선, 현재 시점에 있는 모든 in-sync 레플리카가 메세지를 받는 즉시 승인한다. 예를 들어, 복제본 두 개로만 구성된 토픽에서 레플리카 하나가 실패하면 (즉, in sync 레플리카에 한 개만 남아 있으면) acks=all로 지정한 쓰기는 성공한다. 하지만 남은 레플리카도 실패하면 이 쓰기는 유실될 수 있다. 이렇게하면 파티션의 가용성은 최대로 보장되지만, 가용성보다 내구성이 중요한 사용자라면 이 동작이 달갑지 않을 수도 있다. 그렇기 때문에, 가용성보다 메세지 내구성을 선호할 때 사용할 수 있는 토픽 레벨 설정 두가지를 제공한다:
- 부정확한 리더 선택 비활성화 - 모든 레플리카가 가용 상태가 아니면, 가장 최근 리더가 돌아올 때까지 파티션을 사용할 수 없게된다. 사실상 메세지를 유실할 바엔 차라리 비가용성을 감수하겠다는 거다. 자세한 내용은 이전 섹션 Unclean Leader Election을 참고해라.
- 최소 ISR 크기 지정 - 파티션은 ISR 크기가 특정 최소값을 넘었을 때만 쓰기를 허용한다. 그렇지 않고 단일 복제본에만 기록된 메세지는, 나중에 이 레플리카가 실패하면 유실될 수 있다. 최소 ISR 크기를 지정하면 이를 막을 수 있다. 이 설정은 프로듀서가 acks=all을 사용할 때만 적용되며, 최소한 이만큼의 in-sync 레플리카가 승인하도록 보장한다. 이 설정은 일관성과 가용성 사이의 균형을 제공한다. 최소 ISR 크기를 더 높게 설정하면, 메세지가 더 많은 레플리카에 기록되므로, 유실 가능성이 줄어 일관성이 향상된다. 하지만 in-sync 레플리카 수가 최소 임계치 아래로 떨어지면, 파티션을 데이터 쓰기에 사용할 수 없으므로 가용성이 떨어진다.
Replica Management
위에서 논의한 복제된 로그는 사실 단일 로그, 즉 토픽 파티션 하나만을 다뤘다. 하지만 카프카 클러스터는 수백 또는 수천 개의 파티션을 관리한다. 카프카는 노드 일부에만 대용량 토픽들의 파티션이 모두 몰리지 않도록, 라운드 로빈 방식으로 파티션을 클러스터에 고르게 분산한다. 마찬가지로 각 노드가 가지고 있는 파티션 수에 비래하는 만큼만 리더로 선출되도록 리더십의 균형을 유지한다.
리더십 선출 프로세스는 파티션의 이용 불가능 상태를 해결하는 단계이기 때문에, 최적화도 중요하다. 리더 선출을 단순하게 구현하면, 노드 하나가 실패하면 그 노드가 호스팅하는 모든 파티션의 리더를, 파티션 단위로 선출할 수도 있다. 카프카에선 그대신 브로커 중 하나를 “컨트롤러”로 선출한다. 이 컨트롤러는 브로커 수준의 실패를 감지하고, 실패한 브로커로 영향받는 모든 파티션의 리더를 변경하는 일을 담당한다. 덕분에 리더십 변경을 일괄로 알릴 수 있어, 많은 파티션에 대한 리더 선출 프로세스를 훨씬 빠르고 가볍게 실행할 수 있다. 컨트롤러가 실패하면 남아있는 브로커 중 하나가 새 컨트롤러가 된다.
4.8 Log Compaction
카프카는 로그 컴팩션에선, 토픽 파티션 로그 내의 있는 각 메세지 키마다, 적어도 마지막으로 알려진 값은 무조건 보존한다. 어플리케이션 크래시나 시스템 장애가 생긴 후 상태를 복원하거나, 운영 상의 유지보수로 어플리케이션을 재시작한 다음 다시 캐시를 로드 등의 시나리오에 활용할 수 있다. 먼저 이런 유스 케이스를 자세히 파해쳐본 다음 컴팩션이 어떻게 동작하는지 설명하겠다.
지금까지 데이터 보존에 대해서는, 고정된 시간이 흘렀거나 로그가 미리 결정해둔 크기에 도달하면 오래된 로그 데이터를 폐기하는, 비교적 간단한 접근법만 설명했다. 이 방법은 모든 레코드가 독립적인 로깅같이, 시간에 따른 이력을 남기는 이벤트를 처리할 때 적합하다. 하지만 변경될 수 있는 데이터를 키로 매핑한다면, 이 데이터에 대한 변경 로그 스트림은 생각해볼 필요가 있다 (예를 들어, 데이터베이스 테이블의 변경 사항).
이같은 데이터 스트림을 구체적인 예를 들어 살펴보겠다. 사용자의 이메일 주소를 담은 토픽이 있다고 가정해보다. 사용자가 이메일 주소를 업데이트할 때 마다 사용자 id를 기본 키로 사용해 이 토픽에 메세지를 보낼거다. 이제 id가 123인 사용자에 대해 일정 기간 동안 다음과 같은 메세지를 전송한다고 해보자. 각 메세지는 이메일 주소에 대한 변경사항을 나타낸다 (다른 id에 대한 메세지는 생략한다).
123 => bill@microsoft.com
.
.
.
123 => bill@gatesfoundation.org
.
.
.
123 => bill@gmail.com
로그 컴팩션은 더 세밀한 보관 메커니즘을 제공하므로, 각 기본 키마다 적어도 마지막 변경사항을 보관할 수 있다 (여기선 bill@gmail.com). 이렇게하면 로그에는 최근에 변경된 키뿐만 아니라, 모든 키에 매핑한 최종 값의 스냅샷이 전부 담긴다는 걸 보장할 수 있다. 즉, 다운스트림 컨슈머는 모든 변경 사항에 대한 로그를 전부 유지하지 않고도 이 토픽으로 자신의 상태를 복원할 수 있다.
로그 컴팩션이 유용할만한 유스 케이스부터 몇 가지 살펴보자. 어떻게 사용할 수 있는지는 그 다음에 설명하겠다.
- 데이터베이스 변경사항 구독. 데이터 시스템 여럿에 퍼져있는 데이터 셋이 필요한 경우는 흔히 있으며, 이런 시스템 중 하나가 일종의 데이터베이스인 경우도 많다 (RDBMS나 최신식 키-밸류 스토어). 예를 들어, 데이터베이스와 캐시, 검색 클러스터, 하둡 클러스터를 함께 사용할 수 있다. 데이터베이스에 대한 모든 변경 사항은 캐시와, 검색 클러스터, 궁극적으로 하둡에도 반영돼야 한다. 실시간 업데이트만 처리하면 된다면, 최신 로그만 있으면 된다. 하지만 캐시를 다시 로드하거나, 실패한 검색 노드를 복구하려면 완전한 데이터 셋이 필요할 수도 있다.
- 이벤트 소싱. 어플리케이션을 설계할 때 쿼리 처리도 함께 고려해서, 변경 사항 로그를 어플리케이션의 기본 저장소로 사용하는 어플리케이션 설계 스타일이다.
- 고가용성을 위한 저널링. 로컬에서 뭔가를 계산하는 프로세스는 로컬 상태에 대한 변경 사항을 로그에 남기는 식으로 내결함성을 지원할 수 있으므로, 이 프로세스가 실패해도 다른 프로세스가 이 변경 사항을 다시 로드하고 이어갈 수 있다. 구체적인 예시는 스트림 쿼리 시스템에서의 카운트, 집계 연산과, 기타 “group by”와 유사한 연산을 실행하는 프로세스다. 실시간 스트림 처리 프레임워크 Samza는 이 기능을 정확히 이 용도로 사용한다.
각 케이스들에서 일차적으로 필요한 처리는 실시간 변경 피드 처리지만, 간혹 시스템이 충돌하거나, 데이터를 다시 로드하거나 다시 처리해야 하는 경우라면, 전체 데이터를 로드해와야 한다. 로그 컴팩션을 활용하면 같은 토픽 하나로 두 가지 유스 케이스를 모두 해결할 수 있다. 이런 식으로 로그를 활용하는 방법은 이 블로그 게시글에서 자세히 설명하고 있다.
일반적인 아이디어는 꽤 단순하다. 위 케이스에서 각 변경 사항을 로그에 기록하고 로그를 무한으로 보존한다면, 처음 시작할 때부터 매번 시스템 상태를 기록했을 거다. 이렇게 모든 기록을 가진 완전한 로그를 활용하면, 로그의 앞에 있는 레코드 N개를 가지고와 어떤 시점의 데이터라도 복원할 수 있다. 하지만 이 가상의 완전한 로그는, 아무리 안정적인 데이터 셋이라도 로그가 무제한으로 커질 수 있기 때문에, 같은 레코드를 여러 번 업데이트하는 시스템에선 그닥 실용적이지 않다. 오래된 업데이트를 버려버리는 간단한 로그 보존 메커니즘은 저장 공간은 제한할 수 있지만, 이렇게하면 더 이상 로그로 현재 상태를 복원할 수 없다 — 이제 로그 맨 앞에서부터 데이터를 복원하더라도, 오래된 업데이트는 재현할 수 없으므로 더 이상 현재 상태를 재구성할 수 없다.
로그 컴팩션은 시간 단위로 퉁쳐서 보존하는 대신, 보다 세밀한 레코드 단위 보존을 제공하는 메커니즘이다. 이 아이디어는 동일한 기본 키 상에 더 최신 업데이트 내역이 있는 레코드만 선별해서 제거하는 거다. 이렇게하면 로그는 모든 키마다 최소한 마지막 상태는 유지할 수 있다.
보존 정책은 토픽별로 설정할 수 있으므로, 단일 클러스터에도 크기나 시간 기반으로 보존하는 토픽과, 컴팩션을 통해 보관하는 토픽이 함께 있을 수 있다.
이 기능은 Databus라는 데이터베이스 변경 로그 캐싱 서비스에서 영감을 받았다. Databus는 링크드인의 가장 오래되고 가장 성공적인 인프라 중 하나다. 대부분의 로그 구조 스토리지 시스템과 달리 카프카는 구독해갈 용도로 만들었으며, 빠른 선형 읽기/쓰기를 위한 데이터를 구성한다. Databus와 달리 카프카는 현재 상태를 조회해갈 수 있는 저장소 역할을 수행하므로, 업스트림 데이터 소스를 다시 읽어갈 수 없는 상황에서도 유용하다.
Log Compaction Basics
다음은 카프카 로그의 논리 구조를 각 메세지의 오프셋과 함께 나타낸 개략적인 그림이다.
로그의 헤드는 일반 카프카 로그와 동일하다. 오밀조밀한 연속적인 오프셋이 있으며 모든 메세지가 남아있다. 로그 컴팩션을 사용할 땐 로그의 테일을 따로 처리한다. 위 그림에 있는 로그엔 압축된 테일이 남아있다. 테일에 남아있는 메세지는 처음 작성할 때 할당했던 기존 오프셋을 그대로 유지하며, 절대 변경되지 않는다. 또한 메세지를 압축해 남아 있지 않은 오프셋이더라도, 로그 상 유효한 위치로 유지된다. 압축된 오프셋의 위치는 로그 상 바로 뒤에 있는 오프셋과 구별되지 않는다. 예를 들어, 위 그림에서 오프셋 36, 37, 38은 모두 동일한 위치로 간주하며, 이 오프셋 중 하나에서 읽기 시작하면 38부터 시작하는 메세지 셋을 반환한다.
컴팩션은 삭제도 허용한다. 메세지로 키와 null 페이로드를 보내면 로그에서 삭제한다는 명령으로 처리한다. 이런 레코드를 톰스톤(tombstone)이라고 표현하기도 한다. 이 삭제 마커를 사용하면 해당 키를 지정한 이전 메세지를 전부 삭제하는데 (같은 키를 가진 새 메세지가 그러하듯), 삭제 마커는 공간 확보를 위해 일정 시간이 지나면 로그에서 자체적으로 정리된다는 점에서 특별하다. 위 다이어그램에선 삭제한 로그를 더 이상 보존하지 않는 시점을 “delete retention point”으로 표기했다.
컴팩션은 백그라운드에서 주기적으로 로그 세그먼트를 재복사하는 식으로 진행한다. 데이터 정리는 읽기 요청를 블로킹하지 않으며, 컨슈머와 프로듀서에 영향이 없도록, 지정한 I/O 처리량을 넘어가지 않도록 제한하는 설정을 넣을 수 있다. 로그 세그먼트를 압축하는 실제 프로세스는 다음과 같다:
What guarantees does log compaction provide
로그 컴팩션은 다음을 보장한다:
- 모든 컨슈머는 로그 헤드를 계속해서 따라잡기만 하면 기록한 모든 메세지를 볼 수 있다. 이런 메세지는 연속적인 오프셋을 가질 거다. 토픽의
min.compaction.lag.ms
를 사용해 메세지를 작성한 후 압축하기 전까지 경과해야 하는 최소 시간을 보장할 수 있다. 즉, 각 메세지가 (압축하지 않은 채로) 헤드에 남아있을 기간에 대한 하한을 제공한다. 토픽의max.compaction.lag.ms
로는 메세지를 작성하고 압축 대상에 오르기까지의 최대 지연 시간을 보장할 수 있다. - 메세지 순서는 항상 유지한다. 컴팩션은 메세지를 재정렬하지 않은 채로 일부만 제거한다.
- 메세지의 오프셋은 절대 변경되지 않는다. 오프셋은 로그를 식별하는 영구적인 값이다.
- 로그를 맨 앞에서부터 읽어가는 모든 컨슈머는 적어도 모든 레코드의 최종 상태를 기록한 순서대로 조회할 수 있다. 컨슈머가 토픽의
delete.retention.ms
설정(기본값은 24시간)보다 짧은 시한 내에 로그 헤드에 도달하면 삭제한 레코드에 대한 삭제 마커도 전부 볼 수 있다. 바꿔 말하면, 삭제 마커 제거는 읽기와 동시에 진행되기 때문에, 컨슈머가delete.retention.ms
이상 뒤쳐진다면 삭제 마커를 놓칠 수도 있다.
Log Compaction Details
로그 컴팩션은 로그 클리너가 처리한다. 로그 클리너는 로그 세그먼트 파일을 재복사하는 백그라운드 스레드 풀로, 키가 로그 헤드에 있는 레코드를 제거한다. 각 컴팩터 스레드는 다음과 같이 동작한다:
- 로그 테일 대비 로그 헤드 비율이 가장 높은 로그를 고른다.
- 로그 헤드에 있는 각 키마다 마지막 오프셋의 간결한 요약본을 만든다.
- 로그에서 뒤에서 다시 나타나는 키를 제거해 처음부터 끝까지 재복사한다. 정리된 새 세그먼트는 즉시 로그로 교체되므로, 추가로 필요한 디스크 공간은 추가된 로그 세그먼트 하나가 전부다 (로그의 전체 복사본이 아니다).
- 로그 헤드의 요약본은 본질적으로 공간이 밀집돼있는 해시 테이블이다. 엔트리 당 정확히 24바이트를 사용한다. 결과적으로 클리너 버퍼에 8GB를 사용하면, 클리너가 동작할 때마다 약 366GB의 로그 헤드를 정리할 수 있다 (메세지는 1k로 가정).
Configuring The Log Cleaner
로그 클리너는 기본적으로 활성화된다. 활성화되면 클리너 스레드 풀이 시작된다. 특정 토픽에서만 로그 클리닝을 활성화하려면, 로그에 전용 프로퍼티를 추가해라.
log.cleanup.policy=compact
log.cleanup.policy
프로퍼티는 브로커 설정으로, 브로커의 server.properties
파일에 정의한다. 여기에서 설명하는대로 설정을 재정의하지 않은 클러스터 내 모든 토픽에 반영된다. 로그 클리너엔 압축하지 않는 로그의 “헤드”를 최소량은 유지하도록 설정할 수 있다. 압축 지연 시간을 설정해 활성화할 수 있다.
log.cleaner.min.compaction.lag.ms
이 설정은 최소 메세지 수명이 지나지 않은 최신 메세지를 압축하지 않도록 막는데에 사용할 수 있다. 설정하지 않으면 마지막 세그먼트, 즉 현재 기록 중인 세그먼트를 제외한 모든 로그 세그먼트가 압축 대상에 오른다. 활성 세그먼트는 가지고 있는 모든 메세지가 최소 압축 지연 시간보다 오래됐다고 해도 압축되지 않는다. 로그 클리너는 압축하지 않는 로그의 “헤드”가 압축 대상에 오르게 되기까지의 최대 지연 시간을 설정할 수 있다.
log.cleaner.max.compaction.lag.ms
이 설정으론 생산률이 낮은 로그가 시간 제약 없이 압축 비대상으로 남아있지 않도록 방지할 수 있다. 설정하지 않으면 min.cleanable.dirty.ratio를 초과하지 않는 로그는 압축되지 않는다. 설정했더라도, 로그 클리너 스레드의 가용성과 실제 압축 시간에 지배받기 때문에, 이대로 컴팩션 데드라인으로 보장되는 건 아니다. uncleanable-partitions-count, max-clean-time-secs, max-compaction-delay-secs 메트릭을 모니터링하는 게 좋다.
클리너와 관련한 다른 설정은 여기에서 정리돼있다.
4.9 Quotas
카프카 클러스터는 요청에 할당량을 지정해, 클라이언트가 사용할 브로커 리소스를 제어할 수 있는 기능이 있다. 클라이언트 그룹은 할당량을 공유하며, 카프카 브로커는 각 클라이언트 그룹 단위에 두 가지 타입으로 클라이언트 할당량 제한을 시행할 수 있다:
- 네트워크 대역폭 할당량은 시간당 바이트 전송량의 임계치를 정의한다 (0.9부터).
- 요청 속도 할당량은 CPU 사용률 임계치를 네트워크와 I/O 스레드의 백분율로 정의한다 (0.11부터).
Why are quotas necessary
프로듀서와 컨슈머가 방대한 데이터를 생산/컨슘하거나, 매우 빠른 속도로 요청을 만든다면, 브로커 리소스를 독차지해 네트워크 포화를 유발하고, 다른 클라이언트나 브로커 자체를 사용하지 못할 수도 있다. 할당량을 지정하면 이 문제를 해결할 수 있으며, 대규모 멀티 터넨트 클러스터라면 더욱더 중요하다. 대규모 클러스터에선, 잘못 작동하는 일부 클라이언트 셋때문에, 멀쩡한 클라이언트의 사용자 경험을 저하시킬 수 있기 때문이다. 사실 카프카를 서비스로 실행한다면, 합의된 약정에 따라 API 사용을 제한할 수도 있다.
Client groups
시큐어 클러스터에서 카프카 클라이언트의 identity는 인증된 user를 나타내는 user principal이다. 인증되지 않은 클라이언트를 지원하는 클러스터에선, user principal은 브로커에 설정한 PrincipalBuilder
를 통해 인증되지 않은 user로 묶인다. Client-id는 클라이언트 어플리케이션에서 선택한 유의미한 이름을 가진 클라이언트를 논리적 묶어준다. (user, client-id) 튜플은 user principal과 client-id를 모두 공유하는 클라이언트의 안전한 논리적 그룹을 정의한다.
할당량은 (user, client-id)나, user, 또는 client-id 그룹 단위로 적용할 수 있다. 커넥션을 맺으면, 이 커넥션과 가장 구체적으로 매칭되는 할당량이 적용된다. quota 그룹에선 모든 커넥션이 그룹에 설정한 할당량을 공유한다. 예를 들어, (user=”test-user”, client-id=”test-client”)의 생산 할당량이 10MB/sec라면, 이 할당량은 user=”test-user”, client-id=”test-client”인 모든 프로듀서 인스턴스가 공유한다.
Quota Configuration
할당량 설정은 (user, client-id), user, client-id 그룹에 정의할 수 있다. 더 높은 할당량이나 더 낮은 할당량이 필요하면, 디폴트 할당량을 모든 그룹 단위에 재정의할 수 있다. 이 메커니즘은 토픽별 로그 설정 재정의와 유사하다. User, (user, client-id) 할당량 재정의는 주키퍼 /config/users 밑에 기록되고, client-id 할당량 재정의는 /config/clients 아래 기록된다. 모든 브로커는 이 재정의 정보를 읽어가 즉시 시행한다. 덕분에 클러스터 전체를 순차적으로(rolling) 재시작하지 않고도 할당량을 변경할 수 있다. 자세한 내용은 여기를 참고해라. 각 그룹의 디폴트 할당량도 동일한 메커니즘으로 동적으로 업데이트할 수 있다.
할당량 설정의 우선 순위는 다음과 같다:
- /config/users/<user>/clients/<client-id>
- /config/users/<user>/clients/<default>
- /config/users/<user>
- /config/users/<default>/clients/<client-id>
- /config/users/<default>/clients/<default>
- /config/users/<default>
- /config/clients/<client-id>
- /config/clients/<default>
브로커 프로퍼티(quota.producer.default, quota.consumer.default)로도 client-id 그룹에 대한 디폴트 네트워크 대역폭 할당량을 설정할 수 있다. 이 프로퍼티들은 더 이상 사용하지 않으며 (deprecated), 차후 릴리즈에서 제거할 예정이다. client-id에 대한 디폴트 할당량은 다른 할당량 재정의와 기본값처럼 주키퍼에 설정하면 된다.
Network Bandwidth Quotas
네트워크 대역폭 할당량은, 할당량을 공유하는 각 클라이언트 그룹의 시간당 바이트 전송량에 임계치로 정의한다. 기본적으로 각 고유한 클라이언트 그룹은, 클러스터에서 설정한대로, 고정된 bytes/sec를 할당량으로 부여받는다. 이 할당량은 브로커별로 정의된다. 각 클라이언트 그룹은 클라이언트 속도를 제한하기 전까지, 브로커 당 최대 X bytes/sec만큼 발행/페치할 수 있다.
Request Rate Quotas
요청 속도 할당량은 클라이언트가 quota 윈도우 시간 내에서, 각 브로커 요청 핸들러의 I/O 스레드와 네트워크 스레드를 이용할 수 있는 시간 백분율로 정의한다. 할당량 n%
는 스레드 하나의 n%
를 나타내므로, 총 할당 용량은 ((num.io.threads + num.network.threads) * 100)%다. 각 클라이언트 그룹은 속도를 제한하기 전까지, quota 윈도우 내에서 모든 I/O, 네트워크 스레드를 최대 n%
까지 사용할 수 있다. I/O, 네트워크에 할당하는 스레드 수는 보통 브로커 호스트에서 사용할 수 있는 코어 수를 기반으로 정하기 때문에, 요청 속도 할당량은, 할당량을 공유하는 클라이언트의 각 그룹에서 사용할 수 있는 CPU의 총 백분율을 의미한다.
Enforcement
기본적으로, 고유한 클라이언트 그룹은 모두 클러스터에서 설정한 고정된 할당량을 부여받는다. 이 할당량은 브로커를 기준으로 정의된다. 모든 클라이언트는 속도를 제한하기 전까지, 브로커마다 이 할당량만큼 이용할 수 있다. 이렇게 브로커 당 할당량을 정의하는 게, 클라이언트 단위로 고정된 클러스터 광대역 대역폭을 갖는 것보다 훨씬 낫다고 판단했다. 클라이언트 단위로 고정하려면 모든 브로커 간에 클라이언트 할당량 사용을 공유하는 메커니즘이 필요하기 때문이다. 이 메커니즘을 제대로 동작시키기가 할당량 구현 자체보다도 더 어려울 수 있다!
브로커가 할당량 위반을 감지하면 어떻게 반응할까? 카프카 솔루션에선, 브로커는 먼저 위반한 클라이언트를 할당량 미만으로 떨어트리는 데 필요한 지연 시간을 계산하고, 이 지연 시간을 담은 응답을 즉시 반환한다. 페치 요청이었다면, 응답에 어떤 데이터도 담지 않는다. 그런 다음 브로커는 지연이 끝날 때까지 더 이상 클라이언트의 요청을 처리하지 않으며, 이 클라이언트에 대한 채널을 mute시킨다. 카프카 클라이언트가 응답으로 받은 지연 시간이 0이 아니라면, 클라이언트는 지연 시간 동안 브로커에 추가 요청을 보내지 않는다. 따라서 속도를 제한한 클라이언트 요청은 사실상 양쪽에서 차단한다. 브로커의 지연 응답을 잘 따르지 않는 구버전 클라이언트라도, 브로커가 소켓 채널을 mute시켰기 때문에 back pressure가 적용돼 클라이언트 속도는 제한된다. 클라이언트가 이 채널에 추가 요청을 보내더라도, 지연이 끝난 후에만 응답을 받는다.
할당량 위반을 신속하게 감지하고 바로잡기 위해, 바이트 전송량과 스레드 사용률은 작은 윈도우를 여러 개 사용해서 측정한다 (예를 들어, 1초 단위 윈도우 30개). 일반적으로 큰 측정 윈도우(예를 들어, 30초 단위 윈도우 10개)를 사용한다면, 트래픽이 급증하고 나면 긴 지연이 생겨 사용자 경험 측면에서 좋지 않다.
Next :Implementation
카프카의 내부 구현을 설명합니다. 메세지와 레코드 배치의 디스크 포맷과, 주키퍼로 노드와 오프셋을 관리하는 방법 등을 다룹니다.
전체 목차는 여기에 있습니다.