Posts ISR (In-Sync Replicas) 2편 — HW, Leader Epoch, 리더 교체
Post
Cancel

ISR (In-Sync Replicas) 2편 — HW, Leader Epoch, 리더 교체

앞 글(ISR 1편)에서 ISR 이 무엇이고, follower 의 fetch 가 어떻게 sync 판정과 acks=all 계약으로 이어지는지까지 정리했다.
이 글은 그 위에서 커밋 경계(HW) 가 어떻게 정해지고, 리더가 바뀌는 순간 그 경계가 어떻게 무너지는가 를 다룬다.

HW → 복제 라운드트립 → HW 의 한계(Leader Epoch) → unclean election → ELR 순서. 앞으로 갈수록 “리더 교체 시 유실·분기” 라는 하나의 문제를 파고든다.

선행 개념: LEO·HW·LSO 오프셋 정의는 카프카 파티션 로그의 오프셋들 — LEO, HW, LSO, fetch 프로토콜·LEO piggyback·min.insync.replicasISR 1편.

High Watermark (HW)


ISR 전원이 복제를 마친 경계. acks=all ack 도, consumer 가 보는 범위도 전부 이 값이 정한다.

  • 정의: ISR 모든 멤버에 복제된 최소 offset
  • 의미: committed 메시지의 경계. Consumer 는 HW 까지만 읽을 수 있음
  • 갱신 공식: HW = min(리더 LEO, 모든 ISR follower LEO)

“볼 수 있다” 의 실제 메커니즘

consumer 가 “HW 너머를 안 본다” 는 건 수동적 가시성이 아니라 리더가 강제하는 것:

  • consumer fetch (replica_id=-1): 리더가 HW 까지만 records 를 담아 응답. HW~LEO 의 미커밋 꼬리는 안 줌. → “consumer 가 HW 까지만 본다” 는 결과, “리더가 응답 상한을 HW 로 자른다” 가 메커니즘
  • follower fetch (replica_id≥0): 리더 LEO 까지 다 줌 — follower 가 꼬리를 복제해야 HW 가 전진하니까. (같은 Fetch API, 대상에 따라 상한이 다름)
  • read_committed consumer: HW 보다 더 앞선 LSO(Last Stable Offset) 까지만 (transaction 경계)

상한 관계는 log start offset ≤ LSO ≤ HW ≤ LEO. 여기서 두 가지가 ISR 문맥과 자주 섞인다.

  • isolation_level 은 HW 안쪽(트랜잭션 축)만 조정read_uncommitted 라고 리더 LEO 까지 받는 게 아님. consumer 응답 상한은 언제나 HW.
  • committed 메시지(HW) ≠ committed offset(consumer) — 전자는 broker 의 복제 상태, 후자는 consumer 의 진행 위치(__consumer_offsets).

두 구분의 상세 비교표는 카프카 파티션 로그의 오프셋들.
트랜잭션 commit·LSO·control marker·EOS 의 상세 메커니즘은 별도 글에서 다룬다.

공식 (Design): “A message is considered committed only when all replicas in the in-sync replicas (ISR) for that partition have applied it to their log.” / “Only committed messages are ever given out to the consumer.”

복제 라운드트립 (리더 처리 · follower 처리 · HW 전파)


라운드트립(round trip) = 요청이 나갔다가 응답이 돌아오는 왕복 1회. 여기선 FetchRequest 한 번 → FetchResponse 한 번이 1 라운드트립.

이 단위가 중요한 이유: 복제 상태는 이 왕복이 한 번 돌 때마다만 갱신된다. follower LEO 도, 리더 HW 도, follower 가 아는 HW 도 전부 그렇다. 리더가 임의로 follower 에게 상태를 밀어 넣거나, follower 가 중간에 따로 보고하는 경로가 없기 때문 (→ 1편 §Pull 모델, §LEO Piggyback).

그래서 “복제가 어디까지 진행됐나” 를 따질 때 초 단위가 아니라 라운드 단위로 세는 게 정확하다. 아래 §HW 전파의 1 라운드 지연 이 그 예 — HW 는 시간이 흘러서가 아니라 왕복이 한 번 더 돌아야 follower 에게 도달한다.

