처리 계층의 한계 : Spark, Kafka, etcd, Argo CD
저번 포스팅에서는, 저장과 테이블 포맷 쪽에서 터지는 문제들을 공부하였습니다. 이번에는 처리와 전달 쪽입니다. Spark, Kafka, 그리고 그것들을 얹어 돌리는 플랫폼에서 대규모일 때 무엇이 무너지는지를 정리하겠습니다.
앞 편들과 같은 환경을 가정합니다. 각 문제마다 어떤 상황에서 나타나고, 무엇이 위험하고, 어떻게 푸는지를 함께 적었습니다.
Kafka 3.6 브로커 6대 (8 vCPU / 64GB / gp3 2TB) 파티션 48 복제 3 보관 7일
장중 평균 초당 3만 건, 개장 피크 초당 18만 건, 메시지 평균 1.2KB
Flink 1.18 TaskManager 12대 (4 vCPU / 16GB) 병렬도 48 체크포인트 5분
Spark 3.5 on EKS 익스큐터 200개 (4 vCPU / 24GB) 드라이버 8GB
저장 Iceberg on S3 42TB, Redshift ra3.4xlarge 4노드(슬라이스 32)
운영 EKS 노드 60대 / Pod 2,400개, Airflow DAG 180개, Argo CD Application 340개
1. Spark
1.1. 드라이버가 먼저 죽는다
익스큐터를 아무리 늘려도 드라이버는 하나입니다. 그리고 대규모에서 병목이 되는 것은 대개 이쪽입니다.
상황. 일별 리포트 배치입니다. 결과를 드라이버로 모아 후처리하는 코드가 있습니다.
스테이징 대상 3일치, 결과 4만 행 x 1.1KB = 약 44MB → 힙 8GB 의 0.5%
운영 대상 1년치, 결과 820만 행 x 1.1KB = 약 9GB → 힙 8GB 초과, OOM
왜 위험한가. 단순히 느려지는 것이 아니라 잡이 죽습니다. 그리고 죽는 시점이 배치의 마지막 단계라, 익스큐터 200개가 800 vCPU 로 4분간 계산한 결과가 통째로 버려집니다. 재실행하면 그 계산을 처음부터 다시 합니다. 야간 배치라면 마감 시각을 놓칩니다.
더 나쁜 것은 익스큐터를 늘려도 해결되지 않는다는 점입니다. 자원을 더 투입해도 드라이버는 하나이고 늘릴 수 없습니다.
어떻게 푸나.
- 결과를 드라이버로 가져오지 않고 그대로 테이블에 씁니다. 확인이 필요하면 일부만 가져옵니다.
- 자동 브로드캐스트 임계값을 크게 올려둔 설정이 있으면 점검합니다. 브로드캐스트 데이터는 드라이버를 거쳐 익스큐터로 갑니다.
- 파티션을 수십만 개로 늘리지 않습니다. 드라이버가 모든 태스크 상태를 추적하므로 그 관리 자체가 부담이 됩니다.
1.2. 셔플 파일이 폭증한다
셔플에서 만들어지는 파일 수는 대략 맵 태스크 수 × 리듀스 파티션 수에 비례합니다.
상황. 파티션 수를 병렬도를 위해 늘렸습니다.
파티션 200 → 200 x 200 = 40,000 파일
파티션 1,000 → 1,000 x 1,000 = 1,000,000 파일
익스큐터 하나가 여는 파일 수 약 5,000개
리눅스 기본 파일 디스크립터 한도 1,024 (설정에 따라)
왜 위험한가. 파일이 많아지면 열고 닫는 비용이 실제 데이터 전송보다 커집니다. 그리고 파일 디스크립터 한도에 걸리면 태스크가 실패합니다. 디스크의 inode 도 소모하므로, 용량은 남는데 파일을 더 못 만드는 상황이 생깁니다.
반대로 파티션이 너무 적으면 파티션 하나가 커져 다음에 볼 스필이 늘어납니다. 양쪽이 다 나쁜 구간이 있습니다.
어떻게 푸나. 적응형 실행을 켜서 셔플 후 실제 크기를 보고 파티션을 자동으로 합치게 합니다. 수동으로 정한다면 파티션당 100~200MB 를 목표로 잡습니다. 파일 디스크립터 한도는 노드 설정에서 올려둡니다.
1.3. 스필
조인이나 집계 중 데이터가 메모리에 안 들어가면 디스크로 넘깁니다. 먼저 분명히 해둘 것이 있습니다. 넘긴 데이터는 다시 돌아옵니다. 버려지지 않고, 결과가 틀려지지도 않습니다. 스필은 정확성 문제가 아니라 순전히 비용 문제입니다.
상황.
익스큐터 메모리 24GB (실행 영역 약 14GB)
조인 한쪽의 파티션 크기 38GB (쏠린 키 때문에)
→ 메모리 스필 24GB, 디스크 스필 4GB
같은 태스크 소요 스필 없을 때 3분 → 스필 발생 시 51분
왜 위험한가. 여섯 가지 비용이 겹칩니다.
- 쓰고 다시 읽습니다. 메모리 접근이 100나노초 수준이고 로컬 SSD 가 100마이크로초 수준이니, 1,000배 느린 경로를 타게 됩니다.
- 직렬화 비용이 붙습니다. 위 예에서 메모리 24GB 가 디스크에서 4GB 인 것은 직렬화하고 압축했기 때문이고, 그 변환에 CPU 를 씁니다.
- 병합해야 합니다. 조각이 많으면 여러 번에 나눠 병합하고, 그때마다 또 읽고 씁니다.
- 셔플과 같은 디스크를 씁니다. 한 익스큐터에서 태스크 여러 개가 동시에 스필하면 경합이 심해집니다.
- 스필 직전에 GC 가 폭증합니다. 실제로는 스필 자체보다 이 구간이 더 느린 경우도 많습니다.
- 디스크가 차면 실패합니다. 여기서부터는 비용이 아니라 장애입니다.
캐시가 밀려나는 것과는 다릅니다. 저장 수준을 메모리 전용으로 둔 캐시는 밀려날 때 디스크로 넘기지 않고 그냥 버립니다. 나중에 필요하면 앞 단계를 처음부터 다시 계산합니다. 스필은 "느려진다" 이고 캐시 축출은 "다시 만든다" 입니다.
어떻게 푸나. 원인은 대개 둘입니다. 파티션당 데이터가 너무 크거나(파티션 수가 적거나 쏠렸거나), 익스큐터 메모리가 부족하거나 입니다. 앞 편에서 본 쏠림이 여기서 스필로 나타나므로, 쏠림을 먼저 확인하고 솔팅이나 브로드캐스트로 푸는 것이 순서입니다. 그다음에 파티션 수와 메모리를 조정합니다.
스필이 조금 나는 것은 정상입니다. 0으로 만드는 것이 목표가 아니라, 수십 GB씩 나거나 병합이 여러 패스로 늘어날 때 손보는 것입니다.
1.4. 출력 파일이 파티션 수만큼 생긴다
Spark 는 파티션 하나당 출력 파일 하나를 씁니다.
상황.
파티션 1,000개로 하루치 1.2GB 를 씁니다
→ 파일 1,000개, 평균 1.2MB
목표 크기 256MB 기준으로는 약 200배 잘게 쪼개짐
왜 위험한가. 그날은 아무 문제가 없습니다. 문제는 누적입니다. 매일 1,000개씩 6개월이면 18만 개이고, 앞 편에서 본 대로 쿼리 계획 시간이 늘고 S3 요청 수와 비용이 함께 늘어납니다. 만든 사람은 모르고 읽는 사람이 겪습니다.
어떻게 푸나. 쓰기 직전에 파티션을 합쳐 출력 파일 수를 줄입니다. 위 경우라면 5개면 충분합니다. 다만 너무 줄이면 쓰기 병렬도까지 떨어지므로, 파일 하나가 수백 MB 가 되는 수준을 기준으로 잡습니다.
1.5. 동적 할당과 셔플 데이터
익스큐터를 놀릴 때 반납하는 기능이 있는데, 여기에 함정이 있습니다.
상황. 배치의 앞 단계가 끝나 익스큐터 120개가 유휴 상태가 되어 반납되었습니다. 그 익스큐터들이 쓴 셔플 파일도 함께 사라집니다.
왜 위험한가. 뒤 단계에서 그 데이터가 필요해지면 앞 단계를 다시 계산합니다. 자원을 아끼려던 설정이 오히려 같은 계산을 두 번 하게 만듭니다. 쿠버네티스처럼 Pod 가 사라지면 로컬 디스크도 함께 사라지는 환경에서 특히 자주 겪습니다.
어떻게 푸나. 셔플 데이터를 익스큐터 바깥에 두는 장치를 함께 씁니다. 별도의 셔플 서비스를 두거나, 셔플 데이터를 외부 저장소에 쓰는 방식을 씁니다. 그것이 없다면 동적 할당을 끄거나 최소 익스큐터 수를 넉넉히 잡습니다.
1.6. 무엇을 보는가
상황. Spark 익스큐터 200개짜리 배치가 평소 35분에서 3시간으로 늘었습니다. 로그에는 에러가 없고 잡은 정상 종료합니다. 이럴 때 볼 지표는 정해져 있습니다.
- 태스크 시간 분포의 최대값과 중앙값 비율 : 10배를 넘으면 쏠림입니다
- 셔플 읽기와 쓰기 크기 : 예상보다 크면 재분배가 일어나고 있습니다
- 스필 크기 : 메모리 스필과 디스크 스필을 함께 봅니다
- GC 시간 비중 : 실행 시간의 10%를 넘으면 메모리 설정을 봐야 합니다
2. Kafka
2.1. 파티션을 늘리면 좋기만 한 것이 아니다
파티션은 병렬도의 단위라 늘리고 싶어집니다.
상황. 처리량을 늘리려고 토픽의 파티션을 48개에서 480개로 늘렸습니다. 토픽이 12개라면 클러스터 전체 파티션이 5,760개, 복제 3을 곱하면 17,280개의 파티션 복제본입니다.
브로커 6대 → 브로커당 약 2,880개
파티션마다 열린 세그먼트 파일 약 3개
브로커당 열린 파일 약 8,600개
브로커 하나가 죽었을 때 리더 재선출 대상 약 2,880개
왜 위험한가.
- 리더 선출이 길어집니다. 브로커 하나가 죽으면 그 브로커가 리더였던 파티션 전부에 대해 새 리더를 뽑아야 합니다. 파티션이 많을수록 이 시간이 길어지고, 그동안 해당 파티션은 쓰기와 읽기가 안 됩니다. 수천 개면 수십 초가 걸립니다.
- 프로듀서 메모리가 늘어납니다. 프로듀서는 파티션마다 배치 버퍼를 잡습니다.
- 되돌릴 수 없습니다. 파티션은 늘리는 것만 되고 줄일 수 없습니다.
- 키를 쓰는 경우 순서가 깨집니다. 파티션 수가 바뀌면 같은 키가 다른 파티션으로 갑니다. 앞 편에서 본 나머지 연산의 문제입니다. 바꾸는 시점에 순서 보장이 한 번 끊깁니다.
어떻게 푸나. 필요한 처리량에서 역산해 정합니다. 컨슈머 하나가 초당 8천 건을 처리하고 피크가 초당 18만 건이면 23개가 필요하니, 여유를 두어 48개면 충분합니다. "넉넉하게" 라며 열 배로 잡는 것이 가장 위험한 선택입니다. 늘려야 한다면 키 재배치의 영향을 먼저 계산합니다.
2.2. 리밸런싱이 멈춤을 만든다
컨슈머 그룹에서 멤버가 들어오거나 나가면 파티션을 다시 나눕니다. 전통적인 방식에서는 이때 모든 컨슈머가 일단 자기 파티션을 내려놓고 다시 받습니다.
상황. 체결 이벤트를 Redshift 에 적재하는 컨슈머 그룹입니다.
설정 max.poll.records = 500, max.poll.interval.ms = 300,000 (5분)
정상 500건 적재에 약 8초 → 폴 간격 8초, 한도의 3%
악화 후 500건 적재에 약 340초 → 한도 300초 초과, 죽은 것으로 간주
리밸런싱 소요 약 45초 (전원 정지)
그동안 유입 초당 3만 x 45초 = 135만 건 추가 적체
왜 위험한가. 한 번 멈추는 것으로 끝나지 않고 악순환에 들어갑니다.
적재가 느려짐 → 폴 간격 초과 → 리밸런싱(45초 전원 정지)
→ 랙이 135만 건 더 쌓임 → 재개 후 더 많이 처리해야 함 → 더 느려짐
→ 다시 폴 간격 초과 → 6~8분마다 반복
소비가 사실상 진행되지 않습니다. 랙이 시간당 800만 건씩 늘어나고, 보관 기간 7일 안에 따라잡지 못하면 데이터를 영구히 잃습니다.
어떻게 푸나.
- 급한 처치 :
max.poll.records를 500에서 100으로 줄이면 폴 간격이 68초로 내려가 한도 안에 들어옵니다. - 구조 변경 : 처리를 별도 스레드로 넘기고 폴은 계속 돌게 합니다. 확실하지만 구현이 복잡해집니다.
- 협조적 리밸런싱 : 전부 내려놓지 않고 바뀌는 파티션만 옮기므로 전원 정지가 사라집니다.
- 근본 원인 : 위 사례에서 적재가 40배 느려진 이유는 Redshift 쪽에 있었습니다. 3장에서 이어집니다.
2.3. 복제가 밀리면 쓰기가 막힌다
상황. 안전한 쓰기를 위해 acks=all 과 min.insync.replicas=2 를 설정해두었습니다. 브로커 하나의 디스크가 느려져 복제가 밀렸습니다.
복제본 3개 중 동기화 목록에서 빠진 것 1개
남은 동기화 복제본 2개 → 아직 기준 충족
두 번째 브로커도 밀림 남은 복제본 1개
→ min.insync.replicas 미달 → 프로듀서 쓰기 실패
왜 위험한가. 데이터를 지키려는 설정이 서비스를 멈춥니다. 주문 이벤트를 발행하지 못하면 주문 처리 자체가 막힙니다. 그런데 이 설정을 끄면 복제본이 적은 상태에서 브로커가 죽었을 때 커밋된 데이터가 사라집니다.
어떻게 푸나. 어느 쪽을 택할지는 데이터 성격이 정합니다. 주문이나 체결처럼 유실이 사고가 되는 데이터는 쓰기를 실패시키고 상류에서 버티는 편이 맞습니다. 접속 로그라면 반대입니다. 그리고 애초에 밀리지 않도록 브로커 디스크 지연을 지표로 보고 있어야 합니다.
2.4. 디스크가 차면 브로커가 멈춘다
상황.
하루 유입 (압축 후) 약 400GB
복제 3 실제 저장 1.2TB/일
브로커 6대 x 2TB 총 12TB
보관 7일 필요 8.4TB → 사용률 70%
트래픽이 40% 늘면 필요 11.8TB → 사용률 98%
왜 위험한가. 디스크가 가득 차는 순간 브로커가 멈춥니다. 그리고 그 브로커가 리더였던 파티션들이 재선출을 거치는 동안 쓰기가 막히고, 남은 브로커에 부하가 몰려 연쇄적으로 넘어갈 수 있습니다. 보관 기간을 길게 잡아둔 채 트래픽이 서서히 늘면, 아무 경고 없이 어느 날 도달합니다.
어떻게 푸나. 보관 기간과 트래픽으로 필요 용량을 계산해 여유를 70% 이하로 유지합니다. 디스크 사용률에 경보를 걸고, 보관 기간을 줄이거나 오래된 데이터를 다른 저장소로 내보내는 경로를 미리 만들어둡니다.
2.5. 무엇을 보는가
상황. Kafka 브로커 6대, 파티션 48개에 초당 3만 건이 흐릅니다. 전체 랙 합계는 평소와 같은데 특정 소비자만 처리가 밀리고 있습니다. 합계 하나만 보면 이 상태가 보이지 않습니다.
- 파티션별 컨슈머 랙 : 전체 합이 아니라 파티션별로 봐야 쏠림이 드러납니다
- 동기화 복제본 수 : 기준 아래로 내려가면 쓰기가 위험합니다
- 리밸런싱 빈도 : 잦으면 2.2 의 악순환에 빠져 있을 수 있습니다
- 디스크 사용률과 보관 기간
3. 쿠버네티스 클러스터
3.1. etcd 가 한계를 정한다
클러스터의 모든 상태가 etcd 에 있고, etcd 는 나누는 것이 아니라 복제됩니다.
상황. Airflow 가 Kubernetes 실행자로 태스크마다 Pod 를 띄웁니다.
DAG 180개, 하루 태스크 실행 약 4,200회 → Pod 4,200개/일
완료 후 자동 삭제 설정 없음
Pod 오브젝트 하나 약 8KB
3개월 누적
Pod 오브젝트 약 38만 개 → 약 3GB
관련 이벤트 약 120만 개 → 약 1.2GB
압축 안 된 리비전 약 1.5GB
합계 약 5.7GB (권장 한도 2GB)
왜 위험한가. 한도를 넘으면 쓰기가 거부되고 클러스터가 읽기 전용이 됩니다. 모든 배포가 실패하고, 자기 치유도 멈춥니다. 이미 떠 있는 Pod 는 계속 도니 서비스는 살아 있는데 아무것도 고칠 수 없는 상태가 됩니다. 장애가 겹치면 대응 수단이 없습니다.
그리고 etcd 는 디스크 지연에 극단적으로 민감합니다. 합의를 위해 매 쓰기를 디스크에 확정해야 하기 때문입니다. etcd 가 느려지면 apiserver 가 느려지고, 그러면 클러스터 전체가 느려집니다.
어떻게 푸나.
- 완료 후 자동 삭제를 설정합니다. 이것 하나로 위 사례의 3GB 가 사라집니다.
- 이벤트 보관 기간을 줄입니다.
- 압축과 조각 모음을 주기적으로 돌립니다.
- etcd 크기와 디스크 지연에 경보를 겁니다. 한도의 60%를 넘으면 알리게 합니다.
- etcd 를 반드시 로컬 SSD 에 둡니다.
3.2. Watch 가 많으면 apiserver 가 부담된다
상황. 오퍼레이터를 열다섯 개 설치했습니다. 각각이 여러 종류의 리소스를 구독합니다.
구독자 수 컨트롤러 + kubelet + 오퍼레이터 약 120개
변경 빈도 배포 시 초당 수십 건
전달 부하 구독자 수 x 변경 수
왜 위험한가. 부하가 곱으로 늘어납니다. 오퍼레이터를 하나 추가하는 것이 그 오퍼레이터의 자원만 쓰는 일이 아닙니다. apiserver 가 느려지면 리더 선출 갱신까지 밀려, 멀쩡한 컨트롤러가 리더 자격을 잃고 교체되는 일이 생깁니다. 부하가 장애로 번지는 경로입니다.
어떻게 푸나. 오퍼레이터가 정말 필요한지 먼저 따집니다. 그리고 각 컨트롤러가 필요한 네임스페이스와 리소스만 구독하도록 범위를 좁힙니다. apiserver 의 요청 분류 기능을 켜서, 중요한 요청이 밀리지 않게 자리를 나눠둡니다.
3.3. 큰 오브젝트가 자주 바뀌면 나쁘다
상황. Pod 가 3,000개인 서비스가 있습니다. 예전에는 그 목록이 오브젝트 하나에 담겼습니다.
Pod 하나가 재시작될 때마다 3,000개가 담긴 오브젝트 전체가 재전송
배포 중 재시작 빈도 초당 수십 회
구독자 모든 노드의 kube-proxy
→ 전송량이 곱으로 늘어 apiserver 와 네트워크를 함께 압박
왜 위험한가. 배포할 때마다 네트워크가 포화되고, 그 여파로 다른 서비스의 엔드포인트 갱신까지 늦어집니다. 이미 내려간 Pod 로 트래픽이 계속 가는 시간이 길어집니다.
어떻게 푸나. 조각으로 나누는 방식을 쓰면 바뀐 조각만 전송하면 됩니다. 지금은 기본 동작이라 대체로 신경 쓸 필요가 없지만, 큰 오브젝트 하나를 자주 바꾸는 구조를 직접 만들지 않는 것이 원칙입니다. 큰 ConfigMap 을 자주 갱신하는 것도 같은 문제입니다.
3.4. 노드 쪽에서 막히는 것들
상황과 위험을 함께 정리하면 이렇습니다.
- iptables 규칙 수 : Service 가 수천 개면 규칙이 수만 줄이 되고, 규칙 갱신에 수 초가 걸립니다. 그동안 새 Pod 로 트래픽이 가지 않습니다. → IPVS 나 eBPF 기반 구현으로 바꿉니다.
- 연결 추적 테이블 : 노드당 초당 2,500 연결에 유지 시간 120초면 항목이 30만 개로, 기본 한도 약 26만을 넘깁니다. 넘으면 패킷이 조용히 버려지고 애플리케이션 로그에는 아무것도 남지 않습니다. → 연결을 재사용하게 바꾸고, 한도와 유지 시간을 조정합니다.
- 이미지 내려받기 : 노드 60대가 동시에 240MB 이미지를 받으면 순간 14GB 가 레지스트리로 몰립니다. 배포가 느려지고 레지스트리가 다른 팀의 배포까지 막습니다. → 노드에 캐시를 두거나 이미지를 미리 내려받아 둡니다.
- DNS : 모든 Pod 의 이름 조회가 CoreDNS 로 몰립니다. 포화되면 이름을 못 찾아 호출이 실패하는데, 증상이 "간헐적 연결 실패" 라 원인 찾기가 어렵습니다. → 노드마다 캐시를 두고, 외부 도메인 조회가 잦다면 검색 도메인 설정을 조정합니다.
4. Argo CD
4.1. 공통 차트 하나가 수백 개를 동시에 움직인다
상황. 공통 Helm 차트에서 로그 사이드카 이미지 태그를 올렸습니다.
커밋 1건
→ 렌더링 대상 Application 340개
리포지토리 서버 2대, 하나당 약 1.4초 → 약 4분간 CPU 포화
→ apiserver 갱신 요청 약 1,200건
→ 롤링 업데이트 Pod 1,900개
→ 이미지 내려받기 노드 60대 x 240MB → 순간 약 14GB
→ maxSurge 25% 로 순간 Pod 2,375개 → 노드 여유 초과, 일부 Pending
왜 위험한가. 각각은 정상 동작인데 동시에 일어나면서 서로를 밀어냅니다. 자원이 부족해 Pending 이 생기고, apiserver 가 바빠져 다른 배포까지 느려지고, 레지스트리가 포화됩니다. 그리고 문제가 생겨도 340개를 한꺼번에 되돌려야 합니다.
어떻게 푸나. 공통 변경은 단계적으로 나눠 적용합니다. 50개씩 7차에 나누면 각 단계가 원래 규모로 돌아옵니다. 동기화 창으로 시간대를 제한하거나, 운영 프로젝트는 자동 동기화를 끄고 승인 후 적용으로 둡니다.
4.2. 컨트롤러 메모리와 렌더링 부하
상황. Application 340개, 대상 리소스 약 4만 개입니다.
컨트롤러 캐시 리소스 하나당 수 KB → 수 GB 규모
렌더링 같은 커밋은 캐시가 듣지만, 공통 차트를 바꾸면 캐시가 통째로 무효
왜 위험한가. 컨트롤러가 메모리 부족으로 재시작되면 그동안 동기화와 자가 치유가 멈춥니다. 그리고 재시작 후 전체 상태를 다시 받아야 하므로 apiserver 에 부하가 한 번 더 몰립니다.
어떻게 푸나. 컨트롤러를 클러스터 단위로 샤딩해 나눕니다. 리포지토리 서버를 늘려 렌더링을 분산합니다. 그리고 관심 없는 리소스 종류를 추적 대상에서 제외하면 메모리와 부하가 눈에 띄게 줄어듭니다.
5. 조금 더 들어가보기 : 같은 벽에 반복해서 부딪힌다
두 편에 걸쳐 기술별로 봤는데, 원인을 몇 갈래로 묶을 수 있었습니다.
5.1. 나눌 수 없는 것이 상한을 정한다
- HDFS 의 네임노드 : 힙 48GB 가 파일 수의 상한
- Hive 메타스토어 뒤의 관계형 DB : 파티션 수의 상한
- 쿠버네티스의 etcd : 저장 크기 2GB 가 오브젝트 수의 상한
- Spark 의 드라이버 : 힙 8GB 가 결과 크기와 태스크 수의 상한
- Iceberg 의 카탈로그 : 커밋 처리량의 상한
- Kafka 의 컨트롤러 : 파티션 수의 상한
데이터나 연산은 수평으로 늘리는데, "전체를 아는 무언가" 는 하나로 남습니다.
왜 위험한가. 이 한계에 닿으면 자원을 더 투입해도 해결되지 않습니다. 익스큐터를 늘려도 드라이버 OOM 은 그대로이고, 노드를 늘려도 etcd 한도는 그대로입니다.
어떻게 푸나. 푸는 방식도 반복됩니다. 읽기를 분산하거나(Observer NameNode, 캐시), 범위를 쪼개거나(페더레이션, 샤딩), 계층으로 나눈다(테이블 포맷의 메타데이터 층). 새 기술을 볼 때 "여기서 나눌 수 없는 것은 무엇인가" 를 먼저 물으면 한계가 어디일지 가늠할 수 있습니다.
5.2. 개수가 크기보다 먼저 문제다
작은 파일, 파티션 수, Kafka 파티션 수, 쿠버네티스 오브젝트 수, Iceberg 스냅샷 수. 전부 바이트가 아니라 건수가 문제였습니다.
왜 위험한가. 용량 계획에서 개수를 세지 않으면, 디스크는 여유로운데 시스템이 멈추는 상황을 예측하지 못합니다. 앞서 본 네임노드 사례가 그렇습니다. 1MB 미만 파일이 용량으로는 1.3% 인데 메타데이터로는 힙의 56% 를 썼습니다.
어떻게 푸나. 용량 지표에 개수를 함께 넣습니다. 파일 수, 파티션 수, 오브젝트 수, 스냅샷 수에 각각 경보를 겁니다.
5.3. 동시에 일어나는 것이 위험하다
- 클러스터 재시작 시의 블록 리포트
- 배포 시의 이미지 내려받기
- 캐시가 한꺼번에 만료되는 것
- 공통 차트 변경으로 인한 동기화
- 실패 후의 동시 재시도
왜 위험한가. 평균 부하는 감당 가능한데 한순간에 몰려서 무너집니다. 용량 계획을 평균으로 세우면 이 구간을 예측하지 못합니다.
어떻게 푸나. 대응도 같습니다. 시간축에 흩뿌립니다. 지터를 넣고, 순차적으로 처리하고, 속도를 제한하고, 단계적으로 나눕니다.
5.4. 안전장치가 가용성을 깎는다
- Kafka 의 최소 동기화 복제본은 데이터를 지키지만 쓰기를 실패시킵니다.
- 쿠버네티스의 PodDisruptionBudget 은 서비스를 지키지만 노드 비우기를 막습니다.
- 볼륨의 다중 연결 방지는 데이터를 지키지만 장애 조치를 늦춥니다.
- 어드미션 웹훅은 정책을 지키지만 그 자체가 장애점이 됩니다.
왜 위험한가. 안전장치를 넣은 사람과 그것 때문에 장애를 겪는 사람이 다릅니다. 그리고 장애 상황에서 그 설정이 원인이라는 것을 떠올리기 어렵습니다. 노드 비우기가 끝나지 않을 때 PDB 를 의심하려면, 그것이 걸려 있다는 사실을 알고 있어야 합니다.
어떻게 푸나. 기본값을 그대로 두고 있다면, 그것이 어느 쪽을 택한 값인지 알고 있어야 합니다. 그리고 안전장치를 넣을 때 그것이 실패하거나 발동했을 때 어떻게 되는지를 함께 문서로 남깁니다.
결론
두 편에 걸쳐 기술별 문제를 모으면서 가장 크게 느낀 것은, 결국 같은 벽에 반복해서 부딪힌다는 점이었습니다. 이름은 네임노드, 메타스토어, etcd, 드라이버, 카탈로그로 다 다른데 성격은 하나였습니다. 전체를 아는 무언가는 나누기 어렵고, 그것이 시스템의 상한이 됩니다. 새 기술을 만났을 때 "여기서 나눌 수 없는 것은 무엇인가" 를 먼저 물으면 한계가 어디일지 짐작할 수 있겠다는 생각이 들었습니다.
두 번째는 개수가 크기보다 먼저 문제가 된다는 것이었습니다. 이 시리즈 내내 반복해서 나온 이야기인데, 기술별로 보니 예외가 거의 없었습니다. 용량을 이야기할 때 바이트만 세어온 습관을 고쳐야겠다고 생각했습니다.
세 번째는 정리 작업과 안전장치가 그 자체로 위험하다는 점이었습니다. 고아 파일 제거가 진행 중인 쓰기를 깨뜨리고, PodDisruptionBudget 이 노드 비우기를 영원히 막고, 어드미션 웹훅이 클러스터를 세웁니다. 좋은 의도로 넣은 것이 새로운 장애점이 되는 구조인데, 이것을 알고 있는 것과 모르는 것의 차이가 클 것 같습니다.
마지막으로, 이 시리즈를 쓰면서 지금까지 기술별로 공부한 것들이 하나로 묶이는 느낌을 받았습니다. 따로 배울 때는 각각의 설정과 지표였는데, 증상으로 모아놓고 보니 몇 가지 원리가 이름만 바꿔 반복되고 있었습니다. 새 기술을 배울 때 무엇을 먼저 확인해야 할지에 대한 기준이 생긴 것이 이번 시리즈의 가장 큰 소득이었습니다.
공부 참고 자료
📑 2. Spark - Dynamic Resource Allocation
📑 3. Kafka - Consumer Configs (poll 과 리밸런싱)
📑 4. Kafka - Incremental Cooperative Rebalancing
📑 5. Kafka - Replication 과 min.insync.replicas