Skip to main content

Command Palette

Search for a command to run...

Kafka Tiered Storage 내부 동작 — RemoteLogManager, RemoteStorageManager, 그리고 두 계층 위의 무한 보존

Updated
14 min read

Kafka 3.9에서 production-ready로 승격된 KIP-405 Tiered Storage가 브로커 로컬 디스크 위에 어떻게 "원격 계층"이라는 두 번째 층을 얹는지를 정리합니다. 운영자가 마주치는 remote.log.storage.enable, local.retention.ms 같은 설정 한 줄 뒤에서 RemoteLogManager가 어떤 태스크를 돌리고, RemoteStorageManager / RemoteLogMetadataManager 두 플러그인이 어떤 책임을 나누어 가지며, 4-상태 세그먼트 머신이 어떻게 흘러가는지를 같은 그림 위에서 따라갑니다.

카프카 운영의 가장 흔한 통증 두 가지는 "로그가 디스크를 잡아먹는다"와 "재시작·리밸런스가 느리다"입니다. 둘 다 같은 원인 — 브로커가 모든 retention 기간 동안의 데이터를 로컬 디스크에 가지고 있어야 한다는 제약 — 에서 나옵니다. 90일 retention을 걸면 90일치 디스크가 필요하고, 그만큼 다른 브로커가 합류할 때 따라잡아야 할 데이터도 커집니다.

Tiered Storage는 이 제약을 깨는 KIP-405의 결과입니다. "최근 데이터는 로컬, 오래된 데이터는 S3 같은 원격 오브젝트 스토어"라는 두 계층 구조를 도입해, 로컬 디스크는 hot data만 잡고 cold data는 사실상 무한대로 보존합니다. 2023년 Kafka 3.6에서 Early Access로 들어왔고 2024년 11월의 Kafka 3.9에서 GA(production-ready)로 승격되었습니다.

이 글은 운영자가 토픽 설정 한 줄을 바꾸면 그 뒤에서 어떤 일이 일어나는지를 따라갑니다. 추상 인터페이스 두 개(RSM, RLMM)와 그것을 조립하는 RemoteLogManager(RLM)의 세 가지 태스크, 그리고 그 위에서 흐르는 4-상태 세그먼트 머신을 같은 그림 안에서 풀어냅니다.

두 계층 추상화

먼저 Tiered Storage가 도입한 새 그림부터 봅니다.

flowchart LR
  subgraph Local["Local Tier (broker disk)"]
    Active["Active Segment\n(currently being written)"]
    Recent["Recent Sealed Segments\n(within local.retention)"]
  end
  subgraph Remote["Remote Tier (S3 / GCS / HDFS)"]
    Older["Older Sealed Segments\n(within retention)"]
    Expired["Expired (deleted)"]
  end
  Active -->|"log roll"| Recent
  Recent -->|"RLMCopyTask"| Older
  Recent -->|"local.retention breach"| Drop["Removed from local\n(remains in remote)"]
  Older -->|"retention breach"| Expired

Tiered Storage 이전에는 위 그림 전체가 한 계층이었습니다. 세그먼트가 roll되어 sealed가 되든, 90일 묵었든, 모두 같은 디스크에 있다가 retention이 차야 삭제되었습니다.

Tiered Storage 이후에는 두 가지가 바뀝니다.

  • 세그먼트는 sealed 상태가 되면 원격으로 복사된다. 복사 자체가 retention 만료는 아닙니다. 로컬에 그대로 두면서 원격에도 사본이 생깁니다.
  • 로컬 retention과 전체 retention이 분리된다. local.retention.ms는 "로컬에서 얼마나 들고 있을지", retention.ms는 "원격까지 합쳐 전체로 얼마나 들고 있을지"입니다. local.retention.ms가 짧으면 로컬에서 빨리 사라지고 원격만 남습니다.

핵심은 active 세그먼트는 원격으로 가지 않는다는 것입니다. 현재 쓰기 중인 마지막 세그먼트는 항상 로컬에만 존재합니다. 원격 계층은 sealed segment만 다룹니다(KIP-1176에서 active segment의 tiering 도입을 논의 중이지만 별도 KIP입니다).

핵심 컴포넌트

브로커 안에서 Tiered Storage를 떠받치는 객체는 셋입니다.