1편 §LEO Piggyback 의 한 fetch 사이클을 리더·follower 양쪽 관점에서 펼친 것. 핵심: 리더 LEO 는 producer 쓰기로, HW 는 follower 의 fetch 로 전진 (동력이 다름).

리더가 FetchRequest 를 받으면

단계동작
1. follower LEO 갱신요청의 fetch_offset = 그 follower 의 LEO 로 기록 (LEO piggyback)
2. lastCaughtUpTime 갱신follower 가 리더 LEO 까지 따라왔으면 갱신 → replica.lag.time.max.ms 판정용
3. HW 재계산HW = min(ISR 전원 LEO) (리더 자신 포함)
4. acks=all Produce ackHW 전진 시, purgatory 에서 대기하던 acks=all Produce 를 ack (HW ≥ 해당 offset)
5. ISR 축소/확장 판정lag 초과 follower 제거 / 따라잡은 follower 재진입
6. FetchResponsefetch_offset 부터 records + 현재 HW(high_watermark) 실어 응답

follower 의 fetch 가 HW 를 밀고 → HW 전진이 producer 의 acks=all ack 을 트리거.
복제 진행과 producer ack 이 한 fetch 흐름으로 묶임.

follower 가 FetchResponse 를 받으면

  1. records 를 자기 log 에 append → 자기 LEO 전진
  2. 자기 HW 갱신: 자기 HW = min(응답의 리더 HW, 자기 LEO) — 안 가진 offset 을 committed 로 표시할 수 없으니 min
  3. 즉시 다음 FetchRequest(fetch_offset = 새 LEO) → 새 LEO 를 다시 piggyback

HW 전파의 1 라운드 지연

리더는 “모든 ISR LEO” 를 보고 HW 를 먼저 전진시키고, follower 는 다음 응답에서야 그 HW 를 학습. 이 시차가 HW 기반 truncation 버그의 뿌리 → §HW 의 한계와 Leader Epoch.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
초기: L.LEO=200(producer가 씀), F1.LEO=F2.LEO=100, HW=100

┌─ 라운드 1 ─────────────────────────────────────────────────┐
│ ① F1: Fetch(100) → L: F1.LEO=100 기록, records[100..200]   │
│                        + HW=100 응답                        │
│ ② F1: append→LEO=200, 자기HW=min(100,200)=100              │
│ ③ F2 도 동일하게 200 까지 따라옴                            │
│ ④ L: F1.LEO=F2.LEO=200 기록 → HW=min(200,200,200)=200 전진 │
│      → acks=all Produce ack                                 │
└────────────────────────────────────────────────────────────┘
     ↑ 리더는 여기서 HW=200 을 앎. 하지만 이 라운드의 응답은
       이미 ①에서 HW=100 으로 나간 뒤 → F1 에게 알릴 방법이 없음
┌─ 라운드 2 ─────────────────────────────────────────────────┐
│ ⑤ L: F1 의 Fetch(200) 에 HW=200 실어 응답                   │
│ ⑥ F1: 자기HW=min(200,200)=200  ← 이제서야 학습              │
└────────────────────────────────────────────────────────────┘

왜 한 라운드가 뜨는가 — ①의 응답이 나가는 시점엔 F1 이 아직 데이터를 안 받았으니 HW 는 100 이 맞다. F1 이 받았다는 사실 자체가 ④(다음 요청의 fetch_offset)에서야 리더에게 도착하고, 그때 계산된 새 HW 를 실어 보낼 응답은 그 다음 응답인 ⑤ 뿐이다. 리더가 중간에 “HW 올랐다” 를 따로 통보하는 경로가 없으므로(pull 모델) 구조적으로 왕복 하나가 밀린다.

실제 시간으로 얼마인가 — 라운드 = 왕복 1회이므로 대략 브로커 간 RTT. HW 가 밀려 있다는 건 리더에 아직 복제 안 된 데이터가 있다는 뜻이고, 그러면 replica.fetch.min.bytes(기본 1) 가 즉시 충족돼 응답이 바로 나간다. 즉 이 상황에서 500ms 대기는 걸리지 않고 보통 밀리초 단위. 문제는 이 창이 길어서가 아니라, 그 창에서 브로커가 재시작하면 stale HW 로 truncate 한다는 데 있다.

