Kafka 3.x 업그레이드 락 컨보이가 4,742개 파티션 페치로 브로커 마비함.

Hacker News28일 전조회 4

10개 브로커로 구성된 Kafka 3.7.0 KRaft 클러스터에서 8개의 단일 멤버 컨슈머 그룹이 와일드카드로 285개 토픽, 4,742개 파티션을 구독했다. 각 컨슈머의 페치 요청은 해당 브로커가 리더인 파티션을 전부 포함했고, 그 결과 한 브로커에 864개 파티션을 감시하는 DelayedFetch 하나가 생겼다. 이 단일 오퍼레이션에 걸린 ReentrantLock 때문에 36개 요청 핸들러 중 30개가 같은 락 뒤에 줄을 섰고, 브로커는 한 대씩 순차적으로 멈춰 섰다.

메커니즘은 이렇다. fetch.min.bytes가 아직 채워지지 않아 즉시 응답할 수 없는 컨슈머 페치는 브로커의 DelayedOperationPurgatory에 DelayedFetch로 등록된다. 데이터가 들어오는 순간 깨워야 하므로 같은 오퍼레이션 객체가 페치에 포함된 파티션 수만큼 Watchers 리스트에 TopicPartition 키로 중복 등록된다. 864개 파티션 페치는 곧 하나의 객체가 864개 키 아래 매달려 있다는 뜻이다. 파티션에 대한 작업이 끝날 때마다 — 프로듀스 추가, 팔로워 페치 처리, 컨슈머 읽기 — ReplicaManager는 purgatory.checkAndComplete(key)를 호출한다. 이 함수는 해당 파티션의 watch list를 훑으면서 각 오퍼레이션의 safeTryComplete()를 부르고, 그 안에서 오퍼레이션의 락을 잡은 뒤 tryComplete()를 실행한다. DelayedFetch의 tryComplete는 페치에 담긴 모든 파티션을 순회해 누적 바이트를 계산하고, 임계치를 넘으면 onComplete에서 모든 파티션의 로그를 읽어 응답을 조립한다. 전 과정이 같은 락 안에서 벌어진다.

결정적 변화는 업그레이드에 있었다. Kafka 2.5/2.6의 maybeTryComplete는 tryLock()으로 논블로킹 시도만 하고 실패하면 플래그를 남겨둔 채 넘어갔다. KAFKA-8334가 플래그를 잘못된 시점에 지우면 완료 신호를 놓치는 버그를 수정하면서, 2.7부터 safeTryComplete는 inLock(lock)(tryComplete()) 형태로 락을 블로킹 대기하게 바뀌었다. 수정 자체는 옳지만 비용 모델이 달라졌다. 예전에는 락을 건너뛰던 핸들러가 이제는 락 보유자가 수행하는 전체 파티션 순회 시간까지 기다린다. 수십 개 파티션 페치에서는 아무도 눈치채지 못하지만, 864개 파티션을 864개 키로 감시하고 그 파티션들에 초당 수천 건의 프로듀스가 들어오면 핸들러 풀이 큐로 붕괴한다.

연쇄는 빠르다. 핸들러는 RequestChannel에서 요청을 꺼내는 유일한 스레드다. 30개가 락에 갇히면 요청 큐는 곧 queued.max.requests(이 클러스터에서는 500)에 도달한다. 네트워크 프로세서는 큐의 put에서 블록되어 select를 호출하지 못한다. Kafka는 리스너별로 프로세서를 배정하므로 요청이 가장 많은 리스너가 먼저 막히는데, 여기서는 퍼블리셔가 사용하는 외부 SASL_SSL 리스너였다. 같은 클러스터의 내부 클라이언트는 평문 리스너를 쓰기 때문에 상대적으로 무사했다.

