Django에서의 대용량 트래픽 처리 - 병목을 찾아라

  • 성능 측정과 개선
  • 2016-08-14 (일요일) 15:40 - 16:05
  • 한국어
  • 103
  • Photography and recording is allowed

Slide

http://www.slideshare.net/JueunSeo1/django-64975491

Video

https://youtu.be/JoGHJgYFKsw

PDF

https://github.com/pythonkr/pyconapac-2016-files/raw/master/20160814-103-20-SeoJueun.pdf

Description

버즈빌에서는 잠금화면 리워드 앱인 허니스크린과 잠금화면 SDK인 버즈스크린 그리고 광고 플랫폼인 버즈애드를 운영하고 있습니다. 이 모든 서비스를 django를 이용해 운영하고 있습니다. 하지만 이용자가 늘어감에따라 서버 응답 속도가 늦어지고 장애가 발생하는 일이 잦아지기 시작했습니다. 서버 부하로 인한 문제를 찾기위한 가장 중요한 일은 병목이 생기는 부분을 찾는 것입니다. 가장 첫 번째로 확인한 부분은 데이터베이스 병목이었습니다. 하지만 사용중이던 데이터베이스인 mysql/memcached/redis에서의 CPU와 memory 상태는 정상이었고 응답속도 또한 정상이었습니다. 그렇다면 웹 서버가 문제라고 볼 수 있을텐데 웹 서버의 CPU 점유율 또한 70% 아래를 유지하고 있었습니다. 경험이 있는 개발자라면 여기서 바로 힌트를 얻을 수 있을 것 같습니다. 다른 모든 것이 정상적인 상황인데 웹 서버의 CPU가 100%가 아님에도 불구하고 응답속도가 느려지는 상황이 발생한다면 두 가지 경우를 생각해 볼 수 있을 것 같습니다. 하나는 파이썬 프로세스의 갯수가 CPU 코어보다 적은 상황입니다. Gevent는 concurrent하게 동작하지만 parallel하지는 않기 때문에 worker의 숫자가 CPU 코어의 갯수보다 모자라면 CPU를 100% 활용하지 못하게 됩니다. 하지만 제가 겪은 장애 상황에서는 파이썬 프로세스의 갯수는 CPU 코어의 갯수와 동일하게 설정되어 있었습니다. 그렇다면 다른 한 가지 문제는 I/O에 의한 병목일텐데 결국 gevent가 제대로 동작을 하지 않고 있는 것은 아닌지에 대한 의심을 하기에 이르렀습니다. Gevent는 monkey-patch를 이용하여 구현되어 있는데 C로 구현된 라이브러리 안에서 발생하는 blocking operation에 monkey-patch가 적용되지 않아 greenlet context switch가 불가능한 문제가 있다는 사실을 알게되었습니다. 사실 monkey-patch가 되지 않는다고해서 gevent worker가 동작하지않는것은 아니기에 트래픽이 적은 상황에서는 문제가 있다는 사실 조차도 알 수가 없습니다. 하지만 트래픽이 늘어남에따라 CPU 사용량이 늘고 I/O 요청이 빈번하게 일어나는 상황이 되면 어느순간 CPU 점유율이 100% 아래에서 saturation이 되는 현상이 발생하게 됩니다. 제가 겪은 장애 상황의 경우 mysql driver인 MySQLdb와 memcached driver인 PyLibMCCache가 문제였습니다. 둘 다 C로 구현된 라이브러리였습니다. 이 라이브러리들을 각각 PyMySQL과 python-memcached로 교체하고나니 문제가 해결되었습니다.

