Kafka : 신뢰성 있는 데이터 전달
💡 해당 글은 '카프카 핵심 가이드 2nd Edition'을 읽고 정리한 글입니다.
1️⃣ 신뢰성 보장
카프카에서 보장하는 것은 아래와 같다.
- 파티션 안의 메세지들 간에 순서
- 클라이언트가 쓴 메세지는 모든 인-싱크 레플리카의 파티션에 쓰여진 뒤에야 커밋된 것으로 간주한다.
- 최소 1개의 작동 가능한 레플리카가 남아 있는 한 유실되지 않는다.
- 컨슈머는 커밋된 메세지만 읽을 수 있다.
2️⃣ 복제
카프카의 복제 메커니즘은 파티션별로 다수의 레플리카를 유지한다는 점에서 카프카 신뢰성 보장의 핵심이다.
- 각 파티션은 다수의 레플리카를 가질 수 있으며, 그 중 하나가 리더가 된다.
- 모든 이벤트들은 리더 레플리카에 읽어지고 쓰여진다.
- 다른 레플리카들은 단순히 리더와 동기화를 맞추면서 최신 이벤트를 제시간에 복사해, 리더와 상태를 유지한다.
- 만약 리더가 작동 불능이 되면, 인-싱크 레플리카 중 하나가 새 리더가 된다.
인-싱크 레플리카의 조건은 다음과 같다. (여기서 말하는 -초는 모두 설정 가능한다.)
- 주키퍼와 최근 6초 사이에 하트비트를 주고 받았는지
- 최근 10초사이 리더로부터 메세지를 읽어왔는지
- 최근 10초사이에 랙이 없었는지
만약 레플리카와 주키퍼 사이의 연결이 끊어지거나, 새 메세지를 읽어오지 못한다면 아웃-오브싱크 가 된다.
- 상대적으로 흔한 원인 중 하나는 최대 요청 크기가 큰 탓에 JVM 힙도 크게 사용하면서 가비지 수집 시간이 오래걸려 순간적으로 주키퍼와 연결이 끊어지는 경우이다.
인-싱크 레플리카 수가 줄어들면 파티션의 실질적인 복제 팩터가 줄어들면서, 중단 시간이 길어지거나 데이터 유실 위험이 증가한다.
3️⃣ 브로커 설정
복제 팩터
토픽 단위 설정은 `replication.factor`에, 자동으로 생성되는 토픽들에 적용되는 브로커 단위 설정은 `default.replication.factor`설정에 잡아준다.
- 이미 존재하는 토픽일지라도 우리는 카프카의 레플리카 재할당 툴을 사용해서 레플리카를 추가하거나 삭제할 수 있다.
- 즉, 자연히 복제 팩터 역시 변경이 가능한다.
복제 팩터가 N이면 N-1개의 브로커가 중단되더라도 토픽의 데이터를 읽거나 쓸 수 있다.
- 반대로 복제 팩터가 N이라는 것은 N개의 복사본을 저장해야 한다는 것을 의미하므로 N배의 디스크 공간이 필요하다는 뜻이 된다.
- 결과적으로 가용성과 하드웨어 사용량 사이의 트레이드 오프가 존재한다.
그래서 트레이드 오프는 아래와 같다.
가용성 | 레플리카 수가 많을 수록, 가용성 증가 |
지속성 | 복사본이 많을 수록, 데이터 지속성 증가 |
처리량 | 레플리카가 많을 수록, 처리량 증가 |
종단 지연 | 레플리카가 많을 수록, 종단 지연 증가 |
비용 | 레플리카가 많을 수록, 비용 증가 |
레플리카의 위치 역시 매우 중요하다.
- 랙 단위 사고를 방지하기 위해 브로커들을 서로 다른 랙에 배치한 뒤, `broker.rack` 설정 매개변수로 랙 이름을 잡아준다.
- 서로 다른 랙에 분산되어 저장되도록 함으로써, 브로커 크래시가 났을때를 대비하여 가용성을 높인다.
언클린 리더 선출
기존에는 인-싱크 레플리카 중에서 하나의 리더가 선출된다. 커밋된 데이터에 아무런 유실이 없음을 보장한다는 점에서 이러한 리더 선출을 "클린"이라고 한다.
그런데 만약 작동 불능에 빠진 리더 외에 인-싱크 레플리카가 없다면 어떻게 될까? 이때 두가지 결정을 할 수 있다.
- 예전 리더가 복구 될때까지 해당 파티션을 오프라인 상태로 유지한다.
- 이 방법은 하드웨어적인 문제인 경우 몇시간이 걸릴 수 도 있다.
- `unclean.leader.election.enable` 설정을 true로 하여 언클리 리더 선출을 한다.
- 리더의 동기화를 따라가지 못하는 레플리카를 리덜로 선출하는 것이기 때문에 동기화 문제를 일으킨다.
- 일부 파티션은 중복된 커밋 메세지를 받거나, 일부 파티션에서는 메세지 유실이 발생한다.
- 즉 메세지 일관성 깨짐의 위험성이 있다.
💡 운영자 입장에서는 이러한 상황이 닥쳤을 때 파티션을 사용 가능한 상태로 만들기 위해 데이터 유실을 감수하기로 하고 이 값을 true로 바꾼 뒤 클러스터를 시작하는 것도 언제든 가능하다. 이 경우 클러스터가 복구된 뒤 설정값을 false로 되돌려주는 것을 잊지말자.
최소 인-싱크 레플리카
커밋된 데이터를 2개 이상의 레플리카에 쓰고자 한다면, 인-싱크 레플리카의 최소값을 더 높게 잡아줄 필요가 있다.
- 토픽에 레플리카가 3개 있고, min.insync.replicas를 2로 잡아 줬다고 가정해보자.
- 만약 3개 중 2개의 레플리카가 작동 불능에 빠질 경우, 브로커는 더 이상 쓰기 요청을 받을 수 없을 것이다. (`NotEnoughReplicasException`)
- 즉, 하나만 남을 경우 해당 레플리카는 사실상 읽기 전용이 된다.
레플리카를 인-싱크 상태로 유지하기
아웃-오브-싱크 레플리카는 전반적인 신뢰성을 낮추므로 가능한 한 피할 필요가 있다.
zookeeper.session.timeout.ms
- `zookeeper.session.timeout.ms`는 카프카 브로커가 주키퍼로 하트비트 전송을 멈출 수 있는 최대 시간이다. (기본 : 18초)
- 일반적으로 이 값은 가비지 수집 혹은 네트워크 상황과 같은 무작위적인 변동에 영향을 받지 않을 만큼 높게, 작동을 멈춘 브로커가 적시에 탐지될 수 있을 만큼 낮게 할 필요가 있다.
replica.lag.time.max.ms
- 만약 레플리카가 `replica.lag.time.max.ms`에 설정된 값보다 더 오랫동안 리더로부터 읽어오지 못한다면 아웃-오브-싱크가 된다.
- 기본값은 30초이며, 이것은 컨슈머의 최대 지연에도 영향을 준다는 것을 명심하자.
디스크에 저장하기
카프카는 세그먼트를 교체할 때와 재시작 직전에만 메세지를 디스크로 플러시하며, 그 외의 경우에는 리눅스의 페이지 캐시 기능에 의존한다.
하지만 브로커가 디스크에 더 자주 메세지를 저장하도록 설정하는 것이 가능하다.
- `flush.messages` : 디스크에 저장되지 않은 최대 메세지 수
- `flush.ms`: 디스크에 메세지를 저장하는 주기
4️⃣ 신뢰성 있는 시스템에서 프로듀서 사용하기
응답 보내기
ack = 0
- 프로듀서가 네트워크로 메세지르 전송한 시점에 응답
- 지연은 낮지만, 메세지가 모든 인-싱크 레플리카에 복제되기를 기다려야 하기 때문에 종단 지연은 개선되지 않는다.
ack = 1
- 리더가 파티션 데이터 파일에 쓴 직 후 응답 또는 에러를 전달
- 메세지 복제하는 속도보다 더 빨리 리더에 쓸 수 있기 때문에, 불완전 복제 파티션이 발생할 수 있다.
ack = all(-1)
- 모든-인-싱크 레플리카가 메세지를 받을 때까지 대기한 후 응답을 전달
- 프로듀서는 메세지가 완전히 커밋될 때까지 계속해서 메세지를 재전송할 수 있다.
- 지연이 가장 길어지는 옵션이지만, 가장 안전하다.
프로듀서 재시도 설정하기
프로듀서가 브로커에 메세지를 전송하면 성공 혹은 에러코드를 전달받는데, 에러코드는 두 부류로 나뉜다.
- 재전송으로 해결 가능한 에러 코드 : `LEADER_NOT_AVAILABLE` (재전송시 리더가 선출되어 성공할 것이다.)
- 해결가능하지 않은 에러 코드 : `INVALID_CONFIG` (설정 문제)
기본적으로 메세지가 유실되지 않은 것을 목표로 하기 때문에, 재전송 횟수는 최대한으로 설정하는 것이 좋다.
- 재시도 수를 기본 설정값(MAX_INT)으로 내버려 둘 수 있다.
- 메세지 전송을 포기할 때까지 대기할 수 있는 시간을 지정하는 `delivery.timeout.ms`를 최대로 설정한다.
전송 실패한 메세지를 재시도하는 것은 두 메세지가 모두 브로커에 중복되어 쓰여지는 위험이 있다.
- `enable.idempotence = true`로 설정함으로써 중복으로 쓰인 메세지는 건너뛸 수 있도록 하자.
💡 위에서 언급한 예외 이외에도 개발자 입장에서 다른 종류의 에러를 여러가지 방식으로 처리할 수 있다. 이는 아키텍처 와 요구사항에 따라 대처법이 달라질 것이다. 만약 메세지 재전송이 에러 핸들러가 하는 일의 전부라면, 프로듀서의 재전송 기능을 사용하는 편이 더 낫다는 점을 유념하자.
5️⃣ 신뢰성 있는 시스템에서 컨슈머 사용하기
컨슈머가 해야 할 일은 어느 메세지까지 읽었고 어디까지는 읽지 않았은지를 추적하는 것이다. 이것은 메세지를 읽는 도중에 누락되지 않게 하기 위해 필수적이다.
신뢰성 있는 처리를 위해 중요한 컨슈머 설정
group.id
- 만약 같은 그룹 ID를 갖는 두 개의 컨슈머가 같은 토픽을 구독할 경우, 각각의 컨슈머에는 해당 토픽 전체 파티션의 서로 다른 부분집합이 할당되어 각각 다른 부분의 메세지만 읽게 된다.
- 만약 위와 같은 경에서 각 컨슈머가 파티션의 모든 메세지를 읽기 원한다면, 고유한 그룹 아이디를 지정하여 설정해주자.
auto.offset.reset
- 커밋된 오프셋이 없을 때나 컨슈머가 브로커에 없는 오프셋을 요청할 때 컨슈머가 어떦게 해야할지 결정한다.
- `earliest` : 파티션의 맨 앞에서부터 읽기 시작
- 이는 유실은 최소화할 수 있지만, 많은 메세지가 중복될 수 있다.
- `lastest` : 가장 최근 메세지부터 읽기 시작
- 중복은 최소화할 수 있지만, 유실의 가능성이 존재한다.
enable.auto.commit
- 자동으로 오프셋을 커밋하는 설정으로, 우리가 처리하지 않은 오프셋을 실수로 커밋하는 사태가 발생하지 않도록 보장해준다.
- 하지만 자동 오프셋 커밋 기능은 메세지 중복 처리를 우리가 제어할 수 없다.
- 그래서 멱등 소비자 패턴과 같은 외부 저장소를 활용하여 중복 방지를 해줘야 한다.
auto.commit.interval.ms
- 자동 커밋을 활성할 경우, 이 설정을 사용해서 커밋되는 주기를 설정할 수 있다.
- 5초마다 커밋하는 것이 기본 값이며, 더 자주 커밋할수록 오버헤드 역시 늘어나지만 컨슈머가 정지했을 때 발생할 수 있는 중복의 수는 줄어든다.
컨슈머에서 명시적으로 오프셋 커밋하기
만약 커밋을 직접 수행하기로 했다면 아래 몇가지 주의 사항을 살펴보자.
1. 메세지 처리 먼저, 오프셋 커밋은 나중에
- 폴링 루프에서 먼저 처리하고 루프 사이의 상태는 저장하지 않는 방식
- 만약 스레드가 2개 이상 있다면 중간 상태를 저장하지 않기 때문에 동기화는 더 복잡해진다.
2. 커밋 빈도는 성능과 크래시 발생시 중복 개수 사이의 트레이드오프이다.
- 커밋 작업은 상당한 오버헤드를 수반한다. 그래서 커밋 주기는 성능과 중복 발생의 요구 조건 사이의 균형을 맞춰야 한다.
3. 정확한 시점에 정확한 오프셋을 커밋하자.
- 폴링 루프 중간에서 커밋할 때 흔히 하는 실수는 마지막으로 읽어 온 메세지의 오프셋을 커밋하는 것이다.
- 처리 완료한 메세지를 커밋하는 것을 기억하자.
4. 리밸런스
- 리밴러스 발생시 할당된 파티션을 해제하는데. 이때 해제 되기 전에 오프셋을 커밋하고 새로운 파티션이 할당되었을때 어플리케이션이 보유하고 있던 상태를 삭제해주는 작업을 넣어야 한다.
5. 컨슈머는 재시도를 해야 할 수도 있다.
- poll()을 호출하고 레코드를 처리한 뒤, 일부 레코드는 처리가 완료되지 않아서 나중에 처리되어야 할 수도 있다.
- 재시도 가능한 에러가 발생했을 경우, 마지막으로 처리에 성공한 레코드의 오프셋을 커밋하는 것이다.
- 나중에 처리해야 할 레코드들을 버퍼에 저장하고, 컨슈머의 pause() 메서드를 호출해 추가적인 pol() 호출을 막은 뒤 , 레코드 처리를 계속한다.
- 다른 방법으로는 별도의 토픽에 쓴 뒤 계속 진행하는 것이다.
- 별도의 컨슈머 그룹을 사용해서 재시도 토픽에 저장된 레코드들을 처리할 수 있다.
- 혹은 주 토픽과 재시도 토픽을 모두 구독하는 컨슈머를 하나 둬서 재시도 사이에는 재시도 토픽 구독을 잠시 멈추도록 할수도 있다. ( dead letter queue )
6. 컨슈머가 상태를 유지해야 할 수도 있다.
- 만약 상황에 따라 프로세스가 재시작된 후 , 특정 상태값을 요구한다면 컨슈머의 상태를 저장해야 할 수도 있다.
- 이때 사용할 수 있는 방법은, 마지막으로 누적된 값(상태)을 애플리케이션이 오프셋을 커밋할때 `result` 토픽에 쓰는 것이다.
"신뢰성 있는 카프카 설계하기"와 관련된 영상