Published on

Ray Serve 배포 시 모델 로딩 지연·OOM 해결법

Authors

서빙 환경에서 Ray Serve를 쓰면 배포/스케일링이 편해지지만, 운영에서 가장 자주 터지는 문제는 딱 두 가지입니다. 첫째는 배포 직후 혹은 스케일 아웃 시 모델 로딩이 너무 느려져서 타임아웃/콜드스타트가 커지는 문제, 둘째는 모델 로딩 또는 첫 요청 처리 중 OOM(Out Of Memory)로 레플리카가 죽는 문제입니다.

이 글은 “왜 느린지/왜 죽는지”를 Ray Serve의 초기화 흐름과 메모리 모델을 기준으로 쪼개고, 바로 적용 가능한 설정/코드 패턴을 제시합니다.

참고: 쿠버네티스(EKS)에서 Ray를 운영한다면 노드 디버깅이 필요할 때가 많습니다. SSH가 막힌 환경이면 EKS Bottlerocket 노드 SSH 없이 SSM으로 접속·디버깅도 같이 보시면 원인 추적이 빨라집니다.

Ray Serve에서 “모델 로딩”이 느려지는 구조

Ray Serve의 레플리카는 대략 다음 순서로 준비됩니다.

  1. 컨테이너/파드 기동
  2. Ray worker 프로세스 시작
  3. Serve replica actor 생성
  4. 사용자 코드 초기화(예: __init__에서 모델 로딩)
  5. readiness 통과 후 트래픽 수신

즉, 대부분의 팀이 __init__에서 모델을 로딩하기 때문에 모델 로딩 시간 = 레플리카 준비 시간이 됩니다. 스케일 아웃 시 레플리카가 여러 개 동시에 뜨면, 모델 파일 다운로드/디스크 I/O/CPU 디코딩/메모리 할당이 한꺼번에 발생해 지연이 폭발합니다.

느려지는 대표 원인을 체크리스트로 정리하면 다음과 같습니다.

  • 원격 스토리지(S3/HTTP/NFS)에서 매 레플리카가 매번 다운로드
  • 모델이 크고(수 GB), 로딩 시 압축 해제/가중치 변환으로 CPU를 오래 사용
  • 여러 레플리카가 동시에 로딩하며 노드 디스크/네트워크가 병목
  • GPU 모델인데 torch가 초기화 시 CUDA 컨텍스트/메모리 풀을 크게 잡으며 지연
  • readiness가 너무 엄격해 로드 완료 전까지 트래픽을 전혀 못 받음

OOM이 나는 대표 패턴

OOM도 “모델이 커서”만은 아닙니다. Ray Serve에서는 다음 패턴이 특히 치명적입니다.

  • 레플리카 수를 늘릴 때 각 레플리카가 모델을 별도로 로딩하여 노드 메모리를 합산 초과
  • num_cpus/num_gpus는 제한했지만 메모리 리소스 제한을 사실상 안 걸어 노드 전체가 터짐
  • 요청 배치/동시성이 높아 입력 텐서/중간 activation이 순간적으로 커져 피크 메모리 초과
  • 모델 로딩 중 임시 버퍼가 커서 로딩 피크가 추론 피크보다 큼
  • 파이썬 객체/캐시가 누수되어 서서히 RSS가 증가

특히 “로딩 피크 메모리”는 간과하기 쉽습니다. 예를 들어 safetensors/ckpt를 읽고 FP32로 올렸다가 FP16으로 변환하는 과정이 있다면, 순간적으로 2배 이상 메모리가 필요할 수 있습니다.

1) 모델 로딩을 __init__에서 분리하고 단계적으로 준비하기

가장 먼저 할 일은 초기화(프로세스 기동)와 모델 로딩(무거운 작업)을 분리하는 것입니다. Serve는 레플리카가 준비되기 전까지 트래픽을 못 받으니, __init__에서 모든 걸 다 하면 콜드스타트가 커집니다.

패턴은 두 가지입니다.

  • “초기 __init__는 가볍게” + “첫 요청 시 lazy load”
  • 또는 “백그라운드 태스크로 로딩 시작” + “ready 될 때만 트래픽 허용”

아래는 lazy load 예시입니다.

from ray import serve
import threading

@serve.deployment(ray_actor_options={"num_cpus": 1})
class ModelService:
    def __init__(self, model_uri: str):
        self.model_uri = model_uri
        self._model = None
        self._lock = threading.Lock()

    def _load_model(self):
        # 여기서 다운로드/역직렬화/디바이스 이동 등 수행
        # 예: self._model = load_from_s3(self.model_uri)
        pass

    def _ensure_loaded(self):
        if self._model is not None:
            return
        with self._lock:
            if self._model is None:
                self._load_model()

    async def __call__(self, request):
        self._ensure_loaded()
        # 추론 수행
        return {"ok": True}

app = ModelService.bind("s3://bucket/model")

이 방식은 첫 요청이 느려질 수 있으니, 운영에서는 아래 “웜업”과 같이 쓰는 편이 좋습니다.

