LuterGS / TIL

today I learned
2 stars 0 forks source link

2022.07.28 - Exactly-Once Semantics Are Possible: Here’s How Kafka Does It #1 #7

Open LuterGS opened 2 years ago

LuterGS commented 2 years ago

1회만 실행되는 시멘틱 (Exactly-Once Semantics) 은 가능하다 - 카프카는 어떻게 해냈나?

카프카 0.11 (컨플루언트 플랫폼 3.3) 부터, 카프카 커뮤니티가 기대하던 1회만 실행되는 시맨틱 을 사용할 수 있습니다. 이 글에서 Kakfa Streams API 를 이용해 Kafka 의 1회만 실행되는 시맨틱이 무엇이고 왜 어려운지, 그리고 새로운 멱등성과 트랜잭션 기능이 어떻게 Kafka 의 1회만 실행되는 시맨틱을 구현했는지 설명하겠습니다.

1회만 실행되는 시멘틱은 정말 어려운 문제다.

여러분들이 무슨 생각을 하는지 알고 있습니다. 정확히 1회전송은 실 사용하기엔 너무 많은 비용을 필요로 하기에 불가능하거나, 아얘 잘못 알고 있거나라는 사실을요. 여러분만 그렇게 생각하는 것이 아닙니다. 제 몇몇 직장 동료들도 분산 시스템에서 정확히 1회 전송은 가장 어려운 문제 중에 하나라는 것을 인지하고 있습니다. 또 일부는 정확히 1회 전송은 불가능할 것이라고 명쾌하게 말하기도 했죠. 자, 저는 정확히 1회 전송 (과 정확히 1회 전송하는 stream processing 을 지원하는 것) 이 해결하기 어려운 문제라는 것을 부정하지는 않겠습니다. 그렇지만 전 또 Confluent 의 똑똑한 분산 시스템 엔지니어들이 오픈소스 커뮤니티와 몇 년간 협업해 Apache Kafka 에서 이 문제를 해결하는 것을 목격했습니다. 그럼, 바로 메시징 시멘틱을 살펴보는 것으로 시작하죠.

정확히 1회 전송 시맨틱이 뭐죠? 메시징 시멘틱 설명

분산 주제-구독 (publish-subscribe) 메시징 시스템에서, 시스템을 구성하는 컴퓨터들은 언제든지 다른 컴퓨터와 상관없이 오류 (원문에서 fail) 가 일어날 수 있습니다. Kafka 의 경우엔 각각의 브로커가 언제든지 오류를 일으킬 수 있고, producer 가 topic 으로 메시지를 보낼 때 네트워크 문제로 실패할 수도 있습니다. producer 가 이런 오류를 해결하는 방법에 따라 다양한 시멘틱이 존재합니다.

필수적으로 처리되어야 하는 오류

정확히 1회 시맨틱을 지원하기 위해 해결해야 하는 문제를 서술하기 위해, 간단한 예제 하나로 시작해 보겠습니다.

partition 이 1개인 Kafka topic "EoS" 로 단일 프로세스 producer 로 구성된 어플리케이션이 "Hello Kafka" 라는 메시지를 보낸다고 가정해 보겠습니다. 더 나아가 단일 인스턴스 consumer 가 해당 topic 의 끝에서 메시지를 받아 출력해 준다고 생각해 보겠습니다. consumer 가 메시지를 pull 해서 받아오고, 처리하고, 메시지의 처리가 완료되었다고 메시지를 commit 하면, 그 메시지를 다시 받지 않습니다. 설령 consumer 애플리케이션에 오류가 발생해 재시작된다 하더라도요.