flowchart TB
  RLM["RemoteLogManager (RLM)\nbroker-internal, non-pluggable"]
  RSM["RemoteStorageManager (RSM)\nplugin interface"]
  RLMM["RemoteLogMetadataManager (RLMM)\nplugin interface"]
  RLM -->|"copy / fetch / delete\nsegment bytes"| RSM
  RLM -->|"add / update / list\nsegment metadata"| RLMM
  RSM -.->|"e.g. S3 / GCS / HDFS"| Object["External Object Store"]
  RLMM -.->|"default: internal topic"| MetaTopic["__remote_log_metadata"]
  • RemoteLogManager (RLM): 각 브로커 안에 하나 존재하는 카프카 내부 객체입니다. 플러그인이 아니라 브로커 코드의 일부이며, 어떤 세그먼트가 원격에 올라갈지·언제 삭제될지·어떻게 읽힐지를 결정합니다.
  • RemoteStorageManager (RSM): 실제 바이트를 어디에 두는지를 추상화한 플러그인 인터페이스입니다. S3, GCS, HDFS, Azure Blob 같은 구현체가 이 인터페이스를 채웁니다. remote.log.storage.manager.class.name 설정으로 구현을 고릅니다.
  • RemoteLogMetadataManager (RLMM): "어떤 세그먼트가 원격에 있고 어떤 상태인지"라는 메타데이터를 강일관성(strongly consistent)으로 저장하는 플러그인 인터페이스입니다. remote.log.metadata.manager.class.name으로 구현을 고르고, 기본 구현은 TopicBasedRemoteLogMetadataManager(TBRLMM)로 내부 토픽 __remote_log_metadata에 메타데이터 레코드를 씁니다.

RSM과 RLMM이 분리된 이유는 책임이 다르기 때문입니다. RSM은 큰 바이트 묶음의 저장 위치 추상화이고, RLMM은 그 묶음의 인덱스·상태·생애주기 추상화입니다. S3에 데이터를 두고, 메타데이터는 카프카 내부 토픽에 두는 것이 기본 조합입니다.

RemoteStorageManager — 바이트의 추상화

RSM 인터페이스의 메서드는 다섯 개입니다(storage/api/src/main/java/org/apache/kafka/server/log/remote/storage/RemoteStorageManager.java).

public interface RemoteStorageManager extends Configurable, Closeable {

    enum IndexType {
        OFFSET,
        TIMESTAMP,
        PRODUCER_SNAPSHOT,
        TRANSACTION,
        LEADER_EPOCH,
    }

    Optional<CustomMetadata> copyLogSegmentData(
        RemoteLogSegmentMetadata remoteLogSegmentMetadata,
        LogSegmentData logSegmentData) throws RemoteStorageException;

    InputStream fetchLogSegment(
        RemoteLogSegmentMetadata remoteLogSegmentMetadata,
        int startPosition) throws RemoteStorageException;

    InputStream fetchLogSegment(
        RemoteLogSegmentMetadata remoteLogSegmentMetadata,
        int startPosition,
        int endPosition) throws RemoteStorageException;

    InputStream fetchIndex(
        RemoteLogSegmentMetadata remoteLogSegmentMetadata,
        IndexType indexType) throws RemoteStorageException;

    void deleteLogSegmentData(
        RemoteLogSegmentMetadata remoteLogSegmentMetadata) throws RemoteStorageException;
}

copyLogSegmentData가 받는 LogSegmentData는 한 세그먼트를 구성하는 모든 파일의 묶음입니다. 카프카 입문 시리즈 4편(message flow)에서 본 세그먼트의 친구들이 그대로 올라갑니다.

  • .log — 메시지 바이트
  • .index — offset → 파일 위치 sparse 인덱스
  • .timeindex — timestamp → offset 인덱스 (KIP-33)
  • .txnindex — aborted transaction 정보
  • .snapshot — producer ID snapshot (idempotent producer 복구용)
  • leader-epoch-checkpoint — 리더 epoch 이력

fetchLogSegment는 두 오버로드를 가집니다. startPosition만 받는 버전은 그 위치부터 끝까지 스트리밍, startPosition/endPosition 둘 다 받는 버전은 범위 스트리밍입니다. S3 구현체는 후자에서 Range 헤더로 부분 GET을 보냅니다.

fetchIndex는 데이터가 아닌 인덱스만 따로 받아 옵니다. 컨슈머 fetch가 원격 세그먼트의 특정 offset에 닿아야 할 때, 전체 .log를 받지 않고 .index만 먼저 받아 startPosition을 계산한 뒤 fetchLogSegment(seg, startPosition, endPosition)을 호출하는 식입니다. 이 인덱스는 뒤에서 다룰 RemoteIndexCache가 디스크에 캐시합니다.

