구현: 로그

구현: 로그 (Log)

이 페이지는 Kafka가 토픽의 메시지를 디스크에 어떤 파일 구조로 저장하는지, 그리고 쓰기·읽기·삭제·내구성 보장이 어떻게 설계되어 있는지를 설명해요. 브로커 로그 파일의 이름 규칙이나 세그먼트(segment) 단위 관리 같은 걸 이해하는 데 도움이 되는 내용이에요.

출처: 문서

본문

두 개의 파티션을 가진 my-topic이라는 토픽의 로그는 my-topic-0my-topic-1이라는 두 개의 디렉터리로 구성되며, 각 디렉터리에는 그 토픽의 메시지를 담은 데이터 파일들이 들어 있습니다. 로그 파일의 형식은 "로그 엔트리(log entry)"의 연속입니다. 각 로그 엔트리는 메시지 길이를 저장하는 4바이트 정수 N으로 시작하며, 그 뒤에 N바이트의 메시지 본문이 따라옵니다. 각 메시지는 그 파티션에 그 토픽으로 보내진 모든 메시지 스트림에서 이 메시지의 시작 바이트 위치를 나타내는 64비트 정수 오프셋으로 고유하게 식별됩니다. 각 메시지의 디스크 저장 형식은 아래에 나와 있습니다. 각 로그 파일은 그 파일이 담고 있는 첫 메시지의 오프셋으로 이름이 붙습니다. 그래서 첫 번째 파일은 00000000000000000000.log가 되고, 이후 파일들은 이전 파일보다 대략 S바이트만큼 떨어진 정수 이름을 갖게 됩니다. 여기서 S는 설정에 주어진 최대 로그 파일 크기입니다.

레코드의 정확한 이진 형식은 버전 관리되며 표준 인터페이스로 유지됩니다. 그래서 레코드 배치는 필요할 때 복사나 변환 없이 프로듀서·브로커·클라이언트 사이에서 전달될 수 있습니다. 앞선 섹션에는 레코드의 디스크 저장 형식에 대한 세부 내용이 포함되어 있습니다.

메시지 오프셋을 메시지 ID로 사용하는 것은 특이한 선택입니다. 원래 아이디어는 프로듀서가 생성한 GUID를 사용하고, 각 브로커에 GUID에서 오프셋으로의 매핑을 유지하는 것이었습니다. 하지만 컨슈머는 서버마다 ID를 유지해야 하므로 GUID의 전역 고유성은 아무 가치가 없습니다. 게다가 랜덤 ID에서 오프셋으로의 매핑을 유지하는 것은 디스크와 동기화되어야 하는 무거운 인덱스 구조를 필요로 하며, 사실상 완전한 영구 랜덤 접근 데이터 구조가 필요합니다. 그래서 조회 구조를 단순화하기 위해 파티션 ID와 노드 ID와 결합해 메시지를 고유하게 식별할 수 있는 간단한 파티션별 원자 카운터를 사용하기로 결정했습니다. 이렇게 하면 조회 구조는 단순해지지만, 컨슈머 요청당 여전히 여러 번의 seek(탐색)가 발생할 가능성은 있습니다. 하지만 카운터를 선택하고 나니 오프셋을 직접 사용하는 것이 자연스러워 보였습니다. 어차피 둘 다 파티션에 대해 단조 증가하는 정수이기 때문입니다. 오프셋은 컨슈머 API에서 숨겨져 있으므로 이 결정은 결국 구현 세부 사항이며, 우리는 더 효율적인 방식을 선택했습니다.

쓰기 (Writes)

로그는 항상 마지막 파일에 가는 직렬 추가(serial append)를 허용합니다. 이 파일은 설정 가능한 크기(예: 1GB)에 도달하면 새 파일로 롤오버(rollover)됩니다. 로그는 두 개의 설정 파라미터를 사용합니다. M은 OS에 파일을 디스크로 플러시하도록 강제하기 전에 쓸 메시지 수이고, S는 플러시가 강제되는 초(seconds) 수입니다. 이는 시스템 크래시 시 최대 M개 메시지 또는 S초 분량의 데이터만 손실된다는 내구성 보장을 제공합니다.

읽기 (Reads)

읽기는 메시지의 64비트 논리 오프셋과 S바이트 최대 청크 크기를 지정하여 수행됩니다. 그러면 S바이트 버퍼에 담긴 메시지들에 대한 이터레이터가 반환됩니다. S는 단일 개별 메시지보다 크게 설계되지만, 비정상적으로 큰 메시지가 발생하면 버퍼 크기를 두 배로 늘리면서 메시지가 성공적으로 읽힐 때까지 읽기를 여러 번 재시도할 수 있습니다. 최대 메시지 및 버퍼 크기를 지정하면 서버가 특정 크기보다 큰 메시지를 거부하고, 클라이언트가 완전한 메시지를 읽기 위해 필요로 하는 최대 크기에 대한 상한도 제공할 수 있습니다. 읽기 버퍼가 부분 메시지로 끝날 가능성이 높은데, 이는 크기 구분(size delimiting)으로 쉽게 감지됩니다.

