LabHub
배우기 러닝패스 코스

쿠버네티스 운영 실무 · API 서버가 429 를 돌려준다 · 이론

429 를 누가 받을지 우리가 정한다

LabHub 에서 이어서 보기

한 줄 요약

API 서버는 모든 요청을 FlowSchema 로 분류해 PriorityLevelConfiguration 이라는 칸에 넣고, 칸마다
동시 실행 수를 나눠 준다. 폭주하는 클라이언트가 생겼을 때 누가 굶을지를 이 두 오브젝트로 미리
설계해 둘 수 있다.

왜 이 장치가 필요했나

예전 kube-apiserver 에는 --max-requests-inflight--max-mutating-requests-inflight 두 개의
손잡이밖에 없었다. 동시에 처리할 요청 수를 통째로 제한하는 것이다. 이 방식의 문제는 분명하다 —
누가 그 자리를 차지했는지 구분하지 않는다. 잘못 만든 컨트롤러 하나가 초당 수천 번 목록을 조회하면
그 자리를 다 먹고, 그 뒤에 온 kubelet 의 노드 상태 갱신도, 운영자의 kubectl get pods 도 똑같이
밀린다. 중요한 요청과 시끄러운 요청을 가를 방법이 없었다.

API Priority and Fairness 는 이 상황을 두 단계로 나눈다. 먼저 요청을 분류하고, 분류된 칸마다
따로 폭을 준다. 게다가 짧은 폭주는 거절하지 않고 잠깐 줄을 세운다. 1.29 에서 안정 기능이 됐고
API 그룹은 flowcontrol.apiserver.k8s.io/v1 이다.

어떻게 동작하나

FlowSchema 가 분류표다. 들어오는 요청은 matchingPrecedence숫자로 작은 것부터 차례로
대조되고 처음 맞는 하나에서 멈춘다. 이름과 달리 값이 작을수록 먼저라는 점이 늘 헷갈리는 자리다.
값이 완전히 같은 스키마가 둘이면 이름을 사전순으로 비교해 작은 쪽이 이기는데, 공식 문서는 그런
상황에 기대지 말고 값을 겹치지 말라고 권한다.

규칙은 subjects(누가)와 resourceRules/nonResourceRules(무엇을)의 짝으로 쓴다. 각 필드에 *
쓰면 그 항목은 아예 따지지 않는다는 뜻이다. 그리고 distinguisherMethod 가 한 칸 안에서 요청을
흐름(flow) 으로 더 잘게 나눈다. ByUser 면 사용자 하나가 다른 사용자의 몫을 굶길 수 없고,
ByNamespace 면 네임스페이스 단위로 같은 보호가 걸린다. 비워 두면 그 스키마에 걸린 모든 요청이
하나의 흐름으로 취급된다.

PriorityLevelConfiguration 이 칸이다. typeExempt 면 심사 자체를 받지 않고 즉시 처리된다.
Limited 면 동시 실행 수를 나눠 받는데, 절대 좌석 수가 아니라 nominalConcurrencyShares 라는
지분으로 적는다. 서버의 총 동시 실행 수를 바꾸면 모든 칸이 같은 비율로 함께 늘고 줄게 하려는
설계다. 여기에 lendablePercent(남는 좌석을 빌려 줄 수 있는 비율)와 borrowingLimitPercent(빌려 올 수
있는 상한)가 더해져, 한 칸이 놀고 있을 때 그 좌석이 다른 칸으로 흘러간다.

넘쳤을 때의 행동은 limitResponse 가 정한다. Reject 면 즉시 429, Queue 면 줄을 세운다. 줄의
모양은 queues·handSize·queueLengthLimit 세 값이 정하고, 이 셋이 셔플 샤딩이라는 기법으로
"코끼리가 쥐를 밟을 확률" 을 조정한다. 한 흐름이 쌓을 수 있는 요청의 최대치는
handSize 곱하기 queueLengthLimit 다.

기본으로 들어 있는 것들도 알아 둘 값어치가 있다. 필수(mandatory) 오브젝트 넷 중 exempt 스키마는
system:masters 그룹의 요청을 심사에서 빼고, catch-all 은 아무것도 맞지 않은 요청이 갈 곳을
보장한다. 그 밖에 권장(suggested) 설정으로 system-leader-election·system-nodes·
kube-controller-manager·service-accounts 같은 스키마들이 들어온다.

관측은 apiserver_flowcontrol_ 로 시작하는 지표들이 맡는다. 거절 수는
apiserver_flowcontrol_rejected_requests_total 이고 reason 라벨이 queue-full·
concurrency-limit·time-out·cancelled 중 하나로 이유를 알려 준다. 칸마다 실제로 몇 좌석을
받았는지는 apiserver_flowcontrol_nominal_limit_seats 에 그대로 나온다.

현장에서 만나는 모습

가장 흔한 것은 오퍼레이터 하나의 폭주다. 캐시를 쓰지 않고 매번 전체 목록을 조회하는 컨트롤러가
배포되면, 같은 칸을 쓰는 다른 서비스 어카운트들이 함께 느려진다. 이때 해야 할 일은 그 어카운트만
좁은 칸으로 보내는 FlowSchema 를 하나 추가하는 것이다. 그 순간 피해가 그 칸 안에 갇힌다.

두 번째는 exempt 남용이다. 429 가 뜨니까 급한 마음에 그 워크로드를 exempt 로 보낸다. 증상은
사라지지만 이제 그 클라이언트는 서버를 지키는 장치를 통째로 우회한다. 다음 폭주 때는 API 서버가
같이 넘어간다. 그래서 exempt 를 가리키는 스키마 목록은 사람이 감시해야 하는 짧은 목록이다.

세 번째는 조용히 죽은 스키마다. 가리키는 우선순위 등급 이름을 한 글자 틀리면 오류가 나지 않고
status 에 Dangling 조건만 True 로 남는다. 그 스키마는 없는 것처럼 동작하고, 격리하려던 워크로드는
계속 기본 칸에서 남들을 굶긴다.

이 실습 환경의 한계

이 클러스터에는 실제로 폭주시킬 워크로드가 없다 — kwok 파드는 컨테이너가 아니라서 그 안에서
클라이언트를 돌릴 수 없다. 그래서 429 를 진짜로 만들어 내는 실험은 하지 않는다. 대신 분류가
어떻게 되는지는 실측한다. kubectl --v=8 이 찍어 주는 X-Kubernetes-Pf-Flowschema-Uid 헤더에
그 요청이 실제로 걸린 스키마의 UID 가 들어 있고, 분류는 인가보다 먼저 일어나므로 권한 없는
어카운트를 흉내 낸 요청이 403 으로 끝나도 헤더는 그대로 붙는다. 좌석 수도 실제 지표로 확인할 수 있다.

다음 실습에서 할 것

기본 분류표와 칸 목록을 우선순위 순으로 읽어 저장하는 것에서 시작한다. 그다음 서비스 어카운트를
흉내 내어 요청을 보내고 응답 헤더로 "지금은 어느 칸으로 가는가" 를 실측한다. 폭주하는 오퍼레이터를
위한 좁은 칸과 그리로 보내는 규칙을 만들고, 분류가 바뀌었는지 같은 방법으로 증명한다. 이어서 두
규칙이 한 요청에 걸릴 때의 승패를 실측하고, exempt 를 가리키는 스키마를 감시하는 스크립트를 만든 뒤,
새 칸이 좌석을 실제로 얼마나 받았는지 지표에서 확인한다.

참고 문서: