2017년 7월 7일 금요일

Apache Kafka 에서 정확히 한 번(Exactly-once)이 가능한가?


  이 문서는 “Exactly-once Support in Apache Kafka” 블로그를 번역한 것입니다. 저자의 블로그에서는 정확히 한번(Exactly-once)의 개념을 설명하고 Kafka로 어떻게 이 개념을 이용할 수 있는지 설명합니다. 그러나 이 개념이 왜 어려운지 또 정말로 가능한지에 대해서는 좀더 성찰이 필요할 것 같습니다.

  우리는 목요일에 의미론적 보증을 극적으로 강화하는 Apache Kafka의 새 버전을 출시 했습니다.

  우리는 빠르고 실용적이며 정확한 방식으로 안정적인 스트림 처리를 수행하는 방법을 수년 간의 고민한 끝에 이 릴리스를 발표했습니다. 구현 노력 자체는 약 1 년 정도 걸렸습니다. 이 과정에서 Kafka 커뮤니티는 약  100 페이지의 세부 설계 문서가 논의했고, 비판도 받고, 광범위한 성능 테스트를 수행했고, 특히 정확이 한번(Exactly-once) 기능을 타깃으로 하는 살인적인 수천 라인의 분산 테스트를 수행했습니다.
 
  이 릴리스에 대한 반응은 대부분 "와우, 정말 멋지네"라는 것이 었습니다. 그러나 나는 불행히도 고전적인 실수를 했습니다. 나는 주석을 읽었습니다. 이것은 우리가 거짓말쟁이라고 주장하는 매우 혼란스러운 사람들의 흥분 섞인 주장들이었습니다.

다음은 반응들 중 일부입니다 .

  "정확히 한 번 전달(exactly-once delivery)는 될 수는 없습니다... 아주 간단한 수학적 정리로 인해 불가능합니다. 또한 저자가 혼란스러워서 독자가 모든 것을 불신하게 만드는 것은 의심의 여지가 있습니다. "

  "수학적으로 입증된 사실에 어떻게 대처할 지 재미있는 기사. 이 기사에서 주의 깊게 지정하지 않은 가정을 변경하지 않는다면 모든 경우에 작동할 수는 없으며 이는 시스템에 관한 것입니다. "

  나는 이 반응들이 틀렸다는 것을 믿습니다. 당신이 이것을 생각하는 사람들 중 하나라면, 저는 우리가 실제로 불가능하고 불가능한 것이 무엇인지, 그리고 Kafka에 무엇이 세워 졌는지를 실제로 들여다 보라고 요청합니다. 그리고 더 많은 정보에 입각한 의견을 얻으시길 바랍니다.

  이 부분을 두 부분으로 나누어 봅시다. 첫째, 정확히 한 번(exactly-once)은 이론적으로 불가능한가? 둘째, 어떻게 Kafka는 이 기능을 지원하는가?

