# KubeRay 오토스케일러는 사용률을 보지 않는다
Ray 클러스터를 Kubernetes에 올리고 워커가 늘지 않는다는 문의를 받으면, 열에 아홉은 CPU 사용률 그래프를 먼저 봅니다. 그런데 KubeRay 오토스케일러는 사용률을 아예 판단 근거로 쓰지 않습니다. HPA와 같은 이름으로 부르지만 작동 원리가 다르고, 그 차이를 모르면 "노드는 놀고 있는데 스케일이 안 된다"거나 "작업이 끝났는데 노드가 안 줄어든다"는 상황을 계속 만나게 됩니다.
이 글은 오토스케일러가 무엇을 보고 판단하는지, 왜 축소가 예상보다 늦어지는지, 그리고 이 컴포넌트가 head 파드 안에 산다는 사실이 운영에 어떤 제약을 만드는지를 다룹니다.
# 1. 판단 근거는 사용률이 아니라 논리 자원 수요
공식 문서의 문장이 명확합니다.
The Autoscaler utilizes logical resource requests, indicated in
@ray.remote... not the physical machine utilization, to scale.
@ray.remote(num_gpus=1)로 선언한 태스크가 20개 큐에 쌓이면, 오토스케일러는 "GPU 20장어치 요청이 충족되지 않았다"고 인식하고 그만큼 워커를 띄웁니다. 반대로 CPU를 100% 태우고 있어도 선언된 자원 요청이 이미 충족돼 있으면 아무 일도 일어나지 않습니다.
실무에서는 이 차이가 큽니다.
| 상황 | HPA(사용률 기반) | Ray 오토스케일러(수요 기반) |
|---|---|---|
| CPU 90%, 대기 태스크 없음 | 확장 | 확장하지 않음 |
| CPU 5%, 대기 태스크 100개 | 확장하지 않음 | 확장 |
| 자원 선언을 빠뜨린 태스크 | 사용률에 반영됨 | 존재하지 않는 수요 |
세 번째 줄이 가장 자주 물리는 함정입니다. @ray.remote 데코레이터에 num_cpus, num_gpus를 명시하지 않으면 Ray는 기본값으로 계산하고, 실제로는 GPU를 쓰는 함수인데 오토스케일러 눈에는 GPU 수요가 0으로 보입니다. 스케일링 정확도는 자원 선언의 정확도를 절대 넘지 못합니다. 오토스케일링이 이상하면 노드 지표가 아니라 태스크 선언부터 봐야 합니다.
# 스케일이 안 되는 코드
@ray.remote
def embed(batch): # 실제로는 GPU를 쓴다. 오토스케일러는 모른다
return model.encode(batch)
# 수요가 보이는 코드
@ray.remote(num_gpus=1, num_cpus=4)
def embed(batch):
return model.encode(batch)
수요가 어떻게 집계되는지는 클러스터 상태로 직접 확인할 수 있습니다.
ray status
# Resources 섹션의 Demands 항목이 비어 있으면 오토스케일러는 확장할 이유가 없다
# 2. 축소가 늦는 이유 - idle의 정의
idleTimeoutSeconds는 기본 60초이고, 정의는 이렇습니다.
idleTimeoutSeconds(default 60s): This denotes the waiting time in seconds before scaling down an idle worker pod.
여기서 중요한 건 타임아웃 값이 아니라 무엇을 idle로 볼 것인가입니다.
no active tasks, actors, or referenced objects, either stored in-memory or spilled to disk.
세 번째 조건이 축소를 막는 주범입니다. 태스크가 다 끝나고 액터도 없는데, 그 노드가 만든 객체를 누군가 아직 참조하고 있으면 idle이 아닙니다. 디스크로 스필된 객체까지 포함됩니다. 드라이버가 ObjectRef를 리스트에 담아 들고 있거나, 결과를 모아 두고 후처리를 하는 패턴에서는 작업이 끝난 지 한참 뒤에도 워커가 살아 있습니다.
이걸 타임아웃 조정으로 해결하려 들면 안 됩니다. 참조가 살아 있는 한 시간이 지나도 축소되지 않습니다. 실제로 손댈 곳은 참조 수명입니다.
refs = [embed.remote(b) for b in batches]
results = ray.get(refs)
del refs # 참조를 끊어야 해당 노드가 idle 판정 대상이 된다
축소가 안 되는 노드를 만나면 순서대로 확인합니다.
ray status # Demands 와 노드별 사용 자원
ray memory --stats-only # 살아 있는 객체 참조 요약
kubectl logs <head-pod> -c autoscaler # 어떤 판단을 했는지
# 3. 확장 속도 - upscalingMode
세 가지 모드가 있고, 실질적으로는 두 가지입니다.
| 모드 | 동작 |
|---|---|
Conservative | 대기 중인 워커 파드 수가 이미 붙은 워커 수를 넘지 않도록 제한 |
Default | 제한 없음 |
Aggressive | Default와 동의어 |
Conservative가 의미 있는 상황은 분명합니다. 노드 프로비저닝이 느린 환경, 특히 클러스터 오토스케일러가 EC2 인스턴스를 새로 띄워야 하는 구성에서는 수요가 튀는 순간 수십 개의 파드가 한꺼번에 Pending으로 들어가고, 그중 상당수가 뜨자마자 idle이 됩니다. 대기 파드 수를 현재 워커 수 이하로 묶으면 지수적으로 늘어나되 한 번에 폭주하지는 않습니다.
반대로 GPU 노드를 미리 확보해 둔 온프렘 클러스터라면 제한할 이유가 없습니다. 결정 기준은 "노드 하나가 뜨는 데 걸리는 시간"과 "잘못 띄웠을 때의 비용"입니다.
# 4. 오토스케일러가 head 파드 안에 있다는 것
구현상 위치가 운영 제약을 만듭니다.
the Ray Autoscaler is actually a sidecar container within the Ray head Pod in the actual implementation.
여기서 따라오는 결과가 셋입니다.
head 파드가 죽으면 스케일링 판단도 멈춥니다. head는 GCS와 오토스케일러를 함께 지고 있으므로, head 재시작은 단순한 컴포넌트 재기동이 아닙니다. head 파드를 스팟 인스턴스나 축출 대상 노드에 올리지 않는 것이 기본입니다.
오토스케일러 사이드카의 자원 부족이 클러스터 전체 확장을 막습니다. 워커 수가 늘수록 사이드카의 부하도 늘어납니다. 워커 리소스만 조정하고 head를 방치하면, 규모가 커진 뒤에 판단 지연으로 나타납니다.
로그가 head 파드 안에 있습니다. 오토스케일러가 왜 그렇게 판단했는지는 오직 이 컨테이너 로그에서만 볼 수 있습니다.
kubectl logs <head-pod> -c autoscaler --tail=200
# 5. replicas 필드의 소유권
오토스케일링을 켜면 워커 그룹의 replicas는 오토스케일러가 소유하는 필드가 됩니다. 사람이 kubectl scale이나 매니페스트로 그 값을 지정하면 두 주체가 같은 필드를 두고 다투게 됩니다.
GitOps 환경에서는 이 충돌이 확실하게 드러납니다. 오토스케일러가 워커를 8개로 늘리면 클러스터의 실제 값은 8, Git에 적힌 값은 2가 되어 Application이 계속 OutOfSync로 표시됩니다. 자동 동기화까지 켜져 있으면 배포 도구가 워커를 2개로 되돌리고, 오토스케일러가 다시 8개로 올리는 왕복이 시작됩니다.
해결 방법은 해당 필드를 동기화 대상에서 제외하는 것입니다.
# ArgoCD Application
spec:
ignoreDifferences:
- group: ray.io
kind: RayCluster
jqPathExpressions:
- .spec.workerGroupSpecs[].replicas
무시 규칙이 판정에만 쓰이는지 실제 적용 단계까지 반영되는지는 별개 문제입니다. 이 구분은 ArgoCD ignoreDifferences가 동작하지 않는 세 가지 이유 (opens new window)에 정리했습니다. 요약하면, 되돌림까지 막으려면 RespectIgnoreDifferences=true가 필요합니다.
Git에서 관리할 값은 replicas가 아니라 minReplicas와 maxReplicas 입니다. 상한과 하한은 사람이 정하는 정책이고, 그 사이의 값은 오토스케일러가 정하는 런타임 상태입니다. 이 경계를 지키면 충돌이 생기지 않습니다.
workerGroupSpecs:
- groupName: gpu-workers
minReplicas: 0 # 정책: 사람이 관리
maxReplicas: 16 # 정책: 사람이 관리
replicas: 0 # 런타임: 오토스케일러가 관리, 동기화 제외
# 6. 진단 순서
| 증상 | 먼저 볼 곳 | 흔한 원인 |
|---|---|---|
| 대기 태스크가 있는데 확장 안 됨 | ray status의 Demands | 태스크에 자원 선언 누락 |
| 확장은 되는데 매우 느림 | upscalingMode, Pending 파드 | Conservative + 느린 노드 프로비저닝 |
| 작업이 끝났는데 축소 안 됨 | ray memory | 살아 있는 ObjectRef 참조 |
| 워커 수가 계속 왔다 갔다 함 | 배포 도구 동기화 설정 | replicas 소유권 충돌 |
| 스케일링이 통째로 멈춤 | head 파드 상태 | head 재시작, 사이드카 자원 부족 |
워커 수가 maxReplicas에 도달한 채 대기 | ray status, 워커 그룹 설정 | 상한 도달, maxReplicas 상향 필요 |
| 워커 파드는 생성됐는데 대기 | Pending 파드, 노드 자원·스케줄링 조건 | 클러스터 자원 부족 |
마지막 두 줄은 구분해서 봐야 합니다. Ray 오토스케일러가 파드를 만들었는데 Kubernetes가 스케줄하지 못하면 증상은 같지만 원인은 완전히 다릅니다. 파드가 아예 없는 것과 파드가 Pending인 것은 다른 문제입니다.
kubectl get pods -l ray.io/cluster=<cluster> -o wide
kubectl describe pod <pending-pod> | tail -20
# 7. 마무리
- KubeRay 오토스케일러는 사용률이 아니라 선언된 논리 자원 수요로 판단합니다. 자원 선언이 부정확하면 스케일링도 부정확합니다.
- 축소가 안 되는 원인은 대개 타임아웃이 아니라 살아 있는 객체 참조입니다. 타임아웃을 줄여도 해결되지 않습니다.
- 오토스케일러는 head 파드의 사이드카입니다. head의 안정성과 자원이 곧 스케일링의 안정성입니다.
replicas는 오토스케일러의 것,minReplicas/maxReplicas는 사람의 것입니다. 이 경계를 GitOps 설정에 그대로 반영해야 왕복이 사라집니다.