속도 제한 아키텍처¶
라우터는 남용을 방지하고 클라이언트 간 공정한 리소스 할당을 보장하기 위해 다층 속도 제한을 구현합니다.
아키텍처¶
속도 제한은 이중 윈도우 방식을 사용하여 HTTP 계층에서 구현됩니다:
pub struct ModelEndpointRateLimiter {
cache: Arc<DashMap<String, (Instant, u32, u32)>>, // (window_start, request_count, burst_count)
window_duration: Duration, // 지속 제한을 위한 60초
max_requests_per_window: u32, // 분당 100개 요청
burst_limit: u32, // 최대 20개 요청
burst_window: Duration, // 버스트 감지를 위한 5초
}
클라이언트 식별¶
시스템은 우선순위 기반 클라이언트 식별 전략을 사용합니다:
- API 키 (선호): Bearer 토큰의 처음 16자 사용
- IP 주소 (폴백): 다음 순서로 헤더에서 추출:
X-Forwarded-For(프록시/로드 밸런서 환경용)X-Real-IP(대체 헤더)- IP를 확인할 수 없는 경우 "unknown"으로 폴백
속도 제한 규칙¶
각 클라이언트는 다음과 같이 독립적으로 속도 제한됩니다:
- 지속 제한: 분당 100개 요청 (슬라이딩 윈도우)
- 버스트 보호: 5초 윈도우 내 최대 20개 요청
- 클라이언트별 격리: 각 API 키 또는 IP는 별도의 쿼터를 가짐
설정¶
rate_limiting:
enabled: true
# 전역 기본값
default:
requests_per_minute: 100
burst_limit: 20
burst_window_seconds: 5
# 엔드포인트별 오버라이드
endpoints:
/v1/chat/completions:
requests_per_minute: 60
burst_limit: 10
/v1/models:
requests_per_minute: 300
burst_limit: 50
# 클라이언트별 오버라이드 (API 키 접두사로)
clients:
sk-premium-*:
requests_per_minute: 500
burst_limit: 100
sk-free-*:
requests_per_minute: 20
burst_limit: 5
캐시 관리¶
속도 제한기에는 자동 정리 메커니즘이 포함됩니다:
- 메모리 효율성: 락프리 동시 접근을 위한 DashMap
- 자동 정리: 캐시 > 1000 항목 시 만료된 항목 제거
- TTL 차별화:
- 빈 모델 응답: 5초 캐시 TTL (DoS 방지)
- 일반 응답: 60초 캐시 TTL
보안 고려 사항¶
속도 제한 시스템은 여러 보안 문제를 해결합니다:
- DoS 방지: 빈 응답에 대한 짧은 TTL로 캐시 포이즈닝 방지
- 공정한 리소스 할당: 클라이언트별 제한으로 독점 방지
- 버스트 보호: 이중 윈도우 방식으로 지속적 공격과 버스트 공격 모두 포착
- 클라이언트 스푸핑 완화: IP 주소보다 API 키 우선
메트릭 및 모니터링¶
속도 제한은 메트릭 시스템과 통합됩니다:
pub struct ModelMetrics {
pub rate_limit_violations: AtomicU64, // 거부된 요청 추적
pub empty_responses_returned: AtomicU64, // 빈 응답 비율 모니터링
pub transient_errors: AtomicU64, // 네트워크/타임아웃 실패
pub permanent_errors: AtomicU64, // 인증/설정 실패
}
Prometheus 메트릭¶
# 속도 제한 위반
rate_limit_violations_total{client_id="sk-abc...", endpoint="/v1/chat/completions"} 15
# 현재 요청 수
rate_limit_current_requests{client_id="sk-abc...", window="minute"} 45
rate_limit_current_requests{client_id="sk-abc...", window="burst"} 8
# 속도 제한 상태
rate_limit_remaining{client_id="sk-abc...", window="minute"} 55
응답 헤더¶
속도 제한 정보는 응답 헤더에 포함됩니다:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 55
X-RateLimit-Reset: 1699574460
X-RateLimit-Burst-Limit: 20
X-RateLimit-Burst-Remaining: 12
오류 응답¶
속도 제한이 적용되면 API는 다음을 반환합니다:
{
"error": {
"message": "속도 제한 초과. 45초 후에 다시 시도하세요.",
"type": "rate_limit_exceeded",
"param": null,
"code": "rate_limit_exceeded",
"details": {
"limit": 100,
"remaining": 0,
"reset_at": "2024-01-15T10:30:00Z",
"retry_after": 45
}
}
}
구현 세부 사항¶
속도 제한기는 싱글톤 서비스로 구현됩니다:
static MODEL_RATE_LIMITER: once_cell::sync::Lazy<ModelEndpointRateLimiter> =
once_cell::sync::Lazy::new(ModelEndpointRateLimiter::new);
이렇게 하면 단일 속도 제한 적용 지점, 모든 요청 핸들러에서 일관된 상태, 최소 메모리 오버헤드를 확보할 수 있습니다.
멀티 테넌트 한도 설계¶
현재 구조에서는 한 테넌트(사용자, 팀, 유출된 키)가 공유 백엔드 용량을 독점할 수 있습니다. 한도 차원이 per-client, per-backend, global, per-API-key, per-model뿐이고, per-IP 버킷은 하나의 NAT 뒤의 모든 사용자가 공유하기 때문입니다. 멀티 테넌트 한도 에픽(#2362)은 다음 구성 요소로 단일 테넌트의 점유를 제한하며, 기존 rate_limiting 설정과 함께 동작하도록 설계되었습니다.
참고: 허브에서 동기화된 API 키(
control-plane기능)에는 이미 키별rpm,input_tpm,output_tpm윈도가 있으며src/control_plane/enforcement.rs가 이를 적용합니다.control_plane.policy.minute_limits는 이 윈도에 선택적인 공유 Redis 카운터와 요청 시작 시점의 토큰 예약을 추가합니다(허브 티어 분당 한도 참고). 로컬rate_limiting리미터는 모든 키에 적용되는 자체 토큰 차원(아래)을 가지며 control-plane 예약 코드를 공유하지 않습니다. 허브에 바인딩된 키의 요청은 두 경로의 제한을 모두 받을 수 있습니다.
구성 요소¶
| 구성 요소 | 제한 대상 | 위치 |
|---|---|---|
| Per-user / per-team 차원 | 사용자·팀 식별자별 요청 수 | 출시됨: API 키의 사용자/조직 식별자로 키잉하는 rate_limiting.limits.per_user / per_team 블록 (이슈 #2381) |
| 동시 처리 상한 | 한 클라이언트가 동시에 유지하는 스트림 수 | 출시됨: per_api_key, per_user, per_team, per_client의 rate_limiting.limits.<dimension>.max_in_flight_requests; 스트림 종료 시 해제되는 가드를 가진 식별자별 카운터 (이슈 #2383) |
| 분당 토큰 예산 | 요청 수가 아닌 토큰 수 (모델/키별) | 출시됨: 수락 시점 차원마다 독립된 토큰 버킷을 두고 요청 시작 시점에 과금하고 완료 시점에 정산. 일 단위 두 번째 버킷도 함께 (tokens_per_day, 이슈 #2327) |
| 티어별 제한 재정의 | 허브 티어의 키에 더 크거나 더 좁은 키별 속도, 버스트, 분당 예산 | 출시됨: rate_limiting.tier_overrides.<tier_id>. 요청마다 허브에서 동기화된 키로 해석하며, 일치하는 등급이 없으면 전역 설정으로 대체합니다 (이슈 #2406) |
| 공정 큐잉 | 글로벌 한도 아래 테넌트 간 균등 서비스 | 출시됨: rate_limiting.fair_queuing으로 선택 활성화하는 테넌트별 경계 큐와 가중 공정 스케줄링 (이슈 #2397) |
| 티어 우선순위 | 상위 티어에 더 나은 서비스 | 키의 티어에 따른 가중 처리 순서 |
적용 순서¶
- Per-user / per-team 차원: 출시되었습니다.
RateLimits에 사용자·팀 블록을 추가하고, API 키가 해석한 식별자로 리미터를 키잉합니다 (#2381, #2426). - 동시 처리 상한: 출시되었습니다. 식별자별 카운터가 테넌트가 동시에 유지하는 스트림 수를 제한하고, 상한을 넘으면
429로 거부합니다 (이슈 #2383). - 토큰 예산: 출시되었습니다. 요청 시작 시점에 예약하고 완료 시점에 정산합니다 (#2395, #2419). 스트리밍 응답은 스트림이 정상 종료될 때 정산합니다 (#2420).
- 분산 상태 가시성: 공유 스토어의 저하 상태에
rate_limit_redis_unavailable_total{dimension, action}로 알림을 제공합니다. 이 카운터는 이제 존재합니다 (#2408). - 티어별 제한 재정의: 출시되었습니다. 키별 차원에 티어별 속도, 버스트, 분당 예산을 적용하며, 허브의 분당 윈도는 따로 집행됩니다 (#2406).
- 공정 큐잉과 티어 우선순위: 공정 큐잉은 테넌트별 경계 큐와 가중 공정 스케줄링으로 출시되었습니다 (#2397). 티어 우선순위는 남아 있습니다.
호출자 할당량 읽기¶
GET /v1/quota는 응답 헤더가 전달하는 한도를 요청 시점에 읽어 반환합니다. 별도의 리미터 마운트를 추가하지 않습니다. 신원 마운트는 CALLER_QUOTA_PATH에서 읽기 전용 분기를 타고, 허브 정책 경로는 admit_request를 건너뛰며, 두 경로 모두 항목을 만들지 않는 접근(entry 대신 get, Redis 버킷 스크립트의 비용 0 실행)으로 값을 읽습니다. services::key_budget::read가 장기 예산을 더하고, 핸들러가 세 읽기를 합칩니다.
읽기는 신원에 묶인 버킷을 차감하지 않고 윈도도 만들지 않으므로, per_api_key 또는 max_tokens_budget 한도에 이미 도달한 키도 자기 수준을 확인할 수 있습니다. 공유 global과 per_client 마운트는 이 경로를 /v1/models 비용인 0.1로 계속 차감하지만, 두 분기 모두 그 수준을 읽지 않습니다. 행의 scope는 공유 스토어가 응답했을 때만 fleet이고, 설정된 공유 스토어에 연결할 수 없으면 replica와 degraded: true입니다.
문서는 호출자 신원으로 제한됩니다. 공유 함대 수준, 배치 차원, 키·테넌트 식별자는 제외합니다. permissive 모드에서 미등록 호출자는 빈 문서를 받으므로, 이 엔드포인트는 기존 헤더 동작 밖의 키 유효성 신호를 추가하지 않습니다.
동시 처리 상한¶
max_in_flight_requests(이슈 #2383)는 수락 시점 테넌트 차원 per_api_key, per_user, per_team, per_client에서 동시에 상주하는 요청 수의 테넌트별 상한입니다. 기본값 0은 무제한입니다. 요청 수와 토큰 예산 검사를 모두 통과한 뒤 설정된 차원마다 슬롯 하나를 잡으므로 거부된 요청은 슬롯을 차지하지 않으며, 상한을 넘는 요청은 429, limit_type: "inflight", error.details.inflight_dimension, Retry-After: 1로 거부됩니다. 큐잉하지 않습니다.
슬롯은 응답 본문에 붙는 RAII 가드로, server.max_concurrent_requests와 같은 생명주기입니다. 스트림은 마지막 프레임까지 슬롯을 유지하고, 핸들러 실패와 끊긴 연결은 요청 future와 함께 슬롯을 해제합니다. 회계는 레플리카별이므로 N개 레플리카 함대는 인메모리 요청 버킷처럼 설정 상한의 최대 N배까지 허용합니다. 이 상한은 global, per_backend, per_model에서 무시되며(config validate 경고), 전체 또는 범위 지정 바이패스가 inflight 클래스를 면제할 수 있습니다. 메트릭: rate_limit_inflight_requests{dimension, tenant}(카운트가 0이 되면 제거)와 rate_limit_inflight_refusals_total{dimension, tenant}.
토큰 예산 한도¶
요청 수 차원은 경로별 고정 비용을 청구합니다 (/v1/chat/completions는 1 토큰). 그래서 수 분간 지속되는 20만 토큰 생성이 10 토큰 요청과 같은 한도 점유율을 차지하고 요청 시작이 성공하는 즉시 그 점유율을 반납합니다. tokens_per_minute(이슈 #2395, #2419, 에픽 #2362)는 대신 토큰을 미터링하며 모든 수락 시점 차원(per_api_key, per_user, per_team, per_client, per_model, global)에 적용됩니다. tokens_per_day(이슈 #2327)는 같은 예약 수명 주기를 쓰는 차원별 두 번째 독립 버킷을 분 버킷 옆에 추가합니다. 용량은 속도의 1일치이고, 초당 tokens_per_day / 86400 토큰으로 누출 버킷 방식(캘린더 데이 리셋이 아님)으로 보충되며, 요청은 설정된 모든 차원의 두 예산을 모두 감당해야 합니다.
참고: 허브에서 동기화된 키는 자체
input_tpm,output_tpm윈도와control_plane.policy.minute_limits의 공유 카운터를 유지합니다(허브 티어 분당 한도 참고). 로컬 차원은 그 경로와 독립적이며 모든 키에 적용됩니다. 코드는src/http/middleware/rate_limit_v2/token_budget.rs,token_reservation.rs,store.rs에 있습니다.
예약 수명 주기¶
- 수락: 디스패치 전에
입력 추정치 + 출력 한도를 설정된 모든 차원에 예약하며when.input_tokens추정기를 재사용합니다. 모든 차원을 식별 계층에서 한 번에 예약하므로 요청은 차원마다 정확히 한 번 과금됩니다. - 완료: 예약을 백엔드가 보고한 사용량으로 대체합니다. 남은 예약은 환급하고 초과분은 추가로 과금하며 실패, 사용량 블록 누락, 클라이언트 취소는 마지막 핸들이 버려질 때 예약 전체를 해제합니다.
- 스트리밍: 본문이 끝날 때까지 예약을 유지해, 긴 생성이 진행 중에 점유율을 반납하지 않도록 합니다. 정상 종료된 스트림은 확정된 사용량으로 정산하고, 중간에 끊긴 스트림은 예약 전체를 해제합니다 (#2420).
- 캐시 적중과 재시도: 응답 캐시 적중은 아무것도 생성하지 않으므로 예약을 해제합니다. 재시도와 fallback 홉은 예약을 잡은 요청 안에서 일어나므로 예약 하나가 모든 시도를 덮고 최종 보고 사용량으로 한 번 정산합니다.
예약별 만료는 채택하지 않았습니다. 1분보다 오래 걸리는 정상 생성의 예약까지 만료시키기 때문입니다.
스토리지¶
토큰 버킷은 요청 수 버킷과 마찬가지로 Redis 기반이며 크로스 레플리카 배포가 하나의 쿼터를 공유합니다. Redis 장애 시 동작은 rate_limiting.redis.fallback_to_memory를 따르고 rate_limit_redis_unavailable_total{dimension, action}으로 집계됩니다. 분 버킷은 token_budget 키 네임스페이스 아래에, 일일 버킷은 token_budget_day(#2327) 아래에 두어 두 버킷이 서로를 쓰지 않게 합니다. 일일 버킷 키는 TTL을 최소 1일로 올립니다. 더 짧은 TTL로 만료된 키는 하루치 허용량을 새로 내주게 되기 때문입니다. 각 보류 항목은 자기를 처리한 백엔드를 기록하므로, 장애 중에 이 레플리카의 인메모리 버킷에서 잡은 예약은 그 버킷으로 정산하거나 해제하며 애초에 잡은 적 없는 Redis 버킷에는 손대지 않습니다.