정확히 한 번은 불가능한가요?


  정확히 한 번 전달/의미론이 수학적으로 불가능하다는 것의 정확한 이유를 모른 체 사람들의 확신해 찬 주장이 주변을 떠돌아 다니고 있습니다. 그러나 이것이 분명히 일반적인 지식임에도 불구하고 사람들은 이것이 어떤 증거인지 또는 정확히 한 번 의미가 무엇인지에 대한 정확한 정의에 연결되는 경우는 드뭅니다. 이것들은 FLP 결과 나 Two Generals Problem 와 같은 것들을 증거로 연결하지만 정확히 한 번(exactly-once)에 관한 것은 아닙니다. 분산 시스템에서 가능한 것(비동기, 반동기 등)을 제어하는 설정과 또 그것이 무엇인지를 정확하게 기술하지 않는다면 가능하거나 불가능한 것을 이야기 할 수 없습니다. 그리고 나쁜 일이 일어날 수 있는 것을 설명하는 결함 모델도 이야기 할 수 없습니다.

  그러면 우리가 성취하고자 하는 것과 같은 속성을 공식적으로 정의할 수 있는 방법은 있는 걸까요?

  네 있습니다. 그런 속성이 있다는 것이 밝혀졌습니다. 그것은 "원자적 브로드캐스트(Atomic Broadcast)" 또는 "총 주문 브로드캐스트(Total Order Broadcast)" 라고 합니다. 다음은 널리 사용되는 분산 시스템 교과서들 중 하나의 정의입니다.


  읽어보십시오. 내 생각에 이것은 pub/sub 메시징의 컨텍스트에서 사람들이 정확히 한 번 전달한다는 의미입니다. 즉, 메시지를 게시할 수 있으며 하나 이상의 수신 애플리케이션에 정확히 한 번 전달됩니다.

  그렇다면 원자적 브로드캐스트를 해결할 수 있을까요?

  해결할 수 있습니다. 제가 사진을 찍은 책 외에도 수십 개의 알고리즘을 비교 분류한 논문을 읽을 수 있습니다 .

  그러나 분산 시스템 책을 읽지 못하면 어떻게 이것이 사실이라는 것을 스스로에게 확신시킬 수 있습니까?

  원자적 브로드캐스트는 합의(consensus)와 동등한 것으로 드러났으므로 우리는 합의가 이루어 질 수 있는지 여부를 이해하는 것으로 문제를 좁힐 수 있습니다. 이는 아마도 합의가 분산 시스템에서 가장 많이 연구된 문제이기 때문입니다.

  합의가 가능한 것일까요? 아마 그렇다고 느낄 수 있습니다. 이는 Paxos 및 Raft 와 같은 잘 알려진 알고리즘에 의해 공격받는 문제이고 현대 분산 시스템 실행에 널리 의존하기 때문에 발생합니다. 그러나 이론적인 결과를 원한다면 설정 및 실패 모드에 대해 구체적으로 설명해야합니다. 예를 들어 의견에 있는 몇몇 사람들은 "하나의 잘못된 프로세스를 가진 합의의 불가능성"이라는 제목의 "FLP"논문을 인용했습니다. 그것은 좋아 보이지 않습니다! 첫 번째 문장에서 실패 감지기가 "충돌 오류가 있는 비동기 시스템에서 합의를 해결하는 데 사용될 수 있다"고 주장하는 논문을 쉽게 찾을 수 있습니다. 이것을 어떻게 이용할까요? 이것은 이론적으로 분산된 시스템 주장에서 세부 사항이 중요한 부분입니다.  우리는 설정과 결함 모델에 대해 구체적이어야 합니다. FLP 결과는 매우 제한적인 환경에서 합의가 가능하지 않다는 것을 증명합니다. 로컬 타이머 또는 무작위화와 같은 간단한 작업을 허용하면 가능해집니다. 합의 알고리즘은 이런 것들에 의존하여 일종의 시끄럽지만 결국에는 "올바른 시간 동안 하트 비트가 없는 프로세스가 죽었다"와 같은 올바른 오류 감지를 구현합니다. 이것들이 사람들이 "합의를 이끌어 낸다" 알고리즘을 말할 때 사람들이 참조하는 설정입니다.

  (FLP 외에도 많은 사람들이 Two Generals 문제를 "수학적 정리"와 연결시켰습니다. 그 이유는 실제로 전통적인 메시징 시스템의 유추를 볼 수는 있지만 실제로 Kafka는 그 문제와 별로 비슷하지 않습니다.)

  이것은 깊은 주제입니다. 관심이 있으시면 첫 걸음으로 Martin Kleppmann의 멋진 책을 추천 할 수 있습니다 . 진정으로 사로 잡힌 사람들은 참고 문헌들로 한 달 동안은 바쁘게 지낼 수 있을 것입니다.

  그렇다면 어떻게 이것을 실제로 실천할 수 있을까요? 실용적인 측면에서, 합의는 현대 분산 시스템 개발의 주류입니다. AWS에서 거의 모든 서비스를 사용하거나 AWS를 기반으로하는 서비스 위에 구축된 서비스를 사용했다면 합의로 구축된 시스템에 의존하게 됩니다. 이것은 현재 구축되는 시스템이 많지는 않지만 많은 경우에 해당됩니다. Kafka 는 이 중 하나이며, 그 중심적 추상은 분산된 일관된 로그이며, 사실상 가장 순수한 아날로그에서 다중 라운드로의 합의입니다. 따라서 합의가 가능하다고 믿지 않는다면 Kafka도 가능하다고 믿지 않습니다. 이 경우 Kafka에서 정확히 한 번 지원될 가능성에 대해 너무 걱정할 필요가 없습니다!

