- Published on
Ray Serve 배포 시 OOM·지연 튐 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Ray Serve로 모델/서비스를 배포하면 “평소엔 빠른데 가끔 5~30초 튄다”, “트래픽 조금만 올라가도 OOMKilled 난다” 같은 문제가 자주 발생합니다. 특히 LLM/비전 모델처럼 워커가 무겁고, 배치 추론·스트리밍·멀티테넌시까지 얹으면 증상이 더 복합적으로 나타납니다.
이 글은 OOM과 지연 튐을 원인별로 쪼개서 진단하고, Ray Serve 설정과 코드 레벨에서 재현 가능하게 고치는 방법을 정리합니다. (Kubernetes 환경을 기준으로 설명하지만 VM/온프레에서도 개념은 같습니다.)
1) 증상부터 분류하기: OOM과 지연 튐은 같이 온다
Ray Serve에서 OOM과 지연 튐은 독립 사건처럼 보이지만, 실제로는 같은 뿌리에서 같이 나오는 경우가 많습니다.
- OOM → 재시작 → 콜드 스타트
- 모델 재로딩, 토크나이저/캐시 워밍업, CUDA 컨텍스트 재생성 등으로 지연 튐 발생
- 지연 튐 → 큐 적체 → 동시성 증가
- 요청이 쌓이면서 한 프로세스가 더 많은 작업을 잡고, 순간 메모리 피크가 커져 OOM으로 이어짐
- 오토스케일링 지연
- 스케일 아웃이 늦어 큐가 길어지고, 그 사이 워커가 과부하로 메모리/지연이 함께 악화
따라서 “OOM만 잡자”가 아니라, (1) 메모리 피크를 낮추고 (2) 큐 적체를 줄이며 (3) 콜드 스타트를 완화하는 3축으로 접근하는 게 빠릅니다.
2) Ray Serve에서 OOM이 나는 대표 원인 7가지
2.1 요청당 임시 객체가 너무 크다 (배치/토큰/이미지)
LLM에서 흔한 패턴은 “입력 텍스트를 리스트로 모아 배치 추론”인데, 이때 토큰화 결과(텐서)와 중간 activation, 출력 버퍼가 한 번에 커지며 피크 메모리가 폭증합니다.
- 배치 크기
batch_size를 고정으로 크게 잡음 - 최대 토큰 수
max_tokens가 커서 출력 버퍼가 커짐 - 이미지/오디오 등 바이너리를 요청 바디에 그대로 들고 다님
해결 방향은 동적 배치 크기 상한 + 입력 크기 제한 + 스트리밍/청크 처리입니다.
2.2 Serve Replica가 너무 많은 동시 요청을 잡는다
Serve는 기본적으로 한 replica가 여러 요청을 동시에 처리할 수 있습니다. 모델이 무거운 경우, 동시 요청 수가 늘면 중간 텐서가 겹쳐 메모리 피크가 선형 또는 그 이상으로 증가합니다.
max_concurrent_queries를 너무 크게 둠- 내부적으로
async로 동시에 여러 inference를 날림
해결은 동시성 상한을 낮추고, 대신 오토스케일로 replica 수를 늘리는 방향이 안전합니다.
2.3 오브젝트 스토어(Plasma)와 프로세스 메모리의 합이 노드 메모리를 넘는다
Ray는 프로세스 heap 외에도 오브젝트 스토어가 별도로 메모리를 점유합니다. 대형 입력/출력을 Ray object로 넘기면 object store에 복제/핀(pin)되면서 예상보다 메모리가 커집니다.
- 큰 numpy/torch 텐서를 반환값으로 그대로 리턴
- 불필요하게
ray.put()로 대형 객체를 공유
해결은 “큰 데이터는 가능한 프로세스 내부에서 처리하고, 반환은 작은 메타/핸들만”이 원칙입니다.
2.4 모델 로딩이 replica마다 중복되고, 캐시가 누적된다
- 각 replica가 모델을 개별 로딩 → 노드 메모리 급증
- 토크나이저/전처리 캐시가 무제한으로 쌓임
- 라이브러리(예: HF Transformers)의 내부 캐시가 워커별로 중복
해결은 replica 수와 노드 배치 전략을 조정하고, 캐시에는 TTL/상한을 둡니다. (비슷한 맥락의 메모리 폭주 대응은 AutoGPT 메모리 폭주, TTL·요약·RAG로 잡기도 참고할 만합니다.)
2.5 Python GC/메모리 단편화로 “반납되지 않는 메모리”가 누적된다
PyTorch/NumPy는 메모리 풀과 캐시를 사용합니다. “사용이 끝났는데도 RSS가 내려가지 않는” 상황이 흔합니다.
- CPU: glibc allocator 단편화
- GPU: CUDA caching allocator
이 경우 OOM은 “진짜 누수”가 아니라 피크가 반복되며 단편화/캐시가 쌓인 결과일 수 있습니다.
2.6 Kubernetes 리소스 limit과 Ray의 인지 리소스가 불일치한다
K8s에서 container memory limit이 16Gi인데 Ray가 노드 메모리를 32Gi로 인지하면, 스케줄러가 더 많은 워커를 올려서 OOMKilled가 납니다.
- Ray 시작 옵션에서 메모리/오브젝트 스토어 크기 미조정
- 노드 전체 리소스 기준으로 잡혀 과할당
2.7 스케일 인/업데이트 중 draining이 제대로 안 되어 순간 피크가 뜬다
업데이트 중 구버전 replica와 신버전 replica가 잠깐 공존하면서 메모리가 2배 가까이 튈 수 있습니다. 또한 종료가 지연되면 리소스가 회수되지 않아 OOM 확률이 올라갑니다. (K8s 종료 이슈는 Azure AKS에서 Pod가 Terminating에 멈출 때 해결법도 같이 보면 운영 감이 잡힙니다.)
3) 지연 튐(latency spike) 원인 8가지
3.1 콜드 스타트: replica 생성, 모델 로딩, JIT/커널 워밍업
- 새 replica가 뜨는 동안 요청이 큐에서 대기
- 첫 요청에서 CUDA 커널/그래프 컴파일, 토크나이저 초기화
3.2 큐 적체: backpressure가 없거나, 오토스케일이 늦다
- 짧은 시간에 트래픽이 몰리면 큐가 길어짐
- 오토스케일이 반응하기 전에 이미 tail latency가 폭발
3.3 파이썬 이벤트 루프/스레드풀 병목
async def로 보이지만 내부가 CPU 바운드 전처리면 이벤트 루프가 막힘- 토크나이저가 GIL을 오래 잡으면 동시성 효율이 급락
3.4 큰 요청/응답 직렬화 비용
- JSON으로 큰 배열을 주고받거나 base64 인코딩
- pydantic/fastapi 직렬화가 병목
3.5 Ray object store spill, 네트워크 셔플
- object store가 꽉 차면 디스크 spill이 발생
- 노드 간 전송이 늘면 tail latency가 증가
3.6 GC/메모리 압박으로 인한 pause
- Python GC가 큰 객체 그래프를 훑는 동안 stop-the-world
- 메모리 압박이 올라가면 커널이 reclaim을 하며 지연 증가
3.7 로깅/메트릭 I/O가 동기적으로 동작
- 요청당 로그를 과도하게 남기면 I/O wait로 지연 튐
3.8 “한 놈이 다 해먹는” 핫 파티션
- 특정 사용자/테넌트가 큰 요청만 보내거나, 특정 키가 특정 replica로만 라우팅되며 편향
4) 해결 전략: 설정(리소스) → 동시성 → 배치/입력 제한 → 캐시/GC 순으로
아래는 실전에서 효과가 큰 순서대로 정리한 처방입니다.
5) Ray Serve 설정으로 OOM과 지연 튐을 동시에 줄이기
5.1 Replica 리소스와 동시성 상한을 명시한다
모델이 무거운 서비스는 “한 replica가 처리할 수 있는 동시 요청 수”를 낮추는 게 핵심입니다.
from ray import serve
@serve.deployment(
ray_actor_options={
"num_cpus": 2,
"num_gpus": 1,
# 필요 시 커스텀 리소스로 노드 배치 제어 가능
# "resources": {"accelerator_type:A10": 1}
},
max_concurrent_queries=2, # 메모리 피크를 제어
)
class LLMService:
def __init__(self):
self.model = load_model()
async def __call__(self, request):
payload = await request.json()
return await self.generate(payload)
max_concurrent_queries를 낮추면 OOM 가능성이 크게 줄어듭니다.- 대신 처리량은 replica 수로 확장해야 하므로 오토스케일을 같이 설정합니다.
5.2 오토스케일을 “큐 기반”으로 보수적으로 튜닝한다
오토스케일이 늦으면 큐가 길어지고 tail latency가 폭발합니다. 반대로 너무 공격적이면 스케일 아웃/인이 잦아 콜드 스타트가 늘 수 있습니다.
@serve.deployment(
autoscaling_config={
"min_replicas": 2,
"max_replicas": 10,
"target_ongoing_requests": 1, # replica당 목표 진행 요청 수
"upscale_delay_s": 5,
"downscale_delay_s": 60,
},
max_concurrent_queries=2,
)
class LLMService:
...
target_ongoing_requests를1~2로 두면 큐가 길어지기 전에 확장합니다.downscale_delay_s는 길게 두어 불필요한 콜드 스타트를 줄입니다.
5.3 롤링 업데이트 시 순간 메모리 2배를 피한다
업데이트 중 구버전과 신버전이 공존하면 메모리가 튈 수 있습니다. 가능한 경우:
min_replicas를 줄이지 말고, 여유 노드를 확보한 상태에서 롤링- 모델 파일을 이미지에 포함하거나 로컬 캐시로 두어 다운로드로 인한 지연을 제거
6) 코드 레벨에서 메모리 피크 낮추기 (가장 효과 큼)
6.1 입력 크기 제한과 방어 로직을 먼저 넣는다
대부분의 OOM은 “예외적으로 큰 요청”에서 시작합니다. 요청 크기/토큰 상한을 강제하세요.
MAX_PROMPT_CHARS = 20_000
MAX_MAX_TOKENS = 1024
def validate(payload: dict) -> None:
prompt = payload.get("prompt", "")
if len(prompt) > MAX_PROMPT_CHARS:
raise ValueError("prompt too large")
max_tokens = int(payload.get("max_tokens", 256))
if max_tokens > MAX_MAX_TOKENS:
raise ValueError("max_tokens too large")
6.2 동적 배치: 상한을 두고, 메모리 기준으로 컷한다
배치는 처리량을 올리지만, 피크 메모리를 올립니다. “최대 배치”를 고정으로 크게 두기보다, 입력 길이 기반으로 배치를 나누는 전략이 안전합니다.
def pack_batches(requests, max_tokens_sum: int = 4096):
batch = []
total = 0
for r in requests:
t = r["prompt_tokens"]
if batch and total + t > max_tokens_sum:
yield batch
batch, total = [], 0
batch.append(r)
total += t
if batch:
yield batch
핵심은 “요청 개수”가 아니라 “토큰 합/픽셀 합/프레임 합”처럼 메모리를 더 잘 설명하는 지표로 배치를 제한하는 것입니다.
6.3 큰 바이너리는 base64로 들고 다니지 말고, 외부 스토리지/프리사인 URL로
base64는 메모리도 늘리고 직렬화도 느립니다. 가능하면:
- 업로드는 S3/GCS로
- Serve에는 프리사인 URL만 전달
- 워커는 스트리밍 다운로드 후 처리
7) Ray object store로 인한 메모리/지연 이슈 줄이기
7.1 대형 텐서를 반환하지 말고, 필요한 값만 반환
Serve 핸들러가 대형 배열/텐서를 그대로 반환하면 직렬화 및 object store 사용량이 커집니다.
- 반환은 텍스트/작은 JSON
- 대형 결과는 외부 저장소에 쓰고 key만 반환
7.2 불필요한 ray.put()를 피한다
공유를 위해 ray.put()를 남발하면 object store가 꽉 차고 spill로 지연이 튑니다. 특히 요청당 ray.put()는 금물입니다.
8) 캐시/GC/메모리 단편화 대응
8.1 캐시에 상한과 TTL을 둔다
토크나이저 결과나 전처리 결과 캐시는 성능에 도움이 되지만, 무제한이면 OOM의 지름길입니다.
import time
class TTLCache:
def __init__(self, max_items=1024, ttl_s=300):
self.max_items = max_items
self.ttl_s = ttl_s
self.store = {}
def get(self, k):
v = self.store.get(k)
if not v:
return None
value, exp = v
if time.time() > exp:
self.store.pop(k, None)
return None
return value
def set(self, k, value):
if len(self.store) >= self.max_items:
# 매우 단순한 eviction (실전에서는 LRU 권장)
self.store.pop(next(iter(self.store)))
self.store[k] = (value, time.time() + self.ttl_s)
8.2 PyTorch를 쓴다면 “캐시 메모리”를 이해하고 관측한다
GPU OOM은 실제 사용량뿐 아니라 캐시가 영향을 줍니다. 요청 처리 후에 필요 시:
torch.cuda.empty_cache()는 만능은 아니지만, 특정 워크로드에서 피크를 낮추는 데 도움이 될 수 있습니다.- 더 중요한 건 “동시성/배치 상한”으로 피크 자체를 낮추는 것입니다.
8.3 주기적 워커 재시작(리사이클) 전략
단편화/누적이 의심되면 “완벽한 누수 제거”보다 운영적으로 일정 요청 수/시간 후 재시작이 더 현실적인 해법입니다.
- 예: replica를 6시간마다 롤링 재시작
- 단, 재시작이 곧 콜드 스타트이므로
min_replicas와 워밍업을 함께 설계
9) 지연 튐을 줄이는 워밍업과 프리로딩
9.1 replica 시작 시 워밍업 호출
첫 요청이 느린 이유가 커널 워밍업/캐시 미스라면, 시작 시 더미 inference를 수행하세요.
@serve.deployment(max_concurrent_queries=2)
class Model:
def __init__(self):
self.model = load_model()
self._warmup()
def _warmup(self):
dummy = "warmup"
_ = self.model.generate(dummy)
9.2 모델 파일/토크나이저 파일 다운로드를 배포 단계로 당긴다
런타임에 외부에서 모델을 받으면 네트워크/스토리지 상태에 따라 지연이 튑니다.
- 컨테이너 이미지에 포함
- 또는 init container로 미리 다운로드
- 또는 노드 로컬 캐시 사용
10) Kubernetes에서 특히 자주 하는 실수와 체크리스트
10.1 리소스 request/limit을 명확히
resources.requests.memory와resources.limits.memory를 명시- GPU 노드에서 CPU request가 너무 낮으면 스케줄링이 꼬여 성능이 흔들릴 수 있음
10.2 OOMKilled와 tail latency를 함께 본다
- OOMKilled 이벤트가 있으면, 그 직후 p99 지연이 튀는지 확인
- HPA/KEDA와 Ray Serve autoscale을 동시에 쓰면 스케일 정책이 충돌할 수 있으니 역할을 분리
10.3 Pod 종료가 지연되면 리소스 회수가 늦어져 연쇄 장애
종료가 깔끔하지 않으면 “스케일 인이 됐는데도 메모리가 안 비는” 상황이 생깁니다. 이런 운영 이슈는 Azure AKS에서 Pod가 Terminating에 멈출 때 해결법처럼 종료 경로를 점검하는 게 중요합니다.
11) 실전 처방전: 가장 많이 먹히는 조합
다음 조합은 LLM/비전 모델 Serve에서 OOM과 지연 튐을 동시에 줄이는 데 자주 성공합니다.
max_concurrent_queries를1~2로 낮춘다- 오토스케일을
target_ongoing_requests=1근처로 둔다 - 입력 크기/토큰 상한을 강제한다
- 배치는 “요청 수”가 아니라 “토큰 합/픽셀 합”으로 제한한다
- 큰 결과는 object store/HTTP 응답으로 직접 들고 오지 말고 외부 저장소로
- 워밍업을 넣어 콜드 스타트를 완화한다
- 캐시에는 TTL/상한을 둔다
메모리 문제는 대개 “평균”이 아니라 “피크”에서 터지고, 지연 튐은 대개 “큐”에서 시작합니다. 그러니 관측 지표도 평균이 아니라 다음을 중심으로 잡아야 합니다.
- replica당 동시 요청 수
- 큐 길이(ongoing requests)
- 요청 크기 분포(토큰/바이트)
- RSS/VRAM 피크
- OOMKilled 이벤트와 재시작 횟수
12) 마무리
Ray Serve의 OOM과 지연 튐은 “리소스를 더 준다”로만 해결되지 않는 경우가 많습니다. 동시성/배치/입력 상한을 통해 피크를 제어하고, 오토스케일과 워밍업으로 큐와 콜드 스타트를 줄이면 안정성이 급격히 좋아집니다.
추가로, 운영 환경에서 메모리 누적이 의심될 때는 캐시 정책(TTL)과 워커 리사이클 전략을 함께 가져가면 “원인 규명”과 별개로 장애 빈도를 실질적으로 낮출 수 있습니다. 이 관점은 AutoGPT 메모리 폭주, TTL·요약·RAG로 잡기에서 다룬 운영적 완화와도 결이 같습니다.