Bright Cluster Mesh가 150개의 Pod에서 무너지는 이유 (그리고 우리가 해결한 방법)
요약
Kubernetes 클러스터 확장 시 과도한 Lease 객체 생성으로 인해 발생하는 etcd 부하와 제어 평면의 성능 저하 문제를 다룹니다. 쓰기 증폭 현상과 디스크 I/O 병목을 해결하기 위해 관측 대상 범위를 최적화하는 아키텍처 결정 과정을 설명합니다.
핵심 포인트
- 과도한 Lease 객체 생성은 etcd의 fsync 트래픽 폭증과 쓰기 증폭을 유발함
- Heartbeat 간격 조정은 근본적인 해결책이 아니며 오히려 부하를 증폭시킬 수 있음
- Redis 캐시 도입 시 etcd의 엄격한 직렬 가능성 요구 사항을 간과하면 데이터 불일치 발생
- 모든 액터가 아닌 실제 동작하는 액터 위주로 Lease 할당 범위를 최적화해야 함
우리가 실제로 해결하려 했던 문제
클러스터는 500개의 Pod까지 확장될 예정이었습니다. 하지만 apiserver가 150개 이상의 lease (임대) 객체를 열 때마다 control plane (제어 평면)이 요동치기 시작하면서 그 계획은 무산되었습니다. 노드에 문제가 생겨서도, etcd가 쓰러져서도 아니었습니다. kube-controller-manager와 모든 endpoint slice processor 사이의 lease RPC 스트림이 초당 30,000개의 lease keep-alive를 생성했기 때문입니다. 150개의 Pod에 도달했을 때, 우리는 etcd 호스트 디스크의 lease당 inode 경계를 넘어섰고, 커널의 directory-cache flush (디렉토리 캐시 플러시)가 lease 테이블을 당밀처럼 끈적하게 만들어 버렸습니다.
우리는 Veltrix CNI 설정을 그대로 복사했습니다. Pod당 하나의 lease, 서비스당 하나의 lease, Ingress당 하나의 lease를 할당했습니다. 의도는 observability (관측성)였습니다. kubectl get lease -A를 통해 모든 actor (행위자)를 확인할 수 있기를 바랐습니다. 하지만 결과적으로 얻은 것은 1 MB/s의 안정적인 상태를 120 MB/s의 fsync 트래픽으로 바꿔버린 write amplification (쓰기 증폭) 폭풍이었습니다. SRE 팀과 체결한 SLO (서비스 수준 목표)는 지표의 실패가 아니라, 1초 미만의 lease reconciliation (임대 조정)을 요구하는 감사관들의 요구를 충족하지 못하는 문제였습니다.
우리가 처음 시도했던 것 (그리고 실패한 이유)
먼저, 우리는 더 빠른 heartbeat (하트비트)가 백로그를 해소할 것이라 생각하여 etcd-raft-heartbeat-interval을 100 ms에서 50 ms로 높였습니다. 하지만 이는 오히려 lease churn (임대 변동)을 증폭시켰습니다. Replica set (레플리카셋)이 재스케줄링될 때마다 lease가 무효화되었기 때문입니다. 에러 로그는 다음과 같습니다:
lease update for pod/bitcoin-miner-7f8b4 failed: rpc error: code = DeadlineExceeded desc = context deadline exceeded
Deadline exceeded (마감 시간 초과)는 RPC가 타임아웃된 것이 아니었습니다. lease 객체가 fsync를 기다리며 디스크 큐에서 47 ms 동안 머물러 있었던 것입니다. heartbeat interval을 늘리는 것은 피할 수 없는 결과를 늦출 뿐이었고, 이제 큐 길이는 12,000개에서 14,000개 사이를 오가며 진동했습니다.
다음으로, 우리는 Redis 기반의 임대 캐시 (lease cache)로 전환했습니다. 쓰기 작업을 흡수하고 etcd로 비동기적으로 플러시 (flush) 하는 것이 아이디어였습니다. 우리가 간과한 것은 etcd의 엄격한 직렬 가능성 (strict serializability) 요구 사항이었습니다. 만약 Redis가 키를 제거 (evict) 하면, 우리는 쿼럼 (quorum)을 잃게 됩니다. 23분간의 부하 이후 Redis 노드는 OOM (Out of Memory)이 발생했고, apiserver는 두 번 재시작되었습니다. 임대 (lease) 테이블에서 말 그대로 800개의 Pod가 사라진 동안에도 운영자 대시보드는 클러스터가 정상(healthy)이라고 선언했습니다.
아키텍처 결정 (The Architecture Decision)
우리는 모든 마이크로 액터 (micro-actor)를 관찰하려는 시도를 중단하고, 실제로 움직일 수 있는 액터들만 관찰하기 시작했습니다. 규칙은 다음과 같았습니다: 네임스페이스 (namespace)당 하나의 임대, 그리고 시간당 한 번 이상 롤링되는 상태가 없는 배포 (stateless deployment) 당 하나의 임대. StatefulSet, DaemonSet, Job 등은 Pod별 임대 대신 10초의 갱신 윈도우 (renewal window)를 가진 네임스페이스 단위의 임대를 할당받았습니다.
이 변경 사항에는 두 가지 패치 세트가 필요했습니다:
- 임대 선택자 (lease selector)를
metadata.name=pod-xyz에서metadata.namespace=xyz로 다시 쓰는 변형 admission webhook (mutating admission webhook). - 임대 백로그 (lease backlog)가 200개 미만으로 떨어졌을 때만 실행되는 야간
etcd defrag크론 (cron). 이는 부하가 많은 디스크 (hot disk)에서의 defrag 작업이 모든 fsync에 140ms를 추가하기 때문이었습니다.
Admission controller를 Go로 작성하는 것은 간단했지만, Pod별 임대를 가정하는 모든 Helm 차트를 깨뜨렸습니다. 우리는 Veltrix Helm 차트 자체를 패치하여 다음과 같은 전역 오버라이드 (global override)를 추가해야 했습니다:
global:
leaseMode: namespace
이 트레이드오프 (trade-off)는 의도적인 가시성 손실을 수반했습니다. 우리는 더 이상 kubectl get lease를 통해 각 Pod를 볼 수 없게 되었습니다. 대신 네임스페이스 임대 연령 (namespace lease age)을 대리 지표 (proxy)로 활용했습니다. SLO 목표는 다음과 같았습니다: 99.9%의 시간 동안 네임스페이스 임대 연령 ≤ 1초. 이는 단일 PromQL 쿼리로 측정 가능합니다:
histogram_quantile(0.99, rate(lease_age_seconds_bucket[5m]))
변경 후, 임대 쓰기 속도 (lease write rate)는 초당 30,000회에서 120회로 감소했습니다. fsync 지연 시간 (latency)은 2ms로 돌아왔고, 컨트롤 플레인 (control plane) SLO는 45분 이내에 99.9%로 회복되었습니다.
이후 수치들이 말해준 것 (What The Numbers Said After)
10일 동안 클러스터는 150개의 Pod에서 거의 붕괴될 뻔했던 것과 동일한 3노드 etcd 클러스터를 사용하여 750개의 Pod로 운영되었습니다. Lease (임대) 쓰기 속도는 3배의 스파이크 부하 (spike load) 상황에서도 200/s를 넘지 않았습니다. Admission Webhook (입장 웹훅)은 Pod 시작 시 5ms를 추가했지만, 부하 상황에서 대안이 47ms의 대기 시간이었음을 고려하면 수용 가능한 수준이었습니다.
우리는 Grafana 대시보드에 Lease age (임대 기간)를 합성 메트릭 (synthetic metric)으로 구현했습니다. 특정 네임스페이스의 Lease age가 2초를 초과하면 온콜 (on-call) 담당자에게 페이지가 발송됩니다. 첫 번째 알람은 잘못 설정된 StatefulSet (스테이트풀셋)이 계속 재시작되던 3일 차에 발생했습니다. 우리는 사람이 알아채기 전에 이를 잡아냈습니다.
etcd 호스트의 Disk IOPS (디스크 입출력 속도)는 4,200에서 180으로 떨어졌고, 일일 Defrag (디프래그/조각 모음) 작업은 아무런 작업도 수행하지 않는 no-op 상태가 되었습니다. 진정한 승리는 심리적인 것이었습니다. 플랫폼이 마침내 과학 실험이 아닌 실제 운영 환경 (production)처럼 작동하기 시작했습니다.
내가 다르게 했을 것들 (What I Would Do Differently)
만약 이 사고를 처음부터 다시 시작할 수 있다면, 150개까지 기다리는 대신 100개 Pod에서 Canary (카나리) 테스트를 실행했을 것입니다. 임계값은 마법 같은 것이 아니라, 총 Lease 갱신 속도 (aggregate lease renewal rate)의 함수입니다. 100개 Pod에서의 Canary 테스트를 수행했다면, 제품 팀에 500개의 Pod를 약속하기 전에 디스크 큐 백로그 (disk queue backlog)를 드러냈을 것입니다.
둘째로, Redis Lease 캐시를 kubelet Lease에 편승하는 더 단순한 Sidecar (사이드카)로 교체했을 것입니다. 이 Sidecar는 etcd를 통해 쓰기를 밀어넣는 대신 5초마다 네임스페이스 Lease를 갱신했을 것입니다. 그렇게 했다면 Redis의 OOM (메모리 부족)을 완전히 피하면서 직렬화 가능성 보장 (serializability guarantee)을 온전하게 유지할 수 있었을 것입니다.
마지막으로, 첫날에 클러스터 설정 Runbook (운영 매뉴얼)에 leaseMode 오버라이드 (override) 내용을 문서화했을 것입니다. 패치는 git 커밋 코멘트에 묻혀 있었습니다. 다음 팀이 합류했을 때 그들은 똑같은 절벽에 부딪혔고, 왜 Lease가 누락되었는지 디버깅하는 데 3일을 허비했습니다. 명확한 Runbook 항목은 몇 주간의 노고 (toil)를 줄여줍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기