Kafka로 정확히 한 번 전달되는 애플리케이션을 어떻게 만들 수 있습니까?


  Kafka는 다음과 같은 로그를 갖습니다.

Apache Kafka의 로그

  Kafka의 로그는 강하게 정렬된 레코드 순서며 각 레코드에는 로그의 레코드 위치를 식별하는 순차적 숫자 오프셋이 지정됩니다.

  "생산자"는 이 로그에 레코드를 추가하고, 0 이상의 소비자 애플리케이션은 자신이 제어하는 ​​지정된 오프셋에서 메시지를 읽습니다.

  다음과 같은 애플리케이션을 상상해 봅시다.

  게시자가 메시지를 게시하려고 하고 소비자가 메시지를 읽고 이를 데이터베이스에서 저장하려고 합니다. 우리는 어떻게 이것을 할 수 있고 올바른 해결책을 얻을 수 있을까요?

  발생할 수 있는 두 가지 범주의 문제를 볼 수 있습니다.
  1. 첫 번째 문제는 게시자 애플리케이션이 로그에 메시지를 기록하지만 네트워크를 통해 확인 응답을 받지 못하면 발생합니다. 이것은 이 게시자를 묶어 놓을 것입니다. 이 때 메시지는 실제로 쓰기가 성공했거나 Kafka에 전혀 도착하지 않았을 수 있습니다. 우리는 모릅니다! 우리가 재시도하고 메시지 쓰기가 성공했다면 우리는 복제본을 가질 수 있습니다. 우리가 재시도하지 않고 쓰기가 성공하지 못했다면 우리는 메시지를 잃을 것입니다. 이는 기본 키가 없거나 자동 증가 기본 키가 없는 데이터베이스 테이블에 삽입했을 때와 실질적으로 동일한 딜레마입니다.
  2. 두 번째 문제는 소비자 측면에 있습니다. 소비자 애플리케이션은 로그에서 일부 메시지를 읽고 데이터베이스에 결과를 쓸 수 있지만 위치를 표시하는 오프셋을 업데이트하기 전에 실패할 수 있습니다. 해당 소비자가 다시 시작될 때 (잠재적으로 Kafka 그룹 관리 메커니즘을 사용하는 다른 컴퓨터에서 자동으로) 중복될 수 있습니다. 애플리케이션이 저장된 오프셋을 먼저 업데이트 한 다음 데이터베이스를 업데이트하는 경우, 실패로 인해 재시작시 업데이트가 누락 될 수 있습니다.
  두 문제에 대해 이야기 해 봅시다. 첫 번째 문제는 우리가 게시물에서 발표한 멱등성 지원에 의해 해결됩니다. 따라서 게시자는 중복 가능성에 대한 걱정 없이 성공할 때까지 항상 다시 시도할 수 있습니다 (Kafka는 투명하게 탐지하여 무시합니다).

 믿거나 말거나, 우리는 두 번째 문제에 대해서는 깊이 생각하지 않았습니다. 그러나 우리가 그것을 생각하지 않았기 때문이 아닙니다! Kafka를 알고 있는 사람들을 위해 이미 긴 블로그 게시물이 있기 때문에 우리는 더 깊이 뛰어 들지 않았습니다. 그래서 짧은 요약 설명을 했습니다.

  다음은 좀 더 깊이 있는 토론입니다.

  소비자가 정확히 한 번 처리하도록 하려면 생성된 파생 상태와 업스트림을 가리키는 오프셋을 동기화 상태로 유지해야 합니다. 여기서 중요한 사실은 소비자가 로그에서 오프셋을 제어할 수 있고 원하는 위치에 저장할 수 있다는 것입니다. Kafka 위에 정확하게 한 번 의미를 얻기 위해 이 방법을 사용하는 일반적인 두 가지 방법이 있습니다.
  1. 파생된 상태와 오프셋을 동일한 DB에 저장하고 트랜잭션에서 둘 다 업데이트하십시오. 다시 시작할 때 DB에서 현재 오프셋을 읽고 거기에서 읽기 시작하십시오.
  2. 모든 상태 업데이트와 오프셋을 멱등성(idempotent) 방식으로 작성하십시오. 예를 들어 파생 상태가 발생 횟수를 추적하는 키와 카운터인 경우 오프셋과 함께 카운터를 저장하고 오프셋 <= 현재 저장된 값으로 모든 업데이트를 무시합니다.
  좋습니다. 하지만 "어렵습니다!"라고 반대할 수도 있습니다. 실제로 그렇게 힘들지는 않다고 생각합니다. 그럼에도 저도 트랜잭션은 간단하지 않다고 생각합니다. 여러 테이블을 업데이트하는 경우 트랜잭션 문제가 발생합니다. 오프셋 테이블을 추가하고 이를 업데이트에 포함시키는 것은 로켓 과학이 아닙니다.

  이에 대해 제가 들었던 또 다른 반대는 실제로 "정확히 한 번"이 아니라 실제로는 "효과적 한 번(effectively once)"이라는 것입니다. 나는 (일반적으로는 덜 이해되지만) 이런 측면이 더 좋다고 동의합니다만, 우리는 여전히 정의되지 않은 용어의 정의에 대해 논쟁 중입니다. 우리가 전달과 관련해 잘 정의된 속성을 원한다면 나는 실제로 원자적 브로드캐스트(Atomic Broadcast)가 꽤 좋은 정의라고 생각합니다. 우리가 비공식적으로 말하면, 사람들은 의미에 대해 직관적인 생각을 갖기 때문에 "정확히 한 번"이라고 말하는 것이 좋습니다. (우리가 원자성 에 대한 지원을 발표했다더라도 혼란은 결코 더 적지 않았을 것입니다. ). 더 큰 비판은 사람들이 원하는 진정한 보증이 "정확히"도 "효과적"도 아니며 "한번" 또는 "전달"과 관련된 어떤 것이라는 것입니다. 사람들이 원하는 진정한 보증은 애플리케이션과의 통합에 대해 열심히 생각할 필요없이 오류가 발생했을 때 메시지를 철저히 정확하게 처리하는 것입니다.

  결국, 제가 설명한 해결책은 그다지 복잡하지는 않지만 여전히 애플리케이션의 의미에 대해 생각해야 합니다. 우리는 "애플리케이션에 마법의 요정 가루를 내 뿌릴 수 있습니까" 라는 제목의 블로그에서 이 문제를 다루려고 했습니다 (대답은 "아니오"였습니다).

  우리는 이것을 쉽게 할 수 있을까요? 우리는 할 수 있다고 생각합니다. 여기에서 기능 집합의 두 번째 부분인 트랜잭션이 등장합니다.

  실제로 위에서 제시한 예는 데이터 처리와 결과를 저장 시스템에 통합하는 다른 두 문제를 혼합한 것입니다. 이들은 서로 얽혀 있기 때문에, 개발자는 두 가지 방법을 함께 풀어 내기가 어렵습니다.

  이를 개선하기 위한 아이디어는 애플리케이션을 다른 두 부분으로 분류하는 것입니다. 이 두 부분은 하나 이상의 입력 스트림을 변환하는 (레코드 간에 결합되거나 측면 데이터와 결합될 수 있는) "스트림 처리" 부분과 이 데이터를 데이터 저장소로 전송하는 (동일한 애플리케이션 또는 프로세스에서 실행될 수 있지만 논리적으로는 구분된) 커넥터입니다.

  커넥터는 Kafka로부터 특정 데이터 시스템에 대한 트랜잭션 또는 멱등적 데이터 전달에 대한 추론을 필요로 합니다. 커넥터는 깊게 생각하고 오프셋을 관리해야 하지만 완전히 재사용할 수 있습니다. JDBC를 지원하는 모든 데이터베이스에서 JDBC 커넥터를 정확히 한 번은 제대로 작동하므로,  애플리케이션 개발자는 이를 고려할 필요가 없습니다. 우리는 이미 이런 것들을 가지고 있습니다, 그러므로 이 기능은 개발하기 보다 JDBC 커넥터를 다운로드합니다.

  어려운 부분은 데이터 스트림에서 범용 변환을 올바로 수행하는 것입니다. Kafka의 스트림 API와 함께 트랜잭션 지원도 필요합니다.

  Kafka 스트림 API 는 입력 스트림과 출력 스트림의 상단에 변환을 정의하는 매우 일반적인 API를 제공하는 생산자 및 소비자의 상위 계층입니다. 이 API를 사용하면 애플리케이션에서 수행 할 수 있는 거의 모든 작업을 수행 할 수 있습니다. 고전적인  메시징 시스템 API에 비해 더 강력하지는 않습니다.

  Kafka 스트림 애플리케이션:

  이 애플리케이션은 분산된 "단어 수"를 계산하는 하는 고전적인 빅데이터 예제입니다. 단어 수는 완전히 실시간이며 연속적입니다 (새 문서가 작성 될 때마다 카운트가 증가합니다).

 이 프로그램은 메인 메소드를 가진 보통의 자바 애플리케이션입니다. 이 애플리케이션은 보통의  애플리케이션들처럼 시작되고 배포됩니다. Kafka 소비자는 모든 인스턴스가 들어오는 데이터 스트림을 처리하도록 작동합니다.

  이 애플리케이션은 어떻게 정확성을 보장할 수 있습을까요? 결국엔 입력, 출력, 수신 메시지 전반의 집계 및 분산 처리 등 모든 복잡한 것들을 상상할 수 있습니다.

  그것에 대해 생각해보면 Kafka의 모든 스트림 처리는 다음 세 가지를 수행하고 있습니다.
  1. 입력 메시지 읽기
  2. 상태에 대한 업데이트를 생성(애플리케이션 인스턴스가 실패하고 다른 곳에서 복구되는 경우 내결함성이 필요하기 때문에)
  3. 출력 메시지 생성
  핵심 요구 사항은 이 세 가지가 항상 함께 발생하거나 전혀 발생하지 않도록 보장하는 것입니다. 상태가 업데이트되었지만 출력이 생성되지 않거나 그 반대로 오류가 발생하는 경우를 허용할 수 없습니다.

  우리는 어떻게 이것을 할 수 있을까요?

  우리는 수년에 걸쳐 이것에 관해 정말로 열심히 생각했고, 지금도 이것을 건설하고 있습니다. 기초 작업은 지난 몇 년 동안 한 번도 변경하지 않은 사항이었습니다.
  1. 0.8.1 릴리스에서 Kafka는 상태 변경을 위한 저널 및 스냅샷으로 사용할 수 있는 로그 압축을 추가했습니다. 즉, 임의의 로컬(디스크 또는 메모리 내) 데이터 구조에 대한 일련의 업데이트를 Kafka에 대한 일련의 기록으로 모델링할 수 있습니다. 이를 통해 로컬 연산의 내결함성을 만들 수 있었습니다.
  2. Kafka에서 데이터를 읽는 것은 오프셋을 증가시킵니다. 0.8.2 에서  오프셋 저장에 Kafka 자체를 사용하도록 오프셋 저장 메커니즘을 이동했습니다. 내부에서 오프셋 커밋(Commit)은 Kafka에 쓰여집니다. (소비자 클라이언트가 이 작업을 수행하므로 우리는 모를 수도 있습니다).
  3. Kafka로 데이터를 쓰는 것은 항상 Kafka에 쓰기였습니다.
  위 프로그램은 방금 추가한 (이 세 가지 작업을 투명하게 단일 트랜잭션으로 감싸는) 기능을 설정합니다.   이렇게 하면 읽기, 처리, 상태 업데이트 및 출력 모두가 함께 발생하거나 전혀 발생하지 않습니다.

  이 과정은 느리지 않을까요? 많은 사람들은 분산 트랜잭션이 본질적으로 매우 느리다고 가정합니다. 모든 단일 입력에 대해 트랜잭션을 수행할 필요는 없습니다. 이 경우 입력들을 함께 배치로 처리할 수 있습니다. 배치가 클수록 트랜잭션의 실제 오버헤드는 낮아집니다 (트랜잭션은 트랜잭션의 메시지 수와 관계 없이 일정한 비용을 가집니다). 블로그 포스트는 이것에 대한 성과 결과를 보여 주었고 이는 매우 유망한 성과였습니다.

  결과적으로 스트림 API를 사용하는 애플리케이션에 내 애플리케이션을 인수로 지정하고 출력 시스템과의 통합을 위해 정확히 한 번 커넥터를 사용하는 경우, 이제는 구성 변경만으로 종단 간 정확성을 얻을 수 있습니다.

  정말 멋진 점은 이 기능이 Java API에 전혀 묶여 있지 않다는 것입니다. Java API는 데이터 스트림의 연속적이고 상태를 유지하고 올바른 처리를 모델링하기 위한 범용 네트워크 프로토콜을 둘러싼 단순한 래퍼입니다. 모든 언어에서 이 프로토콜을 사용할 수 있습니다. 우리는 이 기능에  일종의 매우 강력한 클로저 속성을 추가함으로 변환을 수행하고 프로토콜을 구현하는 임의의 프로세스를 통해 입력 및 출력 토픽을 올바르게 연결한다고 생각합니다

