Throttling
- 출처: https://learn.microsoft.com/en-us/azure/architecture/patterns/throttling
- 개요
어플리케이션 인스턴스, 개인 테넌트나 전체 서비스에 의해 사용되는 자원의 소비량을 제어한다. 리소스에 로드가 많이 걸리는 상황에서도 시스템이 계속해서 동작하고 서비스 수준 계약을 충족하도록 해준다.
- Context and problem
클라우드 어플리케이션에 걸리는 부하는 활성 사용자수나 사용자들이 수행하는 활동 유형에 기반하여 시간이 지남에 따라 다르다. 예를 들어 사용자들은 업무 시간에 더 활동적이거나 시스템은 월말마다 비용이 큰 분석을 수행할 수 있다. 이런 활동들은 갑자기 일어나서 예상치 못한 시점에 갑자기 발생할수도 있다. 만약 시스템에서 수행해야할 작업이 자원이 사용가능한 용량보다 크다면 성능이 저하되거나 실패할수도 있다. 만약 시스템이 서비스 수준 계약을 만족해야 한다면 이런 실패는 단순히 불편함만 초래하는 수준이 아니라 일어나서는 안되는 상황일수도 있다.
클라우드 환경에서 어플리케이션의 비즈니스 목적에 따라 다양한 부하 상황을 다루는 많은 전략이 존재한다. 그중 한 가지는 사용자 필요에 따라 미리 할당된 자원에 맞추어 autoscaling 하는것이 대표적이다. Autoscaling은 운영 비용을 최적화하는 동시에 사용자 요구를 지속적으로 만족시킬 수 있는 잠재성이 있다. 하지만 autoscailing으로 추가적인 자원을 생성을 요청할 때, 요청시점에 즉시 자원이 생성되지는 않는다. 따라서 특정 시점에서 실제 필요한 자원 요구량과 실제 생성된 자원의 양이 차이가 날 수 있다.
- Solution
Autoscailing을 대체할 수 있는 전략은 어플리케이션이 사용할 자원의 상한선을 두고 상한에 도달하면 제한을 가하는(throttle) 것이다. 그러기 위해서 시스템은 자원을 얼마나 사용하고 있는지, 언제 사용량이 임계값을 초과하는지, 1명 이상의 사용자로부터 받는 요청에 제한을 가할 수 있는지를 모니터링 해야 한다. 이렇게 함으로써 시스템은 계속해서 기능을 제공하고 서비스 수준 계약(SLAs)을 만족시킬 수 있다.
시스템이 구현할 수 있는 throttling 전략은 다음과 같다.
- Rejecting: 주어진 기간 동안 초당 n번 이상의 시스템 API에 접근된 사용자의 요청을 막는다. 이 방법을 사용하려면 시스템은 각 테넌트나 어플리케이션을 실행하는 사용자에 대한 자원 사용량을 측정하고 있어야 한다.
- Disabling 혹은 Degrading: 중요도가 비교적 덜한 부가서비스의 기능을 비활성화하거나 저하시켜서 중요한 필수 서비스가 충분한 리소스로 동작하도록 우선순위를 주는 방법이다. 예를 들어 만약 비디오 스트리밍을 하는 어플리케이션이 있다면 저해상도로 변경하는 방안을 채택할 수 있다.
- Load leveling을 사용한 활동량을 평탄화를 한다. 여러 테넌트 환경에서 이 접근법을 적용하여 모든 테넌트의 성능을 감소시킨다. 만약 시스템이 테넌트마다 다른 수준의 SLAs 를 지원해야 한다면 우선순위 테넌트의 요청은 즉시 수행한다. 그 외의 테넌트 요청은 보류하여 백로그가 완화되면 처리한다. 우선순위 Queue 패턴을 사용하면 구현이 용이하며 다양한 서비스 수준 및 우선순위에 대한 엔드포인트를 노출할 수 있다.
- 낮은 우선순위의 어플리케이션이나 테넌트를 대신하여 수행되는 작업을 연기한다. 이런 작업들은 연기되거나 제한될 수 있으며 해당 테넌트에게 시스템에 부하가 많이 걸려있으므로 추후 다시 시도하라는 예외를 제공한다.
- 불능 상태에 되거나 오류를 반환할 수 있는 제 3자 서비스와 같이 사용할 때 주의해야한다. 필요없는 오류들로 로그가 가득차지 않도록 실행되는 동시 요청의 수를 줄인다. 또한 제3자 서비스 때문에 실패할것 같은 요청을 재시도하는것과 같은 동작은 쓸모없는 재시도이기 때문에 이런 비용을 줄여야 한다. 그리고 요청이 성공하면 원래대로 throttle을 해제한다.
아래 그림은 3가지 기능을 사용하는 어플리케이션에 대해 시간 경과에 따른 자원사용량을 나타내는 그래프이다. 기능은 컴포넌트가 작업들의 특정 set을 수행하는 기능의 영역이며, 복잡한 계산을 수행하거나, in-memory 캐시같은 서비스를 제공한다. 이런 기능들에 A, B, C 라벨링이 붙었다고 가정한다.
Feature 의 선 바로 아래 영역은 해당 기능을 실행할 때 어플리케이션에서 사용되는 자원을 의미한다. 예를 들어 Feature A 선 아래 영역은 Feature A가 사용하는 자원량을 나타내며, Feature B와 Feature A 선 사이의 영역은 Feature B 실행시 어플리케이션에서 사용하는 자원량을 나타낸다. 따라서 Feature의 영역을 모두 합치면 시스템의 총 자원 사용량이 된다.
위 그림은 작업 연기(deffering operation) 방식에 대한 영향을 설명하고 있다. 시간축 T1 직전에 할당된 총 자원사용량은 임계값에 도달했다. 이시점에 어플리케이션은 가용 자원을 모두 소모하는 위험 상황을 맞이하게 된다. 시스템에서 Feature B는 A와 C 보다 덜 중요하다고 판단하고, 잠시 비활성화하여 B가 사용하던 자원을 해제한다. 시간축 T1과 T2 사이에서 A와 C 기능을 평소 상태로 동작한다. 결국 시간 T2 에서 두 기능의 자원 사용량이 다시 감소하여 B 기능을 다시 활성화하기에 충분한 상태로 복귀한다.
Autoscailing 과 throttling 접근법을 결합하면 어플리케이션 응답성과 SLAs를 지키는데 도움을 줄 수 있다. 요청이 갑자기 많아졌을 때 시스템이 수평 확장(scale out) 하는 동안 throttling이 잠시 도움을 줄 수 있다.
아래 그림은 시간경과에 따라 시스템에서 수행되는 전체 어플리케이션의 총 자원사용량에 대한 영역 그래프인데, throttling과 autoscailing을 어떻게 결합하는지 나타낸다.
T1 에서 자원 사용량이 soft 임계값에 도달하여 시스템은 수평 확장을 수행한다. 하지만 새로운 자원이 즉시 사용될 수 있는 상태는 안미ㅡ로 기존 자원은 고갈되어 시스템이 불능 상태가 될 수 있다. 이를 방지하기 위해 시스템은 임시적으로 throttle을 수행한다. Autoscailing이 완료되면 추가적인 자원을 사용가능하므로 throttling이 해제된다.
- Issue and considerations
이 패턴을 구현할 때 아래 사항을 고려해야 한다.
- 어플리케이션을 throttling 하는것과 사용하기 위한 전략은 시스템 설계 전체에 영향을 주는 아키텍처 결정사항이다. 시스템이 구현되고난 후에 추가하기가 쉽지 않으므로 초기 단계에서 고려되는것이 좋다.
- Throttling은 빠르게 수행되어야 한다. 시스템은 활동 증가를 감지하고 그에 따른 반응을 할 수 있어야 한다. 시스템은 부하가 해소되고나면 원래상대로 빠르게 복귀되어야 한다. 이를 위해서는 적절한 성능 데이터가 지속적으로 모니터링 되어야 한다.
- 만약 서비스가 유저의 요청을 일시적으로 거부해야할 필요가 있다면 429나 503을 반환해서 클라이언트 어플리케이션이 요청이 거부된 이유가 throttling 때문이라는것을 알 수 있도록 해야 한다.
- HTTP 429는 설정된 제한을 초과했거나 일정 기간동안 너무 많이 호출했음을 가리킨다.
- HTTP 503은 서버가 요청을 다룰 준비가 되지 않았음을 나타낸다. 일반적인 이유는 서비스가 일시적으로 기대 이상의 부하를 받고 있기 때문이다.
클라이언트 어플리케이션은 요청을 재시도 하기 전에 일정 기간을 기다릴 수 있다. 클라이언트가 적절한 재시도 전략을 선택할 수 있도록 Retry-After HTTP 헤더를 포함시켜주면 좋다.
- Throttling은 시스템이 autoscale 되는 동안 일시적인 방법으로 사용될 수 있다. 활동이 갑작스럽게 증가하여 오래 지속될것 같지 않다면 scaling은 추가적인 운영 비용이 들기 때문에, scale을 하기보다 단순히 throttle만 하는것이 더 좋은 방법이 될 수 있다.
- 만약 autoscale 하는동안 임시적인 방책으로 throttling을 사용했고 리소스 요구량이 폭증하여 시스템이 계속 기능을 할 수 없는 상황이 직면할 수도 있다. 만약 이를 타개하고자한다면 더 공격적인 autoscailing을 통해 많은 자원을 확보할 필요가 있다.
- 일반적으로 연산마다 리소스 소모량이 다르기 때문에 같은 실행 비용이 들지 않는다. 예를 들어 throttling 상한은 읽기 작업의 경우 더 낮고 쓰기 작업의 경우 더 높은식이 될 수 있다. 연산의 비용을 고려하지 않으면 용량이 소모되어 잠재적인 공격 vector가 노출될 수 있다.
- 실행중 throttling 행위는 동적으로 변하는것이 바람직하다. 만약 시스템이 적용된 설정으로 다룰 수 없는 일반적이지 않은 부하에 직면한다면 시스템을 안정화시키고 현재 부하를 유지하기 위해 throttling 제한은 상향되거나 하향되어야 한다. 이 시점에 비용이 많이 들고 위험하여 느린 배포는 바람직하지 않다. 외부 Store에 의한 throttling 설정을 구성하면 배포없이 구성을 변경할 수 있다.
- When to use this pattern
- 시스템이 계속해서 서비스 계약 수준을 만족하도록 하고 싶을 때
- 단일 테넌트가 어플리케이션에서 제공되는 리소스를 독점하는 상황을 막고 싶을 때
- 요청이 급증하는 상황을 적절히 대응하고 싶을 때
- 기능을 유지하기 위해 최대 자원 수준을 제한하여 시스템의 비용 최적화를 하고 싶을 때
- Example
아래는 멀티 테넌트 시스템 예시이다. 하나의 테넌트가 다른 사용자들에게 영향을 주는것을 방지하기 위해서 유저가 초당 요청할 수 있는 수를 제한하는 식으로 구성하였다. 어플리케이션은 이 제한을 넘어서면 해당 요청을 막는다.
- Golang example
https://pkg.go.dev/golang.org/x/time/rate 패키지를 통해 RateLimiter를 구현해본다. 해당 라이브러리는 Token bucket을 기반으로 구현되었는데 이를 사용하기위해 약간의 개념을 알아보자.
- Limit: 초당 event의 수를 나타내며 float64 형이다.
- Limiter: r과 b 인자를 받아 Limiter를 정의한다.
- r: rate 이며, Limit 즉, 초당 허용할 event의 수를 정의한다. 초당 허용할 event 이므로 1/r 초마다 1개의 token이 추가된다.
- b: burst 이며 최대 허용량인 capacity를 의미한다. 만약 r이 10이라면 초당 11개의 요청이 발생할 때 마지막 요청은 거부되어야 한다. 하지만 b가 20이고 일정기간 동안 요청이 없다면 token이 최대 capacity인 20개까지 충전될 수 있다. 이후 요청이 몰려들면 원래 초당 10개의 요청만 허용해야하지만 일시적으로는 20개의 요청을 허락한다.
- Allow, Reserve, Wait: 모두 1개의 token을 소모하는 메소드들이며, token이 없을 때 행위가 다르다.
- Allow: token이 없다면 단순히 false를 반환한다. 요청을 단순히 drop, skip 한다.
- Reserve: event가 발생하기 전 호출자가 얼마나 기다려야 하는지 나타내는 Reservation을 반환한다. 요청을 rate limit에 따르거나 기다린다면 해당 메소드를 사용한다.
- Wait: 요청을 허용할때까지 block 한다.
예제에서는 simple 하게 Allow를 이용하여 test를 해본다. test 시나리오는 아래와 같다. 초당 허용할 요청(rate)는 10이며, burst size는 20이다.
- Limiter를 생성하자마자 20개의 요청이 들어오며 Allow를 호출한다. Allow 에서 20개 요청에 대해 true를 반환한다.
- 이후 1개의 요청이 들어오는데 이는 bust size를 초과하므로 false를 반환한다.
- 1초 동안 요청이 들어오지 않는다.(Sleep) rate 인 10만큼 token이 충전된다.
- 이후 rate인 10개의 요청이 들어오며 Allow를 호출한다. Allow 에서 10개 요청에 대해 true를 반환한다.
- 이후 1개의 요청이 들어오는데 이는 rate를 초과하므로 false를 반환한다.
- 2초 동안 요청이 들어오지 않는다.(Sleep) bucket size인 20만큼 token이 충전된다.
코드는 아래와 같다.
func TestRateLimiter_Allow(t *testing.T) {
const (
rateLimit = 10.0
bustSize = 20
)
limiter := rate.NewLimiter(rateLimit, bustSize)
require.Equal(t, float64(bustSize), limiter.Tokens())
for i := 0; i < bustSize; i += 1 {
require.True(t, limiter.Allow())
}
require.False(t, limiter.Allow())
time.Sleep(time.Second)
require.Equal(t, int(rateLimit), int(limiter.Tokens()))
for i := 0; i < int(rateLimit); i += 1 {
require.True(t, limiter.Allow())
}
require.False(t, limiter.Allow())
time.Sleep(2 * time.Second)
require.Equal(t, float64(bustSize), limiter.Tokens())
}