최근 집에 홈서버를 장만하면서 다양한 프로젝트를 해당 서버를 통해 배포하고 있습니다. 이 때 저는 웹 서버로 Nginx를 사용하는데. 이를 사용하는 이유는 여러가지가 있겠지만 대표적으로는 빠르게 정적 파일을 처리할 수 있는 점과 설정이 비교적 간단하다는 점이 있습니다. 여기다가 주로 파이썬으로 백엔드를 개발하는 저는 Nginx와 함께 사용하는 wsgi와 asgi 서버로 각각 gunicorn 그리고 uvicorn을 사용하고 있는데 홈서버라는 한정된 자원으로 여러 프로젝트를 진행하기 위해 항상 Nginx, gunicorn 그리고 uvicorn의 워커 갯수를 공식 문서에서 제공하는 권장 사양에 맞춰 진행하고 있었습니다. 그러다 문득 권장 사양이 왜 이렇게 정해졌는지가 궁금해졌고 이에 대한 자료를 찾아 제 나름대로 내린 결론을 정리하고자 합니다.
프로그래밍에서 워커(worker)는 독립적인 작업 단위를 수행하는 별도의 스레드나 프로세스를 의미합니다. 워커는 여러가지 방식이 있으며 대표적인 방식으로는 다음 3가지가 있습니다.
마스터 프로세스에서 인입된 요청을 각각의 워커 프로세스로 전달하는 구조입니다. 마스터 프로세스는 주로 설정 관리, 워커 프로세스 시작/중지, 요청 분배 등을 담당하고 워커 프로세스는 실제 작업을 수행합니다.
마스터 프로세스에서 인입된 요청을 각각의 워커 프로세스로 전달하는 구조입니다. 마스터 프로세스는 주로 설정 관리, 워커 프로세스 시작/중지, 요청 분배 등을 담당하고, 워커 프로세스는 실제 작업을 수행합니다.
모든 노드가 동등한 권한과 책임을 가지는 구조입니다. 노드들이 서로 직접 통신하고 작업을 분배하며, 각 노드는 다른 노드의 상태를 모니터링하고 필요시 작업을 재분배할 수 있습니다.
작업들이 큐에 저장되고, 여러 워커가 큐에서 작업을 가져와 처리하는 구조입니다. 큐는 보통 별도의 서비스(Redis, RabbitMQ 등등)로 관리되며 워커는 작업을 완료하면 큐에서 다음 작업을 가져옵니다.
이렇게 워커를 사용하게 되면 여러가지 장점이 있지만 대표적으로 2가지가 있습니다.
- 병렬 처리
워커를 사용하게 되면 멀티코어 프로세서의 이점을 활용할 수 있습니다. 따라서 하나의 스레드나 프로세서가 작업을 하고 있으면 다른 스레드나 프로세서를 활용하여 추가적인 작업을 병렬적으로 진행할 수 있습니다.- 부하 분산
워커들은 여러 작업을 동시에 처리하므로, 시스템의 부하를 여러 개의 스레드나 프로세스에 분산시켜 효율성을 높일 수 있습니다.
워커가 무엇인지, 그리고 어떤 장점이 있는지를 알아 보았으니 이제 이번 글의 주제인 Nginx, gunicorn, uvicorn의 워커들의 차이에 대해서 알아보겠습니다. 여기서는 각각의 프로그램들의 워커가 어떻게 동작하는지 보다는 그냥 이렇게 설계 되었구나 정도로 작성하겠습니다.
Nginx의 워커는 기본적으로 비동기로 동작하며 워커 하나가 여러개의 요청을 처리할 수 있습니다. Nginx의 워커가 비동기로 설계된 이유는 Nginx가 나온 목적에 맞춰 설계 되었기 때문입니다. Nginx는 리버스 프록싱 및 정적 파일 처리를 위해서 탄생하였는데, 이는 모두 I/O bound 작업입니다. I/O bound 작업에서는 비동기 처리 방식이 유리한데 이 이유는 다음과 같습니다.
- CPU의 유휴 시간을 줄입니다.
I/O 작업을 기다리는 동안 CPU가 다른 작업을 수행할 수 있게 하여 자원을 효율적으로 사용하게 됩니다.- 스레드 오버헤드 감소
1번과 연관된 내용으로 I/O 작업은 CPU가 기다리는 시간이 긴데, 이를 동기적 멀티스레딩으로 처리하면 메모리나 컨텍스트 스위칭에 비용이 발생하게 됩니다. 그러나 비동기 처리 방식은 이벤트 루프 기반으로 동작하기에 스레드 오버헤드를 줄일 수 있습니다.
gunicorn의 워커는 Nginx와 다르게 기본적으로 동기로 동작합니다. 이 역시 gunicorn이 설계된 목적 때문인데, gunicorn은 Django나 Flask 같은 파이썬 기반의 WAS와 Nginx와 같은 웹 서버가 서로 종속성 없이 통신하기 위해 만들어졌습니다. 이 때 파이썬 WAS들은 비즈니스 로직을 수행하는데 이 비즈니스 로직들이 CPU bound 작업인 경우가 많습니다. CPU bound 작업은 동기적으로 동작하는 것이 유리한데 이유는 다음과 같습니다.
- 오버헤드를 감소 시킵니다.
CPU bound 작업은 많은 계산을 필요로 하기 때문에, 프로세스가 각 작업을 기다리지 않고 최대한 효율적으로 실행 되어야 합니다. 이 때 이 모든 작업이 순차적으로 실행되고 컨텍스트 스위칭, 추가적인 스레드에 작업 할당 등의 오버헤드가 발생하지 않는 동기 처리가 유리합니다.- 캐시 활용에 유리합니다.
동기 처리는 하나의 작업 흐름에서 연속적인 데이터 접근이 일어날 가능성이 높아, CPU 캐시를 더 효율적으로 활용할 수 있습니다. 이는 메모리 접근 속도를 높여 작업에 대한 성능을 개선합니다.
uvicorn은 gunicorn과는 또 다르게 기본적으로 비동기로 동작합니다. 이는 WAS들이 CPU bound 작업을 많이 수행하긴 하지만, 파일 읽기/쓰기나 DB 커넥션 같이 I/O bound 작업 또한 많이 수행하기 때문입니다. 즉, uvicorn은 비동기를 지원하거나 비동기로 설계된 파이썬 WAS들과 함께 사용되고 있습니다.
이제 Nginx, gunicorn 그리고 uvicorn 각각 워커의 차이까지 알아 보았으니 드디어 각각의 프로그램은 최대 몇 개의 워커가 적절한지 그리고 그 이유는 무엇인지를 알아보겠습니다.
우선 Nginx 공식 문서에서는 권장 최대 코어수를 CPU의 코어 수라고 설명하고 있습니다. 이는 앞서 설명드린 Nginx 워커가 비동기로 동작하기 때문인데요, 비동기 워커의 수가 CPU 코어 수와 일치하면 좋은지에 대한 이유는 대표적으로 다음과 같습니다.
- 병렬 처리를 최적화할 수 있습니다.
비동기 워커의 특성 상, 하나의 워커가 여러 개의 요청을 처리할 수 있습니다. 즉 CPU의 코어 당 하나의 워커가 점유하게 되면 여러 요청을 처리하는데 성능이 올라가고 이는 병렬 처리를 극대화 시킵니다.- 컨텍스트 스위칭이 감소합니다.
만약 워커 프로세스 수가 CPU 코어 수보다 많으면, 워커 프로세스들이 CPU 자원을 점유하기 위해 경쟁하게 됩니다. 이 때, 운영체제는 워커 프로세스 간의 컨텍스트 스위칭을 자주해야하고, 이는 추가적인 오버헤드를 발생시키게 됩니다.
이렇게 권장 사양에 따라 워커 수를 설정하려면 Nginx의 워커 수 설정 옵션을 활용하면 됩니다. 방법은 nginx.conf 파일의 worker_processes
옵션을 변경하면 되는데요, Nginx에서는 이러한 상황을 대비해 auto 기능이 있습니다. 해당 옵션을 auto로 설정하게 되면 Nginx가 시스템의 CPU 코어 수를 자동으로 감지하여 설정하게 됩니다. 해당 기능을 사용하지 않고 명시적으로 설정을 하고 싶을 경우에는 원하는 워커의 수를 숫자로 넣으면 됩니다.
# 워커 수를 자동으로 설정하고 싶은 경우
worker_processes auto;
# 워커 수를 수동으로 설정하고 싶은 경우
worker_processes 4; # 이렇게 하면 4개의 워커가 설정된다.
gunicorn의 공식 문서에서는 권장 코어수를 (2 * CPU 코어수) + 1이라고 하고 있습니다. 권장하는 워커 수가 Nginx와 다른 이유는 gunicorn의 워커가 동기로 동작하기 때문인데요, 동기 워커의 수가 이렇게 CPU 코어 수 보다 많은 이유는 대표적으로 다음과 같습니다.
- 동시성이 증가합니다.
I/O 작업이 많이 일어나는 WAS들의 특성 상, 대기 시간이 많이 발생할 수 있습니다. 이러한 작업 중에 CPU가 완전히 사용되지 않으므로, 추가적인 워커들이 더 많은 요청을 동시에 처리할 수 있습니다.- CPU 사용률이 극대화 됩니다.
I/O 대기 시간 동안 다른 워커들이 CPU 자원을 사용하기에 코어 수보다 많은 워커를 두면 CPU 자원을 더욱 효과적으로 활용할 수 있습니다.
이렇게 워커 수를 설정하려면 gunicorn의 워커 수 설정 옵션을 활용하면 됩니다. command line으로 gunicorn을 실행시킬 때, --workers
옵션을 주면 워커의 개수를 설정할 수 있습니다.
gunicorn <module_name>:<app_name> --workers 4 # gunicorn의 워커 수를 4개로 실행하는 옵션
uvicorn은 공식 문서에서 워커 수를 권장하지 않습니다. 그 이유는 앞서 설명 드린대로 WAS에서 CPU bound나 I/O bound 작업 중 하나만 수행하는 것이 아닌 모든 것을 수행하기 때문입니다.
uvicorn의 워커는 기본적으로 비동기 이벤트 루프를 통해 I/O Bound 작업과 CPU Bound 작업 모두를 처리합니다. I/O Bound 작업의 경우, 단일 워커에서 비동기적으로 최대한 많은 요청을 처리할 수 있기 때문에, 여러 워커가 필요하지 않을 수 있습니다. 비동기 처리를 통해 I/O Bound 작업은 빠르고 효율적으로 처리됩니다.
반면, CPU Bound 작업은 CPU 자원을 많이 소모하므로 여러 워커를 병렬로 사용하여 처리 성능을 높여야 합니다. CPU Bound 작업은 비동기 처리로는 효율적으로 처리되지 않기 때문에, 각 워커가 별도의 CPU 코어를 점유하여 병렬로 처리하는 것이 중요합니다. 따라서 I/O Bound 작업은 이벤트 루프를 통해 단일 워커에서 처리하는 동안, CPU Bound 작업은 여러 워커가 병렬로 처리하기에 워커의 수를 권장하지 않습니다.
uvicorn을 command line에서 실행 시키거나 파이썬에서 uvicorn 패키지를 import해 실행 시킬 때, --workers
옵션을 활용하면 됩니다.
# command line을 통해 실행시키는 경우
uvicorn <module_name>:<app_name> --workers 4
# 파이썬 모듈을 통해 실행시키는 경우
uvicorn.run('<module_name>:<app_name>', workers=4)
이렇게 Nginx, gunicorn 및 uvicorn에서 몇 개의 워커를 설정하면 좋은지와 그 이유에 대해 알아 보았습니다. 물론 이 글에서는 단순히 "그래서 워커 몇개? 왜?"에만 대답한 것이라 설명이 누락되거나 특수한 경우에 대한 서술이 부족할 수 있습니다. 그렇지만 이렇게 설정과 이유에 대해 알아보면서, 홈서버라는 한정적인 환경에서도 다양한 서비스를 개발할 수 있겠다는 자신감이 생겼습니다. 앞으로도 기술을 쓸 때 왜 이렇게 해야 하는가에 대한 질문을 끊임 없이 던져고 이를 정리해보겠습니다.