Skip to content

Part 9: Kafka on EKS 벤치마크 — 기록된 결과와 재현·해석 범위

보고된 실험: 2026년 9월 2일 02:07–02:36 UTC
검토 갱신: 2026년 9월 12일
실험 버전: Kafka 4.3.1, EKS Kubernetes 1.36; broker/controller combined Pod 3개

PR #166에 보고된 단일 실행 표를 유지하면서 단위·비교 조건과 재현 절차를 검토했습니다. 원시 telemetry와 원본 payload hash는 저장소 보고서에 포함되어 있지 않습니다. 이번 검토는 AWS 실험을 다시 실행하거나 과거 측정의 수집 성공을 독립적으로 확인한 작업이 아닙니다.

기록은 스토리지·캐시·클라이언트 제약을 추가 시험할 근거가 됩니다. 하지만 RF3의 공통 또는 수 시간 지속 상한이 130–135MiB/s라고 확정하지는 못합니다. RF 비교는 acks도 바꾸고 배치 비교는 linger·실행 길이도 바꿉니다. 관찰된 비율을 특정 변수 하나의 인과 효과로 제시하지 않습니다.

브로커 3개·RF3 환경에서 한 파티션의 producer, leader, follower fetch 응답과 비동기 볼륨 쓰기를 보여주며 클러스터 합계 관찰값은 별도로 표시한 데이터 흐름.

브로커 3개·RF3 환경에서 한 파티션의 producer, leader, follower fetch 응답과 비동기 볼륨 쓰기를 보여주며 클러스터 합계 관찰값은 별도로 표시한 데이터 흐름.전체 화면으로 열기 ↗

주요 관찰과 해석 한계

보고된 비교기록값해석 한계
F1 RF3 / acks=all클라이언트 134.74MiB/s, 브로커 표본 구간 약 75초짧은 client rate이며 검증된 지속 디스크 쓰기율은 아님
F4 / F1337.81 / 134.74 = 2.507배RF·acks/기본 멱등성·실행 시간·캐시 상태가 함께 다름
F6 / F1148.38 / 134.74MiB/s, +10.12%; 브로커 CPU는 낮게 기록batch·linger·레코드 수가 모두 바뀌었고 반복 분산 측정 없음
B1 / B2p50 각각 3ms, p99 126 / 17ms정속 단일 비교이며 “비용은 꼬리에만 있음”이라는 일반 규칙은 아님
E4 / E0producer 57.37 / 103.36MiB/s, −44.49%읽기·쓰기 경합은 가능하지만 공용 client의 경합도 있음

보고된 환경

항목
클러스터Amazon EKS, Kubernetes 1.36, ap-northeast-2 (서울), Karpenter 프로비저닝 노드
브로커3 × apache/kafka:4.3.1 (kafka_2.13-4.3.1, OpenJDK 21.0.11), KRaft combined 모드(각 파드가 broker+controller), StatefulSet 직접 배포 — Strimzi 등 Operator 없음
브로커 노드3 × m5.xlarge 온디맨드 (4 vCPU, 16 GiB), 모두 ap-northeast-2b 단일 AZ, podAntiAffinity로 노드당 브로커 1개, Karpenter system NodePool이 측정 직전 새로 만든 노드
브로커 파드 리소스requests cpu 3 / mem 10Gi, limits cpu 4 / mem 12Gi, KAFKA_HEAP_OPTS=-Xms4G -Xmx4G (나머지 메모리도 native/off-heap·page cache 등이 함께 사용)
브로커 스토리지브로커당 gp3 100 GiB PVC 1개 (EBS CSI, StorageClass gp3), gp3 기본 성능 3,000 IOPS / 125 MiB/s (크기와 무관한 베이스라인)
브로커 설정num.partitions=6, default.replication.factor=3, min.insync.replicas=2, log.segment.bytes=1 GiB, num.network.threads=4, num.io.threads=8, num.replica.fetchers=2, log.retention.hours=2
커널Amazon Linux 2023, 6.18.41-94.142.amzn2023.x86_64; vm.dirty_ratio=20, vm.dirty_background_ratio=10, vm.dirty_expire_centisecs=3000
m5.xlarge 인스턴스 한도네트워크 베이스라인 1.25 Gbps (버스트 10 Gbps); EBS 베이스라인 1,150 Mbps = 143.75 MB/s (≈137 MiB/s; 1,150 × 10⁶ ÷ 8 ÷ 1,048,576), 6,000 IOPS
부하 생성기kafka-client 파드 1개 (같은 이미지), m5.large 노드 (2 vCPU, 8 GiB), cgroup CPU 한도 1.9, KAFKA_HEAP_OPTS=-Xms2G -Xmx2G; m5.large 네트워크 베이스라인 0.75 Gbps (버스트 10 Gbps)
도구kafka-producer-perf-test.sh, kafka-consumer-perf-test.sh (apache/kafka 4.3.1 이미지에 동봉)
네트워크 경로같은 AZ 안의 파드 간 통신, PLAINTEXT (TLS/SASL 없음)
토픽테스트마다 새 토픽, 파티션 6개; RF3/min.isr=2 (RF1 테스트만 RF1/min.isr=1); retention.bytes=-1
테스트 프로듀서 설정linger.ms=5, batch.size=65536, buffer.memory=67108864 (64 MiB), compression.type=none — 별도 표기 없으면 모든 테스트 공통
시간당 비용m5.xlarge 온디맨드 $0.236/h × 3 + gp3 100 GiB $0.0912/GB-월 × 3 (서울 리전, 2026-09 Pricing API 조회)