그러나, 이런 행복한 상황만 있다고 기대할 순 없습니다. 큰 규모에선 실패할 거라고 생각할 수 없는 부분에서도 항상 문제가 일어납니다.

  1. 브로커의 오류 가능성 : 카프카는 모든 메시지를 영속성을 띤 partition 에 저장하고 메시지를 n 번 복제하는 고가용성, 영속성, 지속성을 지닌 시스템입니다. 그 결과로, 카프카는 n-1 개의 브로커가 실패하더라도 괜찮습니다. 적어도 하나의 브로커만 살아있으면 그 partition 은 사용 가능하다는 뜻이니까요. Kafka 의 복제 프로토콜은 leader replica (메인 레플리카) 에 메시지가 성공적으로 기록되었다면, 가능한 모든 replica 에 메시지가 기록되는 것을 보장합니다.
  2. producer -> 브로커 RPC 통신 오류 가능성 : Kafka의 지속성은 producer 가 브로커로부터 ack 를 받는 것에 의존합니다. ack 를 받지 못했다는 것이 요청 자체가 실패했다고 단정지을 수는 없습니다. 브로커는 메시지를 정상적으로 기록한 뒤 오류가 나서 producer 에게 ack 를 보내지 못했을 수도 있고, 메시지를 topic 에 기록하기 전에 오류가 날 수도 있기 때문입니다. producer 가 실패의 원인을 알 수 없는 상황에서는 결국 메시지가 제대로 전송되지 않았다고 생각할 수밖에 없고, 따라서 메시지를 다시 전송합니다. 몇몇 상황에서는 이런 행동이 몇몇 메시지가 Kafka partition 로그에 중복 기록되고, 결과적으로 consumer 가 한 번 이상 메시지를 받게 됩니다.
  3. 클라이언트의 오류 가능성 : 정확히 1회 전송은 클라이언트의 실패에도 대응 가능해야 합니다. 그럼 클라이언트가 정말 오류가 났는지, 아니면 브로커로부터 잠시 분리되었는지, 잠시 작동이 멈췄는지 어떻게 알 수 있을까요? 영구적인 오류와 일시적인 오류를 판단할 수 있는 것은 중요합니다. 정확성을 위해, 브로커는 zombie producer 가 발행한 메시지를 거부해야 하니까요. consumer 도 동일한데, 새로운 클라이언트가 작동하면, 실패한 이전 클라이언트가 남긴 상태와 관련없이 복구된 후 안전한 지점 (offset 을 의미하는 듯) 부터 메시지를 processing 해야 하니까요. 이 뜻은 consumer 의 메시지 offset (consumed offset) 은 producer 의 결과 (producer output) 과 항상 동기화되어야 함을 뜻합니다.

Apache Kafka 에서의 정확히 1회 시맨틱

0.11.x 버전 이전에는, Apache Kafka 는 적어도 1회 전송 시맨틱과 partition 별 메시지 정렬을 지원했습니다. 위의 예제에서 보듯이, 그 뜻은 producer 의 재전송은 중복된 메시지를 초래할 수 있음을 의미합니다. 새로운 정확히 1회 시맨틱 기능을 위해 우린 3개의 서로 다른 방법으로 Kakfa 의 소프트웨어 처리 시맨틱을 강화시켰습니다.

멱등성 (idempotence) : partition 별 정확히 1회 순서 시맨틱

멱등 작업은 한 번 수행할 떄와의 차이를 걱정할 필요 없이 여러 번 실행할 수 있습니다. 이제부터 producer 의 전송 작업은 멱등적입니다. producer 가 재전송할 수 있는 에러 상황에선 (아직까지도 이 경우에선 producer 가 여러 번 메시지를 보냅니다) 동일한 메시지들은 브로커에 있는 Kafka log 에 한 번만 기록됩니다. 단일 파티션의 경우 멱등적인 producer 의 전송은 producer 나 브로커의 에러에 관계없이 중복 메시지의 가능성을 없앱니다. 이 기능으로 파티션별 정확히 1회 시맨틱 (중복이나 데이터 손실 가능성이 없고, 정렬된 시맨틱) 을 적용시키려면, producer 를 "enable.idempotence=true" 로 설정해 주세요.

