왜 APF가 필요한가
API 서버는 클러스터의 단일 관문이다. 특정 컨트롤러가 폭주하거나, 노드가 대량으로 재기동하면서 kubelet들이 동시에 LIST 요청을 쏟아내면, API 서버의 처리 능력이 소수 요청에 독점된다. 그 결과 kubectl, 스케줄러, 리더 선출 같은 필수 트래픽까지 지연되어 클러스터 전체가 흔들린다.
과거의 --max-requests-inflight 단일 카운터는 "누구의 요청인가"를 구분하지 못했다. APF(API Priority and Fairness)는 요청을 FlowSchema로 분류하고 PriorityLevelConfiguration 단위로 격리해, 한 주체의 폭주가 다른 주체를 굶기지 않도록 한다. v1.29부터 flowcontrol.apiserver.k8s.io/v1로 안정화됐다.
동작 모델: 어떻게 격리하나
APF는 두 단계로 작동한다. FlowSchema가 요청의 주체(user, serviceaccount)와 리소스를 매칭해 우선순위 레벨과 distinguisher(구분자)를 결정한다. 그다음 PriorityLevel이 동시 실행 한도(concurrency share)를 나눠 갖고, 그 안에서 flow별로 공정 큐잉(shuffle sharding)을 수행한다.
핵심은 동시성 총량은 유지하되, 레벨 간 그리고 flow 간에 나눈다는 점이다. 한 서비스어카운트가 큐를 가득 채워도 다른 flow의 큐는 별도로 보장된다.
현재 상태 진단
튜닝 전에 어디서 병목이 나는지부터 본다. 메트릭 세 개면 충분하다.
kubectl get --raw '/metrics' | grep -E \
'apiserver_flowcontrol_(rejected_requests|current_inflight_requests|current_inflight_seats)_total|request_wait_duration'
# 레벨별 대기·거부 관찰 (Prometheus)
# 거부가 쌓이는 레벨을 찾는다
sum by (priority_level, reason) (
rate(apiserver_flowcontrol_rejected_requests_total[5m])
)
rejected_requests_total이 특정 레벨에서 증가하면 그 레벨의 큐가 넘친 것이고, request_wait_duration이 길면 seat 부족이다. 이 신호에 따라 대응이 갈린다.
내장 레벨과 커스텀 레벨
기본 제공 레벨을 이해하면 대부분 문제가 풀린다.
| 레벨 | 용도 | 특징 |
|---|---|---|
| system | system:nodes(kubelet) | 노드 트래픽 격리 |
| leader-election | 리더 선출 | 낮은 지연 보장 |
| workload-high | 핵심 컨트롤러 | 스케줄러 등 |
| workload-low | 일반 컨트롤러 | 기본 배치 |
| global-default | 미분류 요청 | kubectl 등 |
| catch-all / exempt | 최후/면제 | exempt는 무제한 |
특정 배치 잡이나 오퍼레이터가 global-default를 잠식한다면, 전용 레벨로 분리하는 것이 정석이다.
실전: 폭주 오퍼레이터 격리
아래는 특정 서비스어카운트를 전용 저우선 레벨에 묶어, 그 폭주가 다른 트래픽에 번지지 않게 하는 설정이다. lendablePercent로 유휴 시 seat를 빌려주되, 압박 시 회수하게 한다.
apiVersion: flowcontrol.apiserver.k8s.io/v1
kind: PriorityLevelConfiguration
metadata:
name: noisy-operator
spec:
type: Limited
limited:
nominalConcurrencyShares: 10
lendablePercent: 50
limitResponse:
type: Queue
queuing:
queues: 64
queueLengthLimit: 50
handSize: 6
---
apiVersion: flowcontrol.apiserver.k8s.io/v1
kind: FlowSchema
metadata:
name: noisy-operator
spec:
priorityLevelConfiguration:
name: noisy-operator
matchingPrecedence: 500
distinguisherMethod:
type: ByUser
rules:
- subjects:
- kind: ServiceAccount
serviceAccount:
name: sync-controller
namespace: platform
resourceRules:
- verbs: ["list", "watch", "get"]
apiGroups: ["*"]
resources: ["*"]
matchingPrecedence는 작을수록 먼저 매칭된다(1~10000). 내장 스키마와 충돌하지 않도록 중간대 값을 쓴다. handSize와 queues는 shuffle sharding의 충돌 확률을 결정하므로, flow가 많으면 queues를 키운다.
주의점과 함정
몇 가지를 반드시 지킨다.
- exempt 남용 금지. exempt 레벨은 동시성 제한을 받지 않는다. 여기에 오퍼레이터를 넣으면 APF를 끄는 것과 같다.
- WATCH는 seat가 다르다. long-running watch는 처리 중 seat를 오래 점유하므로, list/watch 폭주는 seat 고갈로 나타난다. concurrency만 늘리면 API 서버 메모리가 압박받는다.
- 총량은 유한하다. 한 레벨의 share를 키우면 다른 레벨이 줄어든다.
nominalConcurrencyShares는 절대값이 아니라 비율이다. - 거부는 429다. APF가 큐를 넘겨 거부하면 클라이언트는
429 Too Many Requests와Retry-After를 받는다. 클라이언트가 지수 백오프를 안 하면 폭주가 재발한다.
검증과 롤백
설정 적용 후에는 반드시 대상 flow가 의도한 레벨로 갔는지 확인한다.
# 어떤 FlowSchema가 매칭되는지 응답 헤더로 확인
kubectl get --raw '/apis/apps/v1/deployments' -v=8 2>&1 \
| grep -i 'X-Kubernetes-PF'
# X-Kubernetes-PF-FlowSchema-UID / PriorityLevel-UID 가 찍힌다
# 거부율이 내려갔는지 재측정
kubectl get --raw '/metrics' \
| grep noisy-operator
APF 오브젝트는 즉시 반영되고 API 서버 재기동이 필요 없다. 문제가 생기면 커스텀 FlowSchema/PriorityLevel만 삭제하면 트래픽이 내장 레벨로 되돌아간다. 프로덕션에서는 matchingPrecedence를 넉넉히 두고 한 레벨씩 점진 적용하며 메트릭을 지켜보는 편이 안전하다.