원문은 첫 측정 전에 02:05:22Z의 kafka-1 재시작 1회와 측정 중 재시작 없음을 기록했습니다. “기동 race”라는 설명은 원인 분석으로 확정된 결과가 아닙니다.

모두 단일 AZ·PLAINTEXT·SASL 없음·Strimzi 없음으로 보고되었습니다. 3개 AZ의 운영 환경과 같은 비교가 아닙니다. TLS, AZ 간 트래픽, 노드·스토리지 한계와 분리된 controller는 별도 측정이 필요합니다.

페이로드와 도구의 의미

보고된 파일은 값 길이 1,008바이트인 합성 JSON 20,000줄이며 약 63.2%는 쉽게 압축되는 x 패딩입니다. 줄 구분 문자는 전송 값에 포함되지 않습니다. 1,000만 건의 payload는 9.388GiB이고, 원문의 디스크상 1,018B/record를 적용하면 복제본당 약 9.481GiB입니다.

E2/E3는 3,000만 × 1,024B = 28.610GiB입니다. 기존의 “30GiB”, “29.3GiB”는 정확한 GiB 환산이 아니었습니다. 300만 × 1,024B는 2.861GiB입니다.

Kafka 4.3.1 도구 기준:

  • --record-size는 레코드마다 A–Z 바이트를 만들고, --payload-file은 이미 읽은 값을 선택합니다. 생성 비용은 CPU에 영향을 주지만 보고되는 send latency의 측정 시작 전에 발생합니다.
  • 출력의 MB/sec는 1,024²로 나누므로 MiB/s입니다. 압축 시에도 NIC·볼륨 바이트가 아닌 압축 전 payload 바이트를 셉니다.
  • latency는 send 직전부터 callback까지이며 동기 send/buffer 대기는 포함하지만 앞선 payload 생성·후속 업무 처리는 포함하지 않습니다. acks=0 callback은 브로커 내구성 확인 응답이 아닙니다.
  • 큰 실행은 정수 ms latency를 주기적으로 표본 추출합니다. p50이 둘 다 3ms라는 사실로 실제 지연 분포가 동일하다고 증명할 수 없습니다.
  • callback 실패는 출력되지만 성공 전송 건수에 포함되지 않습니다. 종료 코드만 보지 말고 최종 성공 건수와 stderr를 확인합니다.
  • 컨슈머 전체 시간·그룹 조인을 뺀 fetch 시간·선택 구간의 rate는 분모가 다릅니다. 마지막 poll 묶음으로 요청 건수를 초과할 수 있습니다.

기존 --producer-props, 컨슈머 --messages는 아직 동작하지만 deprecated입니다. 새 예제는 --command-property, --num-records를 사용합니다.

1. RF·응답 방식·표본 구간

payload-file·무압축 실행의 원문 기록값입니다.

TestacksRF프로듀서레코드rec/sMiB/savg msp50p95p99p99.9max
F1all311,000만140,164134.74443.893211,2762,6894,0264,038
F2131600만248,221238.62222.66230321398426650
F3031600만241,138231.81235.021913331,9774,3274,340
F41111,000만351,407337.81159.51138290358515642
F5all322 × 500만67,694 + 67,360 = 135,05465.07 + 64.75 = 129.82893.88 / 889.22639 / 6302,536 / 2,4933,270 / 3,3014,284 / 4,2284,662 / 4,660
F6all31600만154,349148.38395.822381,1422,0942,5942,621

F4/F1의 산술 비율은 2.507이지만 F4는 RF1/acks=1, F1은 RF3/acks=all입니다. 멱등성을 생략한 Kafka 4.3.1의 기본 동작도 이 acks 설정에 따라 달라집니다. 복제만 통제한 비교가 아닙니다. 약 32초인 F4는 각 브로커에 데이터의 약 1/3만 배치되어 캐시·클라이언트 한계의 영향을 받을 수 있습니다.

F5의 129.82는 두 프로세스가 각각 보고한 rate의 합입니다. 정확한 통합 rate는 공통 시작·끝 구간의 총 성공 바이트로 계산해야 하며 서로 다른 시간 구간의 rate를 더한 값과 같지 않을 수 있습니다.

보고된 브로커 카운터

윈도우쓰기 MiB/s 평균 (peak10s)wIOPS읽기 MiB/s (rIOPS)브로커 CPU 코어NIC tx / rx MiB/s
F1 RF3 acks=all 1,000만 (75 s)115.2–117.5 (134.9–135.1)495–50500.64–0.8480.2–108.1 / 134.2–135.8
F2 RF3 acks=1 600만 (28 s)92.8–100.4 (124.1–127.6)397–42700.53–0.6264.4–80.4 / 123.7–125.0
F3 RF3 acks=0 600만 (28 s)97.2–120.5 (123.5–124.1)414–51400.80–0.8187.3–94.4 / 163.5–170.6
F4 RF1 acks=1 1,000만 (32 s)72.5–80.4 (117.6–129.1)312–34600.30–0.310.3 / 119.9–125.4
F5 프로듀서 2개 RF3 acks=all (81 s)99.9–102.2 (134.8–135.5)431–43900.59–0.8166.5–89.7 / 113.8–115.6
F6 RF3 acks=all batch 256 KiB (43 s)102.3–107.7 (134.8–135.4)426–44500.40–0.5068.7–110.4 / 124.2–124.5
E2 30M × 1,024 B 채우기 (262 s, 랜덤 모드)110.0–111.5 (134.5–135.0)468–47300.72–0.8074.9–77.5 / 114.3