deleteLogSegmentData는 retention 만료 시 호출됩니다. 메타데이터는 별도(RLMM)이므로 여기서는 바이트만 지우면 됩니다.

RemoteLogMetadataManager — 4-상태 세그먼트 머신

세그먼트가 원격에 "있다/없다"는 단순한 boolean이 아닙니다. 업로드 중에 브로커가 죽거나 삭제 중에 실패할 수 있어서, 모든 세그먼트는 4-상태 머신을 통과합니다.

flowchart LR
  Start([new segment]) --> A[COPY_SEGMENT_STARTED]
  A -->|"RSM.copyLogSegmentData succeeded"| B[COPY_SEGMENT_FINISHED]
  B -->|"retention breach"| C[DELETE_SEGMENT_STARTED]
  A -->|"copy never finished\n(dangling, KAFKA-17062)"| C
  C -->|"RSM.deleteLogSegmentData succeeded"| D[DELETE_SEGMENT_FINISHED]

상태 전이 규칙은 RemoteLogMetadataManager 인터페이스의 javadoc에 명문화되어 있습니다.

  • COPY_SEGMENT_STARTED: 리더가 RSM에 copyLogSegmentData를 호출하기 직전에 RLMM에 추가하는 첫 상태. addRemoteLogSegmentMetadata로 들어오는 메타데이터는 반드시 이 상태여야 하며, 그 외의 상태로 들어오면 IllegalArgumentException입니다.
  • COPY_SEGMENT_FINISHED: copyLogSegmentData가 성공적으로 돌아오면 updateRemoteLogSegmentMetadata로 이 상태로 갱신됩니다. 이 상태부터 컨슈머 fetch가 이 세그먼트를 읽을 수 있습니다.
  • DELETE_SEGMENT_STARTED: retention 만료로 RLM이 삭제를 시작했음을 알리는 마커. 컨슈머 fetch에는 이미 보이지 않습니다.
  • DELETE_SEGMENT_FINISHED: RSM deleteLogSegmentData가 성공한 뒤. 이 시점에서 메타데이터는 사실상 tombstone입니다.

이 4-상태 머신 덕분에 모든 부분 실패가 회복 가능합니다. COPY가 STARTED에서 멈춰 있으면 retention 사이즈 계산에서 이를 무시하고 재시도하거나(KAFKA-17428 수정 이후), DELETE가 STARTED에서 멈춰 있으면 다음 RLMExpirationTask가 다시 시도합니다.

TopicBasedRemoteLogMetadataManager — 카프카 위의 카프카

기본 RLMM 구현은 메타데이터를 다시 카프카 토픽에 씁니다. 토픽 이름은 __remote_log_metadata(non-compacted)이고, 기본 50개 파티션입니다(remote.log.metadata.topic.num.partitions). 모든 브로커가 이 토픽의 자기 책임 파티션을 소비하면서 자신이 리더인 사용자 파티션의 원격 세그먼트 상태를 메모리에 캐시합니다.

flowchart LR
  subgraph Broker1["Broker 1"]
    RLMM1[RLMM cache\nfor leader partitions]
  end
  subgraph Broker2["Broker 2"]
    RLMM2[RLMM cache\nfor leader partitions]
  end
  Meta["__remote_log_metadata\n(50 partitions, non-compacted)"]
  RLMM1 -->|"produce state events"| Meta
  RLMM2 -->|"produce state events"| Meta
  Meta -->|"consume relevant partitions"| RLMM1
  Meta -->|"consume relevant partitions"| RLMM2

사용자 토픽 파티션 → 메타데이터 토픽 파티션 매핑은 Utils.toPositive(hash) % numMetadataPartitions라는 단순한 해시 분배입니다. 같은 사용자 파티션의 모든 메타데이터 이벤트는 한 메타데이터 파티션에 모이므로 순서가 보장됩니다.

remote.log.metadata.manager.impl.prefix(기본 rlmm.config.) 접두사 아래의 모든 설정이 RLMM에 그대로 전달됩니다. 예: rlmm.config.remote.log.metadata.consume.wait.ms.

RemoteLogManager의 세 가지 태스크

