출시·고도화 중
레이트 리미터 안내서 · 6/6
서버가 한 대라면 메모리 속 토큰 버킷으로 충분합니다. 하지만 같은 사용자의 요청이 로드 밸런서를 거쳐 여러 서버로 흩어지면, 서버마다 따로 세는 한도는 서버 수만큼 느슨해집니다. 이 장에서는 Redis로 한도를 공유하는 방법, 웹 서버와 라이브러리가 제공하는 레이트 리미터, 그리고 운영에서 자주 겪는 함정을 정리합니다.
가장 흔한 분산 구현은 창마다 키를 하나 만들고 INCR로 세는 것입니다. INCR은 원자적이라 여러 서버가 동시에 올려도 개수가 틀리지 않습니다. 키에는 만료 시간을 주어 지난 창이 저절로 지워지게 합니다.
import time
import redis
r = redis.Redis()
def allow_naive(key: str, limit: int, window: int) -> bool:
bucket = f"rl:{key}:{int(time.time()) // window}"
count = r.incr(bucket) # 원자적으로 1 증가, 없던 키는 1이 된다
if count == 1:
r.expire(bucket, window) # 첫 요청일 때만 만료 시간 지정
return count <= limit이 코드에는 빈틈이 있습니다. INCR과 EXPIRE는 각각은 원자적이지만 둘을 합친 동작은 원자적이지 않습니다. 두 명령 사이에서 프로세스가 죽거나 연결이 끊기면 만료 시간이 없는 키가 영원히 남습니다. 이를 막는 방법은 두 명령을 Lua 스크립트 하나로 묶는 것입니다.
Redis는 EVAL(또는 EVALSHA)로 받은 Lua 스크립트를 실행하는 동안 다른 클라이언트의 명령이나 다른 스크립트를 끼워 넣지 않습니다. 그래서 "읽고, 계산하고, 쓰는" 과정을 경쟁 상태 없이 한 번에 처리할 수 있고, 네트워크 왕복도 한 번으로 줄어듭니다. 알아 둘 점이 몇 가지 있습니다.
KEYS로 넘깁니다. Redis Cluster에서는 한 스크립트의 키들이 같은 해시 슬롯에 있어야 합니다.다음은 토큰 버킷 전체를 Redis 해시 하나에 담는 스크립트입니다.
-- KEYS[1] = 버킷 키, ARGV = rate, capacity, now(초), cost
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2)
return {allowed, tostring(tokens)}키가 없으면 가득 찬 버킷으로 봅니다. 그래서 버킷이 다시 가득 찰 시간이 지나면 키를 지워도(만료시켜도) 판정이 달라지지 않습니다. 남은 토큰을 문자열로 돌려주는 이유는 Lua 숫자를 그대로 돌려주면 Redis가 정수로 잘라 버리기 때문입니다. Python에서는 redis-py의 register_script로 불러 씁니다.
import time
import redis
r = redis.Redis()
token_bucket = r.register_script(open("token_bucket.lua").read())
def allow(user_id: str, rate: float = 5, capacity: float = 10) -> bool:
allowed, _remaining = token_bucket(keys=[f"rl:{user_id}"],
args=[rate, capacity, time.time(), 1])
return allowed == 1now를 각 앱 서버가 넘기면 서버 사이의 시계 차이가 그대로 오차가 됩니다. Redis 5 이상에서는 스크립트 안에서 redis.call('TIME')으로 Redis 서버의 시계를 쓸 수도 있습니다.
limit_req: 리키 버킷 방식입니다. burst만큼 초과 요청을 큐에 담고, nodelay를 주면 큐에 담긴 요청을 기다리게 하지 않고 바로 처리합니다. 기본 거절 코드는 503이므로 limit_req_status 429로 바꿉니다.golang.org/x/time/rate: 크기 b, 초당 r개로 채워지는 토큰 버킷입니다.RateLimiter, Bucket4j: 토큰 버킷 계열이며, Bucket4j는 Redis 등 분산 저장소도 지원합니다.limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
listen 80;
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
}
}Retry-After, 남은 횟수를 알려 주고 문서에 한도를 적어 두면 클라이언트가 스스로 속도를 맞춥니다.INCR과 EXPIRE를 따로 보내면 원자적이지 않으므로 Lua 스크립트로 묶습니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.