기존 sampler는 kubectl을 순차 실행한 뒤 10초 쉬어 실제 간격이 약 12초였고, 브로커마다 읽은 시각이 아닌 라운드 시작 시각 하나를 사용했습니다. 실행 지연·시각 해상도·구간 경계가 중요합니다. 최고 표본은 정상 상태의 추정값이 아니며 135MiB/s 근처 값으로 gp3의 125MiB/s 설정을 135로 모델링해서는 안 됩니다.

원문의 CloudWatch E2 구간은 7,008–7,257MiB/분 = 116.8–121.0MiB/s, 29,637–30,730 write/분 = 약 494–512IOPS입니다. 순차 I/O가 상당했음을 뒷받침하지만 볼륨·인스턴스 EBS 한계, 버퍼와 짧은 표본의 영향을 각각 분리하지는 못합니다. 브로커 CPU가 낮다고 클라이언트 병목까지 배제할 수도 없습니다.

저장량 균형 모델과 실측 상한은 다름

복제본 하나의 인코딩된 log-byte rate를 Xlog, 복제 수를 R, 브로커 수를 B, 브로커별 지속 저장소 예산을 D라 하면 균등 배치에서 평균 쓰기는 대략 R × Xlog / B와 기타 I/O입니다. 브로커 3개·RF3일 때 각 브로커가 모든 파티션의 복제본 하나를 가집니다. 리더 트래픽도 균등하면 브로커별 replica 송신은 약 2 × Xlog / 3, 클러스터 전체 replica 트래픽은 약 2 × Xlog입니다.

이 산술은 가설을 세우는 도구입니다. payload benchmark rate와 동일하지 않으며 인코딩·압축, 캐시/writeback, 읽기와 metadata가 서로 다른 예산을 사용합니다. 다른 조건을 고정한 채 스토리지 처리량 등을 바꾸는 통제 실험으로 원인을 확인합니다.

2. acks 지연

한 파티션에서 acks=1은 leader append, acks=all은 그림의 안정된 ISR에서 필요한 다음 오프셋 복제를 확인한다. 백그라운드 writeback과 메시지별 fsync 보장은 구분한다.

한 파티션에서 acks=1은 leader append, acks=all은 그림의 안정된 ISR에서 필요한 다음 오프셋 복제를 확인한다. 백그라운드 writeback과 메시지별 fsync 보장은 구분한다.전체 화면으로 열기 ↗

B 테스트는 20,000rec/s, RF3, 랜덤 1,024B 값을 사용했습니다.

Testackslinger.msavg msp50p95p99p99.9max
B1all55.2736126173771
B2152.58351740642
B3all03.23352559752

보고된 B1/B2 p99 비율은 7.412입니다. 평균도 5.27/2.58ms로 다르므로 “꼬리만 다르다”는 결론은 과도합니다. B3는 linger를 바꿨지만 작은 배치 때문이라는 인과 설명에는 요청 크기·개수와 반복 측정이 필요합니다. 정속 acks=0 결과는 없습니다.

A 실행에도 랜덤 payload 생성 비용이 들어갑니다.

TestacksRFrec/sMiB/savg msp50p95p99p99.9max
A1all3107,150104.6411.38350164263871
A213105,955103.473.001113879812
A303112,461109.821.48062652636
A411109,926107.351.57161136662

비슷한 처리량은 공통 제약을 시사하지만 그 자체가 CPU profile은 아닙니다. acks=0에서도 client·buffer·socket backpressure는 존재합니다. 브로커 ack가 없다고 모든 backpressure가 사라지거나 callback 성공이 복제본 기록을 증명하는 것은 아닙니다.

시퀀스의 HW는 다음 오프셋입니다. 마지막 레코드가 r이면 요구 오프셋은 r+1이며 HW와 ISR 조건을 충족해야 합니다. 그림은 세 replica가 안정된 ISR에 있는 경우이고, minISR=2인데 항상 팔로워 둘 모두가 ISR이어야 한다는 뜻은 아닙니다.

3. 배치 크기와 프로듀서 수

비교설정MiB/savg msp99 ms브로커 CPU 코어 (윈도우 평균 범위)
F1 (기준)프로듀서 1, batch.size=65536, linger.ms=5, 1,000만 건134.74443.892,6890.64–0.84
F5프로듀서 2, 같은 배치, 2 × 500만 건65.07 + 64.75 = 129.82893.88 / 889.223,270 / 3,3010.59–0.81
F6프로듀서 1, batch.size=262144, linger.ms=10, 600만 건148.38395.822,0940.40–0.50

F6는 batch·linger·레코드 수를 바꿨습니다. 처리량은 10.12% 높고 브로커 CPU는 낮게 기록되었습니다. 중간값 계산 1 − 0.45/0.74 = 39.19%는 이 범위를 설명할 뿐 배치 크기로 인한 독립적인 40% 절감이 아닙니다. 반복이 없으므로 처리량 차이를 “노이즈 범위”로 판정할 수 없습니다.