HW 의 한계와 Leader Epoch (KIP-101)


HW 만으로 truncation 을 결정하면 유실·분기가 난다. Kafka 0.11+ 는 (epoch, offset) 쌍으로 판단한다.

leader 빈번 변경 환경 (rolling restart, AZ failover) 에서 알아둘 것.

근본 원인: follower 의 HW 는 LEO 보다 1 라운드 늦게 전파됨 (§HW 전파의 1 라운드 지연). 이 stale 한 HW 를 truncation 기준으로 쓰면 두 방향 다 틀림 — 과도 truncate(유실) / 분기 미감지(divergence).

시나리오 ① 데이터 유실 (과도 truncate)

1
2
3
4
B=리더, A=팔로워
① A가 m2 fetch → A.LEO=2, 하지만 HW 전파 지연 → A.HW=0 (m2 를 committed 로 아직 모름)
② A 재시작 → 자기 HW(=0) 로 truncate → m2 폐기
③ A가 m2 복구 전에 B 장애 → A 새 리더 → m2 영구 유실

KIP-101: “the follower takes an extra round of RPC to update its high watermark. This gap leaves the possibility for a fast leader change to result in data loss.”

시나리오 ② 로그 분기 (divergence)

1
2
3
① A,B 둘 다 crash. A=m2@0 보유, B=비어있음
② 둘 다 재시작 → B 먼저 떠서 리더 → B가 m3@0 수신
③ A@0=m2, B@0=m3 → 같은 offset 다른 메시지 (분기). HW 기준으론 감지 불가 → 영구 불일치

HW 는 “몇 번째 offset 까지 committed” 만 알 뿐, “이 offset 의 메시지가 같은 메시지인가” 는 못 따짐.

해결: Leader Epoch — offset 대신 (epoch, offset) 쌍 으로 정합성 판단.

구성요소내용
Leader Epoch“32 bit, monotonically increasing number representing a continuous period of leadership”. 리더 교체마다 +1. 모든 record batch 에 stamp
leader-epoch-checkpointreplica 별 파일. epoch → 그 epoch 시작 offset 매핑
OffsetsForLeaderEpochfollower 가 복구 시 리더에 “내 마지막 epoch 의 끝 offset?” 질의

리더는 “the LastOffset for that LeaderEpoch … or the Log End Offset” 응답 → follower 는 HW 가 아니라 이 정확한 분기점까지만 truncate.

  • 시나리오 ① 해결: 리더가 LEO=2 응답 (HW=0 아님) → m2 안 자름 → 유실 방지
  • 시나리오 ② 해결: 리더가 “새 epoch 시작 offset” 응답 → A 가 자기 m2@0 를 orphan 으로 감지 → 그 분기분만 truncate 후 refetch → 일치 복구

연결: 이 truncation 이 1편 부록 B §최초 fetch 시점 의 “fetch 전 truncation” 단계에서 실제로 일어남. Kafka 0.11+ 기본 적용 — 직접 설정 없음.

epoch 가 언제 +1 되나 + 누구에게 묻나

위 “해결” 표는 결과 만 보여준다. 실제로 epoch 가 어떻게 증가·stamp 되고, 팔로워가 누구에게 분기점을 묻는지 의 흐름:

epoch 증가 + stamp

  • Leader Epoch = 리더십의 “세대 번호” (32bit, 단조 증가). 리더가 교체될 때마다 +1.
  • 교체 이후 리더가 받는 모든 record batch 에 당시 epoch 가 stamp 됨 → offset 하나가 아니라 (epoch, offset) 쌍으로 정합성 판단 가능.
1
2
3
4
5
6
7
8
9
초기: epoch=5, 리더=B0. B0·B1 모두 m0,m1 보유 (offset 0,1 — 둘 다 epoch5 stamp)

① B0 다운 → 컨트롤러가 B1 을 새 리더로 선출 → epoch 5→6 ↑
② B1 이 이후 받는 m2(offset 2)는 epoch=6 으로 stamp
③ B1.leader-epoch-checkpoint = { 5→0, 6→2 }   (epoch6 은 offset 2 부터 시작)
④ B0 재기동(이제 팔로워) → 자기 마지막 epoch=5
   → 현재 리더 B1 에 OffsetsForLeaderEpoch(epoch=5) 질의