이후 비슷한 문제를 celery로 구현한 worker에서도 발견하였습니다. CPU 사용률이 100%가 아닌데도 broker에 점점 task가 쌓이기 시작하는 문제를 발견하였습니다. 역시나 I/O에 의한 문제를 의심하였습니다. 하지만 이번에는 gevent가 정상적으로 동작하는 상황이었고 broker 또한 redis를 사용하고 있었기에 병목이 될 만한 부분을 찾기가 쉽지 않았습니다. 결국엔 strace를 이용해서 파이썬 프로세스가 사용하는 system call이 어떤것이 있는지 확인하는 단계에 이르게 되었고 놀랍게도 워커가 코드에서는 전혀 사용하지도 않는 mysql서버에 connect를 하는 부분을 발견하게 되었습니다. 알고보니 celery는 기본적으로 result-backed로 설정된 데이터베이스에 task의 수행결과를 저장하게 되어 있던 것이었습니다. 바로 여기서 mysql에 접속이 일어나 병목이 발생하고 있었습니다. result-backend는 사실 일반적인 경우에는 필요가 없기 때문에 ignore_result 설정을 이용하여 result-backed를 사용하지 않도록 하였더니 문제가 해결되었습니다.

사실 이 두 가지 경우는 정민영 님이 각각 2014년 파이콘 코리아와 2015년 파이콘 코리아에서 발표하셨던 내용에 언급이 되어 있습니다. 하지만 그럼에도 불구하고 실제로 문제를 겪어보기 전 까지는 인지하기 쉽지 않은 부분이었고 문제가 발생하고나면 실제로 병목이 되는 원인을 찾는데도 많은 시간이 걸립니다. 따라서 gevent나 celery를 사용하시는 분들이 계시다면 꼭 말씀드린 부분에 대해서 문제가 없을지 검토해보시면 좋을 것 같습니다.

마지막은 캐싱에 대한 이야기입니다. 대용량 트래픽을 처리하는데 있어서 병목이 되는 부분은 데이터베이스인 경우가 많습니다. 데이터베이스 부하를 줄이기 위한 가장 좋은 방법은 쿼리 결과를 캐싱하는 것입니다. Django에서는 내장되어있는 ORM을 이용해서 데이터베이스에 접근을 하기때문에 ORM레벨에서 캐싱이 지원되는 라이브러리를 검토하였는데 Cacheops가 가장 적합하다는 판단을 하였습니다. 여전히 active하게 개발이 진행되고 있고 오랜시간동안 충분히 검증되었으며 기능또한 다양합니다. 거기다 python function level의 캐싱도 데코레이터를 이용해 쉽게 적용 가능하여 매우 유용합니다. (발표자료에는 사용방법에 대한 간단한 예제 코드를 첨부하겠습니다). 하지만 ORM을 캐싱 할 때는 주의할 점이 있습니다. 캐싱에서 가장 어려운 부분은 invalidation을 하는 것입니다. 하지만 완벽하게 데이터베이스와 캐시 사이에 일관성을 보장하는것은 불가능에 가깝습니다. 기본적으로 cacheops에서는 모델에 업데이트가 일어나는경우 invalidation을 자동적으로 수행해주지만 완벽하다고 볼 수는 없습니다. 데이터베이스 트랜잭션을 사용하던중 에러로 인해 정상적으로 rollback을 수행하지 못하는 상황 또는 데이터베이스에 update가 성공한 이후 캐시를 업데이트하는 중에 에러가 발생하는 경우는 어쩔 수 없이 데이터 불일치가 발생할 수 있습니다. 그래서 제가 선택한 전략은 eventually consistency가 허용되는 경우에만 캐싱을 하는 것이었습니다. 캐싱된 데이터에 대해서 expire시간을 1분 정도로 짧게 가져가 invalidation이 실패하더라도 최대 1분안에 consistency가 성립하도록 하는 방법입니다. 이렇게 하면 혹시나 발생할 invalidation 실패에 대한 문제를 걱정하지 않아도 되지만 대신 캐싱 가능한 범위는 줄어들 수 밖에 없다는 단점이 있습니다. 마지막 팁으로 극단적으로 변경이 일어나지 않을 것으로 예상되는 데이터는 local_get 옵션을 이용하면 redis가 아닌 process local memory에 데이터를 캐싱하여 성능을 크게 향상시킬 수 있어 유용하게 사용 할 수 있습니다.

Comments

blog comments powered by Disqus

Sponsors

키스톤

다이아몬드

플래티넘

골드

스타트업

실버

미디어