1. 막힌 자리
11부에서 data 네임스페이스의 파드(MySQL·Redis·Kafka)에 NetworkPolicy 를 설계하는 중이다. 방식은 하나다. 일단 전부 막고, 들어와야 하는 것만 다시 연다. 그래서 대상마다 같은 질문에 답하게 된다 — 이 포트에 누가 붙어야 하나.
MySQL 3306 은 booking 하나였다. Redis 6379·26379 는 queue 와 booking 이었다. Kafka 9092 에서 답이 나오지 않았다. 브로커가 3대인 것도, 토픽이 5종인 것도, partitions: 4 와 min.insync.replicas: 2 가 무엇을 정하는 값인지도 조회해야 알 수 있었다. 그 설정은 AI 도움을 받아 쓴 것이고, 그때 읽고 넘어갔다고 생각했던 것들이다.
이 글은 그것을 조회로 다시 읽은 기록이다. 순서는 카프카 문서의 목차를 따르지 않는다. 앞 항목이 만들어 낸 문제를 뒤 항목이 푸는 순서로 간다.
여기서 쓰는 쿠버네티스 용어
- 파드(Pod) — 컨테이너를 실행하는 최소 단위. 한 파드는 한 노드(물리·가상 서버) 위에서 돈다.
- 네임스페이스(Namespace) — 클러스터 안을 이름으로 나눈 구획. app(frontend·queue·booking) / data(MySQL·Redis·Kafka) / observability 셋으로 나뉜다.
- 커스텀 리소스(CR)와 오퍼레이터 — 쿠버네티스가 원래 모르는 종류의 객체를 선언 형식으로 등록한 것이 CR, 그 선언을 읽어 실제 파드·설정을 만들고 유지하는 프로그램이 오퍼레이터. Kafka·KafkaNodePool·KafkaTopic 은 Strimzi 오퍼레이터(1.1.0)가 읽는 CR 이고, 그 결과로 Kafka 4.3.0 브로커 파드가 선다.
- PV(PersistentVolume) — 파드가 재시작해도 남는 디스크 영역. 로컬 PV 는 특정 노드의 디스크를 그대로 쓰는 PV 라, 그 PV 를 쓰는 파드는 그 노드에 고정된다.
- HPA(HorizontalPodAutoscaler) — 부하에 따라 파드 수를 min 과 max 사이에서 조절하는 객체.
- NetworkPolicy — 파드가 주고받는 통신을 상대·포트 단위로 제한하는 객체. 정책이 하나도 없으면 전부 통과한다.
2. 요청이 사라지는 지점
- 이 클러스터에 올라간 서비스는 셋이다. frontend(정적 페이지), queue(Go, 대기열과 정원 판정), booking(Java/Spring, 예매).
- queue 가 정원 통과자를 판정하면 booking 이 그 사람에게 좌석 접근 권한을 발급한다.
- 이 두 동작을 queue 가 booking 을 직접 HTTP 로 호출해 잇는다고 하면, booking 파드가 재시작 중인 순간의 호출은 실패하고 그 요청은 어디에도 남지 않는다.
- queue 안에서 재시도를 돌려도 queue 파드 자신이 재시작하면 재시도 대상 목록이 함께 사라진다. 보내는 쪽과 받는 쪽 사이에 요청을 적어 두는 자리를 두면 이 구간이 끊기지 않는다. 카프카가 그 자리다.
카프카
들어온 레코드를 파일 끝에 덧붙이기만 하고, 읽는 쪽이 나중에 순서대로 가져가게 하는 서버. 이 구조를 append-only 로그라 부른다. 여기서 로그는 프로그램이 남기는 진단 메시지가 아니라, 기록을 파일 끝에만 덧붙이고 한 번 적힌 것은 고치지 않는 파일 구조 자체를 가리킨다.
세 가지 이름이 계속 나온다.
- 브로커(broker) — 레코드를 받아 디스크에 적고 읽기 요청에 답하는 서버 프로세스. 이 클러스터에서는 파드 하나가 브로커 하나다.
- 프로듀서(producer) — 레코드를 브로커에 보내는 쪽.
- 컨슈머(consumer) — 브로커에서 레코드를 읽는 쪽.
이 클러스터의 실제 흐름은 이렇게 이어진다.
- 사용자가 queue 에 붙는다. queue 가 Redis 에서 Lua 스크립트로 정원을 원자적으로 확인하고, 통과자만
admissions에 발행한다. - booking 이
admissions를 읽고admitted:{movie}:{requestId}키를 Redis 에 발급한다(TTL 180초). 좌석 조회·점유·예매 확정은 그 키를 요구한다. 없으면 403. - 예매가 확정되면 booking 이
bookings-completed를 발행하고, queue 가 그것을 읽어 자리를 반환해 다음 사람을 들여보낸다. - 세션이 만료되거나 이탈하면 queue 가
admissions-revoked를 발행하고, booking 이 인증 키를 지운다.
queue 와 booking 은 서로를 호출하지 않는다. 양쪽 다 브로커에만 붙는다.
3. 읽고 지우지 않는다 — 오프셋
읽은 레코드를 그 자리에서 지우는 방식이면 두 가지가 막힌다. 컨슈머가 레코드를 꺼낸 뒤 처리 도중 실패하면 그 레코드는 이미 없어서 다시 처리할 수 없다. 그리고 같은 레코드를 성격이 다른 두 소비자가 각각 읽을 수 없다.
카프카는 읽어도 지우지 않는다. 대신 컨슈머 쪽이 "어디까지 읽었는지" 를 숫자로 기억한다. 이 숫자가 오프셋(offset)이다. 로그에 적힌 레코드마다 0부터 1씩 증가하는 정수가 붙고, 컨슈머는 처리를 마친 위치를 커밋한다.
로그 [0][1][2][3][4][5][6]
▲ ▲
커밋한 위치 3 마지막 기록 6
└─ 밀린 레코드 3건
이 위치 값을 컨슈머 파드 안에 두면 파드가 재시작할 때 사라진다. 그래서 브로커가 __consumer_offsets 라는 자기 토픽에 적어 둔다. 토픽 목록을 뽑으면 선언한 적 없는 이 이름이 함께 나온다.
안 하면: 처리에 실패한 레코드를 다시 읽을 수 없고, 컨슈머 파드가 재시작하면 읽던 위치를 잃는다.
4. 이름으로 가른다 — 토픽
로그가 하나뿐이면 정원 통과, 예매 완료, 세션 회수가 한 로그에 섞여 들어온다. 읽는 쪽은 자기와 무관한 레코드까지 전부 읽어 걸러 내야 한다.
토픽(topic)은 레코드를 담는 로그에 붙인 이름이다. 프로듀서는 토픽 이름을 지정해 발행하고, 컨슈머는 토픽 이름을 지정해 구독한다. 이 클러스터는 토픽 5종을 KafkaTopic CR 로 선언해 둔다.
| 토픽 | 파티션 | 복제본 | 발행 | 소비 | 보존 |
|---|---|---|---|---|---|
admissions |
4 | 3 | queue | booking | 3일 |
admissions-revoked |
4 | 3 | queue | booking | 3일 |
bookings-completed |
4 | 3 | booking | queue | 3일 |
admissions.DLT |
1 | 3 | booking 내부 | 없음 | 7일 |
admissions-revoked.DLT |
1 | 3 | booking 내부 | 없음 | 7일 |
DLT(Dead Letter Topic)는 booking 이 레코드 처리에 반복 실패했을 때 그 레코드를 옮겨 두는 토픽이다. 실패한 레코드가 원래 토픽의 진행을 막지 않게 분리한다.
브로커 설정에 따라 토픽은 앱이 처음 붙을 때 자동으로 만들어질 수도 있다. 저장소의 주석은 선언해 두는 이유를 이렇게 적어 두었다 — "선언하지 않으면 앱이 처음 붙을 때 브로커가 기본값(파티션 1)으로 만들고, 파티션은 줄일 수 없어 그 값이 굳는다."
안 하면: 종류가 다른 레코드가 한 로그에 섞이고, 자동 생성에 맡기면 파티션 수가 기본값으로 고정된다.
5. 한 토픽이 한 브로커에 묶인다 — 파티션
토픽 하나가 로그 파일 하나면 그 파일은 브로커 한 대의 디스크에 있다. 그 토픽으로 오는 발행과 소비 요청은 전부 그 한 대로 간다. 브로커를 몇 대 세워 두든 그 토픽에 대해서는 나머지가 아무 요청도 받지 않는다.
파티션(partition)은 한 토픽의 로그를 여러 개로 나눈 것이다. 각 파티션이 독립된 append-only 로그 파일이고, 파티션마다 그것을 맡는 브로커가 따로 정해진다. 레코드마다 붙는 정수인 오프셋도 파티션마다 따로 매겨진다.
나누면 대가가 하나 생긴다. 순서가 보장되는 범위가 파티션 안까지로 줄어든다. 같은 토픽에 발행한 두 레코드가 서로 다른 파티션에 들어가면 어느 쪽이 먼저 처리될지 정해지지 않는다.
프로듀서가 레코드에 키를 붙이면 같은 키는 같은 파티션으로 가고, 그 범위 안에서는 순서가 지켜진다. 이 클러스터의 발행 키는 requestId 이고, 선언에 적어 둔 이유는 "전역 순서는 포기하고 키 단위 순서만 지킨다" 다.
선언해 둔 값
KafkaTopic CR 로 선언한 토픽 5종 중 앱이 주고받는 3종은 파티션 4다. 격리용 admissions.DLT·admissions-revoked.DLT 는 1이다.
4로 정한 근거는 선언에 두 줄로 적혀 있다. 파티션은 늘리는 것만 되고 줄이는 것은 안 되므로 나중에 모자라지 않을 값으로 잡았다는 것("파티션은 늘리기만 가능(줄이기 불가)라 넉넉히 4"), 그리고 앱 HPA 상한과 맞춘 값이라는 것("partitions 4 = 앱 HPA 상한과 짝 — 한 그룹의 동시 소비 파드는 파티션 수를 넘지 못한다")이다.
뒤쪽 제약이 어디서 나오는지는 컨슈머 그룹을 다루는 다음 절에 있다. DLT 를 1로 둔 근거는 "소비자가 없고 사후 조사용이라 병렬성이 필요 없다" 로 적혀 있다.
브로커는 노드가 아니라 파드다
이 클러스터의 브로커는 파드 cgv-cgv-pool-0/1/2 셋이다. 노드(k3s-1/2/3)와 수가 같고 노드마다 하나씩 있어서 브로커와 노드가 같은 것으로 읽히지만, 그 배치는 두 가지가 만든 결과다.
- podAntiAffinity 를
requiredDuringScheduling, topologyKey 를kubernetes.io/hostname으로 선언해 한 노드에 브로커 파드를 두 개 두지 않는다 - 저장소가 노드마다 하나씩 있는 정적 로컬 PV(
local-kafkadata10Gi)라, 그 PV 를 쓰는 파드는 그 노드에 고정된다
브로커 수를 늘리려면 노드와 PV 를 먼저 늘리게 된다. 조회 출력에 나오는 숫자 0·1·2 는 노드 번호가 아니라 브로커 ID 이고, 파드 이름 끝 숫자가 그 ID 다.
사본이 한 벌일 때 브로커 한 대가 내려간다
파티션 1개. 로그 파일이 하나뿐이라 브로커 한 대의 디스크에만 있다. 평소 발행도 소비도 그 한 대가 받고 남은 두 대는 이 토픽에 대해 아무 요청도 받지 않는다. 그 한 대가 내려가면 토픽 전체가 멈춘다. 디스크가 손상되면 그때까지 적힌 레코드는 돌아오지 않는다.
파티션 4개. 4개가 브로커 3대에 나뉘므로 한 대가 두 개를 맡는다. 평소 3대가 전부 요청을 받는다. 한 대가 내려가면 그 대가 맡던 파티션만 멈추고 나머지는 계속 받는다. 내려간 대가 무엇을 맡았느냐에 따라 4개 중 1개 또는 2개가 멈춘다.
나누면 평소 요청을 받는 브로커 수가 늘고, 한 대가 내려갈 때 멈추는 범위가 줄어든다. 줄어들 뿐 없어지지는 않는다. 몇 개로 나누든 파티션 하나의 로그는 여전히 브로커 한 대에만 있다.
6. 같은 파티션을 여러 브로커에 둔다 — 복제·RF·리더
파티션 하나의 로그가 브로커 한 대에만 있으면 그 브로커가 내려간 동안 그 파티션이 멈추고, 디스크가 손상되면 적힌 레코드가 돌아오지 않는다.
복제(replication)는 같은 파티션의 로그를 여러 브로커에 사본으로 두는 것이다. 사본 개수가 복제 계수(replication factor, RF)이고, 사본이 하나뿐인 구성이 RF 1 이다.
사본이 여럿이면 다음 문제가 따라온다. 프로듀서가 쓸 때, 컨슈머가 읽을 때 어디로 가는가. 셋 다 쓰기를 받으면 셋의 로그가 갈라진다. 그래서 파티션마다 사본 하나를 리더(leader)로 정하고 나머지를 팔로워(follower)로 둔다. 프로듀서와 컨슈머는 리더에만 붙고, 팔로워는 리더가 적은 것을 복사해 올 뿐 클라이언트 요청을 받지 않는다. 리더가 내려가면 팔로워 중 하나가 리더가 되어 요청을 이어받는다.
RF 는 브로커 수를 넘을 수 없다. 같은 파티션의 사본 둘을 한 브로커에 두면 그 브로커가 내려갈 때 둘이 함께 사라져 복제한 것이 되지 않으므로, 사본은 서로 다른 브로커에 배치된다. 브로커 3대인 이 클러스터에서 선언할 수 있는 최대 RF 는 3이고, 토픽 5종 전부 RF 3 으로 선언해 뒀다.
사본이 세 벌일 때 브로커 한 대가 내려간다
파티션 1개 · RF 3. 브로커 3대가 같은 로그를 하나씩 갖는다. 그중 리더는 한 대뿐이다. 평소 요청은 전부 그 한 대로 가고 나머지 두 대는 복사만 받는다. 한 대가 내려갈 때 결과는 그 대가 리더였는지로 갈린다. 팔로워였으면 요청 흐름이 그대로고, 리더였으면 남은 사본 중 하나가 리더가 되어 요청을 이어받는다. 어느 쪽이든 레코드는 남고, 어느 쪽이든 그 뒤에도 요청을 받는 것은 한 대다.
파티션 4개 · RF 3. 사본이 12개이고 브로커가 3대라 모든 브로커가 4개 파티션의 사본을 전부 갖는다. 갈리는 것은 리더 자리다. 리더가 파티션마다 다른 브로커에 있어서 평소 3대가 모두 요청을 받는다. 한 대가 내려가면 그 대가 리더였던 파티션만 리더가 바뀐다. 멈추는 파티션도 없고 잃는 레코드도 없다.
세 대가 다 갖고 있는데 왜 넷으로 나누나
설정을 다시 읽을 때 여기서 막혔다. 브로커가 3대이고 RF 3 이면 세 대가 같은 데이터를 전부 갖는다. 그러면 어느 대에 요청해도 답이 나올 텐데, 토픽을 파티션 넷으로 나눌 이유가 무엇인가.
답은 리더의 정의에 있다. 사본을 갖고 있다는 것과 요청을 받는다는 것이 다르다. 팔로워는 같은 데이터를 갖고도 클라이언트 요청을 받지 않는다. 파티션 1개에 RF 3 이면 브로커 3대가 같은 데이터를 갖지만 리더는 그중 1대뿐이고, 프로듀서와 컨슈머는 그 1대에만 붙는다. 남은 2대는 복제만 한다.
요청이 갈리는 것은 사본이 흩어져서가 아니라 리가 흩어져서다. 리더는 파티션마다 하나이므로, 리더가 여럿이 되려면 파티션이 여럿이어야 한다.
RF 사본이 몇 개인지를 정한다 → 잃지 않게 한다
파티션 수 리더가 몇 개인지를 정한다 → 일이 갈리게 한다
조회 출력에서 이것을 읽는다
kubectl -n data exec cgv-cgv-pool-0 -- \
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic admissions
Topic: admissions PartitionCount: 4 ReplicationFactor: 3
Configs: min.insync.replicas=2, retention.ms=259200000, retention.bytes=2147483648, segment.ms=86400000
Partition: 0 Leader: 1 Replicas: 1,2,0 Isr: 0,1,2
Partition: 1 Leader: 2 Replicas: 2,0,1 Isr: 0,1,2
Partition: 2 Leader: 0 Replicas: 0,1,2 Isr: 0,1,2
Partition: 3 Leader: 0 Replicas: 0,1,2 Isr: 0,1,2
Replicas 열부터 본다. 네 줄 모두 0, 1, 2 가 다 들어 있다. 파티션 4개가 각각 브로커 3대 전부에 사본을 두고 있고, 저장된 데이터만 놓고 보면 세 브로커의 내용은 같다.
갈리는 것은 Leader 열이다. 파티션 0의 리더는 브로커 1, 파티션 1은 브로커 2, 파티션 2와 3은 브로커 0이다. 프로듀서가 파티션 0으로 보내는 레코드는 브로커 1로, 파티션 1로 보내는 레코드는 브로커 2로 간다.
같은 출력을 배치로 그리면 두 열이 무엇을 정하는지가 한 장에 들어간다.
브로커0 브로커1 브로커2
(k3s-1) (k3s-2) (k3s-3)
파티션0 사본 ★리더 사본
파티션1 사본 사본 ★리더
파티션2 ★리더 사본 사본
파티션3 ★리더 사본 사본
── 가로로 읽으면 ── 한 파티션이 세 브로커에 다 있다 → RF 3. 잃지 않는다
── 세로로 읽으면 ── ★가 세 브로커에 흩어져 있다 → 파티션 4. 일이 갈린다
파티션을 1로 뒀다면 이 그림에 줄이 하나뿐이고 ★도 하나다. 사본 칸은 그때도 세 개 그대로다. 데이터가 3대에 있는 것은 RF 가 만들고, ★가 3대에 흩어지는 것은 파티션 수가 만든다.
리더가 3등분되지는 않았다. 브로커 0이 파티션 2개, 브로커 1과 2가 각각 1개다. 4를 3으로 나누면 2+1+1 이 최선이다. 이 배치는 조회한 시점의 상태이고, 브로커가 내려갔다 올라오면 리더가 다른 사본으로 옮겨간 상태가 될 수 있다.
파티션이 1개인 토픽에서는 이 배치가 한 줄로 끝난다. admissions.DLT 는 Replicas 가 1,2,0 이고 리더는 브로커 1 하나다. 브로커 0과 2는 이 토픽의 사본을 갖고 있지만 팔로워라서 클라이언트 요청을 받지 않는다.
ISR 과 min.insync.replicas
리더만 요청을 받는다는 것은 쓰기가 리더에 먼저 들어간다는 뜻이다. 리더가 레코드를 자기 로그에 덧붙인 직후, 팔로워가 아직 복제하기 전에 리더가 내려가면 남은 사본에는 그 레코드가 없다.
ISR(in-sync replicas)은 리더의 로그를 따라잡은 상태인 사본 목록이다. 위 출력의 Isr 열이 네 파티션 모두 0,1,2 인 것은 그 시점에 사본 3개가 전부 따라잡았다는 뜻이다. 복제가 늦어지면 그 사본은 ISR 에서 빠지고 숫자가 줄어든다. Replicas 는 사본이 어디에 배치되어 있는지를, Isr 은 그중 지금 따라잡고 있는 것이 어느 것인지를 나타낸다.
min.insync.replicas 는 쓰기를 성공으로 처리하는 데 필요한 ISR 최소 개수다. 선언해 둔 값은 2다.
RF 3 · min.insync.replicas 2
브로커 3대 정상 → ISR 3 → 쓰기 성공
브로커 1대 다운 → ISR 2 → 쓰기 성공
브로커 2대 다운 → ISR 1 → 쓰기 거부. 읽기는 리더가 살아 있는 파티션에 대해 계속된다
선언에 적어 둔 "RF 3 = 브로커 하나 죽어도 유지" 가 이 조합이다. 2대가 내려간 상태에서 쓰기를 계속 받으면 사본 없이 한 브로커에만 있는 레코드가 생기므로, 그 상황에서는 받지 않고 멈추는 쪽으로 정해진다.
이 값은 프로듀서가 acks=all 로 보낼 때 적용된다. queue 와 booking 의 acks 설정은 조회하지 않았다.
7. 누가 무엇을 읽는가 — 컨슈머 그룹
파티션 4개는 발행과 소비 요청을 브로커 세 대로 갈라 놓았다. 갈라진 파티션을 누가 읽을지는 그것만으로 정해지지 않는다.
컨슈머는 파드가 아니다
컨슈머는 앱이 코드에서 만드는 카프카 클라이언트 객체다. "이 토픽을 읽겠다" 고 만든 연결 하나가 컨슈머 하나다.
// queue-go/kafka/kafka.go:221
r := kafkago.NewReader(kafkago.ReaderConfig{
...
GroupID: "queue",
})
// booking/src/main/java/com/cgv/booking/kafka/AdmissionConsumer.java:37
@KafkaListener(topics = "admissions")
// booking/src/main/java/com/cgv/booking/kafka/AdmissionExpiryConsumer.java:44
@KafkaListener(topics = "${cgv.admission-revoked.topic:admissions-revoked}")
queue 는 NewReader 를 한 번 호출하므로 파드 하나에 컨슈머가 하나다. booking 은 @KafkaListener 가 둘이라 파드 하나에 컨슈머가 둘이다. 파드 수와 컨슈머 수는 같을 수도 다를 수도 있다.
group.id — 카프카가 보는 유일한 이름표
컨슈머는 접속할 때 문자열 하나를 함께 보낸다. 그것이 group.id 다. 위 코드의 GroupID: "queue" 가 그 값이고, booking 은 application.yml 한 곳에 적어 두 리스너가 같이 쓴다.
# booking/src/main/resources/application.yml:53
group-id: booking
카프카는 파드가 무엇인지 모른다. 어느 노드에 있는지, 같은 앱인지도 모른다. 아는 것은 "이 문자열을 달고 접속한 연결이 몇 개인가" 뿐이다. 문자열이 같으면 한 묶음(컨슈머 그룹)으로 보고 파티션을 나눠 주고, 다르면 서로 없는 것처럼 각자 전부 읽게 한다.
배정 규칙은 한 방향으로만 걸린다
파티션 하나 → 컨슈머 하나에게만 (파티션 하나를 둘이 나눠 갖는 것은 안 된다)
컨슈머 하나 → 파티션 여러 개 가능
앞 줄이 없으면 같은 레코드를 둘이 읽어 두 번 처리한다. bookings-completed 의 예매 완료 레코드가 두 번 처리되면 정원을 두 번 반환한다.
뒷 줄은 제한이 없다. 컨슈머가 파티션보다 적으면 하나가 여러 개를 맡는다.
조회한 두 가지 상태
kubectl -n data exec cgv-cgv-pool-0 -- \
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group queue
TOPIC PARTITION CONSUMER-ID
bookings-completed 0 queue-...-4826t
bookings-completed 1 queue-...-4826t
bookings-completed 2 queue-...-gtm24
bookings-completed 3 queue-...-gtm24
queue 는 파드 2대이고 각 파드가 컨슈머를 하나씩 만들었다. 그룹 멤버가 2개라 파티션 4개가 2개씩 나뉘었다. CONSUMER-ID 끝의 문자열이 파드 이름 뒤쪽과 같다.
... --describe --group booking
TOPIC PARTITION CONSUMER-ID
admissions 0 consumer-booking-1-5ffe...
admissions 1 consumer-booking-1-5ffe...
admissions 2 consumer-booking-1-5ffe...
admissions 3 consumer-booking-1-5ffe...
admissions-revoked 0 consumer-booking-2-9dfe...
admissions-revoked 1 consumer-booking-2-9dfe...
admissions-revoked 2 consumer-booking-2-9dfe...
admissions-revoked 3 consumer-booking-2-9dfe...
booking 은 파드 1대인데 CONSUMER-ID 가 둘로 갈린다. 파드가 2대여서가 아니라 리스너가 둘이라 컨슈머가 둘이다. 배정은 (그룹, 토픽, 파티션) 단위로 계산되므로 구독한 토픽이 다른 두 컨슈머는 서로 겹치지 않고 각자 4파티션을 전부 맡는다.
admissions 쪽은 컨슈머 하나가 파티션 4개를 맡은 상태다. 리더가 파티션0→브로커1, 파티션1→브로커2, 파티션2·3→브로커0 에 흩어져 있어 이 컨슈머 하나가 브로커 3대에 붙는다.
컨슈머가 늘거나 줄면 다시 배정한다
그룹에 컨슈머가 들어오거나 나가면 브로커가 배정을 다시 계산해 전체에 통보한다. 이것을 리밸런스라 한다.
파티션 4 개는 그대로 두고 컨슈머 수만 바꾸면
컨슈머 1개 컨슈머1 : 파티션 0,1,2,3
컨슈머 2개 컨슈머1 : 파티션 0,1 컨슈머2 : 파티션 2,3 ← queue 의 현재 상태
컨슈머 4개 컨슈머1 : 파티션 0 컨슈머2 : 파티션 1
컨슈머3 : 파티션 2 컨슈머4 : 파티션 3
컨슈머 5개 위와 같고, 컨슈머5 : 배정 없음
앱 설정에 적는 것은 group.id 와 구독할 토픽 이름뿐이다. 어느 파티션을 읽으라는 지정은 코드에 없다. HPA 가 파드를 늘리면 컨슈머가 늘고, 그때마다 리밸런스가 돌아 배정표가 다시 만들어진다.
배정이 정해지면 각 컨슈머는 자기가 맡은 파티션의 리더 브로커에 붙는다. bookings-completed 의 리더는 파티션0→브로커0, 파티션1→브로커1, 파티션2→브로커2, 파티션3→브로커0 이라, 파티션 0·1 을 맡은 4826t 는 브로커 0·1 에, 파티션 2·3 을 맡은 gtm24 는 브로커 2·0 에 붙는다.
리밸런스가 도는 동안 소비가 잠시 멈춘다. 멈추는 범위가 그룹 전체인지 옮겨 가는 파티션에 한정되는지는 클라이언트가 쓰는 배정 전략에 달려 있고, 이 앱들의 전략은 조회하지 않았다.
읽은 위치도 group.id 로 저장된다
컨슈머가 재시작하면 "어디까지 읽었는지" 를 잃는다. 그 값을 컨슈머 안에 두면 새로 뜬 컨슈머는 처음부터 다시 읽거나(중복) 지금 들어오는 것부터 읽는다(유실).
그래서 카프카는 그 위치를 브로커의 내부 토픽 __consumer_offsets 에 적고, 키를 (group.id, 토픽, 파티션) 으로 잡는다. 파드 이름도 컨슈머 식별자도 키에 안 들어간다.
("queue", "bookings-completed", 0) → 80
파드가 교체돼도 값은 브로커에 남는다. 리밸런스로 그 파티션을 새로 받은 컨슈머가 같은 group.id 로 접속하면 80 다음부터 이어 읽는다. 마지막 커밋 이후에 처리한 레코드는 다시 읽는다.
같은 조회의 오프셋 컬럼이 그 값이다.
PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
0 80 80 0
1 - 0 -
2 11 11 0
3 2 2 0
CURRENT-OFFSET 은 커밋된 위치, LOG-END-OFFSET 은 다음 레코드가 붙을 위치, LAG 은 그 차이다. 네 파티션 모두 밀린 것이 없다. 파티션1 은 LOG-END-OFFSET 이 0 이라 들어온 레코드가 없고 커밋한 적도 없어 나머지 두 값이 - 다.
읽은 양은 파티션0 이 80, 파티션2 가 11, 파티션3 이 2 로 고르지 않다. 배정이 균등한 것과 부하가 균등한 것은 별개다. 발행 키에 따라 특정 파티션으로 몰리면 그 파티션을 맡은 컨슈머가 더 일한다.
파티션 수가 상한이 되는 자리
파티션 하나는 컨슈머 하나에게만 간다. 뒤집으면 동시에 읽을 수 있는 컨슈머 수의 상한이 파티션 수다. bookings-completed 는 파티션 4 이므로 queue 파드를 5대로 올리면 다섯 번째 컨슈머는 배정받을 파티션이 없어 그룹에 들어가 있기만 하고 아무것도 읽지 않는다.
같아야 하는 것은 아니다. 컨슈머가 파티션보다 적으면 하나가 여러 개를 맡을 뿐이고, 같을 때가 파티션을 남김없이 쓰는 지점이다.
정하는 순서는 파티션이 먼저다. 파티션은 늘리기만 되고 줄일 수 없어 나중에 모자라지 않을 값으로 잡고, 그 값이 소비 컨슈머의 천장이 된다.
queue 의 HPA 가 min 2 / max 4 인 것이 파티션 4에 맞춘 것이고, 저장소 주석에도 "partitions 4 = 앱 HPA 상한과 짝" 으로 적혀 있다. HPA max 를 파티션 수보다 크게 잡으면 초과분은 자원만 차지한다.
booking 은 replicas 1 이고 HPA 를 붙이지 않았다. 스키마 마이그레이션 도구가 없어 여러 대가 같은 스키마를 건드리면 충돌하기 때문이다. 파티션이 4 라서 못 늘리는 것이 아니라, 파티션 쪽은 4대까지 여유가 있는데 앱 쪽 제약으로 1대에 묶여 있다.
8. 여기까지의 정리
| 푸는 것 | 못 푸는 것 | |
|---|---|---|
| 파티션 | 요청이 브로커 한 대에 쏠리는 것, 컨슈머를 여러 대로 늘리는 것 | 데이터 유실 |
| 복제(RF) | 데이터 유실 | 요청 쏠림 — 사본은 클라이언트를 안 받는다 |
| 리더 | 그 파티션의 일을 어느 브로커가 할지 | — |
| 컨슈머 그룹 | 같은 레코드를 여러 컨슈머가 중복 처리하는 것 | 파티션 수를 넘는 병렬성 |
낱말이 겹쳐 헷갈리는 다섯 자리
| 이렇게 읽기 쉽다 | 실제 |
|---|---|
| 브로커 = 노드 | 브로커는 파드다. 노드당 하나로 배치했을 뿐이다 |
| 컨슈머 = 파드 | 컨슈머는 앱이 코드에서 만든 객체다. 파드 하나에 여러 개일 수 있다 |
| 리더는 브로커의 성질 | 파티션의 성질이다. 같은 브로커가 어떤 파티션의 리더이면서 다른 파티션의 팔로워다 |
| 사본을 가지면 요청도 받는다 | 팔로워는 같은 데이터를 갖고도 요청을 받지 않는다 |
| 컨슈머 수 = 파티션 수 | ≤ 다. 적으면 하나가 여럿을 맡고, 넘으면 남는 컨슈머가 논다 |
숫자를 정하는 순서
잃지 않는 쪽 노드 수 → 브로커 ≤ 노드 → RF ≤ 브로커
일을 나누는 쪽 그 토픽을 읽을 파드 수 → 파티션 ≥ 그 수 → HPA max ≤ 파티션
두 사슬이 따로 시작한다. 브로커 3과 파티션 4가 가까운 숫자인 것은 우연이다.
9. 카프카를 세우는 것은 Strimzi 다
카프카를 쿠버네티스에 직접 세우려면 브로커를 서로 다른 노드에 띄우고, 디스크를 붙이고, 서로를 찾을 주소를 설정하고, 브로커 간 통신용 인증서를 만들어 나눠 주고, 한 대가 죽으면 같은 디스크로 다시 띄우는 일을 전부 손으로 써야 한다.
그 일을 대신 하도록 만들어 둔 프로그램이 Strimzi 다. 카프카를 쿠버네티스에서 운영하려고 오픈소스로 공개해 둔 것이고, strimzi.io 가 차트로 배포한다.
이런 프로그램을 오퍼레이터라 부른다. 선언을 지켜보다가 실물을 그 선언에 맞춰 놓는다.
들여오는 방법 — Helm 차트
# bootstrap/install.sh
helm repo add strimzi https://strimzi.io/charts
helm upgrade --install strimzi strimzi/strimzi-kafka-operator -n data \
--version 1.1.0 -f strimzi/values.yaml
Helm 차트와 오퍼레이터는 층이 다른 말이다. 차트는 설치 방법이고 오퍼레이터는 설치된 프로그램의 종류다. 매니페스트를 직접 kubectl apply 해도 결과는 같다.
설치하면 들어오는 것 둘
CRD 10종. 쿠버네티스에 새 객체 종류를 등록한다. kafkas·kafkanodepools·kafkatopics·strimzipodsets 등이다. 이것이 없으면 kind: Kafka 라고 적은 파일을 API 서버가 "그런 종류를 모른다" 며 거부한다. CRD 가 그 종류의 문법(어떤 필드가 올 수 있는지)을 정하고, 그 문법대로 값을 채워 내가 쓰는 파일이 CR 이다.
오퍼레이터 파드 하나. strimzi-cluster-operator 다. 감시 범위는 STRIMZI_NAMESPACE=data 로 data 네임스페이스 하나다.
어떻게 동작하나
kafka-cluster.yaml · kafka-topics.yaml
│ kubectl apply
▼
API 서버에 CR 로 저장된다 ← 이 시점에는 아직 브로커가 없다
│ watch
▼
strimzi-cluster-operator 가 알림을 받는다
│
├─ 선언과 실물을 비교한다
└─ 다르면 메운다 : 파드·Service·ConfigMap·Secret·NetworkPolicy·PVC 를 만들거나 고친다
│
└─ 그리고 2분마다 전체를 다시 비교한다
(STRIMZI_FULL_RECONCILIATION_INTERVAL_MS=120000)
CR 은 주문서일 뿐 그 자체로는 아무것도 안 한다. 오퍼레이터가 읽고 실물을 만들어야 브로커가 선다. 그래서 손으로 쓴 것은 아래 두 파일이 전부다.
workloads/manifests/kafka/
kafka-cluster.yaml Kafka(버전·리스너·브로커 기본값) · KafkaNodePool(브로커 3대, 역할, 저장소)
kafka-topics.yaml KafkaTopic 5종(파티션·RF·보존)
▼
브로커 파드 3 · StrimziPodSet · Service 2 · ConfigMap 5
· Secret 11(인증서) · NetworkPolicy 2 · PVC 3 · entity-operator
이 구조가 해 주는 것
- 브로커가 죽으면 같은 디스크로 다시 띄운다. 어느 노드에 어느 볼륨을 물릴지를 오퍼레이터가 들고 있다.
- 선언을 고치면 실물이 따라온다.
kafka-topics.yaml의 파티션을 4에서 6으로 바꾸면 실제 토픽도 6이 된다. - 인증서를 손으로 안 만든다. 클라이언트 리스너는
tls: false라 평문인데 브로커 간 복제와 오퍼레이터 통로는 TLS 를 쓴다. 그 CA 와 인증서 11종을 Strimzi 가 만들어 배포해 둔다.
대가 — 만든 쪽이 계속 지킨다
같은 동작이 반대로도 작용한다. Strimzi 가 만든 객체를 손으로 고치면 다음 조정에서 되돌아간다. 늦어도 2분이다.
그 객체 중에 NetworkPolicy 가 있다. cgv-network-policy-kafka 가 브로커 앞에 서 있고, 이번 작업에서 좁혀야 하는 9092 규칙이 거기 들어 있다. 정책 파일을 새로 쓰는 방식이 카프카에만 안 통하는 이유가 이것이다.
10. 브로커가 여는 포트 넷
kubectl -n data get pod cgv-cgv-pool-0 \
-o jsonpath='{range .spec.containers[*].ports[*]}{.name}{"="}{.containerPort}{"\n"}{end}'
tcp-kafkaagent=8443
tcp-ctrlplane=9090
tcp-replication=9091
tcp-clients=9092
IP 하나에 번호 넷이다. 포트는 한 IP 안에서 어느 프로그램이 받을지를 가르는 번호이고, 같은 브로커 프로세스가 용도별로 나눠 열었다.
| 포트 | 무엇이 오나 | 붙는 쪽 |
|---|---|---|
| 9092 | 발행·소비 | app 의 queue · booking |
| 9091 | 브로커 간 복제 | 브로커끼리, data 의 entity-operator |
| 9090 | 컨트롤러 합의(KRaft) | 브로커끼리 |
| 8443 | 브로커 상태 조회 | strimzi-cluster-operator |
9091 에 entity-operator 가 들어가는 것은 Strimzi 가 만든 정책이 그렇게 적고 있어 확인된다. cgv-network-policy-kafka 의 9091 규칙 출처에 strimzi.io/name: cgv-entity-operator 가 들어 있다. KafkaTopic 선언을 실제 토픽으로 반영하려면 이 경로가 필요하다.
9092 만 출처가 data 밖에 있다. 나머지 셋의 출처는 브로커 자신과 data 안의 오퍼레이터라, app 을 상대로 좁혀도 끊기는 것이 없다.
그리고 9092 는 브로커 3대 전부에 열어야 한다. 클라이언트는 cgv-kafka-bootstrap 으로 아무 브로커에나 붙어 파티션별 리더 목록을 받은 뒤 각 파티션의 리더에 직접 접속한다. 부트스트랩 주소로 지정한 브로커가 계속 중계하지 않는다. 리더가 3대에 흩어져 있으므로 한 대만 열면 그 브로커가 리더인 파티션만 쓸 수 있다.
11. 규칙을 적는 자리가 파드마다 다르다
11부에서 data 로 들어오는 접속을 좁히는 작업은 대상마다 적는 자리가 다르다. 같은 네임스페이스 안인데 세 갈래가 된다.
기준은 하나다 — 그 파드 앞에 이미 정책이 서 있는가, 그리고 그것을 만든 쪽이 계속 지키는가.
| 대상 파드 | 이미 서 있는 정책 | 만든 것 | 좁히는 자리 |
|---|---|---|---|
| MySQL · Redis | mysql · redis |
Helm 차트 | 차트 values.yaml 로 끄고 정책 파일을 쓴다 |
| Kafka 브로커 | cgv-network-policy-kafka |
Strimzi 오퍼레이터 | Kafka CR 의 리스너에 적는다 |
| strimzi-cluster-operator | 없다 | — | 정책 파일을 쓴다 |
왜 브로커만 CR 인가
브로커 앞의 정책은 오퍼레이터가 만든 것이고, 오퍼레이터는 2분마다 선언과 실물을 다시 맞춘다. 그래서 두 방식이 다 막힌다.
정책 파일을 새로 써서 더한다
→ 같은 파드를 고르는 정책이 여럿이면 허용이 합쳐진다
→ 출처가 빈 9092 규칙이 남아 있는 한 그 포트는 열린 채로 있다
Strimzi 정책을 지운다
→ 다음 조정에서 CR 에 적힌 대로 다시 만들어진다
그래서 오퍼레이터가 읽는 자리를 고친다.
리스너에 networkPolicyPeers 를 적으면 오퍼레이터가 그것을 읽고 cgv-network-policy-kafka 의 9092 규칙에 from 절을 넣어 재생성한다.
# workloads/manifests/kafka/kafka-cluster.yaml
listeners:
- name: plain
port: 9092
type: internal
tls: false
networkPolicyPeers:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: app}}
podSelector: {matchLabels: {app.kubernetes.io/name: queue}}
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: app}}
podSelector: {matchLabels: {app.kubernetes.io/name: booking}}
kafka-cluster.yaml 을 고친다
│ ArgoCD 가 반영
▼
API 서버의 Kafka CR 이 바뀐다
│ watch
▼
strimzi-cluster-operator 가 읽는다
│
▼
cgv-network-policy-kafka 의 9092 규칙에 from 절이 들어간 채로 재생성된다
│
▼
Calico 가 그 정책을 노드 iptables 규칙으로 옮긴다
정책 객체를 내가 만들지 않는다. 오퍼레이터에게 무엇을 만들지 알려 줄 뿐이다.
9090·9091·8443 은 Strimzi 가 이미 출처를 파드 셀렉터로 지정해 두었으므로 이번에 적을 것이 없다.
클러스터 오퍼레이터는 제한하려는 대상이 아니다
브로커 쪽은 그것으로 끝나는데, 같은 네임스페이스에 정책이 하나도 안 걸린 파드가 하나 남는다. strimzi-cluster-operator 다. Strimzi 는 자기가 만드는 것들(브로커·entity-operator)에는 정책을 붙였지만 자기 자신에게는 안 붙였다.
이 파드에 정책을 쓰는 것은 좁히려는 것이 아니라 default-deny-ingress 가 휩쓴 것을 되돌리는 것이다.
1 mysql·redis·브로커를 좁히려면 default-deny 가 필요하다
정책이 안 고른 파드는 전부 열려 있으므로, 일단 다 고르고 필요한 것만 다시 연다
2 default-deny 는 podSelector: {} — data 의 파드를 전부 고른다
3 아무 정책도 없던 strimzi-cluster-operator 가 여기서 처음 선택된다
4 그 순간 이 파드로 들어오는 것이 전부 막힌다
5 kubelet 이 노드 주소에서 보내는 8080 상태 검사도 막힌다
6 liveness 실패 → 재시작 → 또 실패 → 반복 → 카프카 조정이 멈춘다
되돌릴 것이 8080 하나뿐인 이유
오퍼레이터는 거의 다 나가는 쪽으로 일한다.
나가는 쪽 API 서버를 지켜본다(watch) · 브로커에 8443 으로 상태를 묻는다
객체를 만들고 고친다
들어오는 쪽 kubelet 의 상태 검사 8080 ← 이것 하나뿐
브로커는 앱이 붙고 브로커끼리 붙고 오퍼레이터가 붙어서 포트가 넷인데, 오퍼레이터는 받는 것이 하나다. 그래서 되돌릴 규칙도 한 줄로 끝난다.
# workloads/manifests/netpol/data-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: strimzi-operator-probe
namespace: data
spec:
podSelector:
matchLabels:
strimzi.io/kind: cluster-operator # 이 라벨을 단 파드가 대상
policyTypes: [Ingress] # 나가는 쪽은 손대지 않는다
ingress:
- from: # 원소 3개 = OR
- ipBlock: {cidr: 192.168.0.201/32}
- ipBlock: {cidr: 192.168.0.202/32}
- ipBlock: {cidr: 192.168.0.203/32}
ports:
- port: 8080 # from 과 AND
여는 범위는 "노드 3대에서 8080 으로 오는 것" 하나다.
노드 .201 → 8080 통과
노드 .202 → 8080 통과
노드 .203 → 8080 통과
노드 .201 → 다른 포트 막힘 포트가 다르다
파드 → 8080 막힘 출처가 노드가 아니다
/32 는 그 IP 하나만이라는 뜻이고, kubelet 은 파드가 아니라 노드에서 도는 프로세스라 라벨로 못 고른다. 그래서 노드 주소를 직접 적는다.
policyTypes 에 Ingress 만 적어서 나가는 쪽은 그대로 열려 있다. 나가는 쪽까지 막았으면 8080 을 열어 줘도 오퍼레이터는 API 서버를 못 보고 브로커에 못 붙어 아무 일도 못 한다.
여기는 CR 이 아니라 정책 파일인 이유
브로커 앞의 정책은 Strimzi 가 만들고 2분마다 다시 맞춘다. 그래서 CR 을 고쳐야 했다. 클러스터 오퍼레이터 앞에는 그 정책을 만든 쪽도, 지키는 쪽도 없다. 내가 쓴 파일이 되돌아갈 일이 없다.
data-ingress.yaml 을 쓴다
│ ArgoCD 가 반영
▼
API 서버에 NetworkPolicy 객체가 저장된다 ← 오퍼레이터는 관여하지 않는다
│
▼
Calico 가 노드 iptables 규칙으로 옮긴다
| 이미 있는 정책 | 지키는 쪽 | 적는 자리 | |
|---|---|---|---|
| 브로커 | cgv-network-policy-kafka |
Strimzi | Kafka CR |
| entity-operator | cgv-entity-operator |
Strimzi | 이번에 안 건드린다 |
| 클러스터 오퍼레이터 | 없음 | 없음 | 정책 파일 |
두 경로의 차이
정책 파일 내가 만든 객체를 Calico 가 집행한다
CR 오퍼레이터가 만든 객체를 Calico 가 집행한다
집행하는 쪽은 둘 다 Calico 로 같다. 갈리는 것은 그 객체를 누가 만들고 누가 계속 지키느냐다.
좁혀도 남는 것
리스너는 tls: false 에 인증 설정이 없다. 출처를 지정해도 저 두 라벨을 단 파드는 인증 없이 9092 에 붙는다. 라벨은 누구나 적을 수 있는 문자열이라 이 제한은 출처를 증명하지 않는다. 브로커 인증(SASL·mTLS)이나 메시지 서명은 이번에 하지 않는다.
'HomeLab' 카테고리의 다른 글
| 번외 — 컨테이너 메모리와 런타임 메모리 (0) | 2026.08.17 |
|---|---|
| 번외 — 노드 메모리와 쿠버네티스 메모리 (0) | 2026.08.15 |
| 노트북 홈서버 k3s 구축기 - 11. 앱 검증과 시뮬레이터 (0) | 2026.08.11 |
| 노트북 홈서버 k3s 구축기 - 10. CI 와 CD (0) | 2026.08.09 |
| 번외 — CI 파이프라인 기록 (0) | 2026.08.09 |