RLM은 브로커 안에서 세 종류의 스케줄 태스크를 굴립니다. Kafka 3.9 직전(KAFKA-16853)에 세 태스크가 서로 다른 스레드 풀에 분리되었습니다. 한 풀이 막혀도 다른 책임이 영향받지 않게 하기 위해서입니다.

flowchart TB
  RLM[RemoteLogManager]
  CopyPool["Copier Pool\nremote.log.manager.copier.thread.pool.size"]
  ExpPool["Expiration Pool\nremote.log.manager.expiration.thread.pool.size"]
  FollowerPool["Follower Pool\nremote.log.manager.follower.thread.pool.size"]
  RLM --> CopyPool
  RLM --> ExpPool
  RLM --> FollowerPool
  CopyPool -->|"per leader partition\nevery ~30s"| Copy[RLMCopyTask]
  ExpPool -->|"per leader partition\nevery ~30s"| Exp[RLMExpirationTask]
  FollowerPool -->|"per follower partition\nevery ~30s"| Foll[RLMFollowerTask]

세 태스크의 책임은 다음과 같습니다.

  • RLMCopyTask (리더 전용): 토픽-파티션의 리더 브로커에서 sealed segment를 RSM으로 올립니다. 주기 기본값은 remote.log.manager.task.interval.ms = 30,000ms.
  • RLMExpirationTask (리더 전용): retention.ms 또는 retention.bytes를 초과한 원격 세그먼트를 RSM에서 지웁니다. 같은 30초 주기.
  • RLMFollowerTask (팔로워 전용): 자신이 팔로워인 파티션이 원격에 어디까지 올라가 있는지를 RLMM에서 읽어 메모리에 둡니다. 팔로워가 리더로 승격될 때 즉시 어디서부터 올릴지 알아야 하므로 미리 따라잡아 두는 것입니다.

팔로워는 원격으로 직접 올리지 않습니다. 원격 업로드는 리더만 합니다. 이는 리플리케이션 모델을 그대로 유지하기 위함입니다 — 리더가 진실의 원천(source of truth)이고, 원격은 그 진실의 일부 사본입니다.

쓰기 경로 — 세그먼트가 원격으로 올라가는 길

producer.send()가 호출되고 메시지가 active segment에 박힌 뒤, 이 세그먼트가 결국 원격에 도착하기까지의 전체 여정을 따라가 봅니다.

flowchart TB
  P[Producer.send] --> A["Append to active segment\n(local disk)"]
  A --> R{"roll trigger?\n(segment.bytes /\nsegment.ms)"}
  R -- no --> A
  R -- yes --> S["Active becomes sealed\nnew active opens"]
  S --> T["RLMCopyTask wakes up\n(~30s tick)"]
  T --> Q{"Sealed segment\nnot yet uploaded?"}
  Q -- yes --> M1["RLMM.addRemoteLogSegmentMetadata\nstate = COPY_SEGMENT_STARTED"]
  M1 --> C["RSM.copyLogSegmentData\n(.log + .index + .timeindex +\n.txnindex + .snapshot +\nleader-epoch-checkpoint)"]
  C --> M2["RLMM.updateRemoteLogSegmentMetadata\nstate = COPY_SEGMENT_FINISHED"]
  M2 --> L["Eligible for local.retention\n(local file can be deleted)"]

핵심 사실 몇 가지를 짚어 둡니다.

  • active segment는 절대 올라가지 않는다. roll(2단계 message flow에서 본 segment.bytes/segment.ms)을 통과해 sealed가 된 세그먼트만 후보입니다.
  • COPY_STARTED 메타데이터를 먼저 쓰고 그 다음 바이트를 올린다. 순서가 반대면 업로드 중 브로커가 죽었을 때 "원격에는 있는데 메타데이터에는 없는" orphan이 생겨 회수 불가능합니다.
  • copy가 끝나도 로컬 파일은 즉시 사라지지 않는다. 로컬 retention 정책(local.retention.ms / local.retention.bytes)이 별도로 따라옵니다. copy 완료는 "이 세그먼트를 로컬에서 지워도 안전하다"는 자격을 부여할 뿐입니다.
  • 세그먼트 단위로 모든 보조 파일을 함께 올린다. offset index / time index / transaction index / producer snapshot / leader epoch — 다섯 종을 모두 묶어 한 LogSegmentData로 올립니다. 부분 업로드가 없습니다.

읽기 경로 — 컨슈머 fetch가 원격까지 닿는 길

