
지난 분기에 컨트롤 플레인 한 노드에서 etcd defrag을 잘못 돌리다가 API 서버가 5초간 5xx를 토해낸 적이 있다. 그 뒤로 팀 내부에서 "etcd 만지지 말자"는 분위기가 잠깐 있었는데, 사실 그건 답이 아니다. defrag을 안 돌리면 결국 디스크가 커지고 read latency가 슬금슬금 올라간다. 문제는 동작 원리를 잘 모르는 상태에서 그냥 cron만 돌리는 거였다.
이 글에서는 etcd가 MVCC를 어떻게 구현하는지, compaction과 defrag이 정확히 무슨 일을 하는지, 그리고 K8s API 서버 입장에서 그 영향이 어디서 어떻게 보이는지 정리해본다. 운영 가이드 글은 인터넷에 차고 넘치는데, 정작 "왜"가 빠져있어서 매번 어색하게 cron 주기만 조정하게 된다.
MVCC, 그러니까 etcd는 어떻게 키를 저장하는가
etcd v3는 BoltDB(현재는 bbolt) 위에 자체 MVCC 레이어를 올려놓은 구조다. 사실 내부적으로는 두 개의 인덱스가 따로 돌고 있다.
하나는 in-memory B-tree로 키 → revision 리스트를 매핑한다. /registry/pods/default/nginx-abc라는 키가 있다면 이 키가 r1, r5, r12에서 수정됐다는 정보가 메모리에 들어있다. 또 하나는 디스크에 있는 bbolt B+tree로, revision → 실제 값(KeyValue 프로토버프)을 저장한다. 즉 키로 조회하면 메모리 인덱스에서 최신 revision을 찾고, 그 revision으로 다시 디스크에서 값을 읽어오는 2단계 lookup이다.
여기서 중요한 게 한 가지 있다. 모든 write는 새로운 revision을 만들고 옛 revision을 그대로 둔다. 키 하나를 100번 업데이트하면 디스크에 100개의 KeyValue가 쌓인다. 이게 watch와 일관된 read를 가능하게 만드는 메커니즘인데, 동시에 etcd가 부풀어 오르는 근본 원인이기도 하다.
K8s 환경에서는 이게 더 심하다. kubelet이 노드 status를 10초마다 patch하고, controller-manager가 endpoint를 끊임없이 업데이트하고, leader election lease가 1~2초마다 갱신된다. 노드 200대 클러스터에서 하루 동안 쌓이는 revision 수는 천만 단위로 넘어간다.
compaction은 무엇을 지우는가
compaction을 "오래된 데이터 청소"라고 막연히 알고 있었는데, 실제 동작은 좀 더 까다롭다.
revision N으로 compact를 호출하면 etcd는 N 이전의 revision 중 현재 키의 최신 값이 아닌 것들만 지운다. 무슨 말이냐면, 키 /registry/pods/foo가 r10에서 r50까지 50번 업데이트됐고 현재 컴팩션 타깃이 r45라고 하자. r10~r44 revision은 모두 지워지지만, r45 자체는 그 키의 r45 시점 최신값이므로 살아남는다. r46~r50도 컴팩션 대상 밖이라 그대로다.
컴팩션 전: r10[v1] r20[v2] r30[v3] r40[v4] r45[v5] r50[v6]
컴팩션(rev=45) 후: r45[v5] r50[v6]
이게 watch 일관성을 위해 중요하다. 클라이언트가 "rev=45부터 watch해줘"라고 요청하면 etcd는 r45 시점의 모든 키 상태를 정확히 재구성할 수 있어야 한다. 그래서 r45의 최신 상태 스냅샷을 보존한다.
--auto-compaction-retention=1h로 설정하면 etcd 내부적으로 5분마다 체크해서 "1시간 전 revision까지 compact"를 자동 실행한다. K8s 운영 가이드에서 거의 디폴트로 권장하는 설정인데, 이걸 비워두면 디스크가 GB 단위로 부풀어 오르는 걸 며칠 안에 보게 된다.
그런데 디스크 사이즈는 왜 안 줄어드는가
여기가 처음 만지는 사람들이 가장 헷갈려하는 부분이다. compaction이 수백만 revision을 지웠는데 du -sh /var/lib/etcd를 찍어보면 사이즈가 그대로다. 도대체 왜?
bbolt가 freelist 기반으로 동작하기 때문이다. compaction은 B+tree에서 페이지를 free 상태로 마킹할 뿐, 실제 파일 사이즈는 줄이지 않는다. 새 write가 들어오면 이 free 페이지를 우선 재사용한다. 즉 compaction 직후의 etcd는 "디스크 사용량은 그대로지만 내부적으로는 빈 공간이 많은" 상태다.
이걸 실제로 줄이려면 defragmentation이 필요하다. defrag은 bbolt 파일을 처음부터 다시 쓴다. 살아있는 페이지만 모아 새 파일을 만들고 기존 파일을 교체한다. 이 과정에서 etcd는 db 파일 전체에 대한 lock을 잡는다. 그리고 이게 정확히 K8s API 서버에 5xx를 일으킨 범인이었다.
defrag 동안 정확히 무슨 일이 벌어지는가
defrag은 blocking operation이다. 단일 노드 etcd를 defrag하면 그 노드의 모든 read/write가 defrag이 끝날 때까지 멈춘다. 디스크 사이즈와 IOPS에 따라 다르지만, 4GB짜리 etcd에서 NVMe 기준 보통 30초~2분 정도 걸린다.
3노드 클러스터라고 안전한 게 아니다. 왜냐하면 K8s API 서버는 leader에 붙어서 통신하는데, leader 노드가 defrag으로 멈추면 quorum을 다시 잡을 때까지 write 요청 전체가 timeout으로 떨어진다. 우리가 5xx를 본 게 정확히 이 케이스였다. follower부터 defrag 했으면 됐을 일을, 누군가 leader 먼저 돌렸다.
올바른 순서는 이렇다.
1. etcdctl endpoint status로 leader 확인
2. follower 1 defrag → 완료 대기
3. follower 2 defrag → 완료 대기
4. leadership transfer로 leader를 이미 defrag된 노드로 이전
5. 마지막 남은 노드 defrag
leadership transfer는 etcdctl move-leader로 명시적으로 옮길 수 있다. 옮기는 동안에는 짧은(보통 100ms 이내) 쓰기 정지가 있긴 한데, defrag 자체의 멈춤 시간보다 훨씬 짧다.
# follower부터 defrag
etcdctl --endpoints=https://node-2:2379 defrag
etcdctl --endpoints=https://node-3:2379 defrag
# leader를 이미 defrag된 노드로 이전
etcdctl --endpoints=https://node-1:2379 move-leader <member-id-of-node-2>
# 이제 안전하게 defrag
etcdctl --endpoints=https://node-1:2379 defrag
운영 자동화한다면 etcd-defrag operator(공식)나 자체 스크립트로 위 순서를 강제하는 게 좋다. 사람이 cron 짤 때 leader 체크 로직을 빼먹기 너무 쉽다.
API 서버 latency가 잠깐씩 튀는 이유
여기까지 읽었으면 이제 한 패턴이 보일 거다. defrag이 아니어도, 단순히 etcd 인덱스가 비대해진 상태에서 API 서버 latency가 P99 기준 슬금슬금 올라간다. 왜?
list 요청이 가장 영향을 많이 받는다. kubectl get pods --all-namespaces는 etcd에 range read를 던지는데, etcd는 메모리 B-tree에서 범위에 해당하는 모든 키의 최신 revision을 찾고, 각 revision으로 디스크 lookup을 한다. 키 수가 많고 revision 인덱스가 fragmented되면 이 lookup의 cache hit율이 떨어진다.
특히 K8s 1.31 이후 watch cache가 ResourceVersion 기반으로 더 빡세게 검증하면서, etcd compaction이 너무 공격적으로 돌면 watch 클라이언트가 "required revision has been compacted" 에러로 재연결하는 케이스가 늘었다. compaction 주기를 1시간 미만으로 잡으면 안 되는 이유 중 하나다.
그래서 실무에서 어떻게 잡는가
운영 환경에 따라 다르지만 우리 팀이 지금 쓰는 설정은 대략 이렇다.
--auto-compaction-retention=1h로 자동 컴팩션을 켠다. quota는 --quota-backend-bytes=8589934592(8GB)로 잡았다. 디폴트 2GB는 노드 100대 넘는 클러스터에서 금방 꽉 찬다.
defrag은 주 1회, 새벽 트래픽 적은 시간에 자체 스크립트로 follower → leader 순서로 돈다. 각 노드 사이에 30초 sleep을 둬서 quorum이 안정화될 시간을 준다.
모니터링은 etcd_mvcc_db_total_size_in_bytes와 etcd_mvcc_db_total_size_in_use_bytes를 같이 본다. 두 값의 차이가 전체의 30%를 넘기 시작하면 defrag 타이밍이라는 신호다. Grafana 대시보드에서 둘 다 띄워놓으면 패턴이 눈에 잘 들어온다.
마무리
처음에 etcd 운영을 막연히 두려워했던 이유가 뭐였나 돌아보면, 결국 "내부에서 뭐가 도는지 모르니까"였다. MVCC가 어떻게 revision을 쌓는지, compaction이 뭘 지우고 defrag이 뭘 다시 쓰는지를 이해하고 나니까 cron 주기 정하는 것도, 장애 났을 때 어디부터 봐야 할지도 훨씬 명확해진다.
K8s 운영자 입장에서 etcd는 추상화 뒤에 숨어있는 검은 상자처럼 느껴지지만, 사실 control plane 트러블의 절반은 etcd 동작을 이해하면 풀린다. 다음에는 watch cache와 etcd가 어떻게 상호작용하는지를 파볼 생각이다. 거기서 봐도 또 한 번 놀랄 만한 디테일들이 숨어있다.
혹시 운영 환경에서 다른 컴팩션/디프래그 패턴을 쓰고 계신 분 있으면 댓글로 공유해주시면 좋겠다.
'IT > Kubernets' 카테고리의 다른 글
| kubectl debug --target, 이거 모르는 분 꽤 많더라 (0) | 2026.05.02 |
|---|---|
| ValidatingAdmissionPolicy vs Kyverno, 정책 일부를 옮기고 나서 (0) | 2026.05.01 |
| ingress-nginx EOL 이후, ingress2gateway로 Gateway API 옮기기 (0) | 2026.05.01 |
| Cilium Hubble로 Network Policy 디버깅하기 (0) | 2026.04.30 |
| Pod이 OOMKill 되기 직전, kernel과 kubelet이 보는 것 (0) | 2026.04.30 |