LiteLLM CPU 병목 — 단일 워커에 묶인 LLM 게이트웨이

AI 인프라4분조회

여러 서비스가 각자 LLM 을 부르다 보니, 이걸 한 곳으로 모으고 싶었습니다. 서비스마다 OpenAI·Bedrock 을 직접 부르면 API 키가 흩어지고 비용 추적도 안 되기 때문입니다. 그래서 앞에 LLM 게이트웨이를 뒀습니다. 모든 서비스가 이 게이트웨이 하나만 부르고, 게이트웨이가 실제 provider 로 라우팅·키 관리·비용 추적을 대신하는 구조입니다. 구현은 LiteLLM Proxy — 여러 LLM provider 를 OpenAI 호환 API 하나로 묶어 주는 오픈소스 프록시 — 를 썼습니다.

처음엔 이 게이트웨이를 EC2 VM 한 대에 컨테이너로 띄운 단순한 구성이었습니다. 그런데 부하가 몰리면 응답이 밀리는데, 정작 서버 CPU 는 27.6% 에서 더 안 올라갔습니다. 4 vCPU(c6g.xlarge) 인데 3코어가 놀고 있었습니다.

워커 하나 = 1코어 (GIL 제약)

컨테이너 안의 프로세스를 봤습니다.

PID 1: /usr/bin/python3 /usr/bin/litellm --config=/app/config.yaml

프로세스가 하나뿐인데, 왜 하나인지는 LiteLLM 이 무엇으로 만들어졌는지를 보면 풀립니다.

LiteLLM Proxy 의 백엔드는 FastAPI 입니다. FastAPI 는 ASGI 앱이고, 그걸 실제로 돌리는 건 Uvicorn 이라는 ASGI 서버입니다. 그리고 LiteLLM 은 기본값이 Uvicorn 워커 1개입니다. 워커 하나는 파이썬 프로세스 하나고, 파이썬은 GIL 때문에 한 프로세스가 한 번에 한 코어만 씁니다. 비동기(async)라 I/O 대기는 겹쳐서 잘 처리하지만 — LLM 게이트웨이는 대부분 업스트림 응답을 기다리는 I/O 작업이라 이 점은 잘 맞습니다 — 여러 코어를 동시에 쓰려면 프로세스, 즉 워커 자체를 늘려야 합니다. 그래서 워커 1개 = 1코어입니다.

숫자가 맞아떨어집니다.

1코어 ÷ 4코어 = 25%
+ prisma, 기타 오버헤드 = 27.6%

27.6% 는 부하가 부족해서 나온 천장이 아니라, 한 코어를 100% 쓰고 있는 상태였습니다. 워커 하나가 CPU 를 꽉 쥐니 요청이 큐에 쌓이고 latency 가 튑니다. 나머지 3코어는 손도 못 댑니다.

당장 트래픽을 받아내야 해서, 급한 대로 워커 수를 코어 수에 맞췄습니다.

litellm --config=/app/config.yaml --num_workers 4
PID   1: litellm ... --num_workers 4   # master
PID 190: multiprocessing.spawn (worker 1)
PID 191: multiprocessing.spawn (worker 2)
PID 192: multiprocessing.spawn (worker 3)
PID 193: multiprocessing.spawn (worker 4)

CPU 상한이 25% 에서 100% 로 열리고 처리량이 4배가 됐습니다.

워커 증설로 깨진 Prometheus 집계

워커가 4개가 되자 이번엔 Prometheus 지표가 워커마다 따로 집계돼서 값이 튀기 시작했습니다.

Prometheus client 는 지표를 프로세스 메모리에 담습니다. 워커가 하나일 땐 문제가 없지만, 여러 프로세스가 되면 각자 자기 카운터를 들고 있어서 /metrics 를 긁을 때마다 어느 워커가 응답하느냐에 따라 값이 달라집니다.

이럴 때 쓰라고 멀티프로세스 모드가 있습니다. PROMETHEUS_MULTIPROC_DIR 로 디렉토리를 하나 지정하면 워커들이 지표를 그 디렉토리에 파일로 쓰고, /metrics 는 전체를 합산해서 돌려줍니다.

집계가 워커별 pid 라벨로 분리돼 들어옵니다.

litellm_deployment_state{..., pid="193"} 0.0
litellm_deployment_state{..., pid="196"} 0.0

임시 조치에서 EKS 이관으로

--num_workers 4 는 VM 한 대에서 당장 CPU 를 다 쓰게 만든 임시 조치였습니다. 근본적으로는 두 가지가 걸렸습니다. 워커를 늘리자 위처럼 지표 집계가 꼬였고, 무엇보다 게이트웨이가 VM 한 대에 묶여 있어 그 서버가 죽으면 모든 서비스의 LLM 호출이 같이 멈춥니다. 여러 서비스가 이 게이트웨이 하나를 거치는 구조라, 단일 장애점으로 두기엔 부담이 컸습니다.

그래서 이후엔 EKS 로 Helm 차트를 써서 옮겼습니다. 여기선 확장 방향이 반대입니다. 파드당 워커는 1개로 두고, 파드 수를 늘려(HPA) 수평 확장합니다. LiteLLM 공식 문서도 쿠버네티스에선 이 방식을 권합니다. 단일 컨테이너면 --num_workers N 으로 수직 확장이 맞지만, 쿠버네티스면 워커는 1개로 두고 파드로 늘리는 게 맞습니다.

옮기고 나니 앞의 Prometheus 집계 문제도 자연스럽게 사라졌습니다. 파드마다 /metrics 가 따로 있고 Prometheus 가 각 파드를 별도 타겟으로 긁으니, 한 프로세스 안에서 워커를 여러 개 돌릴 때 생기던 멀티프로세스 합산 문제 자체가 안 생깁니다. 한 파드 = 한 워커 = 한 /metrics 라 집계가 단순해집니다.

결국 스케일 단위를 어디에 둘지의 문제였습니다. 단일 VM 이면 --num_workers 로 프로세스를 늘려 코어를 다 쓰고, 쿠버네티스면 파드당 워커 1개로 두고 파드 수로 늘립니다. 후자로 가면 멀티프로세스 지표 합산 문제도 같이 사라지고, 게이트웨이가 서버 한 대에 묶이지도 않습니다.

참고

  1. 불러오는 중