콘텐츠로 이동

테넌트 간 공정 큐잉

공정 큐잉이 구현되었습니다 (이슈 #2397, 에픽 #2362). 기본값은 꺼짐이며 선택적으로 활성화합니다. rate_limiting.fair_queuing 블록이 없거나 enabled: false이면, 소진된 차원은 이전과 똑같이 즉시 429로 거부합니다.

바뀌는 동작

기능이 꺼져 있으면 거부는 선착순입니다. 전역 차원은 버킷이 비면 거부하고, per_backend는 선택 시점 필터이며(빈 백엔드는 후보 집합에서 제외되고, 모든 후보가 소진되었을 때만 429), 속도 제한 미들웨어에는 요청을 보류하거나 스케줄하는 구조가 없습니다(src/http/middleware/rate_limit_v2/store.rs는 버킷만 유지합니다). 포화 상태에서는 한 무거운 테넌트의 홍수가 풀린 용량을 다른 테넌트보다 먼저 가져갑니다.

기능을 켜면, 테넌트 식별자를 가진 수락 차원(per_api_key, per_user, per_team, per_model)이 거부한 요청은 즉시 응답하지 않고 해당 테넌트의 경계가 있는 큐에 보류되며, 큐는 가중 공정 스케줄링으로 처리됩니다. 차례가 오면 요청은 리미터를 다시 검사합니다. 검사를 통과하면 수락되고, 차원이 여전히 거부하면 자기 예산 안에서 다시 큐에 들어갑니다.

큐는 예산 안에 수락할 수 없는 요청을 디스패치하지 않으며, 대기 시간은 요청 자체의 전체 타임아웃에 포함됩니다. 실효 수락 예산은 max_wait_ms와 설정된 요청 전체 타임아웃(timeouts.request.*.total) 중 작은 값이므로, 큐에 있던 요청은 타임아웃 이후에 처리되는 대신 거부됩니다.

어떤 거부가 큐에 들어가는가

  • 큐에 들어감: 인증된 마운트의 식별자 차원이 내리는 429 거부입니다. 즉 limit_type이 api_key, user, team, model인 경우입니다.
  • 즉시 거부, 동작 유지: 전역 및 클라이언트별 차원의 거부입니다. 해당 마운트는 인증 전에 실행되어 아직 테넌트 식별자가 없습니다. fail-closed 스토어 장애의 503(호출자의 쿼터가 아님)과 기능이 꺼진 라우터의 모든 거부도 즉시 처리됩니다.
  • 영향 없음: per_backend 차원입니다. 이 차원은 수락 이후 디스패치 시점에 과금되는 선택 시점 후보 필터입니다.

user, team, key 식별자를 하나도 갖지 않는 요청은 테넌트에 귀속될 수 없으므로 큐에 들어가지 않고 기존처럼 즉시 429를 받습니다.

테넌트 식별자와 가중치

tenant_dimension은 큐와 가중치의 키가 되는 식별자를 지정합니다. user(기본값), api_key, team 중 하나입니다. 식별자는 리미터가 이미 사용하는 해석 규칙(이슈 #2381)을 그대로 따릅니다. 해시된 사용자 ID, 해시된 팀 ID(팀이 없는 Hub 키가 갖는 플레이스홀더는 식별자가 아닙니다), 또는 키의 안정적이고 비밀 정보가 없는 식별자입니다. 설정된 식별자가 없으면 해석기는 user, team, api_key 순서로 대체하므로, 같은 요청은 항상 같은 큐에 들어갑니다.

weights는 테넌트에 수락 시도의 상대적 몫을 부여하고, default_weight(기본값 1)는 항목이 없는 모든 테넌트에 적용됩니다. 가중치 3인 테넌트는 가중치 1인 테넌트보다 세 배 자주 처리됩니다. 각 항목은 원본 식별자(사용자 ID, 팀 ID 또는 API 키이며 ${ENV} 보간이 적용됩니다)를 지정하고 가중치를 맞추는 데에만 사용합니다. 큐 키 자체는 해시이며 원본 값이 아닙니다.

스케줄링은 다음 대기자가 준비된 테넌트를 대상으로 하는 부드러운 가중 라운드 로빈입니다(src/http/middleware/rate_limit_v2/fair_queue.rs). 스케줄러는 동시에 최대 max_in_flight개의 수락 시도만 허용하므로(기본값 8), 공정성이 수락 경로 전체를 직렬화하지 않습니다.

경계

모든 수준에 상한이 있으므로, 과부하가 수락 경로를 무한 버퍼로 바꾸지 못합니다.

키 기본값 경계
max_queue_depth_per_tenant 32 테넌트 한 곳에 대기하는 요청 수
max_total_queued 4096 프로세스 전체에서 대기하는 요청 수
max_tenants 1024 동시에 추적하는 테넌트 큐 수
max_in_flight 8 스케줄러가 동시에 허용하는 수락 시도 수
max_wait_ms 5000 요청 하나가 큐에서 보낼 수 있는 최대 시간

경계에 맞지 않는 요청은 받았을 위치와 함께 즉시 거부되고, Retry-After가 남은 예산에 맞지 않는 요청도 큐에 넣지 않고 거부합니다.

위치와 예상 대기 시간

큐에 들어간 요청은 자신의 큐 위치와 대기 시간을 알 수 있습니다. 대기 후 거부된 요청(또는 큐 진입 시점에 거부된 요청)은 x-fair-queue-position과 x-fair-queue-wait-ms 헤더를 받고, error.details.fair_queue에 테넌트 차원, 사유(queue_full, tenant_queue_full, tenant_table_full, wait_exceeds_budget, deadline_exceeded), 위치, 대기 시간이 담깁니다. 대기 후 수락된 요청은 응답에 x-fair-queue-position, x-fair-queue-wait-ms, x-fair-queue-estimated-wait-ms 헤더를 받습니다. 예상 대기 시간은 큐 진입 시점에 리미터의 Retry-After와 측정된 처리 간격을 더해 계산합니다.

메트릭

  • rate_limit_fair_queue_depth: 현재 큐에 보류된 요청 수이며, 큐 상태가 바뀔 때마다 갱신됩니다.
  • rate_limit_fair_queue_tenants: 스케줄러가 현재 추적하는 테넌트 큐 수입니다.
  • rate_limit_fair_queue_wait_seconds{tenant_dimension}: 수락 시도가 허용되기까지 요청이 큐에서 보낸 시간입니다.
  • rate_limit_fair_queue_grants_total{tenant_dimension}: 큐에서 허용된 수락 시도 수입니다.
  • rate_limit_fair_queue_refused_total{tenant_dimension, reason}: 큐에 들어가지 못하거나 큐에서 거부된 요청 수입니다.

지연 수락과의 합성

두 기능은 서로 겹치지 않는 거부 부류를 다루므로, 한 설정에서 함께 켤 수 있습니다.

  • 공정 큐잉은 수락 시점의 요청 수 거부(api_key, user, team, model)를 다룹니다.
  • rate_limiting.deferred_admission은 토큰 예산 거부를 다룹니다. 이 예약은 요청 수 검사를 통과한 뒤에만 실행됩니다.

따라서 요청은 먼저 공정 큐에서 대기한 뒤, 요청 수 차원에서 수락되면 토큰 예산에서 지연될 수 있습니다. 두 대기는 요청의 전체 타임아웃을 넘겨 누적되지 않습니다. 공정 큐잉은 대기를 max_wait_ms와 요청 타임아웃 중 작은 값으로 제한하고, 남은 예산에 맞지 않는 토큰 예산 거부는 크기 인식 Retry-After와 함께 응답합니다. 두 기능을 함께 켠 설정은 유효하며, 어느 쪽도 다른 쪽의 기본 꺼짐 동작을 바꾸지 않습니다.

멀티 레플리카 범위

큐는 지연 수락의 보류 카운트와 마찬가지로 라우터 프로세스마다 존재합니다. 각 레플리카가 자기 테넌트를 스케줄링하므로, 공정성은 레플리카 안에서는 정확하고 플릿 전체에서는 근사적입니다. N개 레플리카에서 테넌트의 몫은 요청이 레플리카에 어떻게 분산되는지에 따라 달라집니다. 공유 Redis 큐는 검토했지만 이번 범위에서는 제외했으며, 남은 질문은 아래에 기록합니다.

열린 질문

  • 멀티 레플리카 공정성을 위해 큐를 리미터의 공유 Redis 저장소에 두어야 하는지, 그리고 큐 위치를 레플리카 간에 어떻게 보고할지.
  • 테넌트별 가중치를 로컬 weights 목록뿐 아니라 Hub 티어에서도 가져와야 하는지(티어 우선순위는 이슈 #2406).