오프셋에서 읽는 실제 과정은 데이터가 저장된 로그 세그먼트 파일을 먼저 찾고, 전역 오프셋 값에서 파일별 오프셋을 계산한 다음 그 파일 오프셋에서 읽는 것을 필요로 합니다. 탐색은 각 파일에 대해 유지되는 메모리 내 범위에 대한 간단한 이진 탐색 변형으로 수행됩니다.

로그는 가장 최근에 작성된 메시지를 가져오는 기능도 제공합니다. 그래서 클라이언트가 "지금 바로"부터 구독을 시작할 수 있습니다. 이는 컨슈머가 SLA로 지정된 일수 안에 데이터를 소비하지 못한 경우에도 유용합니다. 이 경우 클라이언트가 존재하지 않는 오프셋을 소비하려고 하면 OutOfRangeException이 주어지며, 사용 사례에 맞게 스스로 리셋하거나 실패 처리할 수 있습니다.

다음은 컨슈머에게 보내지는 결과의 형식입니다.

MessageSetSend (fetch result)

total length     : 4 bytes
error code       : 2 bytes
message 1        : x bytes
...
message n        : x bytes


MultiMessageSetSend (multiFetch result)

total length       : 4 bytes
error code         : 2 bytes
messageSetSend 1
...
messageSetSend n

삭제 (Deletes)

데이터는 한 번에 하나의 로그 세그먼트씩 삭제됩니다. 로그 매니저는 삭제 대상 세그먼트를 식별하기 위해 시간과 크기라는 두 가지 지표를 적용합니다. 시간 기반 정책에서는 레코드 타임스탬프가 고려되며, 세그먼트 파일에서 가장 큰 타임스탬프(레코드 순서는 관련 없음)가 해당 세그먼트 전체의 보존(retention) 시간을 정의합니다. 크기 기반 보존은 기본적으로 비활성화되어 있습니다. 활성화되면 로그 매니저는 파티션의 전체 크기가 구성된 한도 안으로 다시 들어올 때까지 가장 오래된 세그먼트 파일을 계속 삭제합니다. 두 정책이 동시에 활성화되면, 어느 한 정책에 의해 삭제 대상이 된 세그먼트는 삭제됩니다. 읽기 동안 잠금을 유지하면서도 세그먼트 목록을 수정하는 삭제를 허용하기 위해 우리는 copy-on-write 방식의 세그먼트 목록 구현을 사용합니다. 이 구현은 삭제가 진행되는 동안 이진 탐색이 진행될 수 있는 불변 정적 스냅샷 뷰를 제공합니다.

보장 (Guarantees)

로그는 디스크로 플러시를 강제하기 전에 기록되는 최대 메시지 수를 제어하는 설정 파라미터 M을 제공합니다. 시작 시 로그 복구 프로세스가 실행되어 가장 최신 로그 세그먼트의 모든 메시지를 순회하며 각 메시지 엔트리가 유효한지 검증합니다. 메시지 엔트리는 크기와 오프셋의 합이 파일 길이보다 작고, 메시지 페이로드의 CRC32가 메시지와 함께 저장된 CRC와 일치하면 유효합니다. 손상이 감지되면 로그는 마지막 유효 오프셋으로 잘립니다(truncate).

두 종류의 손상이 처리되어야 한다는 점에 유의하세요. 하나는 크래시로 인해 기록되지 않은 블록이 손실되는 잘림(truncation)이고, 다른 하나는 말도 안 되는 블록이 파일에 추가되는 손상(corruption)입니다. 그 이유는 일반적으로 OS가 파일 inode와 실제 블록 데이터 사이의 쓰기 순서를 보장하지 않기 때문입니다. 그래서 기록된 데이터가 손실될 뿐만 아니라, inode가 새 크기로 업데이트되었지만 그 데이터가 담긴 블록이 쓰여지기 전에 크래시가 발생하면 파일에 무의미한 데이터가 추가될 수도 있습니다. CRC가 이러한 코너 케이스를 감지해 로그가 손상되는 것을 막아줍니다(물론 기록되지 않은 메시지 자체는 손실됩니다).

더 알아보기 (Learn more)

  • 로그 세그먼트와 log.segment.bytes, log.retention.hours 같은 보존 설정을 함께 보면 디스크 관리 전략을 이해할 수 있어요.
  • CRC 검증과 복구 프로세스는 크래시 후에도 로그 정합성을 지키는 핵심 메커니즘이에요.