이번엔 반대 방향입니다. 컨슈머가 오래된 offset을 요청했고, 그 offset은 로컬에 없고 원격에만 있습니다. 무슨 일이 벌어질까요?

flowchart TB
  C[Consumer.poll] --> F[FetchRequest to broker]
  F --> RM["ReplicaManager.fetchMessages"]
  RM --> RL["ReplicaManager.readFromLocalLog"]
  RL --> X{"offset in local?"}
  X -- yes --> Z[Return FetchDataInfo]
  X -- no, OffsetOutOfRange --> P["Put request in\nRemoteFetchPurgatory"]
  P --> RR["Submit RemoteLogReader task\nto RemoteStorageThreadPool"]
  RR --> Look["RLMM.remoteLogSegmentMetadata\n(topic, epoch, offset)"]
  Look --> Idx{".index / .timeindex\nin RemoteIndexCache?"}
  Idx -- no --> FI["RSM.fetchIndex\n(IndexType.OFFSET / TIMESTAMP)"]
  FI --> Cache["Cache to remote-log-index-cache"]
  Cache --> Idx
  Idx -- yes --> Pos["Compute startPosition / endPosition\nfrom offset using local index"]
  Pos --> Bytes["RSM.fetchLogSegment(seg, start, end)"]
  Bytes --> Resp["Build FetchDataInfo,\ncomplete the purgatory entry"]

이 경로의 묘미는 OffsetOutOfRangeException을 시그널로 재활용한 점입니다. 원래 이 예외는 "요청한 offset이 로컬 로그에 존재하지 않는다"는 에러였지만, Tiered Storage 위에서는 같은 예외를 "원격에 있을 수 있으니 원격 쪽도 보라"는 시그널로 받습니다. ReplicaManager는 이 예외를 잡아 RemoteFetchPurgatory에 요청을 보관하고, RemoteLogReader 태스크를 RemoteStorageThreadPool 큐에 던집니다.

RemoteStorageThreadPoolremote.log.reader.threads(기본 10) 크기의 풀이고, 큐 상한은 remote.log.reader.max.pending.tasks(기본 100)입니다. 원격 fetch는 본질적으로 S3 GET 같은 네트워크 호출이므로 로컬 fetch보다 훨씬 느립니다. 그래서 별도 풀이 필요합니다 — IO 스레드를 막아서는 안 됩니다.

RemoteIndexCache는 RLM이 관리하는 로컬 캐시(LRU)로, 기본 위치는 브로커 데이터 디렉토리 안의 remote-log-index-cache/입니다. 같은 원격 세그먼트의 인덱스를 여러 번 fetch해야 할 때마다 RSM fetchIndex를 부르면 비효율적이므로, 한 번 가져온 인덱스는 디스크에 캐시합니다. 캐시 크기는 remote.log.index.file.cache.total.size.bytes(기본 1 GiB)로 제한합니다.

KIP-405 GA 단계의 알려진 제약 하나: OffsetOutOfRange로 원격 fetch가 트리거되는 것은 fetch 요청 안의 첫 번째 파티션에 한정됩니다. 한 fetch 요청이 여러 토픽-파티션을 묶어 보낼 때, 그중 두 번째 이후의 파티션이 원격에 있어야 한다면 그 fetch는 빈 결과로 돌아가고, 컨슈머의 다음 fetch에서 다시 시도됩니다.

보존 정책 — 두 개의 retention

Tiered Storage가 가장 직접적으로 바꾸는 운영 노브는 retention 설정입니다.

설정 의미 기본값
retention.ms 로컬+원격 합쳐 총 보존 기간 7일
retention.bytes 로컬+원격 합쳐 총 보존 크기 -1 (무제한)
local.retention.ms 로컬 디스크에 보존할 최대 기간 -2 (= retention.ms)
local.retention.bytes 로컬 디스크에 보존할 최대 크기 -2 (= retention.bytes)

-2라는 특이한 기본값은 "전체 retention과 같음"을 뜻합니다. 즉, 기본 상태에서는 Tiered Storage를 켜도 로컬에 모든 데이터가 그대로 남고, 원격은 그저 사본 역할만 합니다. 운영자가 명시적으로 local.retention.ms를 짧게 잡아야 비로소 의도한 효과(로컬에는 hot만, 원격에는 cold)가 나옵니다.

전형적인 패턴은 다음과 같습니다.

