uvicorn 0.16.0 성능문제

cloud 에서 uvicorn 으로 서비스 해야 하는 상황에서 어떤 구조로 서비스 하는게 효율이 좋을 지 고민 중에 다음과 같은 테스트를 진행했고 뜻밖의 상황을 기록해둔다.

테스트를 마친 지금 결론은 uvicorn 의 특정버전 문제가 아닌 듯 하다. uvicorn 이 worker 프로세스를 제어하는 방식(spawn)에서 파생된 문제로 보인다. (그렇다고 하기에도 워커가 1개일때도 gunicorn 성능이 조금 더 나온다.)

1. env

  • apple m1 macbook air, osx v.11.6.2 bigsur, memory 16GB
  • Running uvicorn 0.16.0 with CPython 3.9.7 on Darwin
  • gunicorn (version 20.1.0)

1) 테스트 코드
DB나 무거운 Job 을 async 하게 처리 한다는 가정하에 sleep(0.1) 간 대기하는 코드를 테스트 함

import asyncio
import time
from fastapi import FastAPI

app = FastAPI()

@app.get("/delay-job")
async def hello():
    await asyncio.sleep(0.1)
    return {"hello":"world"}

2. gunicorn vs uvicorn 동일 workers 테스트

gunicorn 으로 프로세스를 uvicorn 4개 띄웠을때(fork)와 uvicorn 단독으로 4개 띄웠을때(spawn) 성능차이가 난다.
프로세스를 띄우는 방식 자체가 다르긴 한데, 성능차이가 이렇게 나는건 uvicorn 문제가 아닌가 싶다.

Spawn 방식

상위 프로세스는 새로운 파이썬 인터프리터 프로세스를 시작합니다. 자식 프로세스는 프로세스 개체의 run()메서드 를 실행하는 데 필요한 리소스만 상속합니다 . 특히, 부모 프로세스의 불필요한 파일 디스크립터 및 핸들은 상속되지 않습니다. 이 방법을 사용하여 프로세스를 시작하는 것은 fork 또는 forkserver 를 사용하는 것에 비해 다소 느립니다 .

Fork 방식

상위 프로세스는 os.fork()Python 인터프리터를 분기하는 데 사용 합니다. 자식 프로세스가 시작되면 부모 프로세스와 사실상 동일합니다. 부모의 모든 리소스는 자식 프로세스에 상속됩니다. 다중 스레드 프로세스를 안전하게 분기하는 것은 문제가 있음을 유의하십시오.

https://docs.python.org/3/library/multiprocessing.html

1) 부하 테스트 기준

peak concurrency 100, spawn rate 10

2) gunicorn 설정, worker 4

gunicorn app.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000

3) uvicorn 설정, worker 4

uvicorn app.main:app --workers=4 --limit-concurrency=1000 --backlog=2048

3. gunicorn vs uvicorn 단일 워커 테스트

1) gunicorn 설정, worker 1

gunicorn app.main:app --workers 1 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000

2) uvicorn 설정, worker 1

uvicorn app.main:app --workers=1 --loop=uvloop --http httptools --limit-concurrency=1000

4. nginx+uvicorn(container 4) 테스트

아래와 같이 docker-compose 로 nginx(loadbalancer) + uvicorn(worker 1으로 4대) 로 테스트 했을때 guvicorn 으로 테스트 했을때 보다 성능이 조금 떨어지는 것을 확인 할 수 있었다.
nginx(loadbalancer) + uvicorn(worker 4로 4대) 도 테스트 해봤으나 아래 수치에서 큰 변화는 없었다

  • /etc/nginx/conf.d/default.conf (nginx 설정)
upstream app_server {
    server backend1:8000 fail_timeout=0;
    server backend2:8000 fail_timeout=0;
    server backend3:8000 fail_timeout=0;
    server backend4:8000 fail_timeout=0;
}

server {
    listen 80;
    server_name default;

    location / {
      try_files $uri @proxy_to_app;
    }

    location @proxy_to_app {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header Host $http_host;
      proxy_redirect off;
      proxy_pass http://app_server;
    }
}
  • docker-compose.yaml
version: '3'

services:
  web:
    container_name: web
    image: nginx:latest
    depends_on:
      - backend
    ports:
      - "80:80"
    volumes:
      - ./tests/conf.d:/etc/nginx/conf.d
    networks:
      - nginx_network

  backend1:
    container_name: backend1
    image: test-app:20211221
    expose:
      - 8000
    networks:
      - nginx_network
      
  backend2:
    container_name: backend2
    image: test-app:20211221
    expose:
      - 8000
    networks:
      - nginx_network

  backend3:
    container_name: backend3
    image: test-app:20211221
    expose:
      - 8000
    networks:
      - nginx_network

  backend4:
    container_name: backend4
    image: test-app:20211221
    expose:
      - 8000
    networks:
      - nginx_network

networks:
  nginx_network:
    driver: bridge

5. nginx+gunicorn(container 4) 테스트

nginx(loadbalancer) + gunicorn(worker 4로 4대)

결론

  • 참고
multiprocessing fork() vs spawn()
I was reading the description of the two from the python doc: spawnThe parent process starts a fresh python interpreter process. The child process will only inherit those resources necessary to r...

https://www.facebook.com/groups/pythonkorea/posts/4665957046820753/?comment_id=4667019106714547&notif_id=1640010291837102&notif_t=group_comment&ref=notif