F5는 이 구성의 보고된 rate를 높이지 못했지만 두 producer가 같은 제한된 client Pod를 사용했습니다. 독립적인 부하 용량과 공통 시간 구간으로 재시험하기 전에 추가 producer가 언제나 도움이 안 된다고 일반화하지 않습니다.

4. 패딩 코퍼스의 압축

codecrec/sMiB/s (비압축 환산)avg msp50p95p99p99.9디스크상 B/레코드 (복제본당)none 대비
none203,887196.00259.592663704254581,0181.00×
lz4275,356264.704.383112460113.19.0× 축소
snappy198,557190.875.0731035205141.77.2×
zstd160,274154.075.39510184761.316.6×
gzip53,41851.356.01610174560.716.8×

원문의 복제본별 log 크기는 none/lz4/snappy/zstd/gzip 순서로 2,912.4 / 323.5 / 405.3 / 175.3 / 173.8MiB입니다. 300만 건으로 나누면 반올림된 B/record가 재현됩니다. 모든 index·파일시스템·프로비저닝 볼륨 바이트를 뜻하지는 않습니다.

패딩 때문에 많은 실제 데이터셋을 대표하지 않습니다. 비율과 codec 순서 모두 보편적 결과나 수학적 상한이 아닙니다. 원문은 전체 zlib-6를 패딩 포함 18.5배, 제거 7.8배로 기록했습니다. 이번 검토에서 공개 AWK를 GNU Awk 5.1.0으로 실행하면 유효한 1,008B JSON 20,000개·패딩 63.17%는 확인되지만 비율은 15.92배·6.53배였습니다. 새 코퍼스이지 과거 측정을 대체하는 수치가 아닙니다. 이미지·AWK 구현과 payload hash를 함께 보존해야 합니다.

topic compression이 producer이면 지정한 다른 codec으로 의도적으로 재압축하는 대신 producer의 codec을 유지합니다. 브로커 검증과 다른 처리의 CPU까지 0인 것은 아닙니다. 압축 후 낮은 지연은 저장·전송 바이트 감소와 일치하지만 배타적인 병목 이동을 주장하려면 해당 phase의 client CPU·I/O 근거가 필요합니다.

5. 레코드 크기

Test레코드 크기레코드 수rec/sMiB/savg msp50p95p99p99.9
D1100 B1,000만506,38048.293.18291533
A11,024 B300만107,150104.6411.38350164263
D210,240 B30만12,326120.3717.40584157223

생성 비용과 레코드 수가 다른 실행입니다. 100B 경우는 1,024B 대비 rec/s 약 4.73배, payload rate는 46.15%입니다. 두 단위가 모두 중요함을 보여주지만 브로커의 건당 비용을 client 생성·배치·캐시와 분리한 결과는 아닙니다. 집계는 지연·키·실패 의미가 애플리케이션에 맞을 때 검토합니다.

6. 생산과 동시에 재처리

E0 producer는 103.36MiB/s·p99 82ms, E1의 선택된 hot 구간은 434.11MiB/s로 기록되었습니다. E2는 3,000만 × 1,024B(28.610GiB)에서 112.84MiB/s·p99 1,531ms였습니다. 반복적으로 느린 구간이 관찰되었지만 dirty-page writeback은 가설이었습니다.

E2의 다른 기록값은 평균 60.17ms, p50 2, p95 125, p99.9 5,034, max 5,258ms입니다. 51개 보고 구간 중 60MiB/s 미만은 56.8, 36.3, 45.3, 40.8, 45.8의 다섯 구간입니다. 이 관찰만으로 정지 원인을 특정할 수는 없습니다.

E3의 13개 완전 구간 rate는 337.6, 431.3, 414.2, 495.7, 454.9, 452.1, 387.9, 458.7, 439.9, 452.4, 447.7, 491.0, 438.6이며 단순 평균은 438.615MiB/s입니다. 완전히 cold한 캐시는 확립되지 않았습니다. 구간이 다른 NIC·디스크 평균을 빼 정확한 page-cache 바이트율을 산출하지 않습니다.

E1 hotE3 cold
컨슈머 처리량 (정상 구간)434.11 MiB/s평균 438.6 MiB/s (337.6–495.7)
브로커 디스크 읽기 (볼륨당)088–111 MiB/s 평균, 피크 ≈124 (1,315–1,545 rIOPS)
브로커 NIC tx (브로커당)82.8–83.4 MiB/s (15초 윈도우, JVM 기동·그룹 조인 포함)136.6–141.5 MiB/s
브로커 CPU0.11–0.15 코어0.11–0.12 코어
E0 (produce 단독)E4 (리플레이 중 produce)
produce MiB/s103.3657.37
produce p9982 ms2,147 ms
브로커 디스크 (볼륨당)쓰기 60.4–67.4 MiB/s (테스트 중; 나머지는 이후 드레인)쓰기 39.4–47.2 + 읽기 28.6–37.7 MiB/s

E4 producer 감소율은 **44.49%**입니다. 볼륨 읽기·쓰기가 공유되므로 저장소 경합은 가능하지만 producer·consumer도 CPU 1.9개인 client Pod와 NIC를 공유했습니다. 전체 세션의 throttling 합계나 낮은 브로커 CPU로 phase별 클라이언트 경합을 배제할 수 없습니다.

E4 소비 기록은 29,297.33MiB·30,000,466건, 전체 288.79MiB/s·조인 제외 299.62MiB/s입니다. 마지막 poll 묶음 때문에 요청 건수를 초과할 수 있으므로 초과한 건수만으로 중복이라고 판단하지 않습니다.

