Post

Gunicorn & Uvicorn

그냥 생각 없이 다들 쓰니까 쓰던 것들 자세히 알아보기

Gunicorn vs Uvicorn

프로토콜

  • Gunicorn : WSGI(Web Server Gateway Interface) 지원, WSGI는 동기 방식으로 작동하고 한 번에 하나의 요청만 처리 가능
  • Uvicorn : ASGI (Asynchronous Server Gateway Interface) 지원, ASGI는 비동기 방식 작동하고 동시에 여러 요청 처리 가능

동작 방식

  • Gunicorn
    • 멀티프로세스 기반으로 여러개의 worker 프로세스 생성해 요청을 처리. 각 worker는 독립적으로 실행되고 동시에 여러 요청을 처리 가능
      • ex) request 10건이 있고 worker=4 일 때, 최대 4개의 요청을 동시에 처리 가능하고 나머지 요청은 큐에서 대기
    • 하나의 worker 프로세스에서 문제가 발생해도 다른 프로세스에 영향을 주지 않기 때문에 안정성에 유리하지만 메모리 사용량이 많아짐
  • Uvicorn
    • 단일 프로세스에서 asyncio를 사용해 비동기적으로 요청 처리
      • ex) request 10건이 있을 때, 각 요청을 작은 단위로 나눠서 번갈아가며 처리. 어떤 요청이 I/O 작업이라면 응답을 기다리지 않고 다른 작업 처리 하다가 I/O 작업 완료시 다시 돌아와 마저 진행
    • 단일 프로세스 안에서 이벤트 루프를 통해 여러 작업을 처리하기 때문에 Gunicorn 대비 메모리 사용량 적음

Gunicorn은 주로 Django, Flask와 같이 WSGI 기반 프레임워크와 사용하고 Uvicorn은 Fastapi 처럼 ASGI 기반 프레임워크에 사용한다.

그런데 FastAPI에서 모두 동기 함수로만 개발해도 Uvicorn이 필요할까? 라는 궁금증이 들어 찾아보니 애초에 Uvicorn은 각 요청을 coroutine으로 처리하기 때문에, I/O 작업이 있을 시 자동으로 비동기 처리함.

Gunicorn + Uvicorn

FastAPI를 배포할 때 Uvicorn을 단독으로 사용하는 것 보다 Gunicorn과 함께 사용하는 것이 일반적으로 더 좋은 성능을 보장한다고 한다.

  • Uvicorn 단독 실행의 한계
    • Python GIL 특성으로 인해 한 번에 하나의 쓰레드만 python 코드를 실행할 수 있기 때문에, CPU 연산이 많은 경우 병목 현상이 발생함
    • 단일 프로세스/단일 스레드로 동작하기 때문에 멀티 코어CPU의 모든 코어를 활용할 수 없음
  • Gunicorn + Uvicorn 혼합 실행의 장점
    • Gunicorn은 여러 개의 worker 프로세스를 생성하여 각 프로세스가 독립적으로 요청을 처리하기 때문에 멀티 코어 CPU 제대로 활용 가능
    • 각 Worker 프로세스는 Uvicorn을 통해 비동기 작업 처리

gunicorn -w 4 -k uvicorn.workers.UvicornWorker

Gunicorn은 설정 된 수 4개 만큼 Worker 프로세스를 생성하고, 각 프로세스는 uvicorn.workers.UvicornWorker 클래스를 사용해서 Uvicorn 인스턴스를 생성한다.

클라이언트 요청이 들어오면, Gunicorn은 해당 요청을 Worker 프로세스 중 하나에 할당하고, 할당 된 Worker 프로세스 내의 Uvicorn 인스턴스가 요청을 처리하고 응답을 반환한다.

Gunicorn은 Worker 프로세스 간의 로드밸런싱을 자동으로 수행행서 각 프로세스에 균등하게 요청을 분배한다.

성능 비교

얼마나 차이날지 궁금해서 실제로 해봄

Locust 설정

10명의 유저가 동시에 요청하도록 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from locust import HttpUser, task, between


class WebsiteUser(HttpUser):
    wait_time = between(1, 5)
    host = "http://127.0.0.1:8000"

    @task(1)
    def cpu_intensive_task(self):
        self.client.get("/cpu-intensive")

    @task(1)
    def memory_intensive_task(self):
        self.client.get("/memory-intensive")

  • locust result stat
    • Name: 요청의 이름이나 경로
    • requests: 해당 요청이 몇 번 실행되었는지
    • failures: 요청 실패 횟수
    • Median response time: 응답 시간의 중앙값(밀리초 단위). 모든 요청 중간에 위치하는 응답 시간을 의미
    • Average response time: 평균 응답 시간
    • Min/Max response time: 관찰된 최소 및 최대 응답 시간
    • Request per second: 초당 요청 수

Test Endpoints

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import time
from fastapi import FastAPI

app = FastAPI()


@app.get("/cpu-intensive")
def cpu_intensive_task():
    """중첩 for 루프로 CPU 사용량 증가"""
    start_time = time.time()
    for i in range(1000):
        for j in range(100):
            pass
    end_time = time.time()
    return {
        "message": "CPU-intensive task completed",
        "duration": end_time - start_time,
    }


@app.get("/memory-intensive")
def memory_intensive_task():
    """대용량 리스트 생성"""
    start_time = time.time()
    data = [i for i in range(10000)]
    end_time = time.time()
    return {
        "message": "Memory-intensive task completed",
        "duration": end_time - start_time,
    }


@app.get("/sync-google")
def request_google_sync():
    """google.com에 동기적으로 요청"""
    start_time = time.time()
    response = requests.get("https://www.google.com")
    end_time = time.time()
    return {
        "message": "Sync request to google.com completed",
        "duration": end_time - start_time,
    }


@app.get("/async-google")
async def request_google_async():
    """google.com에 비동기적으로 요청"""
    start_time = time.time()
    async with aiohttp.ClientSession() as session:
        async with session.get("https://www.google.com") as response:
            await response.text()
    end_time = time.time()
    return {
        "message": "Async request to google.com completed",
        "duration": end_time - start_time,
    }


uvicorn

uvicorn main:app

image

uvicorn + gunicorn

gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app

  • w 4: worker 프로세스의 개수를 4개로 설정합니다. 이는 CPU 코어 수에 따라 적절히 조절해야 합니다.
  • k uvicorn.workers.UvicornWorker: worker 클래스로 UvicornWorker를 사용하도록 지정, UvicornWorker는 Gunicorn 내에서 Uvicorn을 실행하여 비동기 요청 처리를 담당

image

This post is licensed under CC BY 4.0 by the author.