# 7일치 모두 보존하지만 로컬에는 1일만
retention.ms: 604800000        # 7 days
local.retention.ms: 86400000   # 1 day
remote.storage.enable: true

설정 한 줄을 잘못 잡으면(local.retention.ms > retention.ms) 카프카는 설정 검증 단계에서 거부합니다. localtotal보다 클 수는 없습니다.

주요 설정 한눈에

브로커 수준에서 Tiered Storage를 켜는 스위치들입니다.

설정 기본 설명
remote.log.storage.system.enable false 브로커 전체에서 Tiered Storage 기능을 켜는 마스터 스위치. true여야 RLM·RSM·RLMM이 부팅합니다.
remote.log.storage.manager.class.name (none) RSM 구현 클래스 FQN. Aiven/Confluent/AWS 등이 자체 RSM을 제공합니다.
remote.log.metadata.manager.class.name TopicBasedRemoteLogMetadataManager RLMM 구현. 기본은 카프카 내부 토픽 기반.
remote.log.manager.task.interval.ms 30000 RLMCopyTask·RLMExpirationTask·RLMFollowerTask의 주기.
remote.log.manager.copier.thread.pool.size 10 원격 업로드용 스레드 풀. (구버전의 단일 remote.log.manager.thread.pool.size에서 분리됨, KAFKA-16853)
remote.log.manager.expiration.thread.pool.size 10 원격 삭제용 스레드 풀.
remote.log.reader.threads 10 원격 fetch용 별도 IO 풀.
remote.log.reader.max.pending.tasks 100 원격 fetch 큐 상한. 넘치면 fetch가 빈 결과로 돌아갑니다.
remote.log.index.file.cache.total.size.bytes 1073741824 (1 GiB) RemoteIndexCache 디스크 상한.

토픽 수준 설정은 이렇습니다.

토픽 설정 기본 설명
remote.storage.enable false 이 토픽에 Tiered Storage를 켭니다. 브로커 마스터 스위치가 켜져 있어야 동작.
local.retention.ms -2 로컬 보존 시간. -2면 retention.ms와 동일(=tiering이 효과 없음).
local.retention.bytes -2 로컬 보존 크기. -2면 retention.bytes와 동일.

TopicBasedRemoteLogMetadataManager 관련 추가 설정(접두사 rlmm.config.)도 있습니다.

RLMM 설정 기본 설명
remote.log.metadata.topic.replication.factor 3 __remote_log_metadata 토픽 복제 계수.
remote.log.metadata.topic.num.partitions 50 메타데이터 토픽 파티션 수.
remote.log.metadata.topic.retention.ms -1 (무제한) 메타데이터 토픽 자체의 retention. -1이 안전.
remote.log.metadata.consume.wait.ms 120000 RLMM이 메타데이터 토픽 컨슘에서 기다리는 최대 시간.

한계와 트레이드오프

Kafka 3.9에서 GA가 되었지만 Tiered Storage가 만능은 아닙니다. KIP-405 본문과 운영 사례가 공통으로 지적하는 한계가 셋 있습니다.

1. 압축 토픽 미지원. cleanup.policy=compact인 토픽은 Tiered Storage를 켤 수 없습니다. 압축은 key별로 최신 값만 남기는 in-place 재작성이고, 원격 오브젝트 스토어의 immutable 모델과 충돌하기 때문입니다. compact + delete 혼합도 마찬가지로 거부됩니다. cleanup.policy=delete인 토픽만 후보입니다.

2. JBOD 미지원(특정 버전까지). 한 브로커가 여러 로그 디렉토리를 가지는 JBOD 구성에서는 KIP-405 초기 구현이 Tiered Storage를 거부했습니다. JBOD와 tiering 결합은 별도 KIP/JIRA로 후속 작업되고 있고, 도입 시점의 버전에 따라 다르므로 운영 환경의 Kafka 버전과 release notes를 반드시 확인해야 합니다.

3. 원격 fetch 레이턴시. S3 GET은 로컬 디스크 read보다 한참 느립니다(보통 수십~수백 ms). 컨슈머가 오래된 데이터를 catch-up할 때 throughput이 떨어지고, 다른 컨슈머 fetch까지 영향을 받지 않도록 별도 풀(remote.log.reader.threads)이 분리되어 있긴 하지만, 풀이 가득 차면 그 너머의 fetch는 빈 응답으로 처리됩니다.