E1의 선택 구간, E3의 비가중 구간 평균, E4의 fetch·전체 rate는 같은 값이 아닙니다. 특히 438.6→299.62를 같은 분모의 통제된 감소율로 해석하지 않습니다. 공통 구간으로 비교할 수 있도록 원시 시각·총 바이트와 phase별 자원 카운터를 보존합니다.

7. 얼마나 길게 실행해야 하는가?

Kafka ack는 모든 replica의 메시지별 fsync를 뜻하지 않습니다. page cache에 append가 쌓이는 동안 writeback이 비동기로 진행될 수 있으며 복제가 모든 연관 장애의 무손실을 보장하는 것은 아닙니다.

F4의 일부 구간은 이상화된 3 × 125 = 375MiB/s 볼륨 예산보다 높고 E0 종료 후에도 writeback이 기록되었습니다. 버퍼와 측정 구간을 맞춰야 하는 이유입니다. 10GiB나 1분이라는 공통 기준으로 디스크 정상 상태를 증명할 수는 없습니다. F1의 인코딩 데이터 약 9.5GiB도 그런 증거는 아닙니다.

고정한 warmup과 긴 steady 구간, 종료 후 drain, dirty memory·EBS·네트워크 credit을 관찰하고 순서를 무작위화하여 반복합니다. 인과 비교는 한 번에 한 요소를 바꿉니다. 다른 조건을 유지한 스토리지 처리량 증설 등이 후속 통제 실험이 될 수 있습니다.

네트워크·EBS 비교 바로잡기

EC2는 송신과 수신의 네트워크 credit이 별도입니다. rx+tx를 더해 하나의 방향별 기준과 비교하지 않습니다. F1에서 최대 rx 136MiB/s는 약 1.141Gbps, tx 108MiB/s는 약 0.906Gbps로 각각 m5.xlarge 기준 1.25Gbps보다 낮습니다. 이 F1 값으로 브로커의 네트워크 burst 사용을 증명할 수 없습니다.

반면 F4 client의 payload 337.81MiB/s는 약 2.834Gbps로 m5.large의 0.75Gbps 기준보다 높습니다. credit에 영향을 받는 client 성능은 중요한 한계입니다. packet rate·flow 한계와 노드의 다른 트래픽도 관찰합니다.

m5.xlarge EBS는 기준 1,150Mbps·최대 4,750Mbps, 약 137.09/566.24MiB/s입니다. 기준값은 순간적인 절대 상한이 아닙니다. 현재 gp3 최대는 조건에 따라 2,000MiB/s이며 필요한 IOPS·볼륨 조건과 인스턴스 한계를 함께 봅니다. 기존 1,000MiB/s 설명은 오래되었습니다. 볼륨이나 브로커 크기를 키우는 것만으로 과거 실행의 병목 원인이 입증되지는 않습니다.

비용 계산과 비용 청구·현재 단가는 구분

원문에 기록된 $0.236/브로커-시간, $0.0912/GB-월과 당시의 배분 가정으로 계산하면:

계산결과
브로커 3대 × 50분$0.590
100GiB 볼륨 3개 × 39분, 730시간을 분모로 사용$0.02436
위 배분 항목의 1회 합계약 $0.614
브로커 3대 × 730시간 + 300GiB-월 저장소$544.20

730시간은 비교용 가정이지 9월 청구서가 아닙니다. 기존 client 노드, EKS/control plane, 네트워크와 다른 공용 인프라는 제외되어 있습니다. 다른 워크로드를 위해 남은 노드도 있어 배분 시간은 추정입니다. 배포 전 현재 리전 가격을 다시 확인하며, 이번 검토에서 비용 청구 조회나 새 Pricing API 근거를 만들지는 않았습니다.

브로커 CPU가 낮다는 이유만으로 컴퓨팅 비용이 낭비인 것은 아닙니다. 메모리·네트워크·EBS 대역폭·장애 여유와 배치 조건이 인스턴스 크기를 요구할 수 있습니다.

보완한 재현 예제

표준 EBS CSI와 지정한 인스턴스·AZ 용량이 있는 새 전용 테스트 환경·네임스페이스에서 사용합니다. 기존 Kafka/PVC 위에 적용하지 않습니다. 새 실행은 kafka-storage.sh random-uuid로 만든 ID로 예제 CLUSTER_ID를 바꾸고 실제 image digest·런타임 버전을 기록합니다.

아래는 원본 그대로가 아닌 보완된 재현 템플릿입니다. AZ를 고정하고 gp3 IOPS·처리량·암호화를 명시했으며 readiness 이전 headless DNS, TCP startup/readiness, benchmark Pod만 허용하는 ingress와 API token automount 비활성화를 추가했습니다. NetworkPolicy는 CNI 집행이 필요합니다. 격리된 시험을 위해 PLAINTEXT·combined 역할을 유지하며 운영 환경 설계로 제시하는 것은 아닙니다.

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: bench-kafka
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: bench-kafka-gp3
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
parameters:
  type: gp3
  iops: '3000'
  throughput: '125'
  encrypted: 'true'
---
apiVersion: v1
kind: Service
metadata:
  name: kafka-hs
  namespace: bench-kafka
spec:
  clusterIP: None
  selector:
    app: kafka
  ports:
  - name: broker
    port: 9092
  - name: controller
    port: 9093
  publishNotReadyAddresses: true
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: kafka
  namespace: bench-kafka
