LiteLLM CPU 병목 — 단일 워커에 묶인 LLM 게이트웨이
부하가 몰리면 응답이 밀리면서 정작 서버 CPU 는 27.6% 에서 더 안 올라갔습니다. 4
vCPU(c6g.xlarge) 인데도 3코어가 놀고 있었습니다.
돌고 있던 건 여러 LLM provider 를 OpenAI 호환 API 하나로 묶어 주는 오픈소스 프록시 LiteLLM Proxy 입니다. 앞에 게이트웨이를 하나 두고 서비스들이 그것만 부르게 한 구조입니다. EC2 VM 한 대에 컨테이너로 띄워 뒀고, 트래픽이 늘면 인스턴스 타입을 키우면 된다고 보고 있었습니다.
워커 하나 = 1코어 (GIL 제약)
CPU 를 다 쓰고 있으니 코어를 더 줬는데 사용률이 그대로여서, 컨테이너 안의 프로세스부터 봤습니다.
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)
한 코어에 묶여 있던 상한이 풀려 네 코어를 다 쓰게 됐고, 처리량도 같이 올랐습니다.
워커 증설로 깨진 Prometheus 집계
워커가 4개가 되자 이번엔 Prometheus 지표가 워커마다 따로 집계돼서 값이 튀기 시작했습니다.
Prometheus client 는 지표를 프로세스 메모리에 담습니다. 워커가 하나일 땐 문제가 없지만 여러
프로세스가 되면 각자 자기 카운터를 들고 있어서 /metrics 를 수집할 때마다 어느 워커가
응답하느냐에 따라 값이 달라집니다.
이럴 때 쓰라고 멀티프로세스 모드가 따로 있습니다. PROMETHEUS_MULTIPROC_DIR 로 디렉토리를
하나 지정하면 워커들이 지표를 그 디렉토리에 파일로 쓰고, 카운터는 그걸 합산해서 나옵니다.
다만 게이지는 워커별 pid 라벨을 달고 따로 들어옵니다.
litellm_deployment_state{..., pid="193"} 0.0
litellm_deployment_state{..., pid="196"} 0.0
그래서 대시보드에서 한 값으로 보려면 pid 라벨을 빼고 묶어 줘야 합니다.
임시 조치에서 EKS 이관으로
--num_workers 4 는 VM 한 대에서 당장 CPU 를 다 쓰도록 한 임시 조치였습니다. 근본적으로 걸린 건
두 가지라, 워커를 늘리자 위처럼 지표 집계가 꼬였고 무엇보다 게이트웨이가 VM 한 대에 묶여 있어 그
서버가 죽으면 모든 서비스의 LLM 호출이 같이 멈춥니다. 여러 서비스가 이 게이트웨이 하나를 거치는
구조라, 단일 장애점으로 두기엔 부담이 컸습니다.
이후엔 EKS 로 Helm 차트를 써서 옮겼고, 여기선 확장 방향이 반대입니다. 파드당 워커는 1개로 두고 파드 수를 늘려(HPA) 수평 확장합니다. LiteLLM 공식 문서도 쿠버네티스에선 이 방식을 권합니다.
옮기고 나니 앞의 Prometheus 집계 문제도 같이 사라졌습니다. 한 파드에 워커 하나, 그 워커에
/metrics 하나라, Prometheus 가 각 파드를 별도 타겟으로 스크랩하면 합산할 일 자체가 없습니다.
파이썬 앱의 CPU 사용률이 1 ÷ 코어 수 근처에서 멈춰 있으면 부하가 모자란 게 아니라 워커가
하나인지부터 확인하면 됩니다. 같은 병목이라도 손대는 자리는 어디서 돌리느냐로 갈립니다.
VM 한 대면 프로세스를 늘려 코어를 다 쓰고 쿠버네티스면 파드 수로 늘립니다.
댓글 0