⑤ B1 응답: "epoch5 의 끝 offset = 2"  (epoch6 이 2 에서 시작하므로 epoch5 는 0,1 까지)
⑥ B0: offset 2 위로는 분기 가능성 → 그 분기점까지만 비교/정리 후 refetch

누구에게 묻나 — 옛 리더가 아니라 현재 리더

혼동하기 쉬운 지점: “분기점을 물어볼 옛 리더가 죽었으면 어떡해?” → 물을 일이 없다.

  • 팔로워가 재기동하는 시점엔 항상 현재 리더가 존재 (옛 리더 생존 or 컨트롤러가 새 리더 이미 선출). 질의 대상은 현재 리더.
  • (epoch → 그 epoch 시작 offset) 매핑은 각 replica 의 leader-epoch-checkpoint 파일에 디스크로 영속. 현재 리더도 자기 checkpoint 에 과거 epoch 들 을 다 보유 → 과거 epoch 에 답 가능. → 옛 리더가 죽어도 정보 안 사라짐.

시나리오 ② (divergence) 가 이걸로 어떻게 풀리나

옛 리더 B0 가 자기만 가진 m@200(epoch=5) 를 갖고 HW=200 을 믿는데, 새 리더 B1 은 같은 자리에 m'@200(epoch=6) 보유:

  • HW 만 보면: B0 “난 200 까지 committed” 라며 안 자름 → B0@200=m vs B1@200=m' → 영구 분기 (HW 는 “몇 번째까지” 만 알지 “같은 메시지인지” 는 못 따짐).
  • Leader Epoch: B0 가 OffsetsForLeaderEpoch(epoch=5) 질의 → B1 “epoch5 끝 offset=2(epoch6 시작점)” → B0 는 자기 m@200(epoch5) 이 분기점 너머의 orphan 임을 감지 → 그 분기분만 truncate 후 refetch → 일치 복구.

핵심: HW 값이 무엇이든, (epoch, offset) 쌍 비교로 “같은 offset 다른 epoch = 분기” 를 감지 → 잘못된 꼬리를 버리게 만든다.

운영 시나리오: rolling restart

rolling restart = 브로커를 하나씩 순차로 내림 = 리더 교체의 연쇄 → HW-lag 유실 창이 매 브로커마다 열림.

예전(0.11 미만) — 유실

1
2
3
4
5
6
7
RF=2, acks=all, min.insync.replicas=2, ISR={B0,B1}, B0=리더, 초기 HW=2
① 프로듀서 m2@2 발행
   B0.LEO=3 / B1: m2 fetch→B1.LEO=3 / B1의 다음 fetch(off=3)로 B0.HW=3 → ack
   이 순간 B0.HW=3 (acked) / B1.HW=2  ← HW 전파 1라운드 지연
② B1 차례 재시작 → 자기 HW(=2)로 truncate → m2 폐기 (acks=all 커밋분인데도)
③ B1이 m2 재복구 전에 rolling restart가 B0로 넘어가 B0 down
   → 살아있는 replica=B1뿐 → B1 리더(LEO=2) → m2 영구 유실

acks=all 로 내구성이 보장된 메시지조차 재시작 truncation 이 stale HW 를 기준으로 잘라냄. URP=0 회복을 기다리지 않고 다음 브로커를 내리면 ②③ 구간에 걸림.

0.11+ Leader Epoch — 방지

1
2
3
4
5
동일 상황. m2@2 는 epoch=5. B0.HW=3 / B1.HW=2
② B1 재시작 → HW(=2) truncate 안 함
   대신 OffsetsForLeaderEpoch(epoch=5) → B0 응답 "epoch5 LastOffset=3"
   → B1 로그(off 3까지) 리더와 일치 → m2 안 자름 (B1.LEO=3 유지)
③ rolling restart B0 차례 down → B1 리더(LEO=3, m2 보유) → 유실 없음

운영 takeaway: 0.11+ 면 이 유실은 구조적으로 막힘(설정 불필요). 그래도 각 브로커 재시작 후 UnderReplicatedPartitions=0 회복을 기다린 뒤 다음 브로커를 내릴 것 — 유실이 아니라 가용성 저하·unclean election·ISR 축소 회피용.

