Step 14 — 운영과 최종 프로젝트
학습 목표
kafka-reassign-partitions.sh 로 파티션을 다른 브로커로 옮기고, 스로틀을 걸고 해제한다
- 무중단 롤링 재시작 절차를 단계별 검증과 함께 수행한다
- JMX 로 브로커 지표를 읽고, 반드시 감시해야 할 9개 지표를 구분한다
- 증상 → 원인 → 진단 명령 → 조치로 이어지는 장애 플레이북을 갖춘다
- 종합 실습: 주문 이벤트 파이프라인을 설계·구축하고, 장애를 주입해 유실 0 을 검증한다
- 코스 전체의 "조용한 실패" 를 한 표로 정리한다
선행 스텝: Step 13 — Kafka Streams
예상 소요: 150분
14-0. 실습 준비
모든 브로커가 살아 있어야 합니다. Step 08 에서 브로커를 죽였다면 반드시 되살리고 시작하십시오.
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
결과
NAME STATUS
kafka-1 Up 3 hours (healthy)
kafka-2 Up 3 hours (healthy)
kafka-3 Up 3 hours (healthy)
kafka-ui Up 3 hours
클러스터가 건강한지 세 줄로 확인합니다. 이 코스의 마지막 스텝이니만큼, 이 세 줄을 습관으로 만드십시오.
kt --describe --under-replicated-partitions | wc -l # 0
kt --describe --under-min-isr-partitions | wc -l # 0
kt --describe --unavailable-partitions | wc -l # 0
결과
14-1. 파티션 재할당 — 브로커 간 데이터 이동
브로커를 추가했거나, 특정 브로커에 파티션이 몰렸거나, 브로커를 빼야 할 때 파티션을 옮깁니다. kafka-reassign-partitions.sh 는 3단계로 씁니다.
먼저 실습용 토픽을 만듭니다. 일부러 브로커 1, 2에만 복제본을 두어 불균형을 만듭니다.
docker exec kafka-1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server kafka-1:9092 --create --topic s14_move \
--partitions 3 --replica-assignment 1:2,1:2,1:2
결과
kt --describe --topic s14_move
결과
Topic: s14_move TopicId: Lp8qWzXcQe2rT5yUiOpAsD PartitionCount: 3 ReplicationFactor: 2 Configs: segment.bytes=1048576
Topic: s14_move Partition: 0 Leader: 1 Replicas: 1,2 Isr: 1,2
Topic: s14_move Partition: 1 Leader: 1 Replicas: 1,2 Isr: 1,2
Topic: s14_move Partition: 2 Leader: 1 Replicas: 1,2 Isr: 1,2
kafka-3 이 완전히 놀고 있고, 리더가 전부 kafka-1 에 몰렸습니다. 데이터를 조금 넣어 둡니다(이동할 실체가 있어야 합니다).
docker exec kafka-1 /opt/kafka/bin/kafka-producer-perf-test.sh \
--topic s14_move --num-records 30000 --record-size 200 \
--throughput -1 --producer-props bootstrap.servers=kafka-1:9092
결과
30000 records sent, 44776.119403 records/sec (8.54 MB/sec), 96.83 ms avg latency, 288.00 ms max latency, 88 ms 50th, 241 ms 95th, 277 ms 99th, 286 ms 99.9th.
① --generate — 후보안 만들기
옮길 토픽 목록을 JSON 으로 씁니다.
docker exec kafka-1 sh -c 'cat > /tmp/topics-to-move.json <<EOF
{"topics": [{"topic": "s14_move"}], "version": 1}
EOF'
docker exec kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
--bootstrap-server kafka-1:9092 \
--topics-to-move-json-file /tmp/topics-to-move.json \
--broker-list "1,2,3" \
--generate
결과
Current partition replica assignment
{"version":1,"partitions":[{"topic":"s14_move","partition":0,"replicas":[1,2],"log_dirs":["any","any"]},{"topic":"s14_move","partition":1,"replicas":[1,2],"log_dirs":["any","any"]},{"topic":"s14_move","partition":2,"replicas":[1,2],"log_dirs":["any","any"]}]}
Proposed partition reassignment configuration
{"version":1,"partitions":[{"topic":"s14_move","partition":0,"replicas":[3,1],"log_dirs":["any","any"]},{"topic":"s14_move","partition":1,"replicas":[1,2],"log_dirs":["any","any"]},{"topic":"s14_move","partition":2,"replicas":[2,3],"log_dirs":["any","any"]}]}
두 블록이 나옵니다.
Current — 반드시 파일로 저장하십시오. 롤백용입니다.
Proposed — 실행할 안. 브로커 3이 포함되어 부하가 분산되었습니다.
💡 실무 팁 — Current 를 저장하지 않으면 롤백할 수 없습니다
--generate 는 매번 다른 결과를 낼 수 있습니다(무작위 시드 사용). 그래서 나중에 다시 실행해 원래 배치를 복원할 수 없습니다.
재할당 전에 반드시 Current 블록을 rollback.json 으로 저장하십시오. 이것 하나로 사고 시 복구 시간이 갈립니다.
docker exec kafka-1 sh -c 'cat > /tmp/rollback.json <<EOF
{"version":1,"partitions":[{"topic":"s14_move","partition":0,"replicas":[1,2],"log_dirs":["any","any"]},{"topic":"s14_move","partition":1,"replicas":[1,2],"log_dirs":["any","any"]},{"topic":"s14_move","partition":2,"replicas":[1,2],"log_dirs":["any","any"]}]}
EOF'
docker exec kafka-1 sh -c 'cat > /tmp/reassign.json <<EOF
{"version":1,"partitions":[{"topic":"s14_move","partition":0,"replicas":[3,1],"log_dirs":["any","any"]},{"topic":"s14_move","partition":1,"replicas":[1,2],"log_dirs":["any","any"]},{"topic":"s14_move","partition":2,"replicas":[2,3],"log_dirs":["any","any"]}]}
EOF'
② --execute — 실행 (스로틀과 함께)
docker exec kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
--bootstrap-server kafka-1:9092 \
--reassignment-json-file /tmp/reassign.json \
--throttle 1048576 \
--execute
결과
Current partition replica assignment
{"version":1,"partitions":[{"topic":"s14_move","partition":0,"replicas":[1,2],"log_dirs":["any","any"]},{"topic":"s14_move","partition":1,"replicas":[1,2],"log_dirs":["any","any"]},{"topic":"s14_move","partition":2,"replicas":[1,2],"log_dirs":["any","any"]}]}
Save this to use as the --reassignment-json-file option during rollback
Warning: You must run --verify periodically, until the reassignment completes, to ensure the throttle is removed.
Successfully started partition reassignments for s14_move-0,s14_move-1,s14_move-2
경고 문구를 눈여겨보십시오. "--verify 를 주기적으로 실행해야 스로틀이 제거된다" 고 명시하고 있습니다. 이것이 이 절의 함정입니다.
--throttle 1048576 은 초당 1 MiB 로 복제 대역폭을 제한합니다. 재할당은 대량의 데이터를 복사하므로, 제한 없이 돌리면 정상 트래픽의 대역폭과 디스크 I/O 를 잡아먹어 서비스 지연이 튑니다.
③ --verify — 완료 확인 (그리고 스로틀 해제)
docker exec kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
--bootstrap-server kafka-1:9092 \
--reassignment-json-file /tmp/reassign.json \
--verify
결과 (진행 중)
Status of partition reassignment:
Reassignment of partition s14_move-0 is still in progress.
Reassignment of partition s14_move-1 is completed.
Reassignment of partition s14_move-2 is still in progress.
Clearing broker-level throttles on brokers 1,2,3
Throttle was removed.
잠시 뒤 다시 실행합니다.
docker exec kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
--bootstrap-server kafka-1:9092 \
--reassignment-json-file /tmp/reassign.json --verify
결과 (완료)
Status of partition reassignment:
Reassignment of partition s14_move-0 is completed.
Reassignment of partition s14_move-1 is completed.
Reassignment of partition s14_move-2 is completed.
Clearing broker-level throttles on brokers 1,2,3
Throttle was removed.
결과를 확인합니다.
kt --describe --topic s14_move
결과
Topic: s14_move TopicId: Lp8qWzXcQe2rT5yUiOpAsD PartitionCount: 3 ReplicationFactor: 2 Configs: segment.bytes=1048576
Topic: s14_move Partition: 0 Leader: 3 Replicas: 3,1 Isr: 1,3
Topic: s14_move Partition: 1 Leader: 1 Replicas: 1,2 Isr: 1,2
Topic: s14_move Partition: 2 Leader: 2 Replicas: 2,3 Isr: 2,3
리더가 3, 1, 2 로 분산되었고 kafka-3 도 일을 합니다.
⚠️ 함정 — --verify 를 안 돌리면 스로틀이 영원히 남습니다
스로틀은 재할당이 끝나도 자동으로 해제되지 않습니다. --verify 가 해제해 줍니다. 안 돌리면 leader.replication.throttled.rate / follower.replication.throttled.rate 가 브로커 설정에 남아, 그 이후의 모든 복제가 1 MiB/s 로 제한됩니다.
증상이 지독합니다. 평소에는 멀쩡한데, 브로커를 재시작하거나 장애가 나서 복제를 따라잡아야 할 때 한없이 느립니다. under-replicated 가 몇 시간씩 안 풀립니다. 에러 로그는 없습니다.
확인 방법:
kconf --describe --entity-type brokers --entity-name 1 | grep throttled
결과 (스로틀이 남아 있는 경우)
leader.replication.throttled.rate=1048576 sensitive=false synonyms={DYNAMIC_BROKER_CONFIG:leader.replication.throttled.rate=1048576}
follower.replication.throttled.rate=1048576 sensitive=false synonyms={DYNAMIC_BROKER_CONFIG:follower.replication.throttled.rate=1048576}
수동 해제:
for b in 1 2 3; do
kconf --alter --entity-type brokers --entity-name $b \
--delete-config leader.replication.throttled.rate,follower.replication.throttled.rate
done
토픽 쪽에도 leader.replication.throttled.replicas 가 남을 수 있으니 함께 확인하십시오.
preferred leader 로 되돌리기
재할당 직후에는 리더가 Replicas 목록의 첫 번째가 아닐 수 있습니다. 위 결과에서 파티션 0은 Replicas: 3,1 이고 Leader: 3 이라 맞지만, 브로커 재시작 후에는 어긋나는 일이 흔합니다.
docker exec kafka-1 /opt/kafka/bin/kafka-leader-election.sh \
--bootstrap-server kafka-1:9092 \
--election-type preferred --all-topic-partitions
결과
Successfully completed leader election (PREFERRED) for partitions s14_move-0, s14_move-1, s14_move-2, orders-0, orders-1, orders-2, payments-0, payments-1, payments-2, order-events-0, order-events-1, order-events-2, dlq-0
이미 preferred 인 파티션이 대부분이면 이렇게 나옵니다.
Valid replica already elected for partitions
💡 실무 팁 — auto.leader.rebalance.enable 은 기본 true 입니다
브로커는 leader.imbalance.check.interval.seconds(기본 300초)마다 불균형을 검사하고, leader.imbalance.per.broker.percentage(기본 10%)를 넘으면 자동으로 preferred election 을 수행합니다.
그래서 대개는 수동으로 할 필요가 없습니다. 다만 자동 실행은 예측 불가능한 시점에 리더를 옮기므로, 트래픽이 민감한 클러스터는 이 설정을 끄고 정해진 시간에 수동으로 수행하기도 합니다.
브로커 추가와 제거 — 재할당이 실제로 쓰이는 자리
재할당 명령을 배우는 진짜 이유는 브로커 대수를 바꿀 때입니다. 두 절차의 순서가 정확히 반대라는 점이 핵심입니다.
브로커 추가 — 브로커를 먼저 넣고, 파티션을 나중에 옮깁니다.
① 새 브로커를 클러스터에 조인 (node.id 새로 부여, controller.quorum.voters 갱신)
② kafka-broker-api-versions.sh 로 인식되는지 확인
③ --generate --broker-list "1,2,3,4" 로 재배치 후보 생성
④ ★ Proposed JSON 을 눈으로 검토 — RF 가 유지되는가, 한 브로커에 몰리지 않는가
⑤ --execute --throttle
⑥ --verify 를 "completed" 가 전부 나올 때까지
⑦ preferred leader election 으로 리더도 고르게
②의 확인 명령입니다.
docker exec kafka-1 /opt/kafka/bin/kafka-broker-api-versions.sh \
--bootstrap-server kafka-1:9092 | grep -E '^kafka-[0-9]'
결과
kafka-1:9092 (id: 1 rack: null) -> (
kafka-2:9092 (id: 2 rack: null) -> (
kafka-3:9092 (id: 3 rack: null) -> (
새 브로커를 넣어도 기존 파티션은 저절로 옮겨 오지 않습니다. 새로 만드는 토픽만 새 브로커를 씁니다. ③~⑥을 하지 않으면 새 브로커는 몇 달이고 놀고 있습니다. "브로커를 늘렸는데 부하가 안 줄었다"의 원인이 대개 이것입니다.
브로커 제거 — 파티션을 먼저 빼고, 브로커를 나중에 내립니다.
① 그 브로커에 어떤 파티션이 있는지 확인
② --generate --broker-list "남길 브로커들" 로 후보 생성
③ ★ RF 가 유지되는지 확인 (아래 함정)
④ --execute --throttle
⑤ --verify 완료까지
⑥ 그 브로커에 파티션이 0 개인지 재확인
⑦ 그제서야 브로커 종료 → KRaft 라면 controller.quorum.voters 에서도 제거
①의 확인 명령입니다. 브로커 3 을 빼려 한다면:
kt --describe | grep -E 'Replicas:.*\b3\b' | head -5
결과
Topic: orders Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
Topic: orders Partition: 1 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1
Topic: orders Partition: 2 Leader: 3 Replicas: 3,1,2 Isr: 3,1,2
Topic: payments Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
Topic: payments Partition: 2 Leader: 3 Replicas: 3,1,2 Isr: 3,1,2
⑥의 재확인은 같은 명령이 빈 출력을 내면 통과입니다.
⚠️ 함정 — --generate 는 RF 를 유지해 주지 않습니다
브로커 3 을 빼려고 --broker-list "1,2" 를 주면 Kafka 는 "쓸 수 있는 브로커가 2대"라고 판단하고 RF 3 짜리 토픽을 RF 2 로 줄인 초안을 내놓습니다.
Proposed partition reassignment configuration
{"version":1,"partitions":[{"topic":"s14_move","partition":0,"replicas":[2,1],"log_dirs":["any","any"]}, ...]}
replicas 배열의 길이가 3에서 2로 줄어든 것을 놓치기 쉽습니다. 그대로 실행하면 min.insync.replicas=2 인 토픽이 브로커 한 대만 죽어도 쓰기 불가가 됩니다.
org.apache.kafka.common.errors.NotEnoughReplicasException: The size of the current ISR Set(1) is insufficient to satisfy the min.isr requirement of 2 for partition orders-0
--generate 는 초안 생성기일 뿐입니다. 운영에서는 출력을 그대로 쓰지 않고 replicas 배열을 손으로 고쳐 씁니다. 브로커를 정말 3대에서 2대로 줄이려면 RF 도 함께 내리겠다는 의식적인 결정이 있어야 하고, 그때는 min.insync.replicas 도 같이 조정해야 합니다.
⚠️ 함정 — 브로커를 먼저 내리면 되돌릴 수 없습니다
파티션을 안 빼고 브로커를 내려도 클러스터는 계속 동작합니다. 그 브로커가 리더였던 파티션은 다른 복제본으로 리더가 넘어가니까요. 그래서 문제가 없어 보입니다.
하지만 RF 3 이던 파티션이 사실상 RF 2 로 동작하게 되고, 여기서 브로커 하나가 더 죽으면 ISR 이 1 이 되어 쓰기가 즉시 막힙니다. 게다가 이미 내린 브로커의 디스크를 지웠다면 되돌릴 방법도 없습니다.
원칙은 하나입니다: 제거는 "파티션 먼저, 브로커 나중". 추가는 그 반대.
14-2. 무중단 롤링 재시작
설정 변경이나 버전 업그레이드를 위해 브로커를 한 대씩 재시작하는 절차입니다. 순서를 지키지 않으면 데이터를 잃습니다.
절차
① 사전 점검 under-replicated == 0 확인
↓ (여기서 0이 아니면 절대 시작하지 말 것)
② 한 대 정지 controlled shutdown 으로 리더를 넘기고 종료
↓
③ 재시작 healthy 대기
↓
④ 복구 확인 under-replicated 가 다시 0이 될 때까지 대기
↓
⑤ 리더 복원 preferred leader election
↓
다음 브로커로 (①로)
실행
# ① 사전 점검 — 0이어야 진행
kt --describe --under-replicated-partitions | wc -l
결과
# ② kafka-2 정지
docker compose stop kafka-2
결과
[+] Stopping 1/1
✔ Container kafka-2 Stopped 6.4s
6.4초 걸렸습니다. 이것이 controlled shutdown 입니다. 브로커는 종료 신호를 받으면 즉시 죽지 않고, 자기가 리더인 파티션들의 리더십을 다른 브로커에 넘긴 뒤 종료합니다. 브로커 로그에 이렇게 남습니다.
[2024-03-11 14:22:08,114] INFO [KafkaServer id=2] Starting controlled shutdown (kafka.server.KafkaServer)
[2024-03-11 14:22:08,441] INFO [KafkaServer id=2] Controlled shutdown request returned successfully after 327ms (kafka.server.KafkaServer)
⚠️ 함정 — docker kill 이나 kill -9 는 controlled shutdown 을 건너뜁니다
강제 종료하면 리더십을 넘기지 못하고 죽습니다. 그 브로커가 리더였던 파티션들은 리더가 없는 상태가 되고, 컨트롤러가 새 리더를 선출할 때까지(수 초) 읽기·쓰기가 완전히 멈춥니다.
게다가 recovery-point-offset-checkpoint 가 갱신되지 않아, 재시작 시 브로커가 로그 복구 검사를 수행하느라 기동이 몇 분씩 걸립니다(파티션이 많을수록 오래).
원칙: 항상 SIGTERM(= docker stop, docker compose stop)으로 종료하십시오. controlled.shutdown.enable 은 기본 true 이고 절대 끄지 마십시오.
# 정지 중 상태 확인 — under-replicated 가 나타납니다
kt --describe --under-replicated-partitions
결과
Topic: orders Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,3
Topic: orders Partition: 1 Leader: 3 Replicas: 2,3,1 Isr: 3,1
Topic: orders Partition: 2 Leader: 3 Replicas: 3,1,2 Isr: 3,1
Topic: payments Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,3
...
Isr 에서 2가 빠졌습니다. 정상적인 과정입니다. 파티션 1의 리더가 2에서 3으로 넘어간 것도 보입니다.
# ③ 재시작
docker compose start kafka-2
# healthy 대기
until [ "$(docker inspect -f '{{.State.Health.Status}}' kafka-2)" = "healthy" ]; do
sleep 2
done
echo "kafka-2 healthy"
결과
[+] Running 1/1
✔ Container kafka-2 Started 0.5s
kafka-2 healthy
# ④ 복구 확인 — 0이 될 때까지 대기
until [ "$(kt --describe --under-replicated-partitions | wc -l)" -eq 0 ]; do
echo "복제 따라잡는 중... $(kt --describe --under-replicated-partitions | wc -l) 파티션 남음"
sleep 3
done
echo "복제 완료"
결과
복제 따라잡는 중... 13 파티션 남음
복제 따라잡는 중... 7 파티션 남음
복제 따라잡는 중... 2 파티션 남음
복제 완료
# ⑤ 리더 복원
docker exec kafka-1 /opt/kafka/bin/kafka-leader-election.sh \
--bootstrap-server kafka-1:9092 --election-type preferred --all-topic-partitions
여기까지가 브로커 한 대입니다. kafka-3, kafka-1 순으로 반복합니다.
⚠️ 함정 — ④ 를 건너뛰고 다음 브로커를 내리면 데이터를 잃습니다
kafka-2 가 아직 복제를 따라잡는 중인데 kafka-3 을 내리면, ISR 이 1개로 줄어듭니다.
min.insync.replicas=2 라면 → 프로듀서가 NotEnoughReplicasException 으로 거부됩니다. 서비스 장애지만 데이터는 안전합니다.
min.insync.replicas=1 이라면 → 쓰기가 성공합니다. 그리고 남은 그 한 대가 죽으면 그 데이터는 사라집니다.
④ 의 대기 루프는 형식적인 절차가 아니라 데이터 보호 장치입니다. 자동화 스크립트에서 이 대기를 빼는 것이 롤링 재시작 사고의 가장 흔한 원인입니다.
14-3. JMX 지표 — 무엇을 봐야 하는가
이 클러스터는 JMX 포트를 열어 두었습니다(19999/29999/39999). 브로커 안에서 JmxTool 로 읽습니다.
docker exec kafka-1 /opt/kafka/bin/kafka-run-class.sh kafka.tools.JmxTool \
--object-name 'kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions' \
--jmx-url service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi \
--one-time
결과
Trying to connect to JMX url: service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi.
"time","kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions:Value"
1710165728114,0
Value 가 0 입니다. 정상입니다.
여러 지표를 한 번에 볼 수도 있습니다.
docker exec kafka-1 /opt/kafka/bin/kafka-run-class.sh kafka.tools.JmxTool \
--object-name 'kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec' \
--jmx-url service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi \
--reporting-interval 2000 --one-time
결과
"time","kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec:Count","kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec:FifteenMinuteRate","kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec:FiveMinuteRate","kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec:MeanRate","kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec:OneMinuteRate"
1710165741882,18442096,41883.2,88412.7,14028.4,102841.5
반드시 감시해야 할 9개 지표
| 지표 (MBean) | 정상값 | 벗어나면 | 심각도 |
|---|
ReplicaManager,name=UnderReplicatedPartitions | 0 | 복제 지연 또는 브로커 다운 | 높음 |
ReplicaManager,name=UnderMinIsrPartitionCount | 0 | acks=all 쓰기가 거부되는 중 | 매우 높음 |
KafkaController,name=OfflinePartitionsCount | 0 | 리더 없는 파티션 = 읽기·쓰기 불가 | 치명 |
KafkaController,name=ActiveControllerCount | 클러스터 합 1 | 0이면 컨트롤러 없음, 2 이상이면 split-brain | 치명 |
KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent | > 0.3 | I/O 스레드 포화 → num.io.threads 증설 | 높음 |
SocketServer,name=NetworkProcessorAvgIdlePercent | > 0.3 | 네트워크 스레드 포화 → num.network.threads 증설 | 높음 |
BrokerTopicMetrics,name=BytesInPerSec / BytesOutPerSec | 베이스라인 대비 | 급증·급감 모두 신호 | 정보 |
ReplicaManager,name=IsrShrinksPerSec | 0에 가깝게 | 반복되면 브로커 불안정·GC·디스크 문제 | 중간 |
컨슈머 consumer-fetch-manager-metrics,records-lag-max | SLO 이내 | 랙 급증 → Step 11 플레이북 | 높음 |
ActiveControllerCount 를 세 브로커에서 각각 재 보면 KRaft 의 구조가 보입니다.
for p in 19999 29999 39999; do
echo -n "port $p: "
docker exec kafka-1 /opt/kafka/bin/kafka-run-class.sh kafka.tools.JmxTool \
--object-name 'kafka.controller:type=KafkaController,name=ActiveControllerCount' \
--jmx-url "service:jmx:rmi:///jndi/rmi://host.docker.internal:$p/jmxrmi" \
--one-time 2>/dev/null | tail -1
done
결과
port 19999: 1710165801221,0
port 29999: 1710165802447,1
port 39999: 1710165803662,0
합이 정확히 1 입니다. kafka-2 가 액티브 컨트롤러입니다(Step 02 의 quorum-state 와 일치합니다).
⚠️ 함정 — ActiveControllerCount 합계가 2 이상이면 즉시 대응해야 합니다
네트워크 분단으로 두 컨트롤러가 각자 자기가 리더라고 믿는 상태(split-brain)입니다. 메타데이터가 갈라지고, 복구 후 한쪽 변경이 통째로 버려집니다.
KRaft 는 Raft 쿼럼(과반수)으로 이것을 구조적으로 막지만, 투표자 수가 짝수이거나 설정이 어긋나면 발생할 수 있습니다. controller.quorum.voters 는 반드시 홀수(3 또는 5)로 두십시오.
디스크 사용량
docker exec kafka-1 /opt/kafka/bin/kafka-log-dirs.sh \
--bootstrap-server kafka-1:9092 --describe --topic-list s14_move
결과
Querying brokers for log directories information
Received log directory information from brokers 1,2,3
{"version":1,"brokers":[{"broker":1,"logDirs":[{"logDir":"/var/lib/kafka/data","error":null,"partitions":[{"partition":"s14_move-0","size":2098176,"offsetLag":0,"isFuture":false},{"partition":"s14_move-1","size":2101248,"offsetLag":0,"isFuture":false}]}]},{"broker":2,"logDirs":[{"logDir":"/var/lib/kafka/data","error":null,"partitions":[{"partition":"s14_move-1","size":2101248,"offsetLag":0,"isFuture":false},{"partition":"s14_move-2","size":2095104,"offsetLag":0,"isFuture":false}]}]},{"broker":3,"logDirs":[{"logDir":"/var/lib/kafka/data","error":null,"partitions":[{"partition":"s14_move-0","size":2098176,"offsetLag":0,"isFuture":false},{"partition":"s14_move-2","size":2095104,"offsetLag":0,"isFuture":false}]}]}]}
offsetLag: 0 이면 그 복제본이 리더를 따라잡았다는 뜻입니다. 재할당 진행 상황을 정확히 보는 방법이기도 합니다.
💡 실무 팁 — 디스크가 가득 차면 브로커는 죽습니다
Kafka 는 디스크 부족을 우아하게 처리하지 않습니다. 로그 디렉터리에 쓸 수 없으면 해당 로그 디렉터리를 오프라인으로 표시하고, 그 안의 모든 파티션이 사용 불가가 됩니다.
예방: 디스크 사용률 70% 에서 경보를 걸고, retention.bytes 로 상한을 명시하십시오. retention.ms 만으로는 트래픽이 급증하면 막을 수 없습니다.
급할 때: retention.ms 를 일시적으로 낮춰 브로커가 스스로 지우게 하십시오. 절대 rm 하지 마십시오(Step 02).
14-4. 장애 플레이북
증상에서 출발해 조치까지 가는 표입니다. 운영 문서에 그대로 옮겨 쓸 수 있게 구성했습니다.
| # | 증상 | 원인 후보 | 진단 명령 | 조치 |
|---|
| 1 | 컨슈머 랙 급증 | 처리 지연 / 파티션 부족 / 리밸런싱 반복 / 브로커 병목 | kcg --describe --group G 를 30초 간격 2회 → 랙 증가 속도 | 파티션 ≥ 컨슈머면 컨슈머 증설. 아니면 파티션부터(Step 03, Step 11) |
| 2 | under-replicated 발생 | 브로커 다운 / 네트워크 / 디스크 느림 / 스로틀 잔존 | kt --describe --under-replicated-partitions
kconf --describe --entity-type brokers --entity-name N | grep throttled | 브로커 복구. 스로틀 남았으면 --delete-config (14-1) |
| 3 | OfflinePartitions > 0 | ISR 전멸 / 로그 디렉터리 오프라인 | kt --describe --unavailable-partitions 브로커 로그에서 offline 검색 | 죽은 브로커 복구가 최우선. 불가하면 unclean 선출 판단(Step 08) |
| 4 | 리밸런싱 반복 | max.poll.interval.ms 초과 / 컨슈머 OOM / 네트워크 | 컨슈머 로그에서 poll timeout has expired 검색
kcg --describe --state 의 STATE | max.poll.records 축소 또는 max.poll.interval.ms 증대(Step 05) |
| 5 | 프로듀서 타임아웃 | 리더 없음 / ISR 부족 / 네트워크 / 버퍼 포화 | 예외 종류 확인: TimeoutException vs NotEnoughReplicasException | 후자면 ISR 회복이 먼저. 전자면 delivery.timeout.ms·버퍼 점검(Step 04) |
| 6 | 디스크 가득 | 보존 정책 부재 / 트래픽 급증 / 압축 미동작 | kafka-log-dirs.sh --describe
df -h | retention.ms 일시 축소 → 삭제 확인 → 원복. rm 금지 |
| 7 | ISR 축소 반복 | GC 정지 / 디스크 I/O 포화 / replica.lag.time.max.ms 가 너무 짧음 | IsrShrinksPerSec 지표 브로커 로그 Shrinking ISR | GC 튜닝, 디스크 교체, num.replica.fetchers 증설 |
| 8 | 브로커 OOM | 힙 과소/과대 / 파티션 과다 / 큰 메시지 | 컨테이너 로그 OutOfMemoryError
docker stats | 힙은 6 GiB 안팎 유지(Step 02). 파티션 수 재검토 |
| 9 | 컨트롤러 없음 | 쿼럼 과반 상실 | kafka-metadata-quorum.sh describe --status | 투표자 과반이 살아야 함. 3대 중 2대 이상 복구 |
| 10 | 토픽이 안 지워짐 | delete.topic.enable=false / 삭제 진행 중 | kconf --describe --entity-type brokers --entity-name 1 | grep delete.topic
ls /var/lib/kafka/data | grep -- -delete | 설정 확인. 진행 중이면 file.delete.delay.ms 만큼 대기(Step 03) |
| 11 | 메시지가 조용히 사라짐 | acks=0/1 / min.insync.replicas=1 / auto commit | 프로듀서 설정 감사, kcg --describe 의 오프셋 점프 | Step 04·Step 06·Step 08 의 방어 설정 적용 |
| 12 | 같은 키 순서 뒤바뀜 | 파티션 증가 / max.in.flight>1+재시도 | 그 키의 파티션 분포 확인 프로듀서 enable.idempotence 확인 | enable.idempotence=true. 파티션 증가는 되돌릴 수 없음(Step 03, Step 04) |
14-5. ACL — 권한 (개념)
이 클러스터는 인증이 없는 PLAINTEXT 이므로 ACL 을 실제로 적용하지는 않습니다. 형태만 익혀 둡니다.
# 특정 사용자에게 orders 토픽 쓰기 권한
docker exec kafka-1 /opt/kafka/bin/kafka-acls.sh \
--bootstrap-server kafka-1:9092 \
--add --allow-principal User:order-api \
--operation Write --topic orders
# 컨슈머 그룹까지 포함한 읽기 권한
docker exec kafka-1 /opt/kafka/bin/kafka-acls.sh \
--bootstrap-server kafka-1:9092 \
--add --allow-principal User:order-processor \
--operation Read --topic orders \
--group order-processor
# 전체 조회
docker exec kafka-1 /opt/kafka/bin/kafka-acls.sh \
--bootstrap-server kafka-1:9092 --list
💡 실무 팁 — 컨슈머는 토픽 권한만으로는 못 읽습니다
컨슈머 그룹을 쓰려면 그룹 리소스에 대한 Read 권한이 별도로 필요합니다. 토픽 권한만 주고 "왜 안 되지?" 하는 것이 ACL 도입 초기의 단골 문제입니다.
트랜잭션을 쓴다면 TransactionalId 리소스 권한도 필요합니다(Step 07).
14-6. 종합 실습 — 주문 이벤트 파이프라인
이 코스에서 배운 것을 하나로 묶습니다. 요구사항부터 검증까지 전 과정을 수행합니다.
요구사항
| # | 요구사항 | 관련 스텝 |
|---|
| R1 | 주문 이벤트는 절대 유실되면 안 된다 | 04, 08 |
| R2 | 같은 고객의 주문은 순서가 보장되어야 한다 | 03, 04 |
| R3 | 중복 처리는 허용하되, 비즈니스적으로 멱등해야 한다 | 06, 07 |
| R4 | 처리 실패 메시지는 DLQ 로 격리하고 원인을 남긴다 | 06, 12 |
| R5 | 주문의 최신 상태를 언제든 조회할 수 있어야 한다 | 09 |
| R6 | 브로커 1대가 죽어도 서비스가 계속되어야 한다 | 08 |
| R7 | 컨슈머 랙을 상시 감시할 수 있어야 한다 | 05, 11, 14 |
단계 ① — 토픽 설계
| 토픽 | 파티션 | RF | min.insync | cleanup | retention | 근거 |
|---|
orders | 3 | 3 | 2 | delete | 7일 | R1(RF3+minISR2), R2(key=customer_id), R6 |
payments | 3 | 3 | 2 | delete | 7일 | R1, R6 |
order-events | 3 | 3 | 2 | compact | — | R5(키별 최신 상태 영구 보존) |
dlq | 1 | 3 | 2 | delete | 30일 | R4(순서대로 조사해야 하므로 파티션 1개, 조사 기간 확보로 30일) |
핵심 판단 두 가지를 명시합니다.
min.insync.replicas=2 인 이유: RF 3에 minISR 2면 브로커 1대가 죽어도 쓰기가 계속되고(R6), 2대가 죽으면 쓰기를 거부해 유실을 막습니다(R1). RF 3 + minISR 3 이면 1대만 죽어도 서비스가 멈추고, minISR 1 이면 유실 위험이 생깁니다. 2가 유일한 균형점입니다.
dlq 파티션이 1개인 이유: DLQ 는 처리량이 아니라 조사 편의가 목적입니다. 파티션이 여러 개면 시간 순서대로 훑기가 어렵습니다.
kt --describe --topic orders | head -1
kt --describe --topic order-events | head -1
kt --describe --topic dlq | head -1
결과
Topic: orders TopicId: fH9wKlOpQrT4uYiAsDfGhJ PartitionCount: 3 ReplicationFactor: 3 Configs: min.insync.replicas=2,segment.bytes=1048576,retention.ms=604800000
Topic: order-events TopicId: RtY7uIoPQvW3xZaBcDeFgH PartitionCount: 3 ReplicationFactor: 3 Configs: min.cleanable.dirty.ratio=0.01,cleanup.policy=compact,segment.ms=10000,min.insync.replicas=2,segment.bytes=1048576
Topic: dlq TopicId: 8Kx2vNqTQmS0aWpLdHfGjQ PartitionCount: 1 ReplicationFactor: 3 Configs: min.insync.replicas=2,segment.bytes=1048576,retention.ms=2592000000
단계 ② — 프로듀서 설정 확정
| 설정 | 값 | 근거 |
|---|
acks | all | R1. 0/1 은 리더 장애 시 유실(Step 04, Step 08) |
enable.idempotence | true | R2+R3. 재시도해도 중복 배치가 안 생기고, 순서도 보장(Step 07) |
max.in.flight.requests.per.connection | 5 | 멱등성이 켜져 있으면 5까지 순서 보장. 1로 낮추면 처리량만 손해 |
retries | Integer.MAX_VALUE | 멱등성이 강제하는 값. delivery.timeout.ms 가 실질 상한 |
delivery.timeout.ms | 120000 | 이 시간 안에 성공 못 하면 포기하고 예외. 애플리케이션이 반드시 잡아야 함 |
linger.ms | 10 | R1을 해치지 않으면서 배치 효율 확보(Step 11) |
compression.type | lz4 | 대역폭 절감 대비 CPU 비용이 가장 균형적 |
key | customer_id | R2. 같은 고객 = 같은 파티션 |
주의: enable.idempotence=true 는 3.0부터 기본값이지만, acks 를 명시적으로 1 로 두면 다음 예외로 기동이 실패합니다.
org.apache.kafka.common.config.ConfigException: Must set acks to all in order to use the idempotent producer. Otherwise we cannot guarantee idempotence.
이 에러는 좋은 것입니다. 설정 모순을 기동 시점에 잡아 줍니다.
단계 ③ — 컨슈머 설계
| 설정 | 값 | 근거 |
|---|
group.id | order-processor | |
enable.auto.commit | false | R1+R3. auto commit 은 유실과 중복을 동시에 만듦(Step 06) |
auto.offset.reset | earliest | 새 그룹이 과거 메시지를 조용히 건너뛰는 것을 방지(Step 06) |
isolation.level | read_committed | 트랜잭션 프로듀서를 쓸 경우(Step 07) |
max.poll.records | 100 | 처리 시간이 max.poll.interval.ms 를 넘지 않도록(Step 05) |
partition.assignment.strategy | CooperativeStickyAssignor | 리밸런싱 시 stop-the-world 회피(Step 05) |
처리 루프의 계약은 이렇습니다.
poll()
→ 각 레코드마다:
try : 비즈니스 처리 (order_id 로 멱등하게)
catch : dlq 로 전송 (원인 헤더 포함) — 예외를 삼키되 기록
→ 전부 끝난 뒤 commitSync() ← 처리 후 커밋 = at-least-once
처리 후 커밋이므로 중복이 생길 수 있습니다(R3). 그래서 비즈니스 처리가 order_id 기준으로 멱등해야 합니다. 이것이 Step 07 의 결론 — "exactly-once 는 Kafka 내부 한정이고, 외부 시스템까지 포함하면 at-least-once + 멱등 처리가 현실적인 답" — 을 적용한 것입니다.
단계 ④ — 파이프라인 가동
주문을 흘려 넣습니다.
for i in $(seq 1001 1030); do
c=$(printf "C%03d" $(( (i % 10) + 1 )))
echo "$c:{\"order_id\":\"O-$i\",\"customer_id\":\"$c\",\"amount\":$(( (i * 137) % 200000 )),\"status\":\"CREATED\"}"
done | docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka-1:9092 --topic orders \
--property parse.key=true --property key.separator=: \
--producer-property acks=all \
--producer-property enable.idempotence=true \
--producer-property compression.type=lz4 \
--producer-property linger.ms=10
들어간 건수를 확인합니다.
결과
orders:0:11
orders:1:9
orders:2:10
합 30건입니다.
단계 ⑤ — 장애 주입
(가) 브로커 1대 정지 — R6 검증
docker compose stop kafka-2
kt --describe --topic orders
결과
Topic: orders TopicId: fH9wKlOpQrT4uYiAsDfGhJ PartitionCount: 3 ReplicationFactor: 3 Configs: min.insync.replicas=2,segment.bytes=1048576,retention.ms=604800000
Topic: orders Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,3
Topic: orders Partition: 1 Leader: 3 Replicas: 2,3,1 Isr: 3,1
Topic: orders Partition: 2 Leader: 3 Replicas: 3,1,2 Isr: 3,1
ISR 이 2개로 줄었지만 min.insync.replicas=2 를 여전히 만족합니다. 쓰기를 시도합니다.
echo 'C001:{"order_id":"O-9001","customer_id":"C001","amount":50000,"status":"CREATED"}' \
| docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka-1:9092 --topic orders \
--property parse.key=true --property key.separator=: \
--producer-property acks=all --producer-property enable.idempotence=true
결과 (에러 없이 성공)
R6 충족. 브로커 1대가 죽어도 서비스가 계속됩니다.
한 대 더 죽이면 어떻게 될까요?
docker compose stop kafka-3
echo 'C001:{"order_id":"O-9002","customer_id":"C001","amount":60000,"status":"CREATED"}' \
| docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka-1:9092 --topic orders \
--property parse.key=true --property key.separator=: \
--producer-property acks=all --producer-property enable.idempotence=true
결과
[2024-03-11 15:02:41,882] ERROR Error when sending message to topic orders with key: 4 bytes, value: 76 bytes with error: (org.apache.kafka.clients.producer.internals.ErrorLoggingCallback)
org.apache.kafka.common.errors.NotEnoughReplicasException: The size of the current ISR Set(1) is insufficient to satisfy the min.isr requirement of 2 for partition orders-0
R1 충족. 유실될 수 있는 상황에서 쓰기를 거부했습니다. 서비스는 멈췄지만 데이터는 안전합니다. 이것이 min.insync.replicas=2 를 건 이유입니다.
복구합니다.
docker compose start kafka-2 kafka-3
until [ "$(kt --describe --under-replicated-partitions | wc -l)" -eq 0 ]; do sleep 3; done
echo "복구 완료"
결과
(나) 잘못된 메시지 1건 — R4 검증
echo 'C001:NOT_A_JSON' \
| docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka-1:9092 --topic orders \
--property parse.key=true --property key.separator=:
컨슈머는 이 레코드에서 파싱 예외를 만나고 DLQ 로 보내야 합니다. dlq 로 옮겨진 것을 확인합니다.
docker exec kafka-1 /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server kafka-1:9092 --topic dlq --from-beginning \
--property print.key=true --property print.headers=true \
--timeout-ms 5000 2>/dev/null
결과
dlq.error.class:com.example.OrderParseException,dlq.error.message:Unrecognized token 'NOT_A_JSON',dlq.origin.topic:orders,dlq.origin.partition:1,dlq.origin.offset:31 C001 NOT_A_JSON
R4 충족. 원인·출처 토픽·파티션·오프셋이 헤더에 남았습니다. 이 정보만 있으면 원본을 정확히 찾아 재처리할 수 있습니다.
단계 ⑥ — 검증 체크리스트
| # | 검증 항목 | 명령 | 기대 출력 |
|---|
| V1 | 브로커 3대 정상 | docker compose ps | 세 줄 모두 (healthy) |
| V2 | under-replicated 없음 | kt --describe --under-replicated-partitions | wc -l | 0 |
| V3 | under-min-isr 없음 | kt --describe --under-min-isr-partitions | wc -l | 0 |
| V4 | unavailable 없음 | kt --describe --unavailable-partitions | wc -l | 0 |
| V5 | 스로틀 잔존 없음 | kconf --describe --entity-type brokers --entity-name 1 | grep -c throttled | 0 |
| V6 | 유실 0 | koff --topic orders 합계 = 보낸 건수 | 일치 |
| V7 | 컨슈머 랙 0 | kcg --describe --group order-processor | 모든 파티션 LAG = 0 |
| V8 | DLQ 격리 확인 | koff --topic dlq | 실패 건수와 일치 |
| V9 | 압축 토픽 최신성 | order-events 를 --from-beginning 으로 읽어 키 중복 없음 | 키당 1건 |
| V10 | 리더 균형 | kt --describe | grep -o 'Leader: [0-9]' 분포 | 세 브로커에 고르게 |
랙을 확인합니다.
kcg --describe --group order-processor
결과
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID HOST CLIENT-ID
order-processor orders 0 12 12 0 consumer-order-processor-1-a3f81c04-7b2e-4d19-9f /172.18.0.5 consumer-order-processor-1
order-processor orders 1 11 11 0 consumer-order-processor-2-c81d0f37-2a44-4e88-b1 /172.18.0.6 consumer-order-processor-2
order-processor orders 2 10 10 0 consumer-order-processor-3-9e42b715-8c60-4a03-af /172.18.0.7 consumer-order-processor-3
LAG 이 전부 0, CURRENT-OFFSET = LOG-END-OFFSET. V7 충족입니다.
단계 ⑦ — 운영 인수인계 문서 템플릿
# 주문 이벤트 파이프라인 — 운영 문서
## 구성
- 클러스터: learn-kafka (KRaft, 브로커 3대)
- 토픽: orders / payments / order-events(compact) / dlq
- 컨슈머 그룹: order-processor (인스턴스 3)
## 알람 임계값
| 지표 | 경고 | 심각 | 지속 조건 |
|---|---|---|---|
| UnderReplicatedPartitions | > 0 | > 0 | 5분 지속 |
| UnderMinIsrPartitionCount | > 0 | > 0 | 1분 지속 |
| OfflinePartitionsCount | — | > 0 | 즉시 |
| ActiveControllerCount(합) | ≠ 1 | ≠ 1 | 1분 지속 |
| 컨슈머 랙 (order-processor) | > 10,000 | > 100,000 | 5분 지속 |
| RequestHandlerAvgIdlePercent | < 0.3 | < 0.1 | 10분 지속 |
| 디스크 사용률 | > 70% | > 85% | 즉시 |
| dlq 유입 건수 | > 0 | > 100/시간 | 즉시 |
## 런북
- 랙 급증 → 플레이북 #1
- under-replicated → 플레이북 #2 (스로틀 잔존 여부 반드시 확인)
- 롤링 재시작 → 14-2 절차. **④ 복제 완료 대기를 절대 건너뛰지 말 것**
- 파티션 재할당 → 14-1. **Current 블록을 rollback.json 으로 먼저 저장**
## 절대 하지 말 것
- 세그먼트 파일 `rm`
- `orders` / `order-events` 파티션 수 변경 (키 순서가 깨짐, 되돌릴 수 없음)
- `unclean.leader.election.enable=true`
- `docker kill` / `kill -9` 로 브로커 종료
- 재할당 후 `--verify` 생략
14-7. 코스 전체 요약 — 조용한 실패 12가지
이 코스가 다룬 "에러 없이 잘못 동작하는" 상황을 한자리에 모읍니다.
| # | 증상 | 원인 | 스텝 | 방어 설정 |
|---|
| 1 | 컨슈머가 과거 메시지를 안 읽음 | 기본 auto.offset.reset=latest | 01, 06 | auto.offset.reset=earliest |
| 2 | 오타 토픽이 저절로 생김 | auto.create.topics.enable=true | 01 | =false |
| 3 | 같은 키 순서가 깨짐 | 파티션 증가 | 03 | 파티션을 처음에 넉넉히, 이후 고정 |
| 4 | 복제가 영영 안 따라옴 | max.message.bytes 만 올리고 replica.fetch.max.bytes 방치 | 03 | 4곳을 함께 조정 |
| 5 | 보낸 메시지가 사라짐 | acks=0 / acks=1 + 리더 장애 | 04, 08 | acks=all |
| 6 | 같은 키 순서가 뒤바뀜 | max.in.flight>1 + 재시도 | 04 | enable.idempotence=true |
| 7 | 컨슈머를 늘려도 안 빨라짐 | 컨슈머 수 > 파티션 수 | 05 | 파티션 ≥ 컨슈머 |
| 8 | 처리 안 한 메시지가 유실 | enable.auto.commit=true | 06 | =false + 처리 후 commitSync() |
| 9 | 같은 메시지가 재처리됨 | at-least-once 의 본질 | 06, 07 | 비즈니스 멱등 처리 |
| 10 | 커밋된 데이터가 통째로 사라짐 | unclean.leader.election.enable=true | 08 | =false (기본값 유지) |
| 11 | acks=all 인데도 유실 | min.insync.replicas=1 | 08 | =2 (RF 3 기준) |
| 12 | 스키마 변경이 컨슈머를 멈춤 | 호환성 NONE | 10 | BACKWARD 유지, 필드는 기본값과 함께 추가 |
열두 가지 중 어느 것도 에러 로그를 남기지 않습니다. 이것이 이 코스가 존재하는 이유입니다.
14-8. 정리 (실습 마무리)
이 스텝에서 만든 토픽을 지웁니다.
kt --list | grep -E '^s14_' | while read t; do
docker exec kafka-1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server kafka-1:9092 --delete --topic "$t"
done
스로틀이 남아 있지 않은지 반드시 확인합니다.
for b in 1 2 3; do
echo -n "broker $b: "
kconf --describe --entity-type brokers --entity-name $b | grep -c throttled
done
결과
broker 1: 0
broker 2: 0
broker 3: 0
모든 브로커가 살아 있고 클러스터가 건강한지 마지막으로 확인합니다.
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
kt --describe --under-replicated-partitions | wc -l
결과
NAME STATUS
kafka-1 Up 4 hours (healthy)
kafka-2 Up 12 minutes (healthy)
kafka-3 Up 12 minutes (healthy)
kafka-ui Up 4 hours
0
코스를 마쳤다면 환경 정리
cd kafka/docker
docker compose --profile all down -v
결과
[+] Running 9/9
✔ Container kafka-connect Removed 2.1s
✔ Container schema-registry Removed 1.8s
✔ Container kafka-ui Removed 1.2s
✔ Container kafka-3 Removed 6.4s
✔ Container kafka-2 Removed 6.5s
✔ Container kafka-1 Removed 6.3s
✔ Volume docker_kafka-1-data Removed 0.1s
✔ Volume docker_kafka-2-data Removed 0.1s
✔ Volume docker_kafka-3-data Removed 0.1s
정리
| 개념 | 핵심 |
|---|
| 재할당 3단계 | --generate → --execute → --verify |
Current 블록 | 반드시 rollback.json 으로 저장. --generate 는 매번 결과가 다름 |
--throttle | 재할당 대역폭 제한. --verify 가 해제해 줌 |
| 스로틀 잔존 | 이후 모든 복제가 느려짐. 에러 없음. grep throttled 로 확인 |
| preferred election | kafka-leader-election.sh --election-type preferred |
| controlled shutdown | SIGTERM 으로만 발동. kill -9 금지 |
| 롤링 재시작 | 점검 → 정지 → 재시작 → 복제 완료 대기 → 리더 복원 |
| ④ 대기 생략 | ISR 이 1로 줄어 유실 위험. 데이터 보호 장치 |
UnderReplicatedPartitions | 0이어야 정상 |
UnderMinIsrPartitionCount | 0이 아니면 쓰기가 거부되는 중 |
OfflinePartitionsCount | 0이 아니면 읽기·쓰기 불가 |
ActiveControllerCount | 클러스터 합이 1. 2 이상이면 split-brain |
RequestHandlerAvgIdlePercent | 0.3 미만이면 I/O 스레드 포화 |
kafka-log-dirs.sh | 파티션별 크기와 offsetLag |
| 디스크 대응 | retention.ms 축소 → 확인 → 원복. rm 절대 금지 |
| 컨슈머 ACL | 토픽 권한 + 그룹 권한이 함께 필요 |
| minISR=2 의 의미 | 1대 죽어도 서비스 계속, 2대 죽으면 쓰기 거부로 유실 방지 |
| DLQ 설계 | 파티션 1개(조사 편의), 헤더에 원인·출처 오프셋 |
| 최종 결론 | exactly-once 는 Kafka 내부 한정. 실무는 at-least-once + 멱등 처리 |
연습문제
exercise.sh 에 7문제가 있습니다. 정답은 solution.sh.
s14_ex 토픽을 브로커 1,2 에만 배치해 만들고, 3대에 고르게 재할당하기 (rollback.json 저장 포함)
- 재할당에
--throttle 을 걸고, --verify 없이 끝냈을 때 스로틀이 남는 것을 확인한 뒤 수동 해제하기
- kafka-3 을 롤링 재시작하되, 복제 완료 대기 루프를 직접 작성해 넣기
UnderReplicatedPartitions / OfflinePartitionsCount / ActiveControllerCount 를 JMX 로 읽어 한 줄 헬스체크 만들기
min.insync.replicas 를 1로 낮춘 토픽과 2인 토픽에 브로커 2대를 죽인 채 쓰기를 시도해 결과 차이를 기록하기
- 종합 실습 검증 체크리스트 V1~V5 를 자동으로 검사해 실패 항목을 출력하는 스크립트 작성하기
- 코스 전체 "조용한 실패 12가지" 중 임의의 3개를 골라, 현재 클러스터가 방어되고 있는지 실제 설정으로 감사하기
다음 단계
이 코스는 여기서 끝납니다. Kafka 자체 — 브로커, 프로토콜, 저장 구조, 보장 모델, 운영 — 를 CLI 로 직접 만져 보았습니다.
다음으로 자연스럽게 이어지는 것은 애플리케이션 레벨 통합입니다. @KafkaListener, 컨테이너 팩토리, 에러 핸들러와 재시도 토픽, KafkaTemplate 의 트랜잭션 연동, 테스트용 임베디드 브로커 같은 주제는 별도의 Spring Kafka 코스에서 다룹니다. 이 코스에서 익힌 acks·min.insync.replicas·오프셋 커밋 시점의 의미가 그대로 그 코스의 설정 값으로 이어집니다.
운영을 더 깊이 파고 싶다면 MirrorMaker 2(클러스터 간 복제), Cruise Control(자동 리밸런싱), Tiered Storage(3.6+) 가 다음 관문입니다.
실습 파일
이 스텝은 셸 스크립트 세 개로 진행합니다. practice.sh 는 14-1 ~ 14-8 을 순서대로 재현하며, 브로커를 정지시키는 구간이 세 곳(14-2 롤링 재시작, 14-6 장애 주입 두 번) 있으므로 실행 전 확인을 받습니다. 스크립트는 어떤 경로로 끝나든 마지막에 모든 브로커를 되살리고 클러스터 건강을 검증합니다.
practice.sh
본문의 모든 명령을 절 번호 주석(# [14-2])과 함께 담은 실행 스크립트입니다.
- 상단에
K() 헬퍼와 함께 wait_healthy(), wait_isr_ok() 두 함수를 정의합니다. 전자는 컨테이너 헬스체크를, 후자는 --under-replicated-partitions 가 0이 될 때까지 폴링합니다. 14-2 의 ④ 단계가 이 함수 하나로 표현되며, 실무 롤링 재시작 스크립트에 그대로 옮겨 쓸 수 있습니다.
trap 'docker compose start kafka-1 kafka-2 kafka-3 2>/dev/null' EXIT 를 걸어 두었습니다. 스크립트를 Ctrl+C 로 중단해도 죽인 브로커가 반드시 되살아납니다. 브로커가 죽은 채 방치되는 것이 이 스텝에서 가장 흔한 사고이므로 안전장치를 넣었습니다.
[14-1] 의 JSON 파일들은 docker exec ... sh -c 'cat > ... <<EOF' 로 컨테이너 안에 만듭니다. 호스트에 만들면 컨테이너가 못 읽습니다. 그리고 --generate 결과를 그대로 쓰지 않고 스크립트가 미리 정해 둔 배치를 씁니다. --generate 는 실행할 때마다 결과가 달라 교재와 대조가 안 되기 때문입니다.
[14-1] 의 --verify 는 완료될 때까지 루프를 돕니다. 한 번만 실행하고 "still in progress" 를 보고 넘어가면 스로틀이 남습니다. 루프가 끝난 뒤 grep throttled 로 잔존 여부를 실제로 검사해 보여 줍니다.
[14-2] 롤링 재시작은 kafka-2 한 대만 수행합니다. 세 대를 다 돌면 10분 이상 걸리기 때문입니다. 나머지 두 대는 같은 절차를 반복하면 된다는 주석만 남겼습니다.
[14-3] 의 JMX 호출은 브로커 컨테이너 자기 자신의 localhost:9999 로 붙습니다. 호스트 포트(19999)로 붙으려면 host.docker.internal 이 필요한데 환경에 따라 동작하지 않아, 안정적인 쪽을 택했습니다. 세 브로커의 ActiveControllerCount 를 각각 재는 구간만 컨테이너를 바꿔 가며 호출합니다.
[14-6] 단계 ⑤ 의 장애 주입은 두 단계입니다. 브로커 1대 정지(쓰기 성공해야 함) → 2대 정지(NotEnoughReplicasException 이 나야 함). 두 번째에서 에러가 나는 것이 정답이므로 || true 로 감싸고, 에러 메시지에 NotEnoughReplicas 가 포함됐는지 검사해 통과/실패를 판정합니다.
[14-8] 의 마지막 환경 정리(down -v)는 기본적으로 실행하지 않습니다. read -p 로 확인하며, 기본값이 "아니오"입니다. 실수로 클러스터를 날리면 코스 전체를 다시 세워야 하기 때문입니다.
#!/usr/bin/env bash
# =============================================================================
# Step 14 — 운영과 최종 프로젝트 : practice.sh
#
# 실행:
# bash step-14-operations/practice.sh
#
# ★ 이 스크립트는 브로커를 실제로 정지시킵니다 (14-2, 14-6).
# 중단(Ctrl+C)해도 trap 이 모든 브로커를 되살립니다.
# 그래도 끝난 뒤에는 반드시 'docker compose ps' 로 3대가 healthy 인지 확인하세요.
# =============================================================================
set -uo pipefail
BS=kafka-1:9092
DATA=/var/lib/kafka/data
K() { docker exec kafka-1 /opt/kafka/bin/"$@"; }
S() { docker exec kafka-1 sh -c "$1"; }
hr() { echo; echo "=============================================================="; echo "$*"; echo "=============================================================="; }
# --- 안전장치 : 어떤 경로로 끝나든 브로커를 되살립니다 -------------------------
cleanup() {
echo
echo ">>> [trap] 브로커 복구 중..."
docker compose start kafka-1 kafka-2 kafka-3 >/dev/null 2>&1
}
trap cleanup EXIT
# --- 공용 대기 함수 -----------------------------------------------------------
# 컨테이너가 healthy 가 될 때까지 대기
wait_healthy() {
local c="$1" limit="${2:-120}" waited=0
until [ "$(docker inspect -f '{{.State.Health.Status}}' "$c" 2>/dev/null)" = "healthy" ]; do
sleep 2; waited=$((waited+2))
if [ "$waited" -ge "$limit" ]; then
echo "!! $c 가 ${limit}초 안에 healthy 가 되지 않았습니다"; return 1
fi
done
echo "$c healthy (${waited}s)"
}
# under-replicated 파티션이 0이 될 때까지 대기 — 롤링 재시작 ④단계의 실체
wait_isr_ok() {
local limit="${1:-300}" waited=0 n
while :; do
n=$(K kafka-topics.sh --bootstrap-server "$BS" --describe --under-replicated-partitions 2>/dev/null | wc -l | tr -d ' ')
[ "$n" -eq 0 ] && { echo "복제 완료 (${waited}s)"; return 0; }
echo "복제 따라잡는 중... $n 파티션 남음"
sleep 3; waited=$((waited+3))
if [ "$waited" -ge "$limit" ]; then
# ★ 여기서 다음 브로커로 넘어가면 안 됩니다. 스로틀 잔존이나 디스크 포화를 의심하세요.
echo "!! ${limit}초 안에 복제가 끝나지 않았습니다. 중단합니다."; return 1
fi
done
}
# -----------------------------------------------------------------------------
# [14-0] 실습 준비 — 클러스터 건강 확인
# -----------------------------------------------------------------------------
hr "[14-0] 브로커 상태"
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
hr "[14-0] 3종 헬스체크 (전부 0이어야 정상)"
echo -n "under-replicated : "; K kafka-topics.sh --bootstrap-server "$BS" --describe --under-replicated-partitions | wc -l
echo -n "under-min-isr : "; K kafka-topics.sh --bootstrap-server "$BS" --describe --under-min-isr-partitions | wc -l
echo -n "unavailable : "; K kafka-topics.sh --bootstrap-server "$BS" --describe --unavailable-partitions | wc -l
# -----------------------------------------------------------------------------
# [14-1] 파티션 재할당
# -----------------------------------------------------------------------------
hr "[14-1] 불균형 토픽 s14_move 생성 (브로커 1,2 에만 배치)"
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s14_move \
--partitions 3 --replica-assignment 1:2,1:2,1:2 --if-not-exists
# kafka-3 이 완전히 놀고, 리더가 전부 kafka-1 에 몰린 상태입니다.
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s14_move
hr "[14-1] 옮길 데이터 넣기 (30,000건)"
K kafka-producer-perf-test.sh --topic s14_move \
--num-records 30000 --record-size 200 --throughput -1 \
--producer-props bootstrap.servers="$BS"
hr "[14-1] ① --generate : 후보안 만들기"
# JSON 은 반드시 '컨테이너 안'에 만들어야 합니다. 호스트에 만들면 CLI 가 못 읽습니다.
S "cat > /tmp/topics-to-move.json <<'EOF'
{\"topics\": [{\"topic\": \"s14_move\"}], \"version\": 1}
EOF"
K kafka-reassign-partitions.sh --bootstrap-server "$BS" \
--topics-to-move-json-file /tmp/topics-to-move.json \
--broker-list "1,2,3" --generate
# ★ --generate 는 무작위 시드를 써서 매번 결과가 다릅니다.
# 교재와 대조하기 위해 아래에서는 미리 정해 둔 배치를 씁니다.
# 실무에서는 위 출력의 'Proposed' 블록을 그대로 파일로 저장해 쓰세요.
hr "[14-1] rollback.json 저장 ★ 이것을 빠뜨리면 되돌릴 수 없습니다 ★"
S "cat > /tmp/rollback.json <<'EOF'
{\"version\":1,\"partitions\":[{\"topic\":\"s14_move\",\"partition\":0,\"replicas\":[1,2],\"log_dirs\":[\"any\",\"any\"]},{\"topic\":\"s14_move\",\"partition\":1,\"replicas\":[1,2],\"log_dirs\":[\"any\",\"any\"]},{\"topic\":\"s14_move\",\"partition\":2,\"replicas\":[1,2],\"log_dirs\":[\"any\",\"any\"]}]}
EOF"
S "cat /tmp/rollback.json"
hr "[14-1] 실행할 배치 reassign.json"
S "cat > /tmp/reassign.json <<'EOF'
{\"version\":1,\"partitions\":[{\"topic\":\"s14_move\",\"partition\":0,\"replicas\":[3,1],\"log_dirs\":[\"any\",\"any\"]},{\"topic\":\"s14_move\",\"partition\":1,\"replicas\":[1,2],\"log_dirs\":[\"any\",\"any\"]},{\"topic\":\"s14_move\",\"partition\":2,\"replicas\":[2,3],\"log_dirs\":[\"any\",\"any\"]}]}
EOF"
hr "[14-1] ② --execute (스로틀 1MiB/s)"
K kafka-reassign-partitions.sh --bootstrap-server "$BS" \
--reassignment-json-file /tmp/reassign.json \
--throttle 1048576 --execute
hr "[14-1] ③ --verify : 완료될 때까지 반복 ★ 이것이 스로틀을 해제합니다 ★"
for i in $(seq 1 20); do
out=$(K kafka-reassign-partitions.sh --bootstrap-server "$BS" \
--reassignment-json-file /tmp/reassign.json --verify 2>&1)
echo "$out"
echo "$out" | grep -q "still in progress" || break
sleep 3
done
hr "[14-1] 재할당 결과 — 리더가 3개 브로커에 분산되었는지"
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s14_move
hr "[14-1] 스로틀 잔존 검사 (0 이어야 정상)"
for b in 1 2 3; do
echo -n "broker $b throttled configs: "
K kafka-configs.sh --bootstrap-server "$BS" --describe \
--entity-type brokers --entity-name "$b" 2>/dev/null | grep -c throttled
done
hr "[14-1] preferred leader 로 복원"
K kafka-leader-election.sh --bootstrap-server "$BS" \
--election-type preferred --all-topic-partitions 2>&1 | head -3
# -----------------------------------------------------------------------------
# [14-2] 롤링 재시작 (kafka-2 한 대만 시연)
# -----------------------------------------------------------------------------
hr "[14-2] 롤링 재시작"
echo "kafka-2 를 정지했다가 되살립니다. (약 1~2분)"
read -r -p "진행할까요? [y/N] " ans
if [[ "${ans:-N}" =~ ^[Yy]$ ]]; then
echo "--- ① 사전 점검 : under-replicated 가 0이어야 시작 가능"
n=$(K kafka-topics.sh --bootstrap-server "$BS" --describe --under-replicated-partitions | wc -l | tr -d ' ')
echo "under-replicated = $n"
if [ "$n" -ne 0 ]; then
echo "!! 0이 아닙니다. 롤링 재시작을 시작하면 안 됩니다."
else
echo "--- ② kafka-2 정지 (controlled shutdown — SIGTERM)"
# docker compose stop 은 SIGTERM 을 보냅니다. kill -9 는 절대 쓰지 마세요.
time docker compose stop kafka-2
echo "--- 정지 중 상태 : Isr 에서 2가 빠진 것을 확인"
K kafka-topics.sh --bootstrap-server "$BS" --describe --under-replicated-partitions | head -6
echo "--- ③ 재시작"
docker compose start kafka-2
wait_healthy kafka-2
echo "--- ④ 복제 완료 대기 ★ 이 단계를 건너뛰면 데이터를 잃습니다 ★"
wait_isr_ok 300
echo "--- ⑤ preferred leader 복원"
K kafka-leader-election.sh --bootstrap-server "$BS" \
--election-type preferred --all-topic-partitions 2>&1 | head -3
echo "--- kafka-2 완료. 실무에서는 kafka-3, kafka-1 에 같은 절차를 반복합니다."
fi
else
echo "건너뜁니다."
fi
# -----------------------------------------------------------------------------
# [14-3] JMX 지표
# -----------------------------------------------------------------------------
JMXURL='service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi'
hr "[14-3] UnderReplicatedPartitions (0 이어야 정상)"
K kafka-run-class.sh kafka.tools.JmxTool \
--object-name 'kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions' \
--jmx-url "$JMXURL" --one-time 2>/dev/null
hr "[14-3] OfflinePartitionsCount (0 이어야 정상)"
K kafka-run-class.sh kafka.tools.JmxTool \
--object-name 'kafka.controller:type=KafkaController,name=OfflinePartitionsCount' \
--jmx-url "$JMXURL" --one-time 2>/dev/null
hr "[14-3] BytesInPerSec"
K kafka-run-class.sh kafka.tools.JmxTool \
--object-name 'kafka.server:type=BrokerTopicMetrics,name=BytesInPerSec' \
--jmx-url "$JMXURL" --one-time 2>/dev/null
hr "[14-3] ActiveControllerCount — 세 브로커의 합이 정확히 1이어야 정상"
total=0
for c in kafka-1 kafka-2 kafka-3; do
v=$(docker exec "$c" /opt/kafka/bin/kafka-run-class.sh kafka.tools.JmxTool \
--object-name 'kafka.controller:type=KafkaController,name=ActiveControllerCount' \
--jmx-url "$JMXURL" --one-time 2>/dev/null | tail -1 | awk -F, '{print $2}')
echo "$c : ${v:-?}"
total=$(( total + ${v:-0} ))
done
echo "합계 = $total (1이면 정상, 0이면 컨트롤러 없음, 2 이상이면 split-brain)"
hr "[14-3] 디스크 사용량 — kafka-log-dirs.sh"
# offsetLag 이 0이면 그 복제본이 리더를 따라잡았다는 뜻입니다.
K kafka-log-dirs.sh --bootstrap-server "$BS" --describe --topic-list s14_move
# -----------------------------------------------------------------------------
# [14-6] 종합 실습 — 주문 이벤트 파이프라인
# -----------------------------------------------------------------------------
hr "[14-6] 단계 ① 토픽 설계 검증"
for t in orders payments order-events dlq; do
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic "$t" | head -1
done
hr "[14-6] 단계 ④ 주문 30건 투입 (acks=all + 멱등 + lz4)"
for i in $(seq 1001 1030); do
c=$(printf "C%03d" $(( (i % 10) + 1 )))
echo "$c:{\"order_id\":\"O-$i\",\"customer_id\":\"$c\",\"amount\":$(( (i * 137) % 200000 )),\"status\":\"CREATED\"}"
done | docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server "$BS" --topic orders \
--property parse.key=true --property key.separator=: \
--producer-property acks=all \
--producer-property enable.idempotence=true \
--producer-property compression.type=lz4 \
--producer-property linger.ms=10
echo "투입 후 오프셋:"
K kafka-get-offsets.sh --bootstrap-server "$BS" --topic orders
hr "[14-6] 단계 ⑤ 장애 주입"
echo "브로커를 최대 2대까지 정지시킵니다."
read -r -p "진행할까요? [y/N] " ans2
if [[ "${ans2:-N}" =~ ^[Yy]$ ]]; then
echo "--- (가-1) 브로커 1대 정지 → 쓰기는 계속 성공해야 합니다 (R6)"
docker compose stop kafka-2
sleep 5
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic orders
out1=$(echo 'C001:{"order_id":"O-9001","customer_id":"C001","amount":50000,"status":"CREATED"}' \
| docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server "$BS" --topic orders \
--property parse.key=true --property key.separator=: \
--producer-property acks=all --producer-property enable.idempotence=true 2>&1)
if echo "$out1" | grep -q "Exception"; then
echo "!! 예상과 다릅니다 — 1대만 죽었을 때는 성공해야 합니다"; echo "$out1"
else
echo "OK — ISR 2개로 min.insync.replicas=2 를 만족하므로 쓰기 성공 (R6 충족)"
fi
echo
echo "--- (가-2) 브로커 2대 정지 → 쓰기가 '거부되어야' 합니다 (R1)"
docker compose stop kafka-3
sleep 5
# ★ 여기서는 에러가 나는 것이 정답입니다.
out2=$(echo 'C001:{"order_id":"O-9002","customer_id":"C001","amount":60000,"status":"CREATED"}' \
| docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server "$BS" --topic orders \
--property parse.key=true --property key.separator=: \
--producer-property acks=all --producer-property enable.idempotence=true 2>&1) || true
echo "$out2" | tail -3
if echo "$out2" | grep -q "NotEnoughReplicas"; then
echo "OK — NotEnoughReplicasException 으로 거부됨. 유실을 막았습니다 (R1 충족)"
else
echo "!! NotEnoughReplicasException 이 안 났습니다. min.insync.replicas 설정을 확인하세요."
fi
echo
echo "--- 복구"
docker compose start kafka-2 kafka-3
wait_healthy kafka-2
wait_healthy kafka-3
wait_isr_ok 300
else
echo "건너뜁니다."
fi
hr "[14-6] 단계 ⑥ 검증 체크리스트 V1~V5"
echo -n "V1 브로커 3대 healthy : "; docker compose ps --format '{{.Status}}' | grep -c healthy
echo -n "V2 under-replicated : "; K kafka-topics.sh --bootstrap-server "$BS" --describe --under-replicated-partitions | wc -l
echo -n "V3 under-min-isr : "; K kafka-topics.sh --bootstrap-server "$BS" --describe --under-min-isr-partitions | wc -l
echo -n "V4 unavailable : "; K kafka-topics.sh --bootstrap-server "$BS" --describe --unavailable-partitions | wc -l
echo -n "V5 스로틀 잔존 : "; K kafka-configs.sh --bootstrap-server "$BS" --describe --entity-type brokers --entity-name 1 2>/dev/null | grep -c throttled
# -----------------------------------------------------------------------------
# [14-8] 정리
# -----------------------------------------------------------------------------
hr "[14-8] s14_ 토픽 삭제"
K kafka-topics.sh --bootstrap-server "$BS" --list | grep -E '^s14_' | while read -r t; do
echo "삭제: $t"
docker exec kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BS" --delete --topic "$t"
done
hr "[14-8] 최종 클러스터 상태"
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
echo -n "under-replicated : "; K kafka-topics.sh --bootstrap-server "$BS" --describe --under-replicated-partitions | wc -l
# 코스를 완전히 마쳤다면 아래로 환경을 정리합니다.
# ★ 기본값은 '아니오' 입니다. 실수로 클러스터를 날리면 코스 전체를 다시 세워야 합니다.
echo
read -r -p "코스를 마쳤습니까? 클러스터를 완전히 삭제할까요 (docker compose down -v)? [y/N] " ans3
if [[ "${ans3:-N}" =~ ^[Yy]$ ]]; then
trap - EXIT # 삭제할 것이므로 복구 trap 을 해제합니다
(cd ../../docker 2>/dev/null || cd kafka/docker 2>/dev/null || true; docker compose --profile all down -v)
else
echo "클러스터를 유지합니다."
fi
hr "practice.sh 완료"
exercise.sh
7문제의 문제지입니다. 각 문제는 # 여기에 작성: 자리를 비워 두었습니다.
- 문제 1 은
--replica-assignment 1:2,1:2,1:2 로 불균형 토픽을 만드는 것부터 시작합니다. 문제지가 토픽 생성까지는 해 주고, --generate 이후를 비워 둡니다. Current 블록을 저장하는 단계를 빠뜨리면 감점이며, 정답 스크립트가 그 이유를 설명합니다.
- 문제 2 가 이 문제지의 핵심입니다.
--verify 를 일부러 생략하고 스로틀이 남는 것을 grep throttled 로 확인한 뒤, --delete-config 로 수동 해제합니다. 해제할 설정 이름 두 개(leader.replication.throttled.rate, follower.replication.throttled.rate)를 정확히 써야 하고, 토픽 쪽 leader.replication.throttled.replicas 도 함께 확인해야 완전합니다.
- 문제 3 은 대기 루프를 직접 작성하는 문제입니다.
until [ "$(... | wc -l)" -eq 0 ] 형태가 정답이며, wc -l 이 0인지로 판정해야 한다는 게 포인트입니다. 출력 문자열의 유무로 판정하려 하면 공백 처리에서 틀립니다.
- 문제 4 는 JMX 세 지표를 한 줄 헬스체크로 묶습니다.
ActiveControllerCount 는 세 브로커의 합을 구해야 1인지 알 수 있다는 점이 함정입니다. 한 브로커만 재면 0이 나와 "컨트롤러 없음"으로 오판합니다.
- 문제 5 는
min.insync.replicas 1 vs 2 의 차이를 몸으로 확인하는 문제입니다. 브로커 2대를 죽인 상태에서 minISR=1 토픽은 쓰기가 성공하고 minISR=2 토픽은 거부됩니다. 성공한 쪽이 더 위험하다는 것이 결론이며, 문제지는 두 결과를 나란히 기록하게 합니다.
- 문제 6 은 V1~V5 를 배열로 돌며 실패 항목만 모아 출력하는 스크립트입니다. 각 검사의 "정상 조건"이 전부 0 이라는 공통점을 이용하면 간결해집니다.
- 문제 7 은 정답이 열려 있습니다. 예를 들어 #5(acks), #8(auto commit), #11(minISR)을 골랐다면, 각각
kconf --describe 나 토픽 설정으로 현재 방어 상태를 감사합니다. 프로듀서·컨슈머 설정은 브로커에서 볼 수 없으므로 "애플리케이션 코드를 봐야 한다" 는 한계까지 적어야 완전한 답입니다.
#!/usr/bin/env bash
# =============================================================================
# Step 14 — 운영과 최종 프로젝트 : exercise.sh (문제지)
#
# 실행:
# bash step-14-operations/exercise.sh
#
# 각 문제의 "# 여기에 작성:" 아래를 직접 채우세요.
# 정답은 solution.sh 에 있습니다.
#
# ★ 문제 3, 5 는 브로커를 정지시킵니다. trap 이 복구해 주지만
# 끝난 뒤 반드시 'docker compose ps' 로 3대가 healthy 인지 확인하세요.
# =============================================================================
set -uo pipefail
BS=kafka-1:9092
K() { docker exec kafka-1 /opt/kafka/bin/"$@"; }
S() { docker exec kafka-1 sh -c "$1"; }
hr() { echo; echo "--------------------------------------------------------------"; echo "$*"; echo "--------------------------------------------------------------"; }
cleanup() { echo; echo ">>> [trap] 브로커 복구 중..."; docker compose start kafka-1 kafka-2 kafka-3 >/dev/null 2>&1; }
trap cleanup EXIT
# -----------------------------------------------------------------------------
# 문제 1.
# s14_ex 토픽을 브로커 1,2 에만 배치해 만들고(파티션 3),
# 브로커 3대에 고르게 재할당하십시오.
#
# ★ 반드시 포함해야 할 단계: --generate 출력의 'Current' 블록을
# rollback.json 으로 저장하기. 이걸 빠뜨리면 되돌릴 수 없습니다.
#
# 힌트: --replica-assignment 1:2,1:2,1:2
# JSON 파일은 컨테이너 안(/tmp)에 만들어야 합니다.
# -----------------------------------------------------------------------------
hr "문제 1 — 불균형 토픽 생성 후 3대에 재할당"
# (1-a) 토픽 생성
# 여기에 작성:
# (1-b) topics-to-move.json 만들고 --generate
# 여기에 작성:
# (1-c) Current 블록을 /tmp/rollback_ex.json 으로 저장
# 여기에 작성:
# (1-d) reassign_ex.json 을 만들고 --execute
# 여기에 작성:
# -----------------------------------------------------------------------------
# 문제 2. ★ 이 문제지의 핵심 ★
# 문제 1 의 재할당을 --throttle 을 걸고 실행했다고 가정합니다.
# --verify 를 "일부러 생략"하고, 스로틀이 브로커에 남아 있는 것을 확인한 뒤
# 수동으로 해제하십시오.
#
# 힌트: 확인은 kafka-configs.sh --describe --entity-type brokers | grep throttled
# 해제할 설정 이름 두 개를 정확히 써야 합니다.
# 토픽 쪽에도 남는 설정이 있습니다. 그것도 찾아보세요.
# -----------------------------------------------------------------------------
hr "문제 2 — 스로틀 잔존 확인과 수동 해제"
# (2-a) 스로틀을 걸고 재할당 실행 (--verify 는 하지 않습니다)
# 여기에 작성:
# (2-b) 브로커 1,2,3 에 스로틀이 남아 있는지 확인
# 여기에 작성:
# (2-c) 브로커 스로틀 수동 해제
# 여기에 작성:
# (2-d) 토픽 쪽에 남은 스로틀 설정도 확인하고 해제
# 여기에 작성:
# -----------------------------------------------------------------------------
# 문제 3.
# kafka-3 을 롤링 재시작하십시오.
# 단, ④단계의 "복제 완료 대기 루프"를 직접 작성해서 넣어야 합니다.
#
# 힌트: under-replicated 파티션 수가 0이 될 때까지 폴링합니다.
# 판정은 출력 문자열이 아니라 wc -l 결과가 0인지로 해야 합니다.
# 무한 루프를 막을 타임아웃 상한도 넣으세요.
# -----------------------------------------------------------------------------
hr "문제 3 — 롤링 재시작 + 복제 완료 대기 루프 작성"
# (3-a) 사전 점검 : under-replicated 가 0인지
# 여기에 작성:
# (3-b) kafka-3 정지
# 여기에 작성:
# (3-c) kafka-3 재시작 후 healthy 대기
# 여기에 작성:
# (3-d) ★ 복제 완료 대기 루프 (타임아웃 상한 포함)
# 여기에 작성:
# (3-e) preferred leader 복원
# 여기에 작성:
# -----------------------------------------------------------------------------
# 문제 4.
# 아래 세 지표를 JMX 로 읽어 한 줄 헬스체크를 만드십시오.
# - UnderReplicatedPartitions (0 이어야 정상)
# - OfflinePartitionsCount (0 이어야 정상)
# - ActiveControllerCount (클러스터 '합'이 1 이어야 정상)
#
# 힌트: ActiveControllerCount 는 한 브로커만 재면 안 됩니다. 왜일까요?
# JMX URL: service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi
# -----------------------------------------------------------------------------
hr "문제 4 — JMX 3종 헬스체크"
JMXURL='service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi'
# (4-a) UnderReplicatedPartitions
# 여기에 작성:
# (4-b) OfflinePartitionsCount
# 여기에 작성:
# (4-c) ActiveControllerCount 합계
# 여기에 작성:
# -----------------------------------------------------------------------------
# 문제 5.
# min.insync.replicas 가 1인 토픽과 2인 토픽을 각각 만들고,
# 브로커 2대를 죽인 상태에서 양쪽에 acks=all 로 쓰기를 시도하십시오.
# 두 결과를 기록하고, 어느 쪽이 더 '위험한지' 판단하십시오.
#
# 힌트: 한쪽은 성공하고 한쪽은 실패합니다.
# 성공하는 쪽이 안전한 쪽일까요?
# -----------------------------------------------------------------------------
hr "문제 5 — min.insync.replicas 1 vs 2"
# (5-a) 토픽 두 개 생성 (s14_ex_isr1, s14_ex_isr2)
# 여기에 작성:
# (5-b) 브로커 2대 정지
# 여기에 작성:
# (5-c) s14_ex_isr1 에 acks=all 로 쓰기 시도 — 결과 기록
# 여기에 작성:
# (5-d) s14_ex_isr2 에 acks=all 로 쓰기 시도 — 결과 기록
# 여기에 작성:
# (5-e) 브로커 복구
# 여기에 작성:
# 어느 쪽이 더 위험합니까? 왜입니까? (주석으로 작성)
#
# -----------------------------------------------------------------------------
# 문제 6.
# 종합 실습의 검증 체크리스트 V1~V5 를 자동으로 검사해서
# '실패한 항목만' 출력하는 스크립트를 작성하십시오.
#
# V1 브로커 3대 healthy
# V2 under-replicated == 0
# V3 under-min-isr == 0
# V4 unavailable == 0
# V5 스로틀 잔존 == 0
#
# 힌트: V2~V5 는 전부 "결과가 0이어야 정상" 이라는 공통점이 있습니다.
# 배열에 "이름|명령|기대값" 형태로 담아 루프를 돌리면 간결해집니다.
# -----------------------------------------------------------------------------
hr "문제 6 — V1~V5 자동 검증 스크립트"
# 여기에 작성:
# -----------------------------------------------------------------------------
# 문제 7.
# 코스 전체 "조용한 실패 12가지"(14-7 표) 중 3개를 골라,
# 현재 클러스터가 그 실패에 방어되고 있는지 실제 설정으로 감사하십시오.
#
# 예: #11 min.insync.replicas=1 → 토픽 설정으로 확인 가능
# #5 acks=0/1 → ???
# #8 enable.auto.commit → ???
#
# ★ 세 개 중 브로커에서 확인할 수 없는 것이 있습니다.
# 무엇이고, 왜 확인할 수 없는지도 답에 포함하십시오.
# -----------------------------------------------------------------------------
hr "문제 7 — 조용한 실패 3가지 감사"
# 고른 3가지:
# (1)
# (2)
# (3)
# 감사 명령
# 여기에 작성:
# 브로커에서 확인할 수 없는 항목과 그 이유 (주석으로 작성):
#
# -----------------------------------------------------------------------------
# 뒷정리
# -----------------------------------------------------------------------------
hr "뒷정리 — s14_ex 로 시작하는 토픽 삭제"
K kafka-topics.sh --bootstrap-server "$BS" --list | grep -E '^s14_ex' | while read -r t; do
echo "삭제: $t"
docker exec kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BS" --delete --topic "$t"
done
hr "exercise.sh 완료 — solution.sh 로 채점하세요"
echo "★ 브로커 3대가 살아 있는지 반드시 확인하세요:"
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
solution.sh
7문제의 정답 명령과 왜 그 답인지 설명하는 긴 주석이 들어 있습니다. 풀어 본 뒤에 여세요.
- 정답 1 은
--generate 출력에서 Current 와 Proposed 를 각각 파일로 뽑는 sed 파이프까지 보여 줍니다. 주석은 --generate 가 무작위 시드를 쓰므로 같은 명령을 다시 돌려도 원래 배치가 안 나온다는 점, 그래서 롤백 파일이 유일한 복구 수단이라는 점을 강조합니다.
- 정답 2 는 스로틀 잔존을 실제
synonyms 출력으로 보여 준 뒤 해제합니다. 주석이 이 함정의 증상을 자세히 서술합니다. 평소에는 멀쩡하고, 브로커 재시작이나 장애 복구처럼 복제를 몰아쳐야 할 때만 드러나므로 원인 추적이 매우 어렵습니다. under-replicated 가 몇 시간씩 안 풀리는데 로그는 조용한 것이 전형적인 증상입니다.
- 정답 3 은 대기 루프에 타임아웃 상한(예: 300초)을 추가한 형태를 제시합니다. 주석은 무한 루프의 위험 — 복제가 영영 안 끝나는 상황(스로틀 잔존, 디스크 포화)에서 스크립트가 영원히 멈추는 것 — 을 지적하고, 상한 초과 시 다음 브로커로 진행하지 말고 중단해야 한다고 못 박습니다.
- 정답 4 는 세 브로커의
ActiveControllerCount 를 루프로 더해 합계를 구합니다. 주석은 KRaft 에서 이 값이 컨트롤러 역할을 하는 노드에서만 1 이고 나머지는 0이라는 것, 합이 0이면 쿼럼 상실, 2 이상이면 split-brain 이라는 판정 기준을 정리합니다. 그리고 controller.quorum.voters 를 홀수로 둬야 하는 이유(과반수 계산)를 덧붙입니다.
- 정답 5 는 두 토픽의 결과를 표로 대조합니다. minISR=1 토픽은
Isr: 1 상태에서 쓰기가 성공하고, minISR=2 토픽은 NotEnoughReplicasException 으로 거부됩니다. 주석의 결론이 이 코스 전체를 요약합니다 — "성공하는 쪽이 위험한 쪽이다." 남은 그 한 대가 죽으면 방금 성공한 쓰기가 사라지고, 프로듀서는 이미 성공 응답을 받은 뒤입니다.
- 정답 6 은
checks 배열에 "이름|명령" 형태로 담아 돌리는 형태입니다. 주석은 모든 검사의 정상 조건이 0이라는 규칙성을 활용한 설계이며, 새 검사를 추가할 때 배열에 한 줄만 넣으면 된다는 확장성을 설명합니다. 그리고 이 스크립트를 cron 이나 컨테이너 헬스체크에 그대로 붙일 수 있다는 점을 덧붙입니다.
- 정답 7 은 세 가지 감사를 실제로 수행합니다. #11(minISR)은
kt --describe --topics-with-overrides | grep min.insync 로 브로커에서 확인 가능하지만, #5(acks)와 #8(auto commit)은 클라이언트 설정이라 브로커에서 볼 수 없습니다. 주석은 이 비대칭이 실무에서 중요한 함의를 갖는다고 짚습니다. 브로커 측 방어(min.insync.replicas)는 중앙에서 강제할 수 있지만, 클라이언트 측 설정(acks, enable.auto.commit)은 코드 리뷰와 표준 라이브러리로만 통제됩니다. 그래서 조직 차원의 공용 프로듀서/컨슈머 팩토리를 두는 것이 정석이라는 결론으로 마무리합니다.
#!/usr/bin/env bash
# =============================================================================
# Step 14 — 운영과 최종 프로젝트 : solution.sh (정답 + 해설)
#
# 실행:
# bash step-14-operations/solution.sh
#
# exercise.sh 를 먼저 풀어 본 뒤에 여세요.
# ★ 문제 3, 5 는 브로커를 정지시킵니다. trap 이 복구합니다.
# =============================================================================
set -uo pipefail
BS=kafka-1:9092
JMXURL='service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi'
K() { docker exec kafka-1 /opt/kafka/bin/"$@"; }
S() { docker exec kafka-1 sh -c "$1"; }
hr() { echo; echo "=============================================================="; echo "$*"; echo "=============================================================="; }
cleanup() { echo; echo ">>> [trap] 브로커 복구 중..."; docker compose start kafka-1 kafka-2 kafka-3 >/dev/null 2>&1; }
trap cleanup EXIT
wait_healthy() {
local c="$1" limit="${2:-120}" waited=0
until [ "$(docker inspect -f '{{.State.Health.Status}}' "$c" 2>/dev/null)" = "healthy" ]; do
sleep 2; waited=$((waited+2))
[ "$waited" -ge "$limit" ] && { echo "!! $c healthy 실패"; return 1; }
done
echo "$c healthy (${waited}s)"
}
# =============================================================================
# 정답 1 — 불균형 토픽 생성 후 3대에 재할당
# =============================================================================
hr "정답 1"
# (1-a)
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s14_ex \
--partitions 3 --replica-assignment 1:2,1:2,1:2 --if-not-exists
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s14_ex
# (1-b)
S "cat > /tmp/topics-to-move-ex.json <<'EOF'
{\"topics\": [{\"topic\": \"s14_ex\"}], \"version\": 1}
EOF"
GEN=$(K kafka-reassign-partitions.sh --bootstrap-server "$BS" \
--topics-to-move-json-file /tmp/topics-to-move-ex.json \
--broker-list "1,2,3" --generate 2>/dev/null)
echo "$GEN"
# (1-c) ★ Current 블록을 롤백 파일로 저장
# --generate 출력은 두 블록입니다. 'Current' 다음 줄의 JSON 을 뽑습니다.
CUR=$(echo "$GEN" | grep -A2 'Current partition replica assignment' | grep '^{')
S "cat > /tmp/rollback_ex.json <<'EOF'
$CUR
EOF"
echo
echo "저장된 rollback_ex.json:"
S "cat /tmp/rollback_ex.json"
# (1-d) Proposed 블록으로 실행
PRO=$(echo "$GEN" | grep -A2 'Proposed partition reassignment configuration' | grep '^{')
S "cat > /tmp/reassign_ex.json <<'EOF'
$PRO
EOF"
K kafka-reassign-partitions.sh --bootstrap-server "$BS" \
--reassignment-json-file /tmp/reassign_ex.json --execute
for i in $(seq 1 20); do
out=$(K kafka-reassign-partitions.sh --bootstrap-server "$BS" \
--reassignment-json-file /tmp/reassign_ex.json --verify 2>&1)
echo "$out" | grep -q "still in progress" || { echo "$out"; break; }
sleep 2
done
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s14_ex
# 해설:
# ★ (1-c) 를 빠뜨리면 되돌릴 수 없습니다. 이것이 이 문제의 핵심입니다.
#
# 왜 "나중에 --generate 를 다시 돌리면 되지 않나?" 가 안 되는가:
# --generate 는 브로커 목록을 받아 파티션을 배분할 때 무작위 시드를 씁니다.
# 같은 입력으로 두 번 돌려도 결과가 다릅니다. 즉 '원래 배치' 를 재현할 방법이
# 저장해 둔 파일밖에 없습니다.
#
# 그리고 원래 배치를 모르면 왜 곤란한가:
# 재할당 중에 성능 문제나 디스크 부족이 터지면 즉시 되돌려야 하는데,
# 되돌릴 목표 상태를 모르면 손으로 JSON 을 짜야 합니다.
# 파티션이 수백 개인 운영 토픽에서는 사실상 불가능합니다.
#
# 실무 절차로 굳히세요:
# --generate → Current 를 rollback.json 으로 저장 → --execute → --verify
# =============================================================================
# 정답 2 — 스로틀 잔존 확인과 수동 해제 ★ 이 문제지의 핵심 ★
# =============================================================================
hr "정답 2"
# (2-a) 스로틀을 걸고 재할당. --verify 는 일부러 하지 않습니다.
# 원래 배치로 되돌리는 재할당을 스로틀과 함께 실행합니다.
K kafka-reassign-partitions.sh --bootstrap-server "$BS" \
--reassignment-json-file /tmp/rollback_ex.json \
--throttle 1024 --execute 2>&1 | tail -3
sleep 3
# (2-b) 브로커에 스로틀이 남아 있는지 확인
echo
echo "--- 브로커 스로틀 설정 ---"
for b in 1 2 3; do
echo "[broker $b]"
K kafka-configs.sh --bootstrap-server "$BS" --describe \
--entity-type brokers --entity-name "$b" 2>/dev/null | grep throttled || echo " (없음)"
done
# (2-c) 브로커 스로틀 수동 해제
echo
echo "--- 브로커 스로틀 해제 ---"
for b in 1 2 3; do
K kafka-configs.sh --bootstrap-server "$BS" --alter \
--entity-type brokers --entity-name "$b" \
--delete-config leader.replication.throttled.rate,follower.replication.throttled.rate 2>/dev/null \
&& echo "broker $b 해제 완료"
done
# (2-d) 토픽 쪽 스로틀도 남습니다
echo
echo "--- 토픽 스로틀 설정 ---"
K kafka-configs.sh --bootstrap-server "$BS" --describe \
--entity-type topics --entity-name s14_ex 2>/dev/null | grep throttled || echo " (없음)"
K kafka-configs.sh --bootstrap-server "$BS" --alter \
--entity-type topics --entity-name s14_ex \
--delete-config leader.replication.throttled.replicas,follower.replication.throttled.replicas 2>/dev/null \
&& echo "토픽 스로틀 해제 완료"
echo
echo "--- 최종 확인 (0 이어야 정상) ---"
for b in 1 2 3; do
echo -n "broker $b: "
K kafka-configs.sh --bootstrap-server "$BS" --describe --entity-type brokers --entity-name "$b" 2>/dev/null | grep -c throttled
done
# 해설:
# 해제해야 할 설정은 브로커 2개 + 토픽 2개, 총 4개입니다.
#
# 브로커 : leader.replication.throttled.rate
# follower.replication.throttled.rate
# 토픽 : leader.replication.throttled.replicas
# follower.replication.throttled.replicas
#
# 토픽 쪽을 잊는 경우가 많습니다. 브로커 rate 를 지워도 토픽의 replicas 목록이
# 남아 있으면 "어떤 복제본이 스로틀 대상인가" 라는 표시가 계속 붙어 있게 됩니다.
#
# ★ 왜 이 함정이 그렇게 지독한가:
#
# 증상이 '평소에는 전혀 안 나타납니다'.
# 스로틀은 '복제' 대역폭만 제한하므로, 정상 상태에서는 복제할 것이 거의 없어
# 1KiB/s 제한이 걸려 있어도 아무 문제가 없습니다. 지표도 전부 초록입니다.
#
# 드러나는 순간은 정확히 "복제를 몰아쳐야 할 때" 입니다.
# - 브로커를 재시작한 뒤 따라잡기
# - 장애 복구 후 ISR 회복
# - 새 브로커 추가 후 데이터 이동
#
# 이때 under-replicated 가 몇 시간, 며칠씩 안 풀립니다.
# 디스크도 네트워크도 CPU 도 한가한데 복제만 안 됩니다.
# 에러 로그는 한 줄도 없습니다. 원인이 몇 달 전에 누가 --verify 를 빼먹은 것이라
# 추적이 거의 불가능합니다.
#
# 그래서 Kafka 가 --execute 출력에 경고를 박아 둔 것입니다:
# "Warning: You must run --verify periodically, until the reassignment
# completes, to ensure the throttle is removed."
#
# 운영 규칙으로 만드세요: 재할당 스크립트는 --verify 루프까지 포함해야 완성입니다.
# =============================================================================
# 정답 3 — 롤링 재시작 + 복제 완료 대기 루프
# =============================================================================
hr "정답 3"
# (3-a) 사전 점검
n=$(K kafka-topics.sh --bootstrap-server "$BS" --describe --under-replicated-partitions | wc -l | tr -d ' ')
echo "사전 under-replicated = $n"
if [ "$n" -ne 0 ]; then
echo "!! 0이 아니므로 롤링 재시작을 시작하면 안 됩니다. 건너뜁니다."
else
# (3-b)
echo "--- kafka-3 정지 (SIGTERM = controlled shutdown)"
docker compose stop kafka-3
# (3-c)
echo "--- kafka-3 재시작"
docker compose start kafka-3
wait_healthy kafka-3
# (3-d) ★ 복제 완료 대기 루프 — 타임아웃 상한 포함
echo "--- 복제 완료 대기"
limit=300; waited=0; ok=0
while :; do
m=$(K kafka-topics.sh --bootstrap-server "$BS" --describe --under-replicated-partitions 2>/dev/null | wc -l | tr -d ' ')
if [ "$m" -eq 0 ]; then echo "복제 완료 (${waited}s)"; ok=1; break; fi
echo " 따라잡는 중... $m 파티션 남음"
sleep 3; waited=$((waited+3))
if [ "$waited" -ge "$limit" ]; then
echo "!! ${limit}초 초과. 다음 브로커로 진행하면 안 됩니다."
break
fi
done
# (3-e)
if [ "$ok" -eq 1 ]; then
K kafka-leader-election.sh --bootstrap-server "$BS" \
--election-type preferred --all-topic-partitions 2>&1 | head -3
fi
fi
# 해설:
# 판정을 왜 wc -l 로 하는가:
#
# --under-replicated-partitions 는 정상일 때 '아무것도 출력하지 않습니다'.
# 그래서 이렇게 쓰면 틀립니다:
#
# if [ -z "$(... --under-replicated-partitions)" ]; then # 위험
#
# 명령이 실패하거나(브로커 접속 불가) stderr 로 경고가 나오는 경우
# 빈 문자열이 되어 "정상" 으로 오판할 수 있습니다.
# wc -l 은 줄 수를 세므로 판정이 명확하고, 숫자 비교라 공백 문제도 없습니다.
#
# ★ 타임아웃 상한이 왜 필수인가:
#
# 복제가 '영영 안 끝나는' 상황이 실제로 있습니다.
# - 정답 2 의 스로틀 잔존
# - 디스크 포화로 로그 디렉터리가 오프라인
# - 네트워크 문제로 팔로워가 계속 뒤처짐
#
# 상한이 없으면 자동화 스크립트가 여기서 영원히 멈춥니다.
# 그리고 더 중요한 것 — 상한을 넘겼을 때 '다음 브로커로 진행하면 안 됩니다'.
# 진행하면 ISR 이 1로 줄고, min.insync.replicas 설정에 따라
# 서비스가 멈추거나(2) 데이터를 잃습니다(1).
# 반드시 중단하고 사람이 원인을 봐야 합니다.
# =============================================================================
# 정답 4 — JMX 3종 헬스체크
# =============================================================================
hr "정답 4"
jmx_val() {
local container="$1" mbean="$2"
docker exec "$container" /opt/kafka/bin/kafka-run-class.sh kafka.tools.JmxTool \
--object-name "$mbean" --jmx-url "$JMXURL" --one-time 2>/dev/null \
| tail -1 | awk -F, '{print $2}'
}
# (4-a)
urp=$(jmx_val kafka-1 'kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions')
echo "UnderReplicatedPartitions = ${urp:-?} (0 이어야 정상)"
# (4-b)
off=$(jmx_val kafka-1 'kafka.controller:type=KafkaController,name=OfflinePartitionsCount')
echo "OfflinePartitionsCount = ${off:-?} (0 이어야 정상)"
# (4-c) ★ 세 브로커의 합
total=0
for c in kafka-1 kafka-2 kafka-3; do
v=$(jmx_val "$c" 'kafka.controller:type=KafkaController,name=ActiveControllerCount')
echo " $c ActiveControllerCount = ${v:-0}"
total=$(( total + ${v:-0} ))
done
echo "ActiveControllerCount 합계 = $total (1 이어야 정상)"
echo
if [ "${urp:-1}" -eq 0 ] && [ "${off:-1}" -eq 0 ] && [ "$total" -eq 1 ]; then
echo "HEALTHY"
else
echo "UNHEALTHY"
fi
# 해설:
# ★ ActiveControllerCount 를 한 브로커에서만 재면 안 되는 이유:
#
# 이 지표는 "내가 액티브 컨트롤러인가" 를 나타냅니다.
# 컨트롤러인 노드에서만 1이고, 나머지 노드에서는 0입니다.
#
# 그래서 kafka-1 에서만 쟀는데 0이 나왔다면 두 가지 해석이 가능합니다.
# (가) 클러스터는 정상이고, 컨트롤러가 kafka-2 나 kafka-3 이다
# (나) 컨트롤러가 아예 없다 (쿼럼 상실 = 장애)
#
# 한 노드만 봐서는 이 둘을 구분할 수 없습니다.
# 반드시 '전체 노드의 합' 을 봐야 판정이 됩니다.
#
# 판정 기준:
# 합 == 1 → 정상
# 합 == 0 → 컨트롤러 없음. 쿼럼 과반이 깨진 상태. 메타데이터 변경 불가
# (토픽 생성/삭제, 리더 선출이 전부 멈춤)
# 합 >= 2 → split-brain. 두 노드가 각자 자기가 컨트롤러라고 믿는 중.
# 메타데이터가 갈라지고 복구 시 한쪽 변경이 통째로 버려짐
#
# KRaft 는 Raft 쿼럼(과반수)으로 split-brain 을 구조적으로 막습니다.
# 그러려면 controller.quorum.voters 가 '홀수' 여야 합니다 (3 또는 5).
# 짝수(예: 4)면 2:2 로 갈렸을 때 어느 쪽도 과반이 아니라
# 컨트롤러가 아예 안 서는 상황이 됩니다. 가용성만 나빠지고 이득이 없습니다.
# =============================================================================
# 정답 5 — min.insync.replicas 1 vs 2
# =============================================================================
hr "정답 5"
# (5-a)
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s14_ex_isr1 \
--partitions 1 --replication-factor 3 --config min.insync.replicas=1 --if-not-exists
K kafka-topics.sh --bootstrap-server "$BS" --create --topic s14_ex_isr2 \
--partitions 1 --replication-factor 3 --config min.insync.replicas=2 --if-not-exists
# 두 토픽 모두 리더가 kafka-1 이 되도록 잠시 대기 후 확인
sleep 2
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s14_ex_isr1
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s14_ex_isr2
# (5-b) 브로커 2대 정지
echo
echo "--- kafka-2, kafka-3 정지 (ISR 이 1개로 줄어듭니다)"
docker compose stop kafka-2 kafka-3
sleep 8
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s14_ex_isr1
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic s14_ex_isr2
# (5-c) minISR=1 토픽에 쓰기
echo
echo "--- s14_ex_isr1 (min.insync.replicas=1) 에 acks=all 로 쓰기"
r1=$(echo 'k1:{"test":"isr1"}' | docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server "$BS" --topic s14_ex_isr1 \
--property parse.key=true --property key.separator=: \
--producer-property acks=all 2>&1) || true
if echo "$r1" | grep -q "Exception"; then
echo "결과: 실패"; echo "$r1" | tail -2
else
echo "결과: ★ 성공 ★ (ISR 이 1개뿐인데도 acks=all 이 성공했습니다)"
fi
# (5-d) minISR=2 토픽에 쓰기
echo
echo "--- s14_ex_isr2 (min.insync.replicas=2) 에 acks=all 로 쓰기"
r2=$(echo 'k1:{"test":"isr2"}' | docker exec -i kafka-1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server "$BS" --topic s14_ex_isr2 \
--property parse.key=true --property key.separator=: \
--producer-property acks=all 2>&1) || true
if echo "$r2" | grep -q "NotEnoughReplicas"; then
echo "결과: 거부됨 (NotEnoughReplicasException)"
echo "$r2" | grep NotEnoughReplicas | head -1
else
echo "결과: 예상과 다름"; echo "$r2" | tail -2
fi
# (5-e) 복구
echo
echo "--- 복구"
docker compose start kafka-2 kafka-3
wait_healthy kafka-2
wait_healthy kafka-3
# 해설:
# 결과 대조표
#
# 토픽 min.insync ISR 상태 acks=all 결과
# -------------------------------------------------------------
# s14_ex_isr1 1 1개 ★ 성공 ★
# s14_ex_isr2 2 1개 거부 (NotEnoughReplicas)
#
# ★ 답: 성공한 쪽(minISR=1)이 훨씬 위험합니다.
#
# 왜 그런가. acks=all 의 실제 의미를 정확히 보면 답이 나옵니다.
#
# acks=all 은 "모든 복제본이 받을 때까지" 가 아닙니다.
# "현재 ISR 에 있는 복제본 전부가 받을 때까지" 입니다.
#
# ISR 이 1개로 쪼그라든 상태에서는 'ISR 전부' 가 곧 '리더 혼자' 입니다.
# 즉 acks=all 이 사실상 acks=1 과 똑같이 동작합니다.
# 프로듀서는 성공 응답을 받고, 애플리케이션은 커밋을 진행하고,
# 사용자에게는 "주문이 접수되었습니다" 가 나갑니다.
#
# 그리고 그 마지막 한 대가 죽으면 그 메시지는 사라집니다.
# 복제본이 하나도 없었으니까요.
# 에러 로그는 없습니다. 프로듀서는 이미 성공했다고 믿고 떠났습니다.
#
# min.insync.replicas=2 는 이 구멍을 막습니다.
# "복제본이 2개 미만이면 아예 받지 않겠다" 고 선언하는 것입니다.
# 서비스는 멈추지만 데이터는 안전합니다.
#
# ★ 이 코스 전체를 한 줄로 요약하면 이것입니다:
# "성공하는 쪽이 위험한 쪽이다."
#
# RF 3 기준 조합표:
# minISR=1 → 1대만 살아도 쓰기 성공. 가용성 최고, 유실 위험 있음
# minISR=2 → 2대 필요. 1대 죽어도 서비스 계속, 2대 죽으면 거부. ★ 권장 ★
# minISR=3 → 3대 전부 필요. 1대만 죽어도 서비스 정지. 과도함
# =============================================================================
# 정답 6 — V1~V5 자동 검증 스크립트
# =============================================================================
hr "정답 6"
run_checks() {
local failed=()
# V1 은 형태가 달라 따로 처리 (3이어야 정상)
local healthy
healthy=$(docker compose ps --format '{{.Status}}' 2>/dev/null | grep -c healthy)
[ "$healthy" -ge 3 ] || failed+=("V1 브로커 healthy 수=$healthy (기대 3 이상)")
# V2~V5 는 전부 "0이어야 정상" 이라는 공통점이 있습니다.
local checks=(
"V2 under-replicated|kafka-topics.sh --bootstrap-server $BS --describe --under-replicated-partitions"
"V3 under-min-isr|kafka-topics.sh --bootstrap-server $BS --describe --under-min-isr-partitions"
"V4 unavailable|kafka-topics.sh --bootstrap-server $BS --describe --unavailable-partitions"
)
local name cmd cnt
for entry in "${checks[@]}"; do
name="${entry%%|*}"
cmd="${entry#*|}"
cnt=$(docker exec kafka-1 /opt/kafka/bin/$cmd 2>/dev/null | wc -l | tr -d ' ')
[ "$cnt" -eq 0 ] || failed+=("$name = $cnt (기대 0)")
done
# V5 스로틀 잔존 — 브로커 3대 모두 검사
local tcnt total=0
for b in 1 2 3; do
tcnt=$(docker exec kafka-1 /opt/kafka/bin/kafka-configs.sh --bootstrap-server "$BS" \
--describe --entity-type brokers --entity-name "$b" 2>/dev/null | grep -c throttled)
total=$(( total + tcnt ))
done
[ "$total" -eq 0 ] || failed+=("V5 스로틀 잔존 = $total (기대 0)")
if [ "${#failed[@]}" -eq 0 ]; then
echo "ALL PASS"
return 0
else
echo "FAILED:"
printf ' - %s\n' "${failed[@]}"
return 1
fi
}
run_checks
# 해설:
# 설계 포인트 두 가지.
#
# 1) V2~V5 의 정상 조건이 '전부 0' 이라는 규칙성을 이용했습니다.
# 그래서 "이름|명령" 배열 하나로 묶어 루프를 돌 수 있습니다.
# 새 검사를 추가할 때 배열에 한 줄만 넣으면 되므로 확장성이 좋습니다.
# V1 만 형태가 달라(3 이상이어야 정상) 별도로 처리했습니다.
#
# 2) 실패 항목을 즉시 출력하지 않고 배열에 모았다가 마지막에 한 번에 냅니다.
# 첫 실패에서 멈추면 "다른 것도 깨졌는지" 를 알 수 없기 때문입니다.
# 장애 상황에서는 대개 여러 지표가 동시에 깨지므로 전체 그림이 중요합니다.
#
# 이 함수는 그대로 운영에 붙일 수 있습니다.
# - cron 에 걸고 종료 코드로 알람
# - 컨테이너 healthcheck
# - 배포 파이프라인의 배포 전/후 게이트
#
# 다만 알람으로 쓸 때 주의할 점:
# under-replicated 는 '정상 운영 중에도 일시적으로' 걸립니다.
# (브로커 재시작, GC 정지, 순간적인 네트워크 지연)
# 그래서 즉시 알람을 울리면 오탐이 많습니다.
# "5분 이상 지속될 때" 같은 지속 시간 조건을 반드시 붙이세요.
# 반면 OfflinePartitionsCount 는 일시적일 수 없으므로 즉시 알람이 맞습니다.
# =============================================================================
# 정답 7 — 조용한 실패 3가지 감사
# =============================================================================
hr "정답 7"
echo "고른 3가지: #11 min.insync.replicas=1 / #5 acks=0,1 / #8 enable.auto.commit=true"
echo
echo "--- #11 min.insync.replicas 감사 (브로커에서 확인 가능) ---"
echo "min.insync.replicas 가 2 미만인 토픽:"
K kafka-topics.sh --bootstrap-server "$BS" --describe --topics-with-overrides 2>/dev/null \
| grep -E '^Topic:' \
| while read -r line; do
t=$(echo "$line" | awk '{print $2}')
if echo "$line" | grep -q 'min.insync.replicas=2'; then
: # 정상
else
echo " ⚠ $t — min.insync.replicas override 없음 (브로커 기본값 사용)"
fi
done
echo "(브로커 기본값 확인)"
K kafka-configs.sh --bootstrap-server "$BS" --describe \
--entity-type brokers --entity-name 1 2>/dev/null | grep min.insync || \
echo " 브로커 동적 설정 없음 → docker-compose.yml 의 KAFKA_MIN_INSYNC_REPLICAS=2 적용 중"
echo
echo "--- #3 파티션 증가 위험 감사 (브로커에서 확인 가능) ---"
echo "키 기반 순서에 의존하는 토픽의 현재 파티션 수:"
for t in orders order-events; do
K kafka-topics.sh --bootstrap-server "$BS" --describe --topic "$t" 2>/dev/null | head -1 \
| grep -o 'PartitionCount: [0-9]*' | sed "s/^/ $t : /"
done
echo " → 이 값을 문서에 못 박고, 변경 요청이 오면 3-6 의 함정을 근거로 거절할 것"
echo
echo "--- #5 acks / #8 enable.auto.commit 감사 ---"
echo " ★ 브로커에서 확인할 수 없습니다."
# 해설:
# ★ 이 문제의 핵심은 '감사 가능성의 비대칭' 입니다.
#
# 브로커에서 확인 가능한 것 (서버 측 설정):
# - min.insync.replicas (#11)
# - unclean.leader.election (#10)
# - cleanup.policy, retention (#12 관련)
# - 파티션 수 (#3)
# - auto.create.topics.enable (#2)
#
# 브로커에서 확인 '불가능' 한 것 (클라이언트 측 설정):
# - acks (#5)
# - enable.idempotence (#6)
# - max.in.flight.requests (#6)
# - enable.auto.commit (#8)
# - auto.offset.reset (#1)
# - isolation.level (#9 관련)
#
# 왜 못 보는가:
# 이 값들은 프로듀서/컨슈머 프로세스의 메모리에만 존재합니다.
# 브로커는 요청이 들어오면 그 요청에 담긴 acks 값을 보고 그때그때 처리할 뿐,
# "이 클라이언트가 평소에 어떤 설정을 쓰는지" 를 저장하지 않습니다.
# 클라이언트가 접속할 때 설정을 등록하는 절차 자체가 없습니다.
#
# ★ 그래서 실무적으로 무엇을 해야 하는가:
#
# 1) 클라이언트 설정은 '코드 리뷰' 와 '공용 팩토리' 로만 통제할 수 있습니다.
# 조직 표준 ProducerFactory / ConsumerFactory 를 만들어
# acks=all, enable.idempotence=true, enable.auto.commit=false 를
# 기본값으로 박아 두고, 개별 서비스가 오버라이드하면 리뷰에서 잡습니다.
#
# 2) 브로커 측에서 강제할 수 있는 것은 최대한 브로커에서 강제하세요.
# min.insync.replicas 를 2로 두면, 클라이언트가 acks=all 을 썼을 때
# '진짜로' 2개 복제를 보장받습니다. 이건 서버가 지켜 줍니다.
# 반대로 클라이언트가 acks=1 을 쓰면 서버는 막을 방법이 없습니다.
#
# 3) 간접 탐지는 가능합니다.
# - 컨슈머 그룹의 오프셋이 '처리량과 무관하게 5초마다 정확히' 갱신되면
# enable.auto.commit=true 를 의심할 수 있습니다 (기본 interval 이 5초).
# - 특정 프로듀서의 배치에 producerId 가 -1 이면 (kafka-dump-log.sh)
# 멱등성이 꺼져 있다는 뜻입니다.
# 완벽하진 않지만 감사 단서로는 쓸 만합니다.
# =============================================================================
# 뒷정리
# =============================================================================
hr "뒷정리"
K kafka-topics.sh --bootstrap-server "$BS" --list | grep -E '^s14_ex' | while read -r t; do
echo "삭제: $t"
docker exec kafka-1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BS" --delete --topic "$t"
done
hr "solution.sh 완료"
echo "★ 브로커 3대 최종 확인:"
docker compose ps --format 'table {{.Name}}\t{{.Status}}'