이 기능이 어떻게 작동하냐고요? 근본적으로 이 방식은 TCP 와 비슷하게 작동합니다. Kafka 로 보내진 메시지 배치들은 브로커가 중복 발행된 메시지를 중복 제거하기 위한 sequence 번호를 부여받습니다. (짧은 인메모리 연결에 대해서만 보장하는) TCP 와는 다르게, 이 sequence 번호는 replica 들의 로그에 영구적으로 기록됩니다. 따라서 leader 브로커가 실패하더라도, 넘겨받은 새로운 leader 도 중복을 탐지할 수 있습니다. 이 방식의 오버헤드는 생각보다 낮습니다. 단순히 각 배치 메시지에 몇 개의 숫자 필드가 추가되는 것뿐이니까요. 이 기사 뒤에서 보듯이, 멱등적인 producer 에 대해 무시해도 될 만한 퍼포먼스 부하만 걸립니다.

트랜잭션 : 여러 partition 에 대한 원자적 기록

두 번째로, Kafka 는 이제 새로운 트랜잭션 API 로 여러 partition 에 대해 원자적 쓰기를 제공합니다. 이건 producer 가 보낸 배치 메시지가 consumer 들이 모두 볼 수 있거나, 모두 볼 수 없게 되는 것을 뜻합니다. 또한 이 기능은 처리한 메시지들에 대해 consumer offset 을 저장할 때도 동일한 트랜잭션을 사용해 end-to-end 로 정확히 1회 시맨틱을 보장함을 의미합니다. 이 트랜잭션 API 를 사용하는 예제 코드 조각입니다.

producer.initTransactions();
try {
  producer.beginTransaction();
  producer.send(record1);
  producer.send(record2);
  producer.commitTransaction();
} catch(ProducerFencedException e) {
  producer.close();
} catch(KafkaException e) {
  producer.abortTransaction();
}

위의 코드 조각은 여러 partition 들에 대해 원자적 쓰기를 하기 위해 어떻게 새로운 producer API 를 사용하는지를 설명하고 있습니다. 트랜잭션 메시지의 일부를 한 topic partition 이 가지고 있고, 다른 partition 이 가지지 않고 있을 지도 모른다는 것은 의미가 없습니다 (모두가 가지고 있거나, 그렇지 않거나가 중요하다).

consumer 부분에서, 트랜잭션 메시지들을 읽기 위해선 isolation.level consumer 설정을 조절하는 두 가지 옵션이 있습니다.

  1. read_committed: 트랜잭션이 아닌 메시지들을 읽는 것에 추가로, 트랜잭션이 커밋된 후에 해당 메시지들을 읽는 옵션입니다.
  2. read_uncommitted: 트랜잭션이 커밋되기 전에 정렬된 offset 에 따라 모든 메시지들을 읽습니다. 현재의 Kafka consumer 시맨틱과 비슷합니다.

트랜잭션을 사용하기 위해선, consumer 가 올바른 isolation.level 을 사용하도록 설정하고 새로운 producer API 를 사용해야 하며, producer 에 transactional.id 설정을 고유한 id 로 부여해줘야 합니다. 이 고유 id 는 어플리케이션 재시작에 상관없는 트랜잭션 상태를 보장하기 위해 필요합니다.

LuterGS commented 2 years ago

https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/?utm_source=marketo&utm_medium=nurtureemail&utm_campaign=tm.lifecycle_cd.developer-journey-nurture-understand-email-1_prg.dj_rgn.global_&mkt_tok=NTgyLVFIWC0yNjIAAAGF2m8PCG8077-3GMBCkUJCoKGHruBbddJysXFNEjXQGHdnYP_LLsp6aMXpB9jl7R5eopAVKxTO_0OmFS0yg5QATCJffMKz4x-FoW70G9c34VkZc3A

해당 기사를 직접 번역한 것임을 알림