Unclean Leader Election


ISR 이 전멸했을 때 out-of-sync replica 를 리더로 쓸 것인가 — 가용성과 무손실의 양자택일.

설정가용성일관성동작
unclean.leader.election.enable=false (default, Kafka 0.11+)ISR 전부 down 이면 partition 사용 불가 (no leader)
unclean.leader.election.enable=trueout-of-sync replica 도 leader 가능 → 메시지 유실 위험

true 일 때의 영향

모든 ISR 이 down 일 때 out-of-sync replica 가 leader 됨. 그 replica 가 갖고 있지 않은 ISR 쓰기는 유실. Consumer 도 HW 가 뒤로 점프하는 걸 보게 됨.

관련 메트릭: UncleanLeaderElectionsPerSec (JMX). 0 이 아니면 즉시 조사.

ELR (Eligible Leader Replicas, KIP-966)


Kafka 4.x 의 ISR durability 강화. 위 §Unclean Leader Election 의 양자택일을 완화. KRaft 전용 (컨트롤러 메커니즘은 별도 글에서 다룬다).

풀려는 문제 — “마지막 replica 한 대” 딜레마

RF=3, min.insync.replicas=2 인데 장애로 ISR 이 1 까지 줄어든 상황. 그 마지막 ISR 멤버(=리더)가 unclean shutdown 으로 커밋된 데이터를 잃고 재기동하면 → 다시 리더 선출 → 돌아온 follower 들이 그 리더 기준으로 truncate → 커밋됐던 레코드의 마지막 복사본까지 삭제. 국소적 손실이 전역 손실로 번짐.

KIP-966: “when the last replica in the ISR experiences an unclean shutdown and loses committed data, it will be reelected leader after starting up again, causing rejoining followers to truncate their logs and thereby removing the last copies of the committed records.”

혼동하기 쉬운 지점: “잃은 주체” ≠ “마지막 복사본 보유 주체”

위 문장은 한 줄에 서로 다른 두 replica 얘기가 섞여 있다. L(리더)·F1·F2, RF=3, min.insync=2:

1
2
3
4
5
6
7
8
① 정상: m 을 acks=all 발행 → L·F1·F2 모두 복제 → HW 전진 → m "커밋"(ack). 복사본 3곳.
② 장애 연쇄: F1·F2 가 lag/재시작으로 ISR 에서 빠짐 → ISR={L}
   (단, F1·F2 디스크엔 m 이 그대로 — ISR 에서만 빠진 것이지 데이터가 사라진 게 아님)
③ L unclean shutdown → L 디스크에서 m 소실  ← "커밋된 데이터를 잃음" (잃은 주체 = 리더 L)
④ L 재기동 → 마지막 리더였으니 다시 리더 선출. 근데 L 로그엔 m 없음.
⑤ F1·F2 follower 복귀 → "follower 는 리더 기준 truncate" 규칙으로 자기 m 을 잘라냄
   ← "마지막 복사본 삭제" (마지막 복사본 보유 주체 = follower F1·F2)
⑥ m 이 L·F1·F2 전부에서 사라짐 → 영구 유실
  • “커밋된 데이터를 잃었다” = 리더 L 이 자기 디스크에서 m 을 잃음.
  • “마지막 복사본” = ②~④ 동안 m 을 유일하게 보유하던 follower F1·F2. 데이터를 잃은 L 이 리더로 복귀하자 “follower 는 리더를 따라 truncate” 규칙이 거꾸로 작용 → 온전한 마지막 복사본까지 삭제. L 한 대의 국소 손실이 전역 손실로 번지는 이유 = 데이터를 잃은 리더를 기준으로 신뢰하기 때문.

왜 커밋된 데이터가 디스크에서 사라지나 (③)

Kafka 는 메시지를 OS page cache 에 쓰고 fsync 는 OS 에 위임 (기본 log.flush.* 사실상 “OS 가 알아서”). 즉 커밋(acks=all·HW 전진)page cache 차원의 복제 보장이지 물리 디스크 fsync 완료 가 아니다. unclean shutdown(정전·커널 패닉)은 fsync 되지 않은 page cache 를 통째로 잃으므로, acked 메시지가 그 replica 디스크에서 사라질 수 있다. (정상 종료면 flush 후 내려가 안 잃음 → “unclean” 이 핵심 조건.)

