이번 시간에는 NGINX limit_req에 같은 부하를 세 가지 설정으로 보내 통과·지연·거절 수와 응답 시간을 측정하며 배워가는 시간입니다.
예를 들어서 우리가 API 앞단 NGINX에 limit_req zone=api rate=10r/s;를 걸고 요청 20개를 한꺼번에 보낸다고 해볼게요. 통과하는 요청은 몇 개일까요?
10개가 아니라 1개입니다. 두 번째 요청부터 503이 돌아와요. rate=10r/s는 "1초에 10개까지"가 아니라 "100밀리초에 1개"로 동작하기 때문입니다.
실습 준비
이 글의 명령은 모두 실습 코드의 nginx-rate-limit/ 디렉터리에서 실행합니다.
compose.yaml: 제한을 거는 NGINX, 앞단 NGINX, 느린 앱 컨테이너limit.conf: 제한 설정별 location을 둔 NGINX 설정front.conf: 앞단 프록시 NGINX 설정slow.py: 1초 뒤에 답하는 Python 앱load.py: 부하 모양별로 요청을 보내고 결과를 묶어 찍는 클라이언트
컨테이너는 세 개예요. limit은 제한을 거는 NGINX, front는 앞단 프록시 역할을 하는 NGINX, slow는 1초 뒤에 답하는 Python 앱입니다. front와 slow는 이 글에서는 쓰지 않아요.
compose.yaml은 두 NGINX를 nginx:1.30.5-alpine으로 띄워 limit을 호스트 7160, front를 7161에 열어요. 이 글의 측정은 모두 limit(7160)으로 보냅니다.
limit.conf는 설정마다 location을 하나씩 두고, 결과가 서로 섞이지 않게 location마다 zone을 따로 만들었어요.
zone은 모두 키가 $binary_remote_addr, 크기 1MB, rate=10r/s입니다.
제한을 통과한 요청은 같은 NGINX 안의 8081번 포트로 넘깁니다. 이 포트는 제한 없이 바로 200을 돌려주니까, 응답 시간에는 제한 때문에 생긴 시간만 남는다고 보시면 됩니다.
이 글에서 쓰는 location은 다음과 같고, 각 값의 뜻은 아래에서 하나씩 설명할게요.
/r1은limit_req만 걸고 8081로proxy_pass합니다./r2는burst=5를 더합니다./r3은burst=5 nodelay를 더합니다.
결과는 $limit_req_status로 봅니다. 요청마다 제한 결과가 담기는 변수이고, 값은 PASSED, DELAYED, REJECTED, DELAYED_DRY_RUN, REJECTED_DRY_RUN 중 하나예요. access log와 X-Limit 응답 헤더에 함께 남깁니다. 거절 응답에도 헤더가 붙도록 add_header에 always를 붙였어요.
부하는 호스트에서 Python 표준 라이브러리만 쓰는 load.py로 보냅니다.
python3 load.py <경로> <패턴>처럼 부르고, 기본으로 7160 포트에 보내요. --detail을 주면 요청마다 한 줄씩 출력합니다.
요청마다 상태 코드, 응답 시간, X-Limit 헤더를 모아 같은 결과끼리 묶어 출력해 줘요. 부하 모양은 두 가지입니다.
A: 같은 순간 20개. 스레드 20개가 먼저 연결을 맺고,threading.Barrier로 모두 준비된 다음 동시에 요청을 보냅니다.C: 초당 15개를 일정 간격으로 10초 동안, 모두 150개 보냅니다.
이제 셸 함수를 만들고 컨테이너를 띄울게요. dc는 compose 프로젝트 이름을 고정하는 함수예요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
dc() { docker compose -p ngxrl "$@"; }
dc up -d
dc exec limit nginx -v
nginx version: nginx/1.30.5
실습 1: rate만 걸었을 때
/r1은 rate=10r/s만 걸고 burst는 주지 않았어요.
python3 load.py /r1 A
동시 20개
200 PASSED 1개 응답 24~24ms
503 REJECTED 19개 응답 23~43ms
첫 요청은 통과하고 두 번째 요청부터 거절돼요. 거절 응답 코드는 기본값인 503입니다.
세 번 반복해도 통과·거절 수는 같았습니다.
요청이 한꺼번에 몰리지 않고 고르게 들어와도 결과는 비슷할까요? 패턴 C는 초당 15개를 66.7밀리초 간격으로 보냅니다.
rate가 초당 10개이니 초당 10개쯤 통과하리라 예상하기 쉽죠.
python3 load.py /r1 C
10초 동안 150개
200 PASSED 75개 응답 1~32ms
503 REJECTED 75개 응답 1~28ms
초별 200: 8 7 8 7 8 7 8 7 8 7
출력을 보면 150개 중 75개, 초당 7.5개만 통과했어요. 세 번 모두 75개였습니다.
요청 하나가 통과하면 다음 요청은 66.7밀리초 뒤에 오는데, 100밀리초가 지나지 않았으니 거절됩니다. 그다음 요청은 133밀리초 뒤라 통과해요.
결국 하나 걸러 하나만 통과하고, 실제 처리량은 rate보다 낮아집니다. burst 없이 rate만 쓰면 평균 요청 수가 rate를 조금만 넘어도 이렇게 많이 거절돼요.
실습 2: burst: 칸에 담고 늦게 처리합니다
/r2는 같은 rate에 burst=5를 줬어요. burst는 rate를 넘어 들어온 요청을 바로 거절하지 않고 담아 둘 대기 칸의 수입니다.
패턴 A를 보내고 요청마다 결과를 출력해 볼게요(발췌).
python3 load.py /r2 A --detail
동시 20개
200 DELAYED 5개 응답 122~516ms
200 PASSED 1개 응답 26~26ms
503 REJECTED 14개 응답 58~66ms
200 PASSED 26ms
200 DELAYED 122ms
200 DELAYED 221ms
200 DELAYED 316ms
200 DELAYED 415ms
200 DELAYED 516ms
- 통과는 6개로 늘었어요. 맨 앞 1개는 바로 처리됐고(
PASSED), 5개는 칸에 담겼다가 늦게 처리됐습니다(DELAYED). - 늦게 처리된 요청의 응답 시간은 122, 221, 316, 415, 516ms로 약 100ms씩 늘었어요.
- 나머지 14개는 칸이 가득 차서 바로 거절됐습니다.
두 번 더 반복한 결과도 통과 6개, 거절 14개였고 늦게 처리된 요청은 101~537ms 사이에 끝났어요.
burst를 주면 거절은 줄지만 칸에 담긴 요청은 그만큼 늦게 끝납니다. 칸 하나에 100밀리초씩이니 burst를 크게 잡을수록 마지막 요청의 대기 시간이 길어져요.
이 시간이 클라이언트의 타임아웃보다 길면 어떻게 될까요? 클라이언트는 응답을 받기 전에 포기하고, 서버는 그 요청을 처리만 하고 버리게 됩니다.
실습 3: nodelay: 바로 처리하지만 칸은 천천히 비워집니다
/r3은 burst=5 nodelay예요.
python3 load.py /r3 A
동시 20개
200 PASSED 6개 응답 25~29ms
503 REJECTED 14개 응답 27~30ms
통과 수는 burst만 줬을 때와 같은 6개인데, 6개 모두 거절 응답과 같은 시간대에 끝났어요. 칸에 담긴 요청도 기다리지 않고 바로 처리됐고, $limit_req_status도 DELAYED가 아니라 PASSED로 남았습니다.
nodelay를 "burst만큼 매번 더 받아 주는 옵션"으로 생각하기 쉬운데요, 칸은 여전히 100밀리초에 하나씩만 비워집니다.
패턴 C(초당 15개)를 보내면 nodelay의 효과가 더 분명하게 보여요.
python3 load.py /r3 C
10초 동안 150개
200 PASSED 105개 응답 1~189ms
503 REJECTED 45개 응답 1~32ms
초별 200: 15 10 10 10 10 10 10 10 10 10
첫 1초에는 칸 5개를 함께 써서 15개가 모두 통과했고, 그 뒤로는 매초 정확히 10개씩 통과했어요. 세 번 모두 105개였습니다.
rate만 걸었을 때는 초당 7.5개였죠. 그러니 burst가 있어야 rate만큼 처리됩니다. 요청 간격이 100밀리초보다 조금 짧아도 그 요청을 칸에 담아 둘 수 있기 때문이에요.
정리
범위: NGINX 1.30.5(nginx:1.30.5-alpine), 호스트에서 Docker Desktop 포트 전달을 거쳐 보낸 load.py 부하, 설정마다 같은 순간 요청 20개를 기본으로 3번 반복.
rate=10r/s만 건/r1은 같은 순간 20개 중 1개만 통과시키고 19개를 503으로 거절했고, 초당 15개를 고르게 보내도 150개 중 75개만 통과시켰습니다. rate만 걸지 말고 burst를 함께 주세요.burst=5는 6개를 통과시켰지만 칸에 담긴 5개의 응답이 122~516ms로 100ms씩 늦어졌습니다. burst는 마지막 요청의 대기 시간이 클라이언트 타임아웃보다 짧은 크기로 잡아 주세요.burst=5 nodelay는 6개를 바로 처리했지만, 초당 15개를 보내면 첫 1초 15개 뒤로 매초 10개씩 통과했습니다. nodelay를 burst만큼 매번 더 받아 주는 옵션으로 보지 마세요.