왜 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 부족이다. 이 신호에 따라 대응이 갈린다.

내장 레벨과 커스텀 레벨

기본 제공 레벨을 이해하면 대부분 문제가 풀린다.

레벨용도특징
systemsystem: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를 넉넉히 두고 한 레벨씩 점진 적용하며 메트릭을 지켜보는 편이 안전하다.