OpenResty에서의 Dynamic Rate-Limiting
API7.ai
January 6, 2023
이전 글에서 leaky bucket
과 token bucket
알고리즘을 소개하며, 이들이 버스트 트래픽을 처리하는 데 일반적으로 사용된다는 것을 배웠습니다. 또한, NGINX 설정을 사용하여 요청 속도 제한을 수행하는 방법도 배웠습니다. 그러나 NGINX 설정을 사용하는 것은 사용 가능한 수준에 불과하며, 유용한 수준까지는 아직 갈 길이 멉니다.
첫 번째 문제는 속도 제한 키가 NGINX 변수의 범위로 제한되어 있어 유연하게 설정할 수 없다는 점입니다. 예를 들어, 다른 지역과 클라이언트 채널에 대해 다른 속도 제한 임계값을 설정할 수 있는 방법이 없는데, 이는 NGINX에서 흔히 요구되는 사항입니다.
또 다른 더 큰 문제는 속도를 동적으로 조정할 수 없으며, 변경할 때마다 NGINX 서비스를 다시 로드해야 한다는 점입니다. 결과적으로, 다른 기간에 따라 속도를 제한하는 것은 외부 스크립트를 통해 어설프게 구현할 수밖에 없습니다.
기술이 비즈니스를 서비스하고, 동시에 비즈니스가 기술을 주도한다는 것을 이해하는 것이 중요합니다. NGINX가 탄생했을 당시에는 설정을 동적으로 조정할 필요가 거의 없었고, 역방향 프록시, 로드 밸런싱, 낮은 메모리 사용량 등과 같은 요구사항이 NGINX의 성장을 주도했습니다. 기술 아키텍처와 구현 측면에서, 모바일 인터넷, IoT, 마이크로서비스와 같은 시나리오에서 동적이고 세분화된 제어에 대한 수요가 폭발적으로 증가할 것이라고는 아무도 예측하지 못했습니다.
OpenResty의 Lua 스크립팅 사용은 NGINX의 이러한 부족한 부분을 보완하여 효과적인 보완재가 되었습니다. 이것이 OpenResty가 NGINX를 대체하여 널리 사용되는 이유입니다. 다음 몇 편의 글에서는 OpenResty에서 더 많은 동적 시나리오와 예제를 계속 소개할 것입니다. 먼저 OpenResty를 사용하여 동적 속도 제한을 구현하는 방법을 살펴보겠습니다.
OpenResty에서는 lua-resty-limit-traffic을 사용하여 트래픽을 제한하는 것을 권장합니다. 이 라이브러리는 limit-req
(요청 속도 제한), limit-count
(요청 수 제한), limit-conn
(동시 연결 수 제한)을 포함하며, limit.traffic
을 제공하여 이 세 가지 방법을 통합합니다.
요청 속도 제한
먼저 limit-req
를 살펴보겠습니다. 이는 누출 버킷 알고리즘을 사용하여 요청 속도를 제한합니다.
이전 섹션에서 이 resty 라이브러리의 누출 버킷 알고리즘의 핵심 구현 코드를 간략히 소개했고, 이제 이 라이브러리를 사용하는 방법을 배울 것입니다. 먼저 다음 샘플 코드를 살펴보겠습니다.
resty --shdict='my_limit_req_store 100m' -e 'local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("my_limit_req_store", 200, 100)
local delay, err = lim:incoming("key", true)
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
return ngx.exit(500)
end
if delay >= 0.001 then
ngx.sleep(delay)
end'
lua-resty-limit-traffic
은 shared dict
를 사용하여 키를 저장하고 카운트하므로, limit-req
를 사용하기 전에 my_limit_req_store
에 100m
공간을 선언해야 합니다. limit-conn
과 limit-count
도 마찬가지로 별도의 shared dict
공간이 필요합니다.
limit_req.new("my_limit_req_store", 200, 100)
위 코드는 가장 중요한 코드 중 하나입니다. 이는 my_limit_req_store
라는 shared dict
를 사용하여 통계를 저장하고, 초당 속도를 200
으로 설정합니다. 따라서 200
을 초과하지만 300
미만인 경우(이 값은 200 + 100
에서 계산됨) 대기열에 들어가고, 300
을 초과하면 거부됩니다.
설정이 완료되면 클라이언트의 요청을 처리해야 합니다. lim: incoming("key", true)
가 이를 수행합니다. incoming
에는 두 개의 매개변수가 있으며, 이를 자세히 읽어야 합니다.
첫 번째 매개변수는 사용자가 지정한 속도 제한 키로, 위 예제에서는 문자열 상수입니다. 이는 모든 클라이언트에 대해 균일한 속도 제한을 의미합니다. 다른 지역과 채널에 따라 속도를 제한하려면 이 두 정보를 키로 사용하면 되며, 다음은 이 요구사항을 구현하는 의사 코드입니다.
local province = get_ province(ngx.var.binary_remote_addr)
local channel = ngx.req.get_headers()["channel"]
local key = province .. channel
lim:incoming(key, true)
물론, 키의 의미와 incoming
을 호출하는 조건을 사용자 정의할 수도 있으므로 매우 유연한 속도 제한 효과를 얻을 수 있습니다.
이제 incoming
함수의 두 번째 매개변수를 살펴보겠습니다. 이는 불리언 값이며, 기본값은 false
입니다. 이는 요청이 shared dict
에 기록되지 않고 통계에 포함되지 않음을 의미합니다. 단지 연습일 뿐입니다. true
로 설정하면 실제 효과가 있습니다. 따라서 대부분의 경우 명시적으로 true
로 설정해야 합니다.
이 매개변수가 존재하는 이유를 궁금해할 수 있습니다. 호스트 이름과 클라이언트의 IP 주소를 키로 하는 두 개의 다른 limit-req
인스턴스를 설정하는 시나리오를 고려해보세요. 그런 다음 클라이언트 요청이 처리될 때 이 두 인스턴스의 incoming
메서드가 순서대로 호출됩니다. 다음 의사 코드가 이를 나타냅니다.
local limiter_one, err = limit_req.new("my_limit_req_store", 200, 100)
local limiter_two, err = limit_req.new("my_limit_req_store", 20, 10)
limiter_one :incoming(ngx.var.host, true)
limiter_two:incoming(ngx.var.binary_remote_addr, true)
사용자의 요청이 limiter_one
의 임계값 검사를 통과했지만 limiter_two
의 검사에서 거부된 경우, limiter_one:incoming
함수 호출은 연습으로 간주되어 카운트할 필요가 없습니다.
이 경우 위 코드 로직은 충분히 엄격하지 않습니다. 모든 리미터에 대해 사전에 연습을 해야 하며, 클라이언트 요청을 거부할 수 있는 리미터 임계값이 트리거되면 즉시 반환할 수 있어야 합니다.
for i = 1, n do
local lim = limiters[i]
local delay, err = lim:incoming(keys[i], i == n)
if not delay then
return nil, err
end
end
이것이 incoming
함수의 두 번째 인자의 핵심입니다. 이 코드는 limit.traffic
모듈의 핵심 코드로, 여러 속도 제한기를 결합하는 데 사용됩니다.
요청 수 제한
이제 limit.count
를 살펴보겠습니다. 이는 요청 수를 제한하는 라이브러리로, GitHub API Rate Limiting과 유사하게 고정된 시간 창에서 사용자 요청 수를 제한합니다. 평소처럼 샘플 코드부터 시작하겠습니다.
local limit_count = require "resty.limit.count"
local lim, err = limit_count.new("my_limit_count_store", 5000, 3600)
local key = ngx.req.get_headers()["Authorization"]
local delay, remaining = lim:incoming(key, true)
limit.count
와 limit.req
는 유사하게 사용됩니다. 먼저 nginx.conf
에서 shared dict
를 정의합니다.
lua_shared_dict my_limit_count_store 100m;
그런 다음 리미터 객체를 new
로 생성하고, 마지막으로 incoming
함수를 사용하여 판단하고 처리합니다.
그러나 차이점은 limit-count
의 incoming
함수의 두 번째 반환 값은 남은 호출 횟수를 나타내며, 이에 따라 응답 헤더에 필드를 추가하여 클라이언트에게 더 나은 표시를 제공할 수 있습니다.
ngx.header["X-RateLimit-Limit"] = "5000"
ngx.header["X-RateLimit-Remaining"] = remaining
동시 연결 수 제한
limit.conn
은 동시 연결 수를 제한하는 라이브러리입니다. 이전에 언급한 두 라이브러리와 달리 특별한 leaving API
가 있으며, 여기서 간략히 설명하겠습니다.
위에서 언급한 요청 속도와 요청 수 제한은 access
단계에서 직접 수행할 수 있습니다. 반면 동시 연결 수 제한은 access
단계에서 임계값을 초과하는지 여부를 판단할 뿐만 아니라 log
단계에서 leaving
API를 호출해야 합니다.
log_by_lua_block {
local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay
local key = ctx.limit_conn_key
local conn, err = lim:leaving(key, latency)
}
그러나 이 API의 핵심 코드는 매우 간단하며, 다음 코드 줄은 연결 수를 하나 감소시킵니다. log
단계에서 정리하지 않으면 연결 수가 계속 증가하여 곧 동시성 임계값에 도달할 것입니다.
local conn, err = dict:incr(key, -1)
속도 제한기 결합
이 세 가지 방법에 대한 소개는 여기까지입니다. 마지막으로 limit.rate
, limit.conn
, limit.count
를 결합하는 방법을 살펴보겠습니다. 여기서는 limit.traffic
의 combine
함수를 사용해야 합니다.
local lim1, err = limit_req.new("my_req_store", 300, 200)
local lim2, err = limit_req.new("my_req_store", 200, 100)
local lim3, err = limit_conn.new("my_conn_store", 1000, 1000, 0.5)
local limiters = {lim1, lim2, lim3}
local host = ngx.var.host
local client = ngx.var.binary_remote_addr
local keys = {host, client, client}
local delay, err = limit_traffic.combine(limiters, keys, states)
이 코드는 방금 얻은 지식으로 쉽게 이해할 수 있을 것입니다. combine
함수의 핵심 코드는 limit.rate
분석에서 이미 언급한 대로 주로 drill
함수와 uncommit
함수의 도움으로 구현됩니다. 이 결합을 통해 여러 리미터에 대해 다른 임계값과 키를 설정하여 더 복잡한 비즈니스 요구사항을 충족할 수 있습니다.
요약
limit.traffic
은 오늘 소개한 세 가지 속도 제한기를 지원할 뿐만 아니라, incoming
과 uncommit
API가 있는 속도 제한기는 limit.traffic
의 combine
함수로 관리할 수 있습니다.
마지막으로, 여러분에게 숙제를 드리겠습니다. 이전에 소개한 토큰과 버킷 속도 제한기를 결합한 예제를 작성할 수 있나요? 댓글 섹션에 답을 작성하여 저와 논의해보세요. 또한 이 글을 동료와 친구들과 공유하여 함께 학습하고 소통하는 것도 환영합니다.