비용 모델도 직관과 다릅니다. S3 storage 비용은 EBS보다 한참 싸지만, S3 GET 요청에는 횟수 과금이 붙습니다. 컨슈머가 원격 데이터를 자주 다시 읽는 워크로드(예: 빈번한 reprocessing)에서는 GET 요청 비용이 storage 절감을 상쇄할 수 있습니다. KIP-1267(Tiered Storage Cost Attribution Metrics)가 이런 비용을 토픽별로 가시화하기 위한 후속 작업입니다.

도입 전 점검 항목

운영자 관점에서 Tiered Storage를 도입하기 전에 확인할 것들입니다.

  • Kafka 버전이 3.9 이상인가. Early Access(3.6~3.8)는 production 사용을 권하지 않습니다.
  • 토픽이 cleanup.policy=delete인가. compact 토픽은 후보가 아닙니다.
  • JBOD를 쓰고 있는가. 쓰고 있다면 현재 Kafka 버전이 JBOD+tiering을 지원하는지 release notes로 확인합니다.
  • RSM 구현체 선택. Aiven(tiered-storage-for-apache-kafka), Confluent, AWS MSK, 그리고 LinkedIn(kafka-remote-storage-azure) 등이 OSS RSM을 제공합니다. 비용·일관성 모델·테스트 커버리지를 비교해야 합니다.
  • 메타데이터 토픽 복제 계수. remote.log.metadata.topic.replication.factor는 기본 3입니다. 사용자 토픽보다 보수적으로 잡는 것이 안전합니다. 메타데이터 손실은 데이터 손실로 직결됩니다.
  • local.retention.ms 산정. 가장 흔한 컨슈머의 catch-up 시간을 측정해 그보다 약간 여유 있게 잡습니다. 너무 짧으면 정상 catch-up조차 원격을 두드리고, 너무 길면 tiering 효과가 줄어듭니다.
  • 모니터링. Tiered Storage가 켜지면 카프카 브로커가 새 JMX 메트릭 그룹(kafka.server:type=BrokerTopicMetrics)을 노출합니다. 최소한 다음 네 가지는 대시보드에 추가해야 합니다.
메트릭 의미
RemoteCopyBytesPerSec 초당 원격 업로드 바이트. RLMCopyTask가 따라잡지 못하면 0으로 떨어지거나 출렁입니다.
RemoteFetchBytesPerSec 초당 원격 fetch 바이트. 컨슈머 catch-up 패턴이 보입니다.
RemoteLogReaderTaskQueueSize RemoteStorageThreadPool 큐 길이. remote.log.reader.max.pending.tasks에 가까워지면 컨슈머가 빈 응답을 받기 시작합니다.
RemoteCopyLagBytes 아직 원격에 올라가지 않은 sealed segment의 총 크기. 지속 증가하면 copier pool이 부족하거나 RSM이 느리다는 신호입니다.

여기에 RLMM 측 lag — 메타데이터 토픽 컨슈머가 따라가는 속도 — 를 묶어 보면 "tiering이 정상 작동 중"인지 한눈에 판단할 수 있습니다.

마치며

Tiered Storage는 카프카에 "원격 계층"이라는 새 추상을 도입했지만, 그 추상은 카프카가 처음부터 가진 디자인 위에서 가장 자연스럽게 풀렸습니다. 세그먼트가 sealed가 되면 백그라운드로 어딘가로 옮겨지고, retention 정책에 따라 사라진다 — 이미 카프카가 로컬 디스크 위에서 하던 일을, 한 계층 아래의 더 싼 저장소에 같은 모양으로 펼친 셈입니다.

이 글에서 다룬 세 추상(RLM, RSM, RLMM)과 4-상태 머신, 세 종류의 RLM 태스크, 그리고 두 retention 노브를 머릿속에 함께 두고 보면, remote.storage.enable=true 한 줄 뒤에서 일어나는 일이 보이기 시작합니다. 그리고 그 일은 카프카 입문 시리즈에서 본 segment·index·replication·KRaft 메타데이터와 같은 어휘로 쓰여 있습니다.

다음 단계로는 KIP-950(per-topic 동적 비활성화)이 어떻게 "원격 사본은 두되 새 업로드만 멈추는" 안전한 롤백 경로를 만드는지, KIP-1176이 왜 active segment까지 tiering 범위를 넓히려 하는지를 같은 그림 위에 얹어 보면 됩니다. 두 KIP 모두 이 글의 4-상태 머신과 세 태스크를 그대로 확장하는 방식입니다.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