브로커 메트릭이 침묵한 이유는 구조적이다. Kafka의 요청 메트릭은 네트워크 스레드가 소켓에서 요청을 완전히 읽어 큐에 넣은 시점부터 시간을 잰다. 네트워크 스레드가 읽지 않고 있으면 그 요청은 어떤 메트릭에도 존재하지 않는다. 퍼블리셔는 30~240초를 기다렸지만 Produce total time p99는 최악의 순간에도 7.5초를 넘지 않았다. 브로커와 컨트롤러 역할이 결합된 노드에서는 JMX 빈 충돌로 핸들러 idle 비율이 1.0을 넘게 보고되기까지 했다. 실제로 문제를 드러낸 지표는 FetchFollower의 LocalTimeMs가 1~2ms에서 10~90ms로 치솟은 것과 NetworkProcessorAvgIdlePercent였다. 외부 TLS 리스너의 프로세서 8개가 idle 0이었고, io-ratio와 io-wait-ratio의 합은 0.1로 떨어졌다. 바쁜 스레드가 아니라 블록된 스레드의 서명이다.

컨보이가 어느 브로커에서 형성되는지는 리더십 분포가 결정한다. 10개 브로커에 고르게 퍼져 있으면 각자 4,742개 중 약 470개를 리드해 락 도착률이 한 스레드가 감당할 수준 아래로 유지된다. 그런데 하드웨어 마이그레이션, 노드 퇴역, 대규모 재할당, 스토리지 컨트롤러 장애가 겹치면서 리더십이 한 브로커에 몰렸다. 8일 동안 정체 지점이 5번 옮겨 다녔고, 브로커별 리드 파티션 순위가 정체 심각도 순위를 그대로 예측했다. 리더십이 브로커당 약 980개로 균등해지자 문제 브로커가 약 470개로 줄면서 다음 2분 스크레이프에서 풀렸다. 이는 여유일 뿐 수정이 아니다. 재시작, 노드 손실, 리밸런싱 중 무엇이든 방아쇠를 다시 당길 수 있다.

퍼블리셔 쪽 설계는 느린 브로커를 8일짜리 실패 배치와 백로그로 증폭시켰다. 메시지 하나당 프로듀스 요청 하나를 보내고 전달 보고를 기다린 뒤 다음을 보냈으며, 배치 전체에 타이머 하나(120초, 이후 240초)를 걸어 두었다. 배치 예산이 메시지별 지연의 합보다 작으면 개별 프로듀스가 모두 성공해도 배치는 실패한다. 60개 배치라면 메시지당 2초만 걸려도 예산을 넘긴다. 더 나쁜 것은 취소 토큰이 프로듀스를 취소하는 게 아니라 continuation을 취소한다는 점이다. 전달 보고를 읽지 않으니 실제 오류 코드가 로그에 남지 않고 타이머가 울렸다는 사실만 남는다. 진행 중이던 메시지는 librdkafka 큐에 남아 재전송될 수 있어 중복까지 생긴다.

실무에서 확인할 지점은 세 가지다. 첫째, 와일드카드 구독으로 수백 개 토픽을 읽는 컨슈머 그룹이 단일 멤버로 돌고 있지 않은지, 그리고 한 페치가 브로커 하나의 리더 파티션 수백 개를 감싸고 있지 않은지 봐야 한다. 그룹 멤버를 늘려 페치 폭을 줄이고 fetch.max.bytes·max.partition.fetch.bytes 같은 상한을 점검하는 것이 우선이다. 둘째, 브로커 메트릭만으로 건강을 판단하지 말고 클라이언트가 관측한 지연, FetchFollower LocalTimeMs, NetworkProcessorAvgIdlePercent, io-ratio와 io-wait-ratio의 합을 함께 봐야 한다. 셋째, 프로듀스 루프에서 배치 단위 타임아웃과 메시지 단위 지연을 분리하고, 취소 토큰이 실제 전송까지 취소하는지 확인해야 한다.

원문은 와이드 페치 자체를 잘못이라고 단정하지 않으며 KAFKA-8334의 수정이 옳다는 점도 인정한다. 문제는 데이터 구조가 전제하는 가정 — 한 브로커에서 하나의 대기 오퍼레이션이 수백 개 키로 감시되지 않고 완료 검사가 저렴하다는 가정 — 이 깨질 때 드러난다. 컨슈머 8개를 중단하자 3분 만에 처리량 2.8배, 지연 5분의 1로 회복됐다는 결과 역시 근본 수정이 아니라 증상 완화에 가깝다.