정확히 한 번에 대해 생각해 보기로 돌아가기


  나는 회의적인 사람들에게 우리가 한 일을 이해하고 그것을 이해하도록 격려하기 위해 이 글을 썼습니다. 벤더로부터 오는 많은 헛소리들에 나는 회의적입니다. 그러나 나는 이 기능 세트가 정말 흥미롭고 LIAR을 모든 대문자로 외칠뿐 아니라 실제로 무엇을하는지,하지 않는지, 실제로 한계가 무엇인지 이해함으로써 이해가 훨씬 더 높아질 것이라고 생각합니다.

  업계에서는 올바른 결과를 얻을 수 없다는 것, 근본적으로 비효율적이고, 일괄처리 없이는 불완전하다는 것 등,  롤백되는 과정에 있는 스트림 처리와 관련하여 많은 가정이 있었습니다. 나는 정확히 한 번 처리하는 것이 불가능하다는 주변의 광범위하고 모호한 주장이 결국 양동이에 빠지게 된다고 생각합니다. 그것들은 나에게 일종의 분산 시스템인 broscience("나는 형제의 형제에게서 구글에서 일하는 형제가 정확히 한번이 CAP 이론에 위배된다는 말하는 것을 들었습니다”)를 생각나게 합니다. 나에게 진보는 일반적으로 실제로 가능하지 않은 것을 더 깊이 이해하고 문제를 재정의하여 우리를 앞으로 나아갈 실제적인 추상화를 구축하려는 시도로 이루어집니다.

  이런 것이 일어나는 좋은 예는 Spanner 와 CockroachDB 와 같은 시스템에서 수행되는 작업입니다. 이 작업은  가능한 범위 내에서 애플리케이션 개발자에게 유용한 기능을 제공하기 위해 많은 것을 수행합니다. 나는 NoSQL 분야에서 많은 경험을 갖고 있습니다. 이 시스템이 무엇을 하고 있는지는 대부분의 사람들이 불가능하고 비현실적인 조합이라고 생각하는 것으로 잘못 인식되었습니다. 나는 이것이 우리에게 교훈이되어야 한다고 생각한다. 애플리케이션을 구현하는 가난한 사람에게 모든 어려운 문제를 포기하고 구멍을 내기보다는 문제 공간을 재정의하여 올바른, 빠르며 가능한 대부분의 사용 가능한 시스템 기본 요소를 구축하는 방법을 이해해야 합니다.

원문 : Exactly-once Support in Apache Kafka