spec:
  serviceName: kafka-hs
  replicas: 3
  podManagementPolicy: Parallel
  selector:
    matchLabels:
      app: kafka
  template:
    metadata:
      labels:
        app: kafka
      annotations:
        karpenter.sh/do-not-disrupt: 'true'
    spec:
      terminationGracePeriodSeconds: 60
      nodeSelector:
        node.kubernetes.io/instance-type: m5.xlarge
        karpenter.sh/capacity-type: on-demand
        topology.kubernetes.io/zone: ap-northeast-2b
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchLabels:
                app: kafka
            topologyKey: kubernetes.io/hostname
      securityContext:
        fsGroup: 1000
      containers:
      - name: kafka
        image: apache/kafka:4.3.1
        command:
        - /bin/bash
        - -c
        - |
          set -e
          ORD=${HOSTNAME##*-}
          export KAFKA_NODE_ID=$ORD
          export KAFKA_ADVERTISED_LISTENERS="PLAINTEXT://${HOSTNAME}.kafka-hs.bench-kafka.svc.cluster.local:9092"
          exec /etc/kafka/docker/run
        env:
        - name: CLUSTER_ID
          value: UdHYY7YQRrunSRromZFozw
        - name: KAFKA_PROCESS_ROLES
          value: broker,controller
        - name: KAFKA_CONTROLLER_QUORUM_VOTERS
          value: 0@kafka-0.kafka-hs.bench-kafka.svc.cluster.local:9093,1@kafka-1.kafka-hs.bench-kafka.svc.cluster.local:9093,2@kafka-2.kafka-hs.bench-kafka.svc.cluster.local:9093
        - name: KAFKA_LISTENERS
          value: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
        - name: KAFKA_LISTENER_SECURITY_PROTOCOL_MAP
          value: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
        - name: KAFKA_INTER_BROKER_LISTENER_NAME
          value: PLAINTEXT
        - name: KAFKA_CONTROLLER_LISTENER_NAMES
          value: CONTROLLER
        - name: KAFKA_LOG_DIRS
          value: /var/lib/kafka/data/kafka
        - name: KAFKA_NUM_PARTITIONS
          value: '6'
        - name: KAFKA_DEFAULT_REPLICATION_FACTOR
          value: '3'
        - name: KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR
          value: '3'
        - name: KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR
          value: '3'
        - name: KAFKA_TRANSACTION_STATE_LOG_MIN_ISR
          value: '2'
        - name: KAFKA_MIN_INSYNC_REPLICAS
          value: '2'
        - name: KAFKA_LOG_RETENTION_HOURS
          value: '2'
        - name: KAFKA_LOG_SEGMENT_BYTES
          value: '1073741824'
        - name: KAFKA_NUM_NETWORK_THREADS
          value: '4'
        - name: KAFKA_NUM_IO_THREADS
          value: '8'
        - name: KAFKA_NUM_REPLICA_FETCHERS
          value: '2'
        - name: KAFKA_HEAP_OPTS
          value: -Xms4G -Xmx4G
        ports:
        - containerPort: 9092
        - containerPort: 9093
        resources:
          requests:
            cpu: '3'
            memory: 10Gi
          limits:
            cpu: '4'
            memory: 12Gi
        volumeMounts:
        - name: data
          mountPath: /var/lib/kafka/data
        startupProbe:
          tcpSocket:
            port: 9092
          periodSeconds: 5
          failureThreshold: 60
        readinessProbe:
          tcpSocket:
            port: 9092
          periodSeconds: 5
      automountServiceAccountToken: false
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes:
      - ReadWriteOnce
      storageClassName: bench-kafka-gp3
      resources:
        requests:
          storage: 100Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: kafka-client
  namespace: bench-kafka
  labels:
    app: kafka-client
spec:
  restartPolicy: Never
  nodeSelector:
    node.kubernetes.io/instance-type: m5.large
    topology.kubernetes.io/zone: ap-northeast-2b
    karpenter.sh/capacity-type: on-demand
  containers:
  - name: client
    image: apache/kafka:4.3.1
    command:
    - sleep
    - infinity
    env:
    - name: KAFKA_HEAP_OPTS
      value: -Xms2G -Xmx2G
    resources:
      requests:
        cpu: 500m
        memory: 2500Mi
      limits:
        cpu: 1900m
        memory: 4Gi
  automountServiceAccountToken: false
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: benchmark-brokers
  namespace: bench-kafka
spec:
  podSelector:
    matchLabels:
      app: kafka
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchExpressions:
        - key: app
          operator: In
          values:
          - kafka
          - kafka-client
    ports:
    - protocol: TCP
      port: 9092
    - protocol: TCP
      port: 9093

bench-kafka.yaml로 저장하고 리소스·가용 용량을 검토한 다음 실행합니다.

bash
kubectl apply -f bench-kafka.yaml
kubectl -n bench-kafka rollout status statefulset/kafka --timeout=600s
kubectl -n bench-kafka wait --for=condition=Ready pod/kafka-client --timeout=600s
kubectl -n bench-kafka get pods -o wide
kubectl -n bench-kafka get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[*].imageID}{"\n"}{end}'

공개된 생성기를 payload.awk로 저장하고 client Pod 안에서 실행합니다. 비교를 위해 유지한 생성기이며 압축하기 쉬운 패딩은 의도적으로 들어 있습니다.

