AWS EC2 인스턴스가 가끔씩 터지는 문제
Posted at 
2023.06.14 16:02
외주 프로젝트에서 발생한 문제와 그에 대한 해결 방법을 정리하였습니다.

문제

외주를 하며 만든 백엔드 서버를 AWS EC2 t3.micro 인스턴스에 올려놓고 사용하고 있었다.

django + gunicorn + nginx로 구성된 서버인데, 인스턴스에 올려놓고 서버를 사용하다 보면 대략 12일 정도에 한번씩 CPU 사용량이 100%가 되며 서버에 접속이 되지 않고 ssh로 직접 접근도 안 되는 상황이 발생했다.

aws-cpu-100.png

임시방편으로 위 블로그의 방법대로 인스턴스 중지 & 시작을 해보니 정상적으로 동작하였고, 다음 12일 후에 다시 CPU 사용률이 100%가 되는 문제가 다시 발생했다.

원인을 찾기 위한 여정

처음에는 CPU 사용률이 100% 가까이 되며 서버가 터지는 것을 보고 인스턴스 사양이 낮아서 그럴 것이라고 생각했다.

하지만 이전 직장에서 동시접속자 평균 20명의 서비스에서도 동일한 t3.micro 인스턴스를 사용하였는데 동시접속자가 5명도 안 되는 서비스에서 사양으로 인한 문제는 발생하지 않을 것이라고 판단하고 다른 원인을 생각해보았다.

두 번째로는 혹시 인스턴스의 크레딧을 모두 소비해서 버스트 기능이 작동하지 않아서 문제가 발생한 것은 아닌지 확인해 보았다.

확인해보니 크레딧은 충분히 남아있었고, CPU 사용량 증가에 따라 크레딧 사용률만 늘어나고 있었다. aws-cpu-credit.png

마지막으로, 혹시 AWS 인스턴스 자체가 문제인가 싶어 다른 Paas서비스인 railway에 임시로 서비스를 올려 테스트해보았다. 테스트에는 스트레스 테스트 도구인 locust를 사용했다.

테스트 결과, 프로젝트에서 주로 사용하는 기능인 오답노트 업로드 기능을 사용할 때마다 메모리 사용량이 급증하는 것을 확인할 수 있었다. memory6h-사각형.png 테스트 시마다 메모리 사용량이 급증하는 모습

조금 더 확인해본 결과, 이것은 railway에 올린 서비스에서 파일을 로컬로 저장할 때 파일을 메모리에 저장해서 발생하는 문제였다.

AWS S3에 파일을 저장하도록 코드를 수정했고, 위 그래프 정도로 메모리 사용량이 늘지는 않았지만 테스트를 실행할 때마다 메모리 사용량이 조금씩 증가하는 모습을 확인할 수 있었다.

따라서 원인은 AWS 인스턴스의 문제가 아니라 메모리 누수로 인해 메모리가 가득 차면서 CPU가 정상적으로 작동하지 않는 문제라고 판단하였다.

이에 대해서 구글링을 해봤더니 gunicorn을 사용할 때 가끔 worker 노드들이 메모리를 들고 일을 하다가 해제를 못 하는 케이스가 발생하기 때문에 max-requestsmax-requests-jitter를 설정해 주어야 한다고 한다. 위의 두 개의 옵션을 통해 일정 횟수의 request가 올 때마다 worker를 재시작하도록 하여 메모리 누수를 방지할 수 있다. memory.png max-requests, max-requests-jitter 옵션을 적용했을 때 확실히 메모리 사용량이 감소한 것을 확인할 수 있다.

max-requests, max-requests-jitter

gunicorn을 사용할 때, max-requests 옵션을 주지 않으면 worker가 restart되지 않아 메모리를 계속 잡고 있는 문제가 발생한다.

따라서 일정 횟수의 request 뒤에 worker를 restart해주는 max-requests 옵션을 사용하면 worker가 잡고 있는 메모리를 놓아줄 수 있다.

하지만 요청이 몰렸을 때, 모든 worker가 restart 되는 상황이 있다면 다운타임 상황이 발생할 수 있으므로 max-requests-jitter 옵션을 이용해 max-requests 값에 0 ~ jitter 값 사이의 랜덤 값을 추가하여 동시에 모든 worker가 restart되는 현상을 방지할 수 있다.

# https://github.com/benoitc/gunicorn/blob/master/gunicorn/workers/base.py

if cfg.max_requests > 0:
    jitter = randint(0, cfg.max_requests_jitter)
    self.max_requests = cfg.max_requests + jitter
else:
    self.max_requests = sys.maxsize

저 두 옵션을 설정하면 worker들이 재시작되며 응답 시간이 오래 걸리는 문제가 발생하기 때문에 몇번의 request마다 worker를 재시작할지를 효율적으로 판단하는 것이 중요할 것으로 보인다. locust.png 옵션을 추가하지 않았을 때와 추가했을 때의 RPS와 Response Time의 차이

중간에 끊어져 있는 부분 전후로 차이가 발생한 것을 볼 수 있다.

ETC

t2.micro를 사용하는 테스트 서버에서 테스트해본 결과, 프로덕션 서버와 마찬가지로 max-requests, max-requests-jitter옵션을 설정하지 않았음에도 생각보다 서버가 잘 터지지 않는 모습을 보였다.

차이점을 확인해보니 gunicorn worker의 개수로 테스트 서버에서는 3개를 사용하고 있었지만, 프로덕션 서버에서는 8개를 사용하고 있었다.

공식 문서를 확인해본 결과, gunicorn의 worker 개수는 다음과 같은 공식을 따르는 것을 추천한다고 적혀있었다.

(2 * cpu 코어의 개수) + 1

결론

인스턴스의 사양 문제가 아닌 메모리 문제로 인해 서버가 터진 것을 알 수 있었다.

메모리 문제의 원인으로는 gunicorn에 max-requests, max-requests-jitter 옵션을 설정하지 않았고, worker의 개수를 사양보다 너무 많이 지정하여 발생하는 문제로 확인되었다.

처음에는 인스턴스 사양 문제 혹은 django의 InMemoryUploadedFile이 api 호출이 종료된 이후에도 메모리에 남아있을 가능성 등을 생각했었지만, 결과적으로는 gunicorn에 올바른 옵션을 주지 않아 발생한 문제였다.

프레소
Copyright © PRESSO. All Rights Reserved.