배포 직후 웜업(프리로딩) 요청을 자동화

배포 후 내부에서 한 번 호출해 모델을 미리 올려두면, 사용자 트래픽의 콜드스타트를 줄일 수 있습니다.

  • 쿠버네티스라면 postStart 훅 또는 별도 Job으로 웜업 호출
  • ALB/Ingress 타임아웃이 짧으면 웜업 호출이 504로 보일 수 있으니, 타임아웃도 같이 점검

ALB 타임아웃 이슈가 섞여 보이면 AWS ALB 504 Gateway Timeout 원인·해결 12가지도 함께 확인하세요.

2) 모델 파일을 “레플리카마다 다운로드”하지 않게 만들기

로딩 지연의 1순위는 스토리지입니다. 레플리카가 10개로 늘면 다운로드도 10번입니다.

해결 전략 A: 이미지에 모델을 bake-in

  • 장점: 가장 빠르고 예측 가능
  • 단점: 이미지가 커지고 배포가 느려질 수 있음

대형 모델을 이미지에 넣다 보면 빌드/푸시 중 디스크가 터지는 경우가 많습니다. 이 경우 Docker 빌드 중 no space left? 레이어·캐시 최적화처럼 레이어 최적화가 필요합니다.

해결 전략 B: 노드 로컬 캐시(공유 볼륨 또는 hostPath) 사용

  • 동일 노드에 여러 레플리카가 뜰 때 다운로드 1회로 줄일 수 있음
  • EKS에서는 EBS/EFS/로컬 NVMe 등 선택지가 있음

핵심은 “다운로드 경로를 고정하고, 이미 파일이 있으면 재다운로드하지 않기”입니다.

import os
from pathlib import Path

CACHE_DIR = Path("/models/cache")

def ensure_model_cached(model_id: str, url: str) -> Path:
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    target = CACHE_DIR / f"{model_id}.bin"
    if target.exists() and target.stat().st_size > 0:
        return target

    # 파일 다운로드(예시)
    # download(url, target)
    return target

해결 전략 C: 오브젝트 스토리지 접근 권한(IRSA) 문제 제거

EKS에서 S3/Parameter Store로 모델을 받아오는데 “어떤 파드만 403/AccessDenied”가 뜨면, 로딩 지연처럼 보이다가 결국 실패합니다. 이 경우 권한/웹 아이덴티티 설정을 먼저 안정화해야 합니다.

3) Ray Serve 리소스 모델: num_gpus만으로는 OOM을 못 막는다

많이 하는 착각이 “GPU 1개만 할당했으니 메모리도 안전하겠지”입니다. 실제로는 다음을 동시에 고려해야 합니다.

  • 파드 리소스 제한: 쿠버네티스 resources.limits.memory
  • Ray actor 옵션: ray_actor_options로 CPU/GPU/메모리 힌트
  • Serve 동시성: 한 레플리카가 동시에 처리하는 요청 수

Ray는 스케줄링 관점에서 num_cpus, num_gpus를 강하게 보지만, 메모리는 상대적으로 운영자가 명시적으로 가드해야 안전합니다.

레플리카당 메모리 상한을 명시하고, 파드 limit과 맞추기

Ray actor 옵션에 memory를 지정해 스케줄러가 과적재를 피하게 만들 수 있습니다(환경/버전에 따라 동작 차이가 있을 수 있으니 실제 클러스터에서 검증 권장).

from ray import serve

@serve.deployment(
    ray_actor_options={
        "num_cpus": 2,
        "num_gpus": 1,
        "memory": 12 * 1024**3,  # 12GiB 힌트
    },
)
class GPUModel:
    def __init__(self):
        pass

    async def __call__(self, request):
        return "ok"

그리고 쿠버네티스에서는 파드에 명확한 limit을 걸어야 노드 전체가 같이 죽는 상황을 줄입니다.

resources:
  requests:
    cpu: "2"
    memory: "14Gi"
    nvidia.com/gpu: "1"
  limits:
    cpu: "2"
    memory: "14Gi"
    nvidia.com/gpu: "1"

4) OOM의 절반은 “동시성”에서 온다: max_concurrent_queries와 배치 전략

같은 모델이라도 동시 요청 수가 늘면 입력 텐서/출력 버퍼/전처리 객체가 누적되며 피크 메모리가 급격히 증가합니다. 특히 LLM/비전 모델은 입력 크기 편차가 커서 더 위험합니다.

Serve에는 레플리카 동시성 제어 수단이 있습니다.

  • 레플리카가 동시에 처리할 수 있는 요청 수 제한
  • 배치 추론을 한다면 배치 크기/대기 시간 제한
from ray import serve

@serve.deployment(
    max_concurrent_queries=2,  # 레플리카당 동시 처리 상한
    ray_actor_options={"num_gpus": 1, "num_cpus": 2},
)
class LimitedConcurrency:
    async def __call__(self, request):
        # 추론
        return {"ok": True}