ELR 이 막는 법: CleanShutdownFile 의 broker epoch 으로 L 의 unclean 종료를 컨트롤러가 감지 → L 을 깨끗한 리더로 신뢰 안 함. ②에서 빠진 F1·F2 를 ELR 에 기억(HW 까지 데이터 보유) → ④에서 L 대신 F1·F2 를 무손실 리더로 선출 → m 보존. 데이터를 잃은 리더를 기준으로 삼는 경로 자체를 차단.

기존 대응은 unclean election 을 꺼서 파티션 사용 불가(가용성 포기) 뿐이었음.

ELR = ISR 과 별개의 메타데이터 집합

KIP-966: “we use ELR to store the replicas that are not in ISR but guarantee to have the data at least to High Watermark.”

ISR 에서 빠졌지만 HW(=커밋 경계)까지 데이터는 확실히 가진 replica 를 컨트롤러가 따로 기억. ISR 이 min.insync 아래로 줄 때 빠진 멤버를 “잊지 않고” ELR 에 기록 → 그 멤버는 HW 까지 데이터 보유가 보장되므로 리더로 뽑아도 커밋 데이터 무손실. → MinISR-1 만큼의 unclean shutdown 내성 확보.

 ISRELR
역할복제 quorum, HW 전진ISR 미달 시 무손실 리더 후보 풀
멤버 조건리더 LEO 추종 중ISR엔 없지만 HW까지 데이터 보유 (lagging/fenced 여도 OK)
존재 시점항상ISR < min.insync.replicas 일 때만
관리 주체리더 판정 → 컨트롤러 commit (AlterPartition)컨트롤러 (KRaft 메타데이터)

리더 선출 우선순위 (ELR 이 unclean election 을 밀어냄)

  1. ISR 멤버
  2. unfenced ELR 멤버 ← 여기서 무손실 구제
  3. fenced ELR 멤버 (unclean.recovery.strategy: Aggressive / Balanced / None)
  4. 최후수단: 전 replica 대상 unclean recovery (최고 epoch + 최장 LEO 결정적 선택)

부수 변경 — HW 전진 규칙

KIP-966: “High Watermark can only advance if the ISR size is larger or equal to min.insync.replicas.”

기존 HW = min(ISR LEOs) 는 ISR 크기와 무관하게 전진했음. 이제 ISR ≥ min.insync 일 때만 HW 전진. 이게 ELR 의 “HW 까지 데이터 보유 보장”을 실제로 의미 있게 만드는 토대 — 커밋(HW) 경계가 항상 최소 min.insync 대수에 복제돼 있음을 강제.

버전 / 활성화

버전상태
Kafka 4.0사용 가능하나 기본 비활성
Kafka 4.1신규 클러스터 기본 활성
  • 활성: eligible.leader.replicas.version=1 (다운그레이드 =0)
  • KRaft 전용 (ZK 모드엔 없음)
  • 새 메타데이터: PartitionRecordEligibleLeaderReplicas / LastKnownELR / LastKnownLeader, CleanShutdownFile 이 broker epoch 기록(unclean 재기동 감지)

트레이드오프 의사결정 (RF=3 기준)


앞의 설정들을 워크로드 성격별로 조합한 것.

시나리오min.insync.replicasunclean.leader.election.enable노림
금융·결제·이벤트 소싱·주문2false일관성 절대 우선, 유실 방지
일반 서비스2false균형 (default)
로그·텔레메트리1true가용성 우선, 일부 유실 허용

메트릭 (JMX)


운영 중 ISR 상태를 보는 최소 세트.

메트릭용도
UnderReplicatedPartitionsISR < RF 인 partition 수. > 0 이면 alert
UncleanLeaderElectionsPerSecunclean election 발생 빈도. > 0 이면 즉시 조사
IsrShrinksPerSec / IsrExpandsPerSecISR 변동 빈도. flapping 감지

참고 자료


This post is licensed under CC BY 4.0 by the author.

ISR (In-Sync Replicas) 1편 — Replica, fetch, acks

-