ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • nginx로 악의적인 반복 요청 방지하기
    Server 2022. 6. 3. 10:52
    반응형

    웹 서비스 하나를 개발하였는데 백엔드 서버는 Django 와 gunicorn, nginx 를 같이 사용하고 있고 별도의 프론트엔드 서버와 통신하고 있다. 이제 서버 방어체계를 구축해야 한다. DDos 방어까지는 아니더라도 동일 ip 의 악의적인 다중 http request 는 막야아하며 NGINX 로 쉽게 대비할 수 있을 것 같다.

    NGINX limit_req

    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 을 설정한다. $binary_remote_addr 을 기준으로 동작하고 이는 요청한 클라이언트의 IP 를 의미하며 동일 IP 를 기준으로 요청 수를 관리한다는 의미다.
    • zone=mylimit:10m
      해당 설정의 이름을 'mylimit' 이란 변수에 할당하고 키 상태를 유지할 수 있는 메모리 크기를 10m 로 설정한다. '키' 는 IP를 의미하며 메모리 크기를 초과할 경우 먼저 저장된 IP 의 request 정보는 사라진다.
    • rate=10r/s
      1초당 10request 허용을 의미한다. 주의해야할 건 1초동안 들어온 10개의 요청은 처리된다! 가 아니다. 0.1초에 1개의 요청을 처리할 수 있다! 가 맞다. 이는 동일 IP 에서 1초 동안 10개의 요청을 수신받을 경우 0.1초에 1개를 처리한다는 의미이며 0.1초 내에 2개의 요청일 올 경우 두번째 요청은 설정한 에러로 응답한다. 즉, 이전 요청와 다음 요청 사이의 텀이 0.1초 이내라면 후자의 요청은 '실패'다. (이 같은 동작은 대부분의 유저가 원하지 않기에 NGINX 는 다른 옵션을 제공하며 밑에 기술되어 있다.)
    limit_req zone=mylimit;
    • limit_req_zone 에 설정해둔 변수(사실 nginx 에서 mylimit 을 '변수'라 칭하는 지는 모르겠다.) mylimit을 설정한다.

    아직 부족하다. 일정한 간격으로 api 를 요청하는 프론트 서버는 없고 유저의 동작 하나에 오직 한 개의 api 를 요청하지 않는 경우는 너무나도 많다. 홈페이지 접속에 API 10개를 호출한다면 이 중 상당수는 0.1 초 내에 서버에 도착할 것이고 1등을 제외하고는 모두 response FAIL 이다.

    limit_req zone=mylimit burst=20;
    • burst=20
      기존에는 0.1초 내에 ( 본문에 0.1초 단위를 많이 쓰는데 절대 특별한 의미는 없고 단지 예시의 시작을 10r/s 으로 정의했기 때문이니 오해하지 말자 ) 5개의 요청이 오면 4개는 실패였다. burst 설정은 이와 같은 문제를 막기 위해 설정한 수(20) 만큼의 요청을 queue에 저장한다. 0.1초 내에 5개의 요청이 왔을 때 4개는 queue에 저장되며 0.1초마다 pop 되어 실행된다. 1초 내의 20개 이하의 요청은 어떻게 오든 실패하지 않는다.

    하지만 문제가 있다. 이는 서비스를 느리게 만든다. 1초에 20개의 요청이 왔을 때 20번째 요청이 언제 실행될까? 2초 뒤에 실행된다. 0.1초 마다 한 개씩 큐에서 빼기 때문이다. 한 가지 설정을 더 해주자.

    limit_req zone=mylimit burst=20 nodelay;
    • nodelay
      더이상 nginx는 queue 에서 0.1초 마다 pop 하지 않는다. nginx 는 요청마다 간격을 두지 않는다. 0.1초 내에 들어온 20개의 요청을 완료하는데 실제로 0.09초가 걸린다면 20개의 요청은 0.09초만에 처리된다.

    여기까지의 설정에 대한 결론을 요약하자면

    • 1초에 20개 까지의 요청을 수용한다.
    • 20개의 요청은 기존 설정인 0.1초 마다 1개가 실행되지 않고 '지연없이' 처리된다.

    헷갈릴 수 있는 부분은 '지연없이' 요청이 처리되었을 때 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 에 추가해야 하는데 이 논리에 오류가 없으려면

    • 백엔드 서버 내부에서 nginx 설정을 변경 후 재적용 할 때 blue/green 배포가 가능해야 하며
    • 증설된 프론트 서버가 autoscailing_group 에 등록이 됨과 동시에 등록된 프론트 서버의 IP가 백엔드 서버의 white_list 에 동적으로 추가하는 동작의 싱크가 맞아야한다.

    우선 나는 이 논리를 구현할 자신이 없다.

    대안으로 두 가지를 생각해봤다.

    • 프론트 서버에서 백엔드로 하는 요청은 기존과 다른 로드 밸런서를 사용한다. 서버 투 서버 요청에 대응하는 로드밸런서를 별도로 만들고 생성한 로드밸런서의 IP 가 변하지 않는한 whitelist 를 동적으로 관리해줄 필요는 없다. 또한 로드밸런서 주소가 유출되어 제3자가 해당 로드밸런서로 요청을 하는 경우가 생길 수 있는데 이는 AWS 의 internal 로드밸런서를 사용함으로써 막을 수 있고 이는 동일 VPC 영역 내의 요청만을 허용해주게 된다.
    • 프론트서버에서 백엔드서버로, 서버2서버 요청에 한하여 식별할 수 있는 값을 부여한다. 아이디어 하나는 header 에 비밀키를 부여해서 보내는 것이다. 프론트서버 내부에서 요청하는 것이기에 http request header 값이 유출될 우려가 없다.

    후자로 진행해보기로 했다.

    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 로 변경되었다.

    • header 에 secret_key 가 제대로 올 경우 $limit_count == ""
    • 제대로 오지 않을 경우 $limit_count == $binary_remote_addr

    이다. 여기서 빈 문자열 "" 는 아무것도 수행하지 않음을 의미하며 헤더에 시크릿키가 설정이 되어있을 경우에 한 해 request를 제한하지 않는다는 뜻이다. 이제 프론트서버에서 직접 백엔드서버로 하는 요청에 직접 시크릿키를 설정하면 21명 이상의 유저가 프론트 서버를 통해 요청을 하더라도 limit_req 룰에 의한 제약을 받지 않는다.

    여기까지 동작이 되는 것은 확인했다. jmeter 로 header 에 secret_key 를 심어 보내는 다수의 요청에 200 코드를 반환하며 secret_key 값을 보내지 않거나 틀리게 넣고 다수의 요청을 보내는 경우 일부는 429 로 실패한다.

    하지만 아직도 확실하지 않은 부분이 있기에 테스트는 더 필요하다.

    • Nginx 단에서 header 로 조건절을 수행하는데 어느정도 오버헤드가 걸리는지 테스트가 필요하다.
    • best case 가 아닐지라도 use case 인지 확인이 되지 않았다. 많은 서비스에 필요한 사항일텐데 어디엔가 제대로 정리되어 있는 것이 없어 그냥 불안하다.

    의견은 언제나 환영이며 부정적인 의견은 2배로 환영입니다.😀

     
    반응형

    댓글

Designed by Tistory.