Django나 FastAPI와 같은 웹 프레임워크를 사용하면 간단하게 파이썬 API 서버를 만들 수 있다. Django나 Fastapi와 같은 웹 프레임워크는 웹 애플리케이션 개발을 쉽게 하기 위한 다양한 기능을 제공한다. 예컨대, Routing, Template Rending, Session Management, Request / Response handling 등과 같은 기능이 있다. 이렇게 웹 프레임워크로 개발한 애플리케이션을 안정적이고 빠르게 서빙하기 위해서 보통은 WSGI(Web Server Gateway Interface)와 같은 인터페이스를 사용한다.
최초에 WSGI는 다양한 웹 프레임워크를 위한 인터페이스를 지원하기 위해서 고안되었지만(PEP3333), 최근에는 점차 성능을 개선시키고, 보안이나 안정성을 강화시키는 등의 노력이 들어가고 있다. 비동기 프레임워크의 대표주자인 FastAPI와 더불어 Django도 3.0 버전부터 비동기 애플리케이션을 지원한다. 다양한 프레임워크에서 비동기를 지원하면서 이제는 ASGI(Asynchronous Server Gateway Interface) 도구들에 대한 관심이 많아지고 있다.
가장 대표적인 ASGI 구현체가 바로 uvicorn이다. Uvicorn은 고성능 ASGI 서버 구현체이다. 주로 Python에서 비동기 웹 프레임워크를 지원하기 위해 설계되었다. Uvicorn은 HTTP, WebSocket 프로토콜을 모두 지원하며, HTTP/2와 같은 최신 웹 표준도 지원한다. 내부적으로는 uvloop와 httptools를 사용하여 매우 높은 성능을 제공한다. uvloop는 이벤트 루프의 성능을 극대화하는 라이브러리이고, httptools는 빠르고 효율적인 HTTP 파서를 제공한다.
Uvicorn은 FastAPI, Starlette, Django Channels 등과 같은 ASGI 기반의 웹 프레임워크와 잘 통합된다. 이러한 프레임워크를 사용하면 비동기 I/O 작업을 효율적으로 처리할 수 있어, 높은 동시성을 요구하는 애플리케이션에서 매우 유용하다.
Uvicorn은 프로덕션 환경에서도 사용 가능하다. 다만, 일반적으로 Gunicorn과 같은 다른 WSGI 서버와 함께 결합해서 멀티 프로세스 성능을 확보하는 편이다. Gunicorn은 자체적으로는 WSGI이지만, 각 Worker들이 비동기로 수행될 수 있도록 ASGI 서버를 지원한다.
Gunicorn과 Uvicorn을 함께 사용하면, 비동기 성능을 살리지 못하지 않을까 하는 의문이 들 수도 있다. 겉으로만 보면, 각 worker들은 비동기로 요청을 처리하지만, 정작 트래픽을 가장 앞에서 분배해 주는 Gunicorn이 동기로 동작하는 것처럼 보이기 때문이다.
그러면, Gunicorn이 요청을 분배하는 방식을 살펴보자.
Gunicorn은 기본적으로 멀티 프로세싱(Multi Processing)을 지원하는데, 이때 fork 방식을 사용한다. fork는 부모 프로세스(parent process)로부터 자식 프로세스(child process)를 생성하는 시스템 호출이다. fork 호출은 자식 프로세스를 생성하여 부모 프로세스의 주소 공간을 복사하는 역할을 한다.
다음은 Gunicorn에서 master process가 worker process를 실행하는 부분이다.
위 코드에서 보면, os.fork() 함수를 수행한다. 해당 함수가 바로 fork() 시스템 콜을 통해서 현재 프로세스를 복사해 자식 프로세스를 만드는 부분이다. 프로세스를 그대로 복사하기 때문에 자식프로세스도 해당 코드라인부터 실행된다. 이때, 부모 프로세스는 pid로 자식 프로세스의 pid를 반환받지만, 자식 프로세스는 0번을 반환받는다. master 프로세스는 로직에 따라 worker 정보를 저장한 후 return 된다.
하지만 자식 프로세스는 worker.init_process()까지 수행된다. 여기서 자식 프로세스는 수명 주기가 다할 때까지 멈춰있게 된다. 만약 init_process() 함수가 종료되면, sys.exit(0) 코드라인이 호출되고 비로소 자식 프로세스는 종료된다.
중요한 건, fork()를 통해 생성된 자식 프로세스는 부모 프로세스가 바인딩한 socket을 상속받는 것이다.
master 프로세스가 worker 시작할 때 변수로 전달받은 바인딩 정보를 가지고 먼저 socket을 연다. 그런데 자식 프로세스가 fork()하는 과정에서 이를 그대로 상속받아서 사용한다. 자식 프로세스는 서버를 수행할 때 공유받은 socket에서 accept() 함수를 호출하고, 클라이언트 요청을 기다린다.
다음과 같은 샘플 애플리케이션을 만들고, gunicorn으로 실행해 보면 같은 FD(File Descriptor)를 사용하는 것을 알 수 있다.
다음과 같이 worker를 4개로 설정하고 수행해 보자. 그러면 master 프로세스까지 총 5개의 프로세스가 수행된다.
이제, 실제 8000번을 LISTEN 하고 있는 프로세스가 무엇인지 확인해 보면 된다.
결과를 보면, 5개의 프로세스가 8000번 포트에 바인딩된 소켓을 사용하고 있는 것을 알 수 있다.
그러면, 실제로 accept() 함수를 호출하는지 확인해 보자.
worker 프로세스에 strace를 걸어보면, 다음과 같이 accept4() 시스템 콜을 호출하는 것을 볼 수 있다. 참고로, master 프로세스는 accept() 함수를 호출하지 않는다. 왜냐하면, master 프로세스는 요청을 처리하는 프로세스가 아니라, worker를 관리하기 위한 프로세스이기 때문이다. 실제로 strace에서도 accept() 함수를 호출하는 기록이 보이지 않는다.
(아래 화면에서, -1 EAGAIN에러가 발생하는 이유는 Asyncio에서 소켓이 Non Blocking으로 동작하기 때문이다. 즉, 해당 동작은 정상으로 간주해도 무방하다.)
그러면 accept4() 시스템 콜은 어떻게 요청을 분산하는 것일까?
accept4() 시스템 콜은 내부적으로 관리하는 queue에서 요청 소켓 객체(연결 요청)를 꺼내서 반환한다. worker 프로세스들은 accept() 함수를 통해서 연결 요청을 받을 때까지 대기한다. 커널은 queue에서 요청 하나를 꺼내서, accept()를 부른 프로세스 중 하나를 깨워 해당 객체를 전달한다. 정확하게 Round Robin 방식은 아니지만, 그래도 어느 정도는 균일하게 요청을 분배하는 셈이다.
요컨대, Gunicorn와 Uvicorn은 함께 사용할 때 강력한 조합이 될 수 있다. Gunicorn은 내부적으로 운영체제 수준에서 요청을 분배하고, 각 worker 프로세스가 자체적으로 동기/비동기 구현을 할 수 있도록 지원한다. 따라서 애플리케이션 특성에 맞게 worker_class를 골라서 사용하면, 견고하게 웹 애플리케이션을 만들 수 있다.
* 혹시나 잘못된 내용이 있다면 댓글로 조언 부탁드립니다!