nginx IP별 레이트리밋 — 비싼 Gemini 경로 + 전역 2층 - #332
Conversation
- 비싼 Gemini 경로(위시 등록·이미지 OCR, 토너먼트 link·images 추가) 4개에만 limit_req 적용. 일반 조회·CRUD 는 제외 - map 으로 POST 만 카운트 키에 담아, 같은 경로의 GET(위시 목록 조회)은 빈 키로 제외 — location 을 메서드별로 쪼개지 않고 POST 만 잡는다 - 키는 $binary_remote_addr: EIP 직결(앞단 LB 없음)이라 진짜 클라이언트 IP 이고, 단일 노드(blue/green 도 한 박스)라 shared memory zone 카운터가 IP 별로 정확하다 - 일부러 느슨하게(분당 30회 + burst 20 nodelay) 둬 CGNAT·공유망 오탐을 0 으로 하고 명백한 flood 만 막는 거친 1차 그물로 설계. 인증 사용자별 정밀 quota(userId + Redis)는 앱 레이어 후속 작업으로 분리 — 둘은 대체가 아니라 보완 - 초과 시 limit_req_status 로 기본 503 대신 429 반환. 단 이 429 바디는 ApiResponseBody 래퍼를 거치지 않는다(앱 앞 nginx 가 끊으므로) - proxy_set_header·proxy_read_timeout 공통 설정을 server 레벨로 올려 location 간 중복 제거 - docker nginx 로 nginx -t 문법 검증 통과(ssl·upstream include 는 검증용 더미로 우회)
|
Slack 스레드 연동용 메타데이터입니다. slack-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Slack 알림 연동이 끊깁니다. |
WalkthroughPOST 전용 IP 키와 두 개의 limit_req_zone(piki_llm, piki_general)을 도입하고, 비용이 큰 특정 POST 경로들에 빡센 제한을 적용하며 429은 고정 JSON으로 즉시 반환하도록 nginx 구성을 변경합니다. 서버 공통 ChangesNginx 레이트리밋 및 경로 세분화
리뷰 포인트 & 개선 제안
짧고 굵게: 설정이 깔끔합니다 — 다만 정규식 범위와 수치 근거는 운영 검증이 필요합니다. 🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@infra/nginx/api.depromeet18team3.cloud.conf`:
- Line 35: Nginx currently returns plain 429 (limit_req_status 429) which breaks
clients expecting ApiResponseBody; add an error_page 429 that routes to a custom
`@rate_limited` handler which returns a JSON ApiResponseBody shape
({"data":null,"detail":"…","pageResponse":{"nextCursor":null,"hasNext":false}})
with Content-Type: application/json and a Retry-After header (use default_type
application/json and set_retry_after), and ensure the handler is referenced by
error_page 429; also relax the strict location matching (avoid exact-match
"location =" and trailing-end "$")—use a regex that accepts optional trailing
slash (e.g., /?$) or add a normalized redirect location so rate-limiting cannot
be bypassed by a trailing slash.
- Around line 71-83: The Nginx locations for the endpoints defined by location =
/api/v1/wishlists, location = /api/v1/wishlists/images and location ~
^/api/v1/tournaments/[^/]+/items/(link|images)$ can be bypassed by requests with
a trailing slash (e.g. /api/v1/wishlists/), so update these blocks to match both
forms or canonicalize: change the exact-match locations to regexes that accept
an optional trailing slash (e.g. ^/api/v1/wishlists/?$ and
^/api/v1/wishlists/images/?$) and adjust the tournament regex to include /?$ at
the end (e.g. ^/api/v1/tournaments/[^/]+/items/(link|images)/?$), ensuring the
limit_req and proxy_pass directives remain inside those updated location blocks;
alternatively add a small redirect rule to canonicalize trailing slashes to the
non-slash form before proxying.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: 3334c2f2-dea3-487e-8848-417b9e8a95f9
📒 Files selected for processing (1)
infra/nginx/api.depromeet18team3.cloud.conf
- 비싼 Gemini 경로만 막던 데서, 모든 경로에 아주 느슨한 전역 그물(piki_general, 초당 20 + burst 40)을 2층으로 추가 - 일반 조회·CRUD 도 무제한이면 스크래핑·flood 에 노출되므로 명백한 폭주는 컷한다. 단 일반 경로는 정상 트래픽(화면당 GET 여럿·무한스크롤)이 비싼 경로보다 잦아, 같은 강도면 정상 사용자가 막힌다 — 그래서 비싼 경로(1/2)보다 훨씬 느슨하게 둬 오탐을 막는 거친 그물로 설계 - 키는 raw $binary_remote_addr(메서드·경로 무관 전부 카운트). 비싼 경로는 piki_llm(초당 0.5)이 이보다 타이트해 지배하므로 location / 에만 적용(겹치지 않음) - docker nginx -t 문법 검증 통과
CodeRabbit 리뷰 반영.
- (A) limit_req_status 429 만으로는 nginx 기본 응답이라, 클라이언트가 "모든 응답=ApiResponseBody" 로 파싱하면 레이트리밋 구간에서 깨진다. error_page 429 → @rate_limited 로 같은 스키마({data, detail, pageResponse}) 고정 JSON + Retry-After 를 내린다. #309 로 바디에서 status·code 가 빠져 동적 필드가 없어 정확히 흉내 가능. 완전한 해법은 후속 userId 앱 레이어(GlobalExceptionHandler 가 진짜 ApiResponseBody 생성)이고, 그 전까지의 정합성 보강이다
- (B) location = 와 ...$ 로만 매칭하면 trailing slash 변형(/api/v1/wishlists/ 등)이 location / 로 떨어져 piki_llm(비싼 경로 타이트 제한)을 우회한다. 세 비싼 경로를 모두 정규식 + /?$ 로 묶어 차단. Spring trailing-slash 매칭이 기본 false 라 앱은 404 를 주지만(비싼 호출 미실행), 방어를 견고하게 둔다
- docker 로 런타임 검증: error_page 429 가 실제로 JSON + Retry-After 를 반환함을 확인. (검증 중 return 은 rewrite phase 라 limit_req(preaccess) 를 우회함을 발견 — 미니 테스트를 proxy_pass 백엔드로 고쳐 재현)
There was a problem hiding this comment.
♻️ Duplicate comments (1)
infra/nginx/api.depromeet18team3.cloud.conf (1)
92-105:⚠️ Potential issue | 🔴 Critical | ⚡ Quick win비용 regex location에
piki_general도 함께 걸어 전역 레이트리밋 우회 가능성을 제거하세요.
infra/nginx/api.depromeet18team3.cloud.conf에서piki_general은location / { limit_req zone=piki_general ... }에만 있고, 비용 경로 3개(location ~ ^/api/v1/...)는 regex 매칭으로location /을 대체합니다. 그 결과 비용 경로에 해당하는 GET/PUT 등 비-POST 요청은piki_general이 적용되지 않아/api/v1/wishlists같은 엔드포인트가 스크래핑/플러드에 상대적으로 취약해질 수 있습니다. 비용 3개 location에도limit_req zone=piki_general ...을 추가해 우회 여지를 없애세요.🔧 제안 diff
location ~ ^/api/v1/tournaments/[^/]+/items/(link|images)/?$ { + limit_req zone=piki_general burst=40 nodelay; limit_req zone=piki_llm burst=20 nodelay; proxy_pass http://team3; } location ~ ^/api/v1/wishlists/?$ { + limit_req zone=piki_general burst=40 nodelay; limit_req zone=piki_llm burst=20 nodelay; proxy_pass http://team3; } location ~ ^/api/v1/wishlists/images/?$ { + limit_req zone=piki_general burst=40 nodelay; limit_req zone=piki_llm burst=20 nodelay; proxy_pass http://team3; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@infra/nginx/api.depromeet18team3.cloud.conf` around lines 92 - 105, The three regex location blocks matching "^/api/v1/tournaments/[^/]+/items/(link|images)/?$", "^/api/v1/wishlists/?$" and "^/api/v1/wishlists/images/?$" currently only apply limit_req zone=piki_llm; update each of these location blocks to also include limit_req zone=piki_general (with appropriate burst/nodelay settings) so the global rate limit cannot be bypassed by regex locations, ensuring both piki_llm and piki_general run for those endpoints.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@infra/nginx/api.depromeet18team3.cloud.conf`:
- Around line 92-105: The three regex location blocks matching
"^/api/v1/tournaments/[^/]+/items/(link|images)/?$", "^/api/v1/wishlists/?$" and
"^/api/v1/wishlists/images/?$" currently only apply limit_req zone=piki_llm;
update each of these location blocks to also include limit_req zone=piki_general
(with appropriate burst/nodelay settings) so the global rate limit cannot be
bypassed by regex locations, ensuring both piki_llm and piki_general run for
those endpoints.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: 2695253b-fb82-4078-9e60-50f23c4479b8
📒 Files selected for processing (1)
infra/nginx/api.depromeet18team3.cloud.conf
- SSE 누락 조사 중, 같은 분리 누락으로 dev 에만 빠진 설정이 더 있음을 확인해 보정한다. dev/prod nginx conf 가 별도 파일이라 #374 분리 때 dev 가 prod 축약본으로 만들어지면서 레이트리밋(#332)과 listen 443 의 http2 가 dev 에서 누락돼 있었다. - 레이트리밋: 비싼 Gemini 경로(piki_llm 분당 30) + 전역 그물(piki_general 초당 20) + 429 를 ApiResponseBody 모양 JSON 으로 내리는 처리까지 prod 와 동일하게 추가. dev 서버에서도 429 가 발생하는 동작 변화가 있으나, 드리프트를 남기면 레이트리밋 회귀를 prod 에서만 처음 겪게 되므로 정합을 택했다. - http2: listen 443 ssl http2. 클라이언트와 nginx 사이 leg 최적화라 백엔드 동작과 무관하고 무해하다. - 정합 후 dev conf 는 prod 와 nginx 지시어가 100% 동일하고, server_name·ssl_certificate 경로의 도메인 문자열만 환경별로 다르다. 주석은 dev 고유 헤더(정합 의도·certbot 안내)만 차이. - 검증: 로컬 docker nginx -t 통과. dev conf 를 prod 도메인으로 정규화해 diff 하면 지시어 차이 0(주석만 다름) 확인.
* fix: dev nginx 에 SSE 구독 프록시 설정 추가 (prod 와 정합) - dev 서버에서 /api/v1/notifications/subscribe 가 catch-all location / 로 빠져 nginx 기본값(proxy_buffering on + proxy_http_version 1.0)으로 처리돼, SSE 스트림(connect·하트비트·알림)이 버퍼에 갇혀 클라이언트까지 흐르지 않던 문제. connect·하트비트조차 안 와 비즈니스 로직(수신자 actor 제외)이 아니라 전송 파이프라인 문제로 좁혀짐. - 원인은 prod conf(#367)에 있던 SSE 전용 location 이 dev/prod 분리(#374) 때 dev conf 축약본으로 옮겨지지 않은 누락. #399 가 같은 종류의 누락(read_timeout)을 한 번 보정했으나 SSE location 은 남아 있었다. - prod 와 동일하게 proxy_http_version 1.1(1.0 은 chunked 스트리밍 불가) + proxy_buffering off 를 둔 SSE 전용 location 을 추가. - 공통 proxy_set_header 4개와 proxy_read_timeout 60s 를 location / 에서 server 레벨로 추출해 SSE location 도 상속하게 함 (prod 구조와 일치, location / 동작은 불변). - 레이트리밋·http2 도 같은 분리 누락으로 dev 에 빠져 있으나 동작 변화가 있어 이 핫픽스에서 제외하고 별도로 다룬다. - 로컬 docker nginx -t 로 dev·prod conf 둘 다 syntax 통과 검증. * chore: dev nginx 를 prod 와 정합 — 레이트리밋·http2 추가 - SSE 누락 조사 중, 같은 분리 누락으로 dev 에만 빠진 설정이 더 있음을 확인해 보정한다. dev/prod nginx conf 가 별도 파일이라 #374 분리 때 dev 가 prod 축약본으로 만들어지면서 레이트리밋(#332)과 listen 443 의 http2 가 dev 에서 누락돼 있었다. - 레이트리밋: 비싼 Gemini 경로(piki_llm 분당 30) + 전역 그물(piki_general 초당 20) + 429 를 ApiResponseBody 모양 JSON 으로 내리는 처리까지 prod 와 동일하게 추가. dev 서버에서도 429 가 발생하는 동작 변화가 있으나, 드리프트를 남기면 레이트리밋 회귀를 prod 에서만 처음 겪게 되므로 정합을 택했다. - http2: listen 443 ssl http2. 클라이언트와 nginx 사이 leg 최적화라 백엔드 동작과 무관하고 무해하다. - 정합 후 dev conf 는 prod 와 nginx 지시어가 100% 동일하고, server_name·ssl_certificate 경로의 도메인 문자열만 환경별로 다르다. 주석은 dev 고유 헤더(정합 의도·certbot 안내)만 차이. - 검증: 로컬 docker nginx -t 통과. dev conf 를 prod 도메인으로 정규화해 diff 하면 지시어 차이 0(주석만 다름) 확인.
Situation
Task
Action
적용 범위와 키 설계
limit_req적용 —POST /api/v1/wishlists(링크 추출),POST /api/v1/wishlists/images(OCR),POST /api/v1/tournaments/{id}/items/link·/items/images. 일반 경로는 제한 없음.map $request_method로 POST 만 카운트 키에 담는다. 같은 경로의 GET(위시 목록 조회)·기타 메서드는 빈 키가 되어 nginx 가 세지 않는다 — location 을 메서드별로 쪼개지 않고 POST 만 잡는 트릭.$binary_remote_addr. EIP 직결이라 이게 진짜 클라이언트 IP 이고, 단일 노드라 shared memory zone 카운터가 IP 별로 정확하다. (앞단 LB 도입 시real_ip모듈로 키를 바꿔야 한다는 점을 주석에 남김.)느슨한 1차 그물 정책
burst=20 nodelay. CGNAT·공유망(한 공인 IP 뒤 여러 사용자) 오탐을 0 에 가깝게 두고 명백한 flood 만 막는다.ApiResponseBody.fail로 내려 응답 포맷도 일관되게 가져갈 수 있다. IP 와 userId 는 대체가 아니라 보완(IP 는 미인증·flood, userId 는 인증 후 비용·공정성).정리·검증
limit_req_status 429(기본 503 대신). 단 이 429 바디는ApiResponseBody래퍼를 거치지 않는다(앱 앞 nginx 가 끊으므로) — 일관 포맷이 필요하면error_pageJSON 흉내 또는 위 userId 앱 레이어로.proxy_set_header·proxy_read_timeout 90s공통 설정을 location 에서 server 레벨로 올려 중복 제거.nginx -t문법 검증 통과(ssl 인증서·upstream include 는 검증용 더미로 우회). 실제 배포 검증은 deploy.yml 의nginx -t가 한 번 더 한다.Result
rate/burst조정 가능.연관 이슈
Updates
전역(일반 경로) 레이트리밋 추가 — 2층 구조로 확장
piki_general, 초당 20 +burst=40 nodelay)을 더해 2층으로 확장했다. 일반 조회·CRUD 도 무제한이면 스크래핑·flood 에 노출되기 때문 — "호출이 과한 건 좋을 게 없다"는 판단. (585e6d0)585e6d0)$binary_remote_addr(메서드·경로 무관 전부 카운트).location /에만 적용했다 — 비싼 경로는piki_llm(초당 0.5)이 이보다 타이트해 지배하므로 겹치지 않는다. dockernginx -t재검증 통과. (585e6d0)CodeRabbit 리뷰 대응
error_page 429 = @rate_limited로 같은 스키마({data, detail, pageResponse}) 고정 JSON +Retry-After반환. 공통 응답 status·code 제거 + 요청 추적 traceId 도입 #309 로 바디에서 status·code 가 빠져 동적 필드가 없어 정확히 흉내 가능. docker 로 429 응답이application/json+Retry-After+ 해당 JSON 바디임을 런타임 확인. (edac946)location =·...$만으로는 trailing slash 변형(/api/v1/wishlists/등)이location /로 떨어져piki_llm우회 가능 → 세 비싼 경로를 정규식 +/?$로 묶어 차단. Spring trailing-slash 매칭이 기본 false 라 앱은 404(비싼 호출 미실행)지만 방어적으로. (edac946)return은 rewrite phase 라limit_req(preaccess phase)보다 먼저 실행돼 우회된다. 실제 conf 는proxy_pass라 정상 동작하며, 런타임 미니 테스트만proxy_pass백엔드로 고쳐 재현했다.레이트리밋 정책 요약 (2층)
piki_llmmap) →$binary_remote_addrpiki_general$binary_remote_addrSummary by CodeRabbit