트래픽이 많다면 “동시성 제한 = 처리량 감소”가 아니라, OOM으로 전체가 죽는 것보다 안정적으로 처리량을 유지하는 선택이 됩니다. 처리량은 레플리카 수로 스케일하면 됩니다.

5) 스케일 아웃 시 “동시 로딩 폭주”를 막기

오토스케일이 걸려 있을 때, 트래픽 스파이크에서 레플리카가 여러 개 동시에 뜨며 모델 로딩이 한꺼번에 발생합니다. 이때 네트워크/디스크가 병목이 되어 로딩이 더 느려지고, readiness가 늦어져 더 많은 스케일 아웃을 유발하는 악순환이 생깁니다.

완화 방법:

  • 최소 레플리카를 min_replicas로 유지해 콜드스타트를 줄이기
  • 스케일 업 속도 제한(한 번에 늘어나는 레플리카 수 제한)
  • 모델 다운로드를 initContainer로 분리하고 캐시 사용

Serve 설정은 버전에 따라 차이가 있지만, 핵심은 “스파이크에서 0에서 N으로 뛰지 않게” 만드는 것입니다.

6) 로딩 피크 메모리 줄이기: dtype/디바이스 이동 순서 최적화

프레임워크별로 로딩 피크를 줄이는 요령이 있습니다.

  • 가능하면 바로 목표 dtype으로 로드(FP16/BF16)
  • CPU에서 거대한 중간 객체를 만들지 말고, 스트리밍/메모리 매핑 사용
  • GPU로 옮기는 시점도 제어(한 번에 올리지 말고 샤딩/레이어 단위)

예시(개념 코드):

import torch

def load_torch_model(path: str, device: str = "cuda"):
    # 1) CPU에서 FP32로 다 올린 뒤 변환하면 피크가 커질 수 있음
    # 2) 가능하면 로드 단계에서 dtype을 줄이거나, 로드 직후 즉시 변환
    state = torch.load(path, map_location="cpu")

    model = build_model()
    model.load_state_dict(state)

    model = model.to(dtype=torch.float16)
    model = model.to(device)
    model.eval()
    return model

실제 최적화는 모델 포맷(safetensors), 로더 구현, 샤딩 방식에 따라 달라집니다. 다만 “로딩 단계에서의 임시 버퍼”가 OOM의 주범인 경우가 많으니, 추론 중 OOM만 보지 말고 로딩 RSS 피크를 반드시 측정하세요.

7) 관측: 어디서 느리고 어디서 죽는지 로그/메트릭을 먼저 고정

해결법을 적용하기 전에, 최소한 아래 3가지는 수치로 남겨야 합니다.

  • 레플리카 1개가 “준비됨”까지 걸린 시간(모델 로딩 시간)
  • 로딩 중 최대 RSS/컨테이너 메모리 사용량(피크)
  • 요청 처리 중 최대 GPU 메모리 사용량

간단한 로딩 타이밍 로깅 예시:

import time
import logging

log = logging.getLogger("serve")

def timed_load(load_fn, name: str = "model_load"):
    t0 = time.time()
    obj = load_fn()
    dt = time.time() - t0
    log.info("%s took %.2fs", name, dt)
    return obj

OOM은 “죽고 나서야” 알기 쉬우므로, 쿠버네티스 이벤트(OOMKilled), 컨테이너 종료 코드, Ray 대시보드의 actor 재시작 로그를 함께 봐야 합니다.

8) 실전 처방전: 가장 효과 큰 조합

현장에서 재현 빈도가 높은 조합을 우선순위로 정리하면 다음 순서가 실패 확률이 낮습니다.

  1. 파드 메모리 limit 명시 + 레플리카당 동시성 제한(max_concurrent_queries)
  2. 모델 다운로드를 캐시/공유 디스크로 전환(레플리카당 다운로드 제거)
  3. __init__를 가볍게 하고 웜업으로 프리로딩
  4. 스케일 아웃 폭주 방지(최소 레플리카 유지, 스케일 속도 제한)
  5. 로딩 단계 dtype/포맷 최적화로 로딩 피크 메모리 감소

이 조합만으로도 “배포 직후 5~10분 동안 504/타임아웃”과 “트래픽 증가 시 랜덤 OOM”의 대부분이 사라집니다.

마무리

Ray Serve의 모델 로딩 지연과 OOM은 대개 “모델 자체가 크다”보다 배포/스케일링 시점에 I/O와 메모리 피크가 어떻게 겹치는지에서 시작합니다.

  • 로딩을 분리하고(초기화 vs 로딩),
  • 레플리카당 다운로드를 없애며(캐시/이미지 bake-in),
  • 파드 메모리 limit과 동시성 제한으로 피크를 제어하면,

Serve는 충분히 안정적으로 운영할 수 있습니다.

다음 단계로는 (1) 모델 포맷 전환(safetensors), (2) 텐서RT/ONNX 같은 런타임 최적화, (3) 배치 서빙 설계까지 확장하면 비용과 지연을 동시에 줄일 수 있습니다.