awk
BEGIN{srand(42); split("payments orders inventory auth search checkout shipping catalog notify gateway",ns," ");
     split("INFO INFO INFO INFO WARN ERROR DEBUG",lv," ");
     for(i=0;i<20000;i++){
       n=ns[int(rand()*10)+1]; l=lv[int(rand()*7)+1]; d=int(rand()*900)+5; u=int(rand()*100000);
       msg=sprintf("{\"ts\":\"2026-09-02T02:%02d:%02d.%03dZ\",\"level\":\"%s\",\"namespace\":\"%s\",\"pod\":\"%s-7d9f8b6c4-%05x\",\"trace_id\":\"%08x%08x%08x%08x\",\"http\":{\"method\":\"POST\",\"path\":\"/api/v1/%s/%d\",\"status\":%d,\"duration_ms\":%d,\"bytes\":%d},\"user_id\":%d,\"region\":\"ap-northeast-2\",\"msg\":\"request completed upstream=%s-svc:8080 retries=%d cache=%s\"",
         int(rand()*60),int(rand()*60),int(rand()*1000),l,n,n,int(rand()*1048576),int(rand()*4294967296),int(rand()*4294967296),int(rand()*4294967296),int(rand()*4294967296),n,u,(l=="ERROR"?500:200),d,int(rand()*20000),u,n,int(rand()*3),(rand()<0.7?"hit":"miss"));
       pad=1000-length(msg)-2; if(pad<0)pad=0; p=""; for(k=0;k<pad;k++)p=p "x";
       printf "%s,\"pad\":\"%s\"}\n", msg, p }}
bash
mkdir -p /tmp/results
LC_ALL=C awk -f payload.awk > /tmp/results/payload-1k.txt
sha256sum /tmp/results/payload-1k.txt
wc -l -c /tmp/results/payload-1k.txt

runs.sh도 client Pod 안에서 실행합니다. 토픽 생성·데이터 채우기, 별도 로그, producer 최종 성공 건수 검사를 포함하며 전체 시간은 20분 후 종료 신호·30초 후 강제 종료로 제한합니다. consumer의 --timeout은 전체 시간이 아닌 무응답 간격입니다.

bash
#!/bin/bash
set -euo pipefail
BIN=/opt/kafka/bin
BS="kafka-0.kafka-hs.bench-kafka.svc.cluster.local:9092,kafka-1.kafka-hs.bench-kafka.svc.cluster.local:9092,kafka-2.kafka-hs.bench-kafka.svc.cluster.local:9092"
BENCH_RESULTS=/tmp/results
mkdir -p "$BENCH_RESULTS"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"

new_topic() {
  "$BIN/kafka-topics.sh" --bootstrap-server "$BS" --create --topic "$1" \
    --partitions 6 --replication-factor "$2" \
    --config "min.insync.replicas=$3" --config retention.bytes=-1
}
produce() {
  local topic="$1" count="$2" mode="$3" value="$4" rate="$5" acks="$6" codec="$7"
  shift 7
  local result="$BENCH_RESULTS/$topic-$BASHPID.log"
  date -u +%Y-%m-%dT%H:%M:%SZ > "$result.start"
  timeout -k 30 1200 "$BIN/kafka-producer-perf-test.sh" \
    --bootstrap-server "$BS" --topic "$topic" --num-records "$count" \
    --throughput "$rate" "$mode" "$value" --print-metrics \
    --command-property "acks=$acks" "compression.type=$codec" \
      linger.ms=5 batch.size=65536 buffer.memory=67108864 "$@" 2>&1 | tee "$result"
  date -u +%Y-%m-%dT%H:%M:%SZ > "$result.end"
  # The tool can print callback errors without making every failure a nonzero exit.
  # This check is for warmup-records=0 and the final, non-window summary.
  awk -v expected="$count" '/ms 99[.]9th[.]/ {seen=1; actual=$1}
    END {if (!seen || actual != expected) exit 1}' "$result"
}

# Historical F1-shaped workload: a new topic and the published padded payload.
F_TOPIC="bench-f1-$RUN_ID"
new_topic "$F_TOPIC" 3 2
produce "$F_TOPIC" 10000000 --payload-file "$BENCH_RESULTS/payload-1k.txt" -1 all none

# Historical B1-shaped workload: pre-create the latency topic.
B_TOPIC="bench-b1-$RUN_ID"
new_topic "$B_TOPIC" 3 2
produce "$B_TOPIC" 1200000 --record-size 1024 20000 all none

# E2/E3-shaped replay: populate before consuming; 30M*1024 B = 28.61 GiB.
E_TOPIC="bench-e2-$RUN_ID"
new_topic "$E_TOPIC" 3 2
produce "$E_TOPIC" 30000000 --record-size 1024 -1 all none
timeout -k 30 1200 "$BIN/kafka-consumer-perf-test.sh" --bootstrap-server "$BS" \
  --topic "$E_TOPIC" --num-records 30000000 --group "bench-e3-$RUN_ID" \
  --timeout 600000 --show-detailed-stats --reporting-interval 5000 --print-metrics \
  2>&1 | tee "$BENCH_RESULTS/$E_TOPIC-consumer.log"

printf '%s\n' "$F_TOPIC" "$B_TOPIC" "$E_TOPIC" > "$BENCH_RESULTS/topics-$RUN_ID.txt"
# Preserve logs and validate success/readback before deleting this run's topics.

