웹 서비스 하나를 개발하였는데 백엔드 서버는 Django 와 gunicorn, nginx 를 같이 사용하고 있고 별도의 프론트엔드 서버와 통신하고 있다. 이제 서버 방어체계를 구축해야 한다. DDos 방어까지는 아니더라도 동일 ip 의 악의적인 다중 http request 는 막야아하며 NGINX 로 쉽게 대비할 수 있을 것 같다.
nginx 문서에 깔끔하게 설명이 잘 되어있다.
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
location / {
limit_req zone=mylimit;
...
}
}
상기 설정의 의미는 다음과 같다.
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
limit_req zone=mylimit;
아직 부족하다. 일정한 간격으로 api 를 요청하는 프론트 서버는 없고 유저의 동작 하나에 오직 한 개의 api 를 요청하지 않는 경우는 너무나도 많다. 홈페이지 접속에 API 10개를 호출한다면 이 중 상당수는 0.1 초 내에 서버에 도착할 것이고 1등을 제외하고는 모두 response FAIL 이다.
limit_req zone=mylimit burst=20;
하지만 문제가 있다. 이는 서비스를 느리게 만든다. 1초에 20개의 요청이 왔을 때 20번째 요청이 언제 실행될까? 2초 뒤에 실행된다. 0.1초 마다 한 개씩 큐에서 빼기 때문이다. 한 가지 설정을 더 해주자.
limit_req zone=mylimit burst=20 nodelay;
여기까지의 설정에 대한 결론을 요약하자면
헷갈릴 수 있는 부분은 '지연없이' 요청이 처리되었을 때 NGINX 에서 어떻게 1초에 20개 제한을 둘 수 있는지 인데 NGINX에서는 요청은 실제로 처리되나 해당 요청은 SLOT 에 남아있으며 SLOT은 0.1초에 한 개씩 할당된 요청이 해제된다고 설명한다.
여기까지 읽었다면 드는 생각이 있다. 아무리봐도 burst, nodelay 옵션은 일반적인 웹 서비스에서 무조건 필요할 것 같은데 NGINX 는 이를 옵션으로 제공하는 이유가 뭘까?
걱정하지 않아도 된다. NGINX 에서도 이를 권장하고 있다.
Note: For most deployments, we recommend including the burst and nodelay parameters to the limit_req directive.
여기까지 왔으면 필수 설정들은 되었고 부가적으로 다중 요청에 대한 에러를 커스텀해주자.
limit_req_status 429;
limit_req_log_level error;
이제 503 대신 429(too many request) 에러를 반환한다.
이제 다 된 줄 알았다. 근데 무시무시한 이슈가 남아있다. 프론트엔드 서버에서는 백엔드 서버의 response를 기반으로 서버사이드 렌더링을 수행한다. 이는 client(사용자의 웹 브라우저) 가 아닌 서버 to 서버의 request 이다. 그러면 프론트엔드 서버에서 오는 모든 요청은 동일 IP 이며 이는 위에 만든 NGINX limit_req 룰의 적용을 받는다. 즉, 21명의 유저가 1초 내에 요청을 하면 1명의 유저는 오류가 난다는 뜻이다! 원래 의도하였던 1명의 유저가 1초내에 20개 초과의 요청 방지와 결이 달라지는 것이다.
어떻게 해결하지?
가장 간단하게는 nginx whitelist 에 프론트 서버의 ip를 추가하면 된다. 프론트 서버의 요청은 limit_req 룰에 의해 제한 받지 않는다. 하지만 역시 문제가 있다. 프론트 서버의 오토 스케일링에 대응할 수 없다. 이를 대응하기 위해선 오토 스케일링 된 서버의 IP를 동적으로 whitelist 에 추가해야 하는데 이 논리에 오류가 없으려면
우선 나는 이 논리를 구현할 자신이 없다.
대안으로 두 가지를 생각해봤다.
후자로 진행해보기로 했다.
map $http_moonseok $limit_count {
"secret_key" "";
default $binary_remote_addr;
}
nginx 문법이다. http request header 의 key 가 'moonseok' 이고 value 가 'secret_key' 이면 $limit_count 라는 변수에 "" 을 할당하고 그렇지 않다면 $binary_remote_addr 을 할당한다. 그리고 위에서 설명한 limit_req_zone 을 수정해주자
limit_req_zone $limit_count zone=one:10m rate=20r/s;
맨 위에서는 $binary_remote_addr 을 통해 IP를 기준으로 요청을 제한하였는데 해당 부분이 $limit_count 로 변경되었다.
이다. 여기서 빈 문자열 "" 는 아무것도 수행하지 않음을 의미하며 헤더에 시크릿키가 설정이 되어있을 경우에 한 해 request를 제한하지 않는다는 뜻이다. 이제 프론트서버에서 직접 백엔드서버로 하는 요청에 직접 시크릿키를 설정하면 21명 이상의 유저가 프론트 서버를 통해 요청을 하더라도 limit_req 룰에 의한 제약을 받지 않는다.
여기까지 동작이 되는 것은 확인했다. jmeter 로 header 에 secret_key 를 심어 보내는 다수의 요청에 200 코드를 반환하며 secret_key 값을 보내지 않거나 틀리게 넣고 다수의 요청을 보내는 경우 일부는 429 로 실패한다.
하지만 아직도 확실하지 않은 부분이 있기에 테스트는 더 필요하다.
의견은 언제나 환영이며 부정적인 의견은 2배로 환영입니다.😀
nginx 에서 burst 파라미터를 위한 queue는 따로 있지 않고 buffer에 저장하는거로 알고 있는데 위에 작성하신 내용의 reference 문서가 있나요?