OpenResty에서의 Dynamic Rate-Limiting

API7.ai

January 6, 2023

OpenResty (NGINX + Lua)

이전 글에서 leaky buckettoken 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-trafficshared dict를 사용하여 키를 저장하고 카운트하므로, limit-req를 사용하기 전에 my_limit_req_store100m 공간을 선언해야 합니다. limit-connlimit-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.countlimit.req는 유사하게 사용됩니다. 먼저 nginx.conf에서 shared dict를 정의합니다.

lua_shared_dict my_limit_count_store 100m;

그런 다음 리미터 객체를 new로 생성하고, 마지막으로 incoming 함수를 사용하여 판단하고 처리합니다.

그러나 차이점은 limit-countincoming 함수의 두 번째 반환 값은 남은 호출 횟수를 나타내며, 이에 따라 응답 헤더에 필드를 추가하여 클라이언트에게 더 나은 표시를 제공할 수 있습니다.

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.trafficcombine 함수를 사용해야 합니다.

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은 오늘 소개한 세 가지 속도 제한기를 지원할 뿐만 아니라, incominguncommit API가 있는 속도 제한기는 limit.trafficcombine 함수로 관리할 수 있습니다.

마지막으로, 여러분에게 숙제를 드리겠습니다. 이전에 소개한 토큰과 버킷 속도 제한기를 결합한 예제를 작성할 수 있나요? 댓글 섹션에 답을 작성하여 저와 논의해보세요. 또한 이 글을 동료와 친구들과 공유하여 함께 학습하고 소통하는 것도 환영합니다.