- Published on
Ray Serve 배포 OOMKilled·콜드스타트 5분 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Ray Serve를 K8s에 올리면 처음엔 잘 뜨는 것처럼 보이다가 곧바로 OOMKilled가 나거나, 트래픽이 들어올 때마다 콜드스타트가 5분 이상 걸리는 경우가 흔합니다. 특히 LLM, 임베딩, 이미지 모델처럼 “가중치 로드가 크고, 파이썬 런타임이 무거운” 워크로드는 작은 설정 실수 하나가 장애로 이어집니다.
이 글은 다음 두 문제를 한 번에 줄이는 데 초점을 둡니다.
OOMKilled를 “운 좋으면 안 터지는” 상태가 아니라, 왜 터지는지 수치로 설명 가능한 상태로 만들기- 콜드스타트 5분을 수십 초 단위로 줄이기(환경에 따라 20~60초 목표)
아래 내용은 Ray Serve 단독 배포뿐 아니라 KubeRay(RayService) 기반 운영에도 그대로 적용됩니다.
증상부터 정확히 분류하기
1) OOMKilled의 전형적인 패턴
- Pod 이벤트에
OOMKilled또는Container killed by OOM가 찍힘 - Ray actor가 뜨는 중간에 죽거나, 모델 로드 직후 죽음
- 재시작 후 또 죽음(BackOff)
확인 커맨드:
kubectl describe pod -n <ns> <pod-name>
kubectl get events -n <ns> --sort-by=.lastTimestamp | tail -n 30
<ns> 같은 부등호가 들어간 표기는 MDX에서 JSX로 오인될 수 있으니, 위처럼 반드시 인라인 코드로 감싸서 기록하는 습관을 권장합니다.
2) 콜드스타트 5분의 전형적인 패턴
- 첫 요청이 수분 동안 대기 후 타임아웃
- Serve replica가
STARTING상태에 오래 머무름 - 이미지 풀(pull) 또는 모델 다운로드가 길게 발생
확인 포인트:
- Pod 로그: 모델 다운로드/로드, 토크나이저 초기화, JIT 컴파일,
pip설치 흔적 - 노드 이벤트: 이미지 풀 시간
- PV/PVC: 모델 캐시가 매번 비어 있는지
Ray Serve에서 메모리가 터지는 진짜 이유
Ray Serve의 메모리는 보통 “모델 하나의 크기”가 아니라 다음이 합쳐져 폭발합니다.
- Python 프로세스 오버헤드(런타임 + 라이브러리)
- 모델 가중치(GPU/CPU 메모리)
- 토크나이저/전처리 캐시
- 요청 큐/배치 버퍼(concurrency, batching)
- object store(plasma) 메모리
- (중요) replica 수 x (1~5) 의 곱셈 효과
특히 Ray는 내부적으로 object store를 쓰기 때문에, 컨테이너 메모리를 “모델만큼만” 잡으면 쉽게 터집니다.
빠른 산정: 최소 메모리 가이드
- CPU inference(또는 모델을 CPU에 올리는 경우)
컨테이너 메모리=모델 크기+2~6Gi(파이썬+서빙) +object store+버퍼
- GPU inference(가중치는 GPU, 전처리/큐는 CPU)
컨테이너 메모리=2~6Gi+object store+버퍼
여기서 object store는 Ray가 자동으로 잡는 비율이 있어, 컨테이너 limit이 작을수록 더 민감해집니다.
1단계: K8s 리소스 request/limit부터 제대로 잡기
가장 흔한 실패는 requests는 작게, limits만 크게 잡거나(스케줄링 불안정), 반대로 limits를 너무 작게 잡아 OOM을 유발하는 경우입니다.
KubeRay RayService 예시(개념용):
apiVersion: ray.io/v1
kind: RayService
metadata:
name: serve
spec:
serveConfigV2: |
applications:
- name: app
import_path: app:entrypoint
deployments:
- name: Model
num_replicas: 2
ray_actor_options:
num_cpus: 2
memory: 8589934592
rayClusterConfig:
workerGroupSpecs:
- groupName: workers
replicas: 2
template:
spec:
containers:
- name: ray-worker
image: your-image:tag
resources:
requests:
cpu: "2"
memory: "10Gi"
limits:
cpu: "2"
memory: "10Gi"
핵심은 두 가지입니다.
requests.memory와limits.memory를 가급적 동일하게 두어 노드 내 메모리 압박으로 인한 축출(eviction) 가능성을 낮춤ray_actor_options.memory(바이트 단위)를 함께 설정해 Ray 스케줄러가 actor를 메모리 기준으로 배치하도록 유도
ray_actor_options.memory는 “K8s limit을 대신하는” 기능이 아니라, Ray 내부에서 과도한 배치를 막는 안전장치로 이해하는 게 좋습니다.
2단계: object store 메모리와 /dev/shm 문제 잡기
Ray object store는 공유 메모리(/dev/shm)를 적극 활용합니다. 컨테이너 환경에서 /dev/shm가 작으면(기본 64Mi) 성능 저하 또는 예기치 못한 메모리 문제로 이어질 수 있습니다.
K8s에서는 emptyDir로 /dev/shm를 키우는 패턴을 씁니다.
spec:
volumes:
- name: dshm
emptyDir:
medium: Memory
sizeLimit: "2Gi"
containers:
- name: ray-worker
volumeMounts:
- name: dshm
mountPath: /dev/shm
sizeLimit은 워크로드에 맞춰 조정하세요. 배치/대용량 텐서 전달이 많으면 더 크게 잡아야 합니다.
3단계: Serve replica 동시성 설정이 OOM을 만든다
Serve는 replica 당 동시에 처리 가능한 요청 수가 커질수록, 메모리 피크가 커집니다.
max_concurrent_queries를 크게 잡으면 큐/버퍼가 커지고, 전처리 텐서가 동시 생성됨- dynamic batching을 켜면 배치 텐서가 커져 피크가 증가
Serve 설정 예시(파이썬):
from ray import serve
@serve.deployment(
num_replicas=2,
ray_actor_options={"num_cpus": 2},
max_concurrent_queries=8,
)
class Model:
def __init__(self):
self.model = load_model()
async def __call__(self, request):
payload = await request.json()
return self.model.infer(payload)
OOM이 난다면 아래 순서로 낮춰보는 것이 실전에서 가장 빠릅니다.
num_replicas를 줄여 “복제에 의한 곱셈” 제거max_concurrent_queries를 낮춰 피크 메모리 감소- batching 파라미터(배치 크기/대기 시간) 축소
4단계: 콜드스타트 5분의 원인을 3가지로 쪼개기
콜드스타트는 보통 아래 셋 중 하나(또는 복합)입니다.
- 이미지 풀이 느림(대형 이미지, 레지스트리 지연)
- 모델 다운로드가 매번 발생(캐시 미사용)
- 프로세스 초기화가 큼(import, 토크나이저/모델 로드, JIT)
각각의 해결책이 다릅니다.
5단계: 이미지 풀 시간을 줄이는 실전 팁
1) 이미지 슬림화
- 멀티스테이지 빌드로 빌드 도구 제거
- 불필요한 CUDA/컴파일러/테스트 파일 제거
pip캐시 정리
Dockerfile 예시:
FROM python:3.11-slim AS runtime
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
CMD ["python", "-m", "ray.serve", "run", "app:entrypoint"]
2) 노드에 이미지 프리풀(pre-pull)
DaemonSet으로 워커 노드에 미리 이미지를 받아두면, 스케일아웃 시 콜드스타트가 크게 줄어듭니다.
6단계: 모델 다운로드를 ‘매번’ 하지 않게 만들기
1) 캐시 디렉터리를 PV로 고정
Hugging Face 계열을 예로 들면, 캐시를 PVC에 붙여 replica 재시작/재스케줄링에도 재사용하게 만듭니다.
env:
- name: HF_HOME
value: /cache/hf
- name: TRANSFORMERS_CACHE
value: /cache/hf
volumeMounts:
- name: model-cache
mountPath: /cache
volumes:
- name: model-cache
persistentVolumeClaim:
claimName: model-cache-pvc
2) initContainer로 “다운로드만” 분리
Serve 컨테이너가 뜨기 전에 모델을 받아두면, readiness가 안정적이고 첫 요청이 빨라집니다.
initContainers:
- name: warm-model
image: your-image:tag
command: ["bash", "-lc"]
args:
- |
python -c "from transformers import AutoTokenizer, AutoModel; AutoTokenizer.from_pretrained('org/model'); AutoModel.from_pretrained('org/model')"
volumeMounts:
- name: model-cache
mountPath: /cache
env:
- name: HF_HOME
value: /cache/hf
이 패턴의 장점은 “서빙 프로세스”와 “자산 준비”를 분리해 장애 지점을 명확히 하는 것입니다.
7단계: readiness/liveness 프로브를 콜드스타트 친화적으로
콜드스타트가 긴데도 aggressive한 liveness 프로브를 걸면, 초기화 중에 컨테이너가 재시작 루프에 빠져 “영원히 안 뜨는” 상태가 됩니다.
권장 방향:
startupProbe를 적극 사용(초기화 시간 확보)livenessProbe는 충분히 늦게, 실패 임계치를 크게readinessProbe는 “모델 로드 완료”를 기준으로
예시:
startupProbe:
httpGet:
path: /-/healthz
port: 8000
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet:
path: /-/healthz
port: 8000
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /-/healthz
port: 8000
initialDelaySeconds: 60
periodSeconds: 10
failureThreshold: 5
Serve의 헬스 엔드포인트는 구성에 따라 다를 수 있으니, 실제 서비스 라우팅 경로와 분리된 경량 health 핸들러를 두는 것을 추천합니다.
8단계: Ray Serve 초기화(import) 자체를 줄이는 방법
파이썬 서비스는 종종 “모델 로드”보다 “import 지옥”이 더 느립니다.
- 무거운 모듈을 전역 import하지 말고, deployment
__init__또는 요청 경로에서 지연 로드 - 토크나이저/모델을 전역 싱글톤으로 두되, 프로세스 시작 시 한 번만 로드
- 불필요한 로깅 핸들러/서드파티 초기화 제거
예시(지연 import):
from ray import serve
@serve.deployment
class Model:
def __init__(self):
from transformers import AutoModelForCausalLM, AutoTokenizer
self.tok = AutoTokenizer.from_pretrained("org/model")
self.model = AutoModelForCausalLM.from_pretrained("org/model")
async def __call__(self, request):
data = await request.json()
return {"ok": True, "len": len(data.get("text", ""))}
9단계: 스케일링 전략을 바꿔 콜드스타트를 ‘숨기기’
콜드스타트를 0으로 만들기 어렵다면, 사용자에게 보이지 않게 숨기는 접근이 필요합니다.
- 최소 replica를 0이 아니라 1 이상으로 유지
- 트래픽 패턴이 있으면 스케줄러로 사전 스케일아웃
- 요청 타임아웃을 “초기 부팅 시간”에 맞게 조정
Ray Serve의 autoscaling을 쓴다면, 급격한 scale-to-zero는 운영 편의성은 높지만 첫 요청 SLO를 망가뜨리기 쉽습니다.
10단계: 관측으로 재발 방지(메모리/시간을 숫자로)
1) OOM 직전 메모리 추적
- Pod 메모리 사용량(working set)
- Ray dashboard의 actor/worker 메모리
- object store 사용량
Prometheus/Grafana가 있다면 “배포 직후 10분” 구간을 확대해서 피크를 봐야 합니다. 평균은 의미가 없습니다.
2) 콜드스타트 분해 측정
로그에 타임스탬프를 박아 다음을 분리하세요.
- 컨테이너 시작
->Ray worker ready - Ray worker ready
->Serve replica ready - Serve replica ready
->첫 요청 200
이렇게 쪼개면 병목이 이미지 풀인지, 모델 다운로드인지, 파이썬 초기화인지 한 번에 드러납니다.
자주 같이 터지는 EKS 네트워크 이슈도 점검
모델 다운로드가 간헐적으로 느리거나 실패하면서 콜드스타트가 길어지는 경우, DNS 플랩/간헐 실패가 원인인 경우도 있습니다. EKS에서 CoreDNS는 정상인데도 외부 도메인 조회가 간헐 실패한다면 아래 글의 체크리스트가 그대로 도움이 됩니다.
또한 외부 스토리지나 파라미터 스토어에서 설정을 읽는 초기화 단계가 있다면, 권한 문제로 재시도하며 콜드스타트가 늘어날 수 있습니다.
Ingress 레벨에서 502가 보이는데 Pod 로그가 비어 “서버가 안 뜬 것처럼” 보일 때는, 실제로는 readiness/헬스체크 불일치인 경우가 많습니다.
체크리스트: 가장 효과가 큰 순서
requests.memory == limits.memory로 고정하고, replica 수/동시성부터 낮춰 OOM 재현을 멈춘다/dev/shm을emptyDir로 키워 object store 병목과 메모리 이상을 제거한다- 모델 캐시를 PVC로 고정하고, initContainer로 다운로드를 분리한다
startupProbe를 도입해 초기화 중 재시작 루프를 차단한다- 이미지 슬림화 + pre-pull로 스케일아웃 콜드스타트를 줄인다
마무리
Ray Serve의 OOMKilled와 5분 콜드스타트는 “리소스를 더 주면 언젠가 해결”되는 문제가 아니라, 메모리 피크(복제/동시성/object store)와 초기화 비용(이미지 풀/다운로드/import)을 분해해 각각을 줄여야 해결됩니다.
위 단계대로 적용하면, OOM은 재현 가능하고 설명 가능한 형태로 통제되고, 콜드스타트는 최소 replica 유지 또는 캐시/프리풀 전략으로 사용자 체감에서 사실상 사라지게 만들 수 있습니다.