템플릿은 F1·B1·E2/E3 형태의 실행을 보여줍니다. 나머지는 새 토픽에 표의 RF/minISR, 건수, acks, codec, batch/linger를 적용합니다. produce helper는 필수 인자 7개 뒤에 추가 Kafka 속성을 받으며 F6은 batch.size=262144 linger.ms=10입니다. F5는 500만 건 producer 두 개의 로그와 공통 시간 구간을 따로 기록합니다. E4는 같은 client 재현과 별도 부하 용량을 쓰는 통제 변형을 구분합니다. 실행 사이 writeback/drain을 확인하고 특히 acks=0의 내용·오류를 검증합니다. 요청 건수를 채웠다는 사실만으로 정상 상태 실험이라 부르지 않습니다.

실제 장치와 실제 시간 간격 수집

PVC→PV→EBS 볼륨과 block device를 확인한 뒤 devices.txt를 만듭니다. nvme1n1이 언제나 데이터 볼륨인 것은 아닙니다. 예제는 cgroup v2 CPU 카운터, sysfs 가시성과 eth0를 전제로 하므로 실제 환경을 먼저 확인합니다.

kubectl이 있는 곳에 sample-broker.sh로 저장합니다.

sh
#!/bin/sh
set -eu
device="${1:?Pass the verified data-volume block-device name}"
interface="${2:-eth0}"
case "$device" in *[!a-zA-Z0-9_-]*|'') echo "Invalid block-device name" >&2; exit 2;; esac
case "$interface" in *[!a-zA-Z0-9_.-]*|'') echo "Invalid network interface" >&2; exit 2;; esac
read -r start_uptime ignored < /proc/uptime
read -r read_ios read_merges read_sectors read_ms write_ios write_merges write_sectors write_ms rest \
  < "/sys/class/block/$device/stat"
cpu_usage="$(awk '$1=="usage_usec" {print $2}' /sys/fs/cgroup/cpu.stat)"
cpu_throttled="$(awk '$1=="throttled_usec" {print $2}' /sys/fs/cgroup/cpu.stat)"
: "${cpu_usage:?Missing cgroup v2 CPU usage counter}"
: "${cpu_throttled:?Missing cgroup v2 CPU throttling counter}"
net_tx="$(cat "/sys/class/net/$interface/statistics/tx_bytes")"
net_rx="$(cat "/sys/class/net/$interface/statistics/rx_bytes")"
read -r end_uptime ignored < /proc/uptime
printf '{"device":"%s","interface":"%s","start_uptime_s":%s,"end_uptime_s":%s,"read_ios":%s,"read_sectors":%s,"write_ios":%s,"write_sectors":%s,"cpu_usage_usec":%s,"cpu_throttled_usec":%s,"net_tx_bytes":%s,"net_rx_bytes":%s}\n' \
  "$device" "$interface" "$start_uptime" "$end_uptime" "$read_ios" "$read_sectors" "$write_ios" "$write_sectors" \
  "$cpu_usage" "$cpu_throttled" "$net_tx" "$net_rx"

다음을 sampler.sh로 저장합니다. devices.txt에는 확인한 kafka-N 장치이름 [인터페이스]를 한 줄에 하나씩 적으며 인터페이스 기본값은 eth0입니다.

bash
#!/bin/bash
set -euo pipefail
# devices.txt: "pod block-device [interface]" per line; interface defaults to eth0.
# Example only: kafka-0 nvme1n1. Verify PVC -> PV -> EBS volume -> device first.
while true; do
  while read -r pod device interface; do
    [[ "$pod" =~ ^kafka-[0-2]$ ]] || { echo "Invalid benchmark pod" >&2; exit 2; }
    utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    sample="$(kubectl -n bench-kafka exec -i "$pod" -- sh -s -- "$device" "${interface:-eth0}" < sample-broker.sh)"
    printf '%s\t%s\t%s\n' "$utc" "$pod" "$sample" >> broker-samples.tsv
  done < devices.txt
  sleep 10
done

Pod별 단조 증가 sample 시각과 읽기 시작·끝 범위를 사용합니다. sector 차이×512/경과 시간은 bytes/s, I/O 차이는 IOPS, usage_usec 차이/1,000,000/ 경과 시간은 CPU core입니다. reset·음수 차이·누락 표본을 제외합니다. 같은 phase의 client CPU/NIC, producer buffer/request 메트릭도 수집하며 확인한 volume ID의 CloudWatch VolumeReadBytes·VolumeWriteBytes·VolumeWriteOps와 시간 구간을 맞춥니다.

정리 전에 결과를 내보냅니다. namespace 삭제는 파괴적이며 노드·클러스터 범위 StorageClass를 자동 삭제하지 않습니다. 템플릿은 Delete reclaim을 사용하지만 PVC/PV/EBS 삭제와 남은 노드 비용을 확인해야 합니다.

bash
kubectl delete namespace bench-kafka
# After confirming this run's PVs/volumes are gone and no claims use the class:
kubectl delete storageclass bench-kafka-gp3

검토 근거와 함께 읽기

이번 검토는 산술, 공개 payload 생성기, 실제 Kafka 성능 도구의 옵션·callback 동작, 리소스 스키마·셸 구문과 다이어그램 렌더링을 검사했습니다. AWS 처리량 벤치마크를 새로 실행한 결과는 아닙니다.