EndpointSlice와 kube-proxy, 사실 내부적으로는 이렇게 돈다

Kubernetes Service가 어떻게 Pod에 트래픽을 흘려보내는지, 표면적으로는 다들 안다. Service → Endpoints → Pod IP 이렇게 이어진다는 그림 말이다. 그런데 실제로 v1.33 기준으로 클러스터 안에서 벌어지는 일은 이 그림과 조금 다르다. Endpoints API는 이제 사실상 legacy로 밀려나 있고, 그 자리를 EndpointSlice가 완전히 차지했다. 최근에 우리 팀 신규 클러스터를 1.33으로 올리면서 노드 350대짜리 스테이징에서 Service 업데이트 지연을 재현하다가, 이 내부 동작을 다시 파고들 일이 있었다. 오늘은 그 정리다.
이 글은 짧지 않다. Service의 실체가 궁금하거나, kube-proxy 성능이 왜 클러스터 규모에 따라 갑자기 무너지는지 이해하고 싶은 사람에게 도움이 될 만하다.
Endpoints가 왜 죽어야 했나
먼저 Endpoints API를 잠깐 되짚어보자. Service 하나당 Endpoints 오브젝트 하나가 붙는다. 이 오브젝트 안에 그 Service를 backing 하는 모든 Pod IP가 리스트로 들어간다. 이게 문제였다.
Pod 5000개짜리 Deployment를 생각해보자. Endpoints 오브젝트 하나 안에 IP 5000개가 배열로 쌓인다. 그런데 그 중 하나만 죽어도, 즉 IP 하나만 빠져도, Kubernetes는 이 Endpoints 오브젝트 전체를 다시 써야 한다. etcd에 새 리비전이 커밋되고, watch 하는 모든 kube-proxy에게 전체 오브젝트가 통째로 다시 배포된다.
노드가 300대라고 치자. Pod 하나 죽으면 등속으로 300대 노드에 5000개 IP 리스트 전체가 다시 전송된다. 대역폭도 문제지만, 각 노드의 kube-proxy는 이 리스트를 받아서 iptables 룰을 다시 계산한다. 결과적으로 Pod 하나의 rolling update가 클러스터 전체 kube-proxy CPU를 튀게 만든다. 실제로 대규모 클러스터에서 배포 시 kube-proxy latency spike가 초 단위로 튀는 게 흔한 문제였다.
EndpointSlice는 이 구조를 그냥 잘게 쪼갠 거다. 한 Slice에 기본 100개까지의 endpoint를 담고, Slice가 여러 개면 여러 개로 두는 식이다. 5000개 Pod면 Slice 50개. Pod 하나 죽으면 그게 속한 Slice 하나만 업데이트되고, 나머지 49개 Slice는 건드리지 않는다.
간단해 보이지만, 이 "쪼개기"의 실제 알고리즘이 재밌다.
Slice에 endpoint를 어떻게 배분하나
직관적으로는 "새 endpoint가 생기면 가장 덜 찬 Slice에 넣으면 되지 않나?" 싶다. 근데 그렇게 하면 Slice가 오래될수록 점점 fragmented 되고, 결국 100개짜리 Slice 하나 대신 50개짜리 두 개가 되는 경우가 생긴다. Slice 수가 많아지면 watch 오브젝트 수 자체가 늘어나서 API server 부담이 커진다.
Kubernetes EndpointSlice controller의 알고리즘은 다음 우선순위로 돈다:
1. 이미 존재하는 Slice 중 이 endpoint를 넣을 수 있는 곳(공간이 있고, 라벨/포트 조건이 맞는 곳)을 찾는다
2. 없으면 새 Slice를 만든다
3. 일정 조건에서 Slice들을 병합한다 — 다만 공격적으로 하지 않는다
이 세 번째가 포인트다. 병합은 update보다 create/delete가 API server 입장에서 더 비싸기 때문에 보수적으로 한다. 즉 Slice가 좀 sparsely 찬 상태로 남는 걸 허용한다. 이걸 처음 봤을 때 "왜 이렇게 낭비하지" 싶었는데, 대규모에서는 이게 오히려 안정성 측면에서 낫다는 게 팀 내부 결론이었다. 몇 개 Slice가 반쯤 비어있는 것보다, Slice 병합 도중 발생하는 짧은 endpoint 누락 window가 훨씬 위험하니까.
한 가지 놓치기 쉬운 게 있다. Slice는 Service 단위가 아니라 (Service, port set, address type) 튜플 단위로 만들어진다. Service가 TCP:80, UDP:53 두 포트를 노출하면, 원칙적으로 이 둘은 다른 Slice에 담길 수 있다. Dual-stack이면 IPv4용 Slice와 IPv6용 Slice가 또 따로 생긴다. 그래서 실제 프로덕션에서 kubectl get endpointslice -n <ns> -l kubernetes.io/service-name=<svc> 해보면 예상보다 Slice 수가 많은 경우가 흔하다.
kube-proxy가 이 Slice를 어떻게 소비하나
kube-proxy는 각 노드에서 데몬으로 돈다. EndpointSlice와 Service를 watch 하고, 그 결과를 노드의 데이터 플레인(iptables, ipvs, nftables 등)에 반영한다. 여기서 v1.28 이후 큰 변화가 있었는데, 그 전까지 kube-proxy는 endpoint가 하나만 바뀌어도 관련 iptables 체인을 통째로 다시 썼다.
iptables는 rule set 전체를 atomic하게 교체하는 걸 기본으로 한다. iptables-restore로 새 룰셋을 넣고, kernel이 커밋한다. 이게 endpoint 100개짜리 Service면 문제가 없다. 그런데 5000개, 10000개 스케일이 되면 rule 개수가 폭발한다. Service 하나당 KUBE-SVC 체인, endpoint 하나당 KUBE-SEP 체인이 만들어지고, 그 안에 라운드로빈용 statistical match 룰이 들어가는 구조라서 실제로 iptables 룰 수가 endpoint 수에 선형으로 늘어난다.
v1.28에서 도입된 minimal sync mode는, 사실상 "바뀐 Service 관련 체인만 부분적으로 다시 쓴다"는 접근이다. 정확히는 iptables-restore가 여전히 atomic이지만, 재작성해야 하는 룰 범위를 좁혀서 매번 넘겨야 하는 문자열 크기를 줄인 거다. 벤치마크로는 Service가 5000개, endpoint가 30000개인 클러스터에서 sync latency가 4-5배 개선됐다는 게 릴리스 노트에 있다. 실제로 우리도 v1.27에서 v1.28로 올렸을 때 kube-proxy p99 sync duration이 800ms 근처에서 200ms 아래로 내려온 걸 확인했다.
한 가지 유의할 점. kube-proxy 모드는 iptables만 있는 게 아니다. 최근에 안정화된 nftables 모드가 있다. v1.31에서 GA 됐고, 최근 우리 팀도 신규 클러스터엔 nftables로 붙이는 걸 논의 중이다. nftables는 iptables보다 rule matching이 tree 기반이라 endpoint 수가 많아져도 룩업 성능이 로그 스케일에 가깝게 유지된다. 이론상은 그런데, 실제 대규모 운영 사례가 아직 많지 않아서 조심스러운 상태다.
Topology aware routing이 EndpointSlice에 얹혀 있는 이유
EndpointSlice가 단순히 성능 개선용이면 이 정도로 크게 다룰 이유가 없다. 결정적인 건, Slice가 endpoint별로 라벨과 조건을 담을 수 있다는 점이다. topology.kubernetes.io/zone, nodeName, hints.forZones 같은 정보가 각 endpoint에 붙는다.
Topology aware routing (구 topology aware hints)은 이 정보를 활용한다. Slice가 만들어질 때 EndpointSlice controller가 endpoint 분포와 노드 CPU 용량을 보고, 각 endpoint에 "이 endpoint는 zone A의 클라이언트가 우선적으로 붙어야 한다"는 힌트를 심는다. kube-proxy는 이 힌트를 보고 zone-local endpoint를 우선 선택하는 라우팅 룰을 만든다.
우리 팀에선 이걸 켰다가 한 번 사고를 낼 뻔했다. Zone A에 endpoint가 2개, Zone B에 endpoint가 20개 있는 상황에서 topology aware routing을 켰더니 Zone A의 클라이언트 트래픽이 그 2개 endpoint로 몰려서 CPU가 100% 튀었다. 로직상 endpoint controller가 zone별 트래픽 비율과 endpoint 비율이 맞지 않으면 hint를 뿌리지 않게 되어있는데, 우리는 그 임계치 근방에서 왔다갔다 하는 상태였다. 결국 특정 워크로드에만 켜고, PreferClose 정책은 클러스터 전역 default로 안 쓰는 방향으로 굳었다.
그럼 지금부터 뭘 하면 되나
EndpointSlice 자체는 딱히 뭘 안 해도 된다. Kubernetes v1.21 이후로 기본이고, kube-proxy는 EndpointSlice를 자동으로 소비한다. Endpoints API 오브젝트는 호환성 때문에 여전히 만들어지긴 하지만, kube-proxy가 그걸 보고 있진 않다.
그래도 규모가 커진다면 챙겨볼 만한 게 몇 가지 있다:
kubectl get endpointslice -A | wc -l로 클러스터 전체 Slice 수를 가끔 확인한다. 이 숫자가 Service 수 대비 지나치게 많다면 fragmentation이 있다는 신호다.- kube-proxy metrics 중
kubeproxy_sync_proxy_rules_duration_seconds를 dashboard에 걸어두면 좋다. p99가 슬금슬금 오르면 iptables 룰 규모가 문제일 가능성이 크다. - v1.31+ 클러스터라면 nftables 모드 마이그레이션을 검토할 수 있다. 다만 롤백이 쉽지 않으니 stg에서 오래 굴려보고 판단할 것.
사실 이 글을 쓰기 시작할 땐 "EndpointSlice 내부 알고리즘 정리하고 끝내자" 였는데, 쓰다 보니 kube-proxy sync mode랑 topology routing까지 다 얽혀 있어서 여기까지 왔다. Kubernetes 네트워킹은 결국 이 세 조각이 붙어서 도는 시스템이라, 하나만 이해해선 트러블슈팅이 잘 안 된다. 어차피 언젠가는 다 봐야 한다.
다음엔 nftables 모드에서 우리 팀이 겪은 것들을 정리해볼까 싶다. 아직 검증 중이라 시기상조지만.
#kubernetes #kube-proxy #endpointslice #네트워킹 #클러스터운영 #내부동작