kube-apiserver가 429를 뱉을 때 - API Priority and Fairness 내부 살펴보기

kubectl이 갑자기 Too Many Requests를 뱉기 시작하면 대부분 첫 반응은 비슷하다. "누가 또 apiserver 두들기고 있냐." 그리고 나서 metrics를 보면 특정 컨트롤러가 리스트 요청을 폭주하고 있거나, 새 오퍼레이터가 배포되면서 watch를 무한 재연결하고 있거나 그런 식이다.
근데 그때 이런 의문이 든다. 왜 kubelet의 상태 업데이트는 여전히 잘 되는데 내 kubectl은 막힐까? 왜 어떤 요청은 큐잉되고 어떤 요청은 즉시 429가 뜰까? 이 뒤에는 API Priority and Fairness — 줄여서 APF — 라는 녀석이 돌아가고 있다. 사실 v1.29에서 GA된 지 좀 됐는데, 실제로 이걸 튜닝하거나 내부를 뜯어본 사람은 생각보다 적다. 최근에 EKS 컨트롤 플레인 스케일링 이슈로 이걸 며칠 파고 들었는데, 정리해두면 좋을 것 같아서 써본다.
FlowSchema와 PriorityLevelConfiguration - 두 개의 축
APF는 두 리소스로 돌아간다. 요청이 오면 먼저 FlowSchema를 매칭한다. FlowSchema는 "누가 어떤 요청을 보냈는지"를 기반으로 분류하는 규칙이다. subjects에 kubelet, controller, 사용자 그룹 같은 걸 명시하고, resourceRules / nonResourceRules로 대상 리소스를 좁힌다.
FlowSchema에 걸리면 두 가지가 정해진다. 첫째, 어느 PriorityLevelConfiguration으로 보낼지. 둘째, 그 안에서 어떤 flow로 취급할지 (distinguisherMethod가 ByUser면 사용자별, ByNamespace면 네임스페이스별로 flow가 나뉜다).
PriorityLevelConfiguration은 실제로 동시성 상한과 큐를 정의한다. 기본으로 제공되는 것들이 재밌는데:
system- kubelet과 노드 컴포넌트용. 제일 넉넉하다.leader-election- 리더 선출 요청 전용. 이게 막히면 컨트롤러가 죽으니까 별도로 뺐다.workload-high/workload-low- 컨트롤러 매니저, 스케줄러 같은 시스템 컨트롤러global-default- 나머지 다exempt- 검사 자체를 스킵 (healthz 같은 것)catch-all- flow schema에 안 걸린 것들의 마지막 방어선
여기서 핵심은 각 priority level이 독립적인 concurrency limit을 가진다는 거다. system이 아무리 폭주해도 leader-election이 굶지 않는다. 이게 "priority" 부분.
Seat이라는 추상화 - 왜 list 요청은 여러 자리를 먹는가
여기서부터 재밌어진다. APF는 요청이 얼마나 "무거운지"를 seat이라는 단위로 측정한다. 대부분의 요청은 seat 1개를 잡고 시작한다. 근데 list 요청은 다르다.
apiserver는 list가 들어오면 응답에 몇 개의 객체가 포함될지를 미리 추정한다. objectsPerSeat (기본 100) 로 나눠서 필요한 seat 수를 계산한다. 10만 개의 Pod을 리스트하는 요청은 seat을 1000개 (혹은 상한선까지) 잡는다. 그러면 다른 요청이 못 들어온다.
mutating 요청도 마찬가지다. 이 요청이 watch를 얼마나 트리거할지 — 즉 apiserver가 뒷단에서 얼마나 일해야 할지 — 를 계산해서 seat이 정해진다. watchesPerSeat 기본값이 있고 (구현체마다 조금씩 다르지만 대략 10 정도), 트리거될 watch가 많은 리소스일수록 무거워진다.
이 설계가 왜 중요하냐면, 예전 max-inflight 방식은 요청 수만 세었기 때문이다. --max-requests-inflight=400이면 400개 넘게 못 받는다. 그런데 그 중 하나가 100만 개짜리 list여도 그냥 1개로 취급했다. 그러니 apiserver 메모리는 터지는데 rate limit은 안 걸리는 이상한 상황이 났다. APF는 이걸 seat으로 정규화해서 해결한다.
큐와 shuffle sharding
concurrency limit에 도달하면 그 다음부터는 큐잉된다. 큐잉이 안 되는 priority level (queuingConfiguration이 없거나 exempt)은 즉시 429가 튀지만, 대부분은 큐잉을 쓴다.
여기서 흥미로운 게 shuffle sharding이다. 한 priority level에 여러 개의 큐가 있고, flow마다 그 중 몇 개의 큐에 요청을 뿌린다. 다시 말해 사용자 A의 요청은 항상 큐 3, 7, 12에 나눠 들어가고, 사용자 B는 큐 1, 5, 9에 들어간다. handSize로 조절한다.
왜 이렇게 하냐면 - 나쁜 flow 하나가 하나의 큐를 다 채워도 다른 flow에 미치는 영향을 확률적으로 최소화하기 위해서다. 큐 개수 (queues)와 handSize를 잘 조합하면 두 flow가 같은 큐 세트에 완전히 겹칠 확률이 매우 낮아진다. queues=64, handSize=8이면 두 flow가 완전히 겹칠 확률이 대략 1 / C(64, 8) 수준. 통계로 격리하는 셈이다.
큐에서 뽑을 때는 fair queuing 알고리즘 — 가상 시간 기반으로 어느 큐가 가장 적게 서비스받았는지 계산해서 다음 요청을 뽑는다. 이 부분은 Linux 트래픽 컨트롤의 SFQ 아이디어를 가져왔다.
Seat demand의 exponential smoothing
최근에 문서를 파다가 알게 된 건데, seat 수요 예측에 exponential smoothing이 들어간다. 계수가 0.977이라는 좀 이상한 숫자인데, 이건 5분 half-life를 만들기 위한 값이다. 즉, 5분 전의 부하는 절반 정도만 반영된다.
이게 왜 중요하냐면 - CA (Cluster Autoscaler) 같은 컴포넌트가 스파이크성으로 요청을 던지는데, 그때마다 seat 배정이 요동치면 다른 flow에 영향이 크다. 반대로 반응이 너무 느리면 정말 필요한 순간에 자원을 못 준다. 5분 정도의 완만한 이동평균이 실전에서 균형점이라고 판단한 듯하다.
이걸 안다는 게 실무에서 어떤 의미냐면 - APF 튜닝을 할 때 즉시 반영을 기대하면 안 된다. 설정을 바꾼 뒤 최소 10분 정도는 지켜봐야 실제 거동이 보인다.
실무에서 뭘 봐야 하나
APF 관련 메트릭이 apiserver에서 여러 개 나오는데 실전에서 봐야 할 건 이 정도:
apiserver_flowcontrol_rejected_requests_total{priority_level="...", flow_schema="...", reason="..."}
apiserver_flowcontrol_current_inqueue_requests{priority_level="..."}
apiserver_flowcontrol_request_wait_duration_seconds{...}
apiserver_flowcontrol_dispatched_requests_total{...}
apiserver_flowcontrol_priority_level_seat_utilization{...}
Grafana에서 seat_utilization이 지속적으로 1.0에 붙어 있으면 그 priority level은 포화 상태다. request_wait_duration의 P99가 몇 초씩 나오면 큐가 밀려 있다는 뜻. rejected_requests의 reason별 분포를 보면 큐가 가득 차서 (queue-full) 튕긴 건지, 시간 초과로 (time-out) 튕긴 건지, 아니면 큐잉 자체가 안 되는 priority여서 (concurrency-limit) 즉시 거부된 건지가 나온다.
우리 팀에서는 kube-controller-manager가 대량의 이벤트를 만들 때 workload-high가 포화되는 걸 자주 봤다. 근데 이걸 그냥 concurrency를 올리는 걸로 해결하면 안 된다. 컨트롤 플레인 노드 리소스가 한정된 상태에서 concurrency만 늘리면 CPU/메모리 파열이 온다. 오히려 어떤 컨트롤러가 왜 그렇게 많이 요청하는지를 봐야 한다 - 대부분은 informer 캐시 설정이 잘못됐거나, resync period가 짧거나, watch가 재연결을 반복하는 게 원인이다.
커스텀 FlowSchema를 만들 때 함정
내부 오퍼레이터를 위해 별도 FlowSchema를 만드는 건 나쁜 선택은 아니다. 근데 몇 가지 주의점이 있다.
첫째, matching precedence를 잘 정해야 한다. FlowSchema는 matchingPrecedence (숫자가 작을수록 먼저) 순으로 평가되는데, 여기서 실수가 흔하다. 기본 FlowSchema들은 대개 1000~9900 사이의 값을 쓰니까, 커스텀은 그보다 낮게 (더 우선하게) 두거나, 아니면 명확히 뒤로 밀어야 한다.
둘째, distinguisherMethod를 뭘로 할지. ByUser가 기본에 가깝지만, 오퍼레이터가 하나의 서비스 어카운트로 여러 워커에서 요청을 보내면 다 같은 flow로 묶여서 shuffle sharding의 이점을 못 본다. 이 경우 ByNamespace로 나눠주는 게 나을 수도 있다.
셋째, 큐 없는 (type: Reject) priority level을 쉽게 만들지 마라. 큐잉 없이 즉시 429 뱉는 게 필요한 경우도 있긴 한데 (예: probe 같은 시간 제약이 강한 요청), 잘못 쓰면 정상 트래픽도 즉시 튕겨나간다.
좀 더 파보고 싶다면
Kubernetes enhancements 리포의 KEP-1040 문서가 원본이다. 왜 이렇게 설계됐는지, 어떤 대안을 검토했는지가 다 나와 있다. 특히 shuffle sharding 파트의 확률 계산은 한번 읽어볼 만하다.
그리고 apiserver의 staging/src/k8s.io/apiserver/pkg/util/flowcontrol/ 아래 코드를 보면 실제 구현이 있다. fairqueuing 서브 패키지의 queueset.go에 큐 선택 로직이 다 들어 있어서, virtual time이 어떻게 갱신되는지, seat이 어떻게 계산되는지 코드로 확인할 수 있다. 문서만 봐서는 이해가 애매한 부분이 코드를 보면 명확해진다.
APF는 처음 접하면 개념이 많아 보이는데, 결국 "요청을 분류하고 (FlowSchema), 격리해서 자원을 배분하고 (PriorityLevel), 공평하게 서비스한다 (shuffle sharding + fair queuing)" 이 세 축으로 정리된다. 여기까지 이해하고 나면 apiserver가 왜 저런 결정을 내리는지 감이 잡힌다.