Kubernetes 환경에서 vGPU 리소스를 요청하는 파드는 Mutating Webhook을 통해 스케줄러 이름이 hami-scheduler로 변경됩니다. 이 스케줄러는 기본적으로 Kubernetes의 kube-scheduler 이미지를 사용하지만, Scheduler Extender 패턴을 통해 HAMi 고유의 가상 GPU 할당 로직을 수행합니다. 본 글에서는 hami-scheduler의 내부 동작 방식, 리소스 인식 메커니즘, 그리고 실제 노드 바인딩까지의 전체 워크플로우를 분석합니다.
스케줄러 아키텍처 및 배포 구성
hami-scheduler는 단일 파드 내에서 두 개의 컨테이너로 구성됩니다. 하나는 표준 kube-scheduler 바이너리이고, 다른 하나는 실제 vGPU 스케줄링 API를 제공하는 Extender 사이드카입니다.
KubeSchedulerConfiguration 설정
Extender는 kube-scheduler가 외부 HTTP 서버로 스케줄링 로직을 위임할 수 있도록 합니다. 설정 파일에서 ignoredByScheduler: true로 지정하면, 기본 스케줄러는 해당 가상 리소스를 무시하여 리소스 부족으로 인한 스케줄링 실패를 방지합니다.
apiVersion: kubescheduler.config.k8s.io/v1beta2
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: hami-virtual-scheduler
extenders:
- urlPrefix: "https://localhost:8443"
filterVerb: evaluate
bindVerb: commit
managedResources:
- name: nvidia.com/vgpu
ignoredByScheduler: true
- name: nvidia.com/gpumem
ignoredByScheduler: true
- name: nvidia.com/gpucores
ignoredByScheduler: true
가상 GPU 리소스 인식 메커니즘
vGPU의 코어와 메모리는 물리적 리소스가 아닌 가상 리소스이므로 Kubernetes API 서버가 직접 관리하지 않습니다. 따라서 HAMi는 노드와 파드의 어노테이션(Annotation)을 통해 리소스 상태를 추적합니다.
1. 노드 GPU 리소스 동기화
Device Plugin이 노드의 어노테이션에 기록한 물리적 GPU 정보를 주기적으로 또는 이벤트 기반으로 읽어들여 메모리 캐시에 저장합니다.
func (mgr *ResourceTracker) syncNodeDeviceCache() {
syncInterval := time.NewTicker(15 * time.Second)
defer syncInterval.Stop()
for {
select {
case <-mgr.stopSignal:
return
case <-mgr.nodeEventTrigger:
case <-syncInterval.C:
}
nodes, err := mgr.nodeLister.List(labels.Everything())
if err != nil {
klog.Errorf("Failed to list nodes: %v", err)
continue
}
for _, node := range nodes {
gpuDevices := parseGPUDevicesFromAnnotations(node.Annotations)
mgr.updateNodeCache(node.Name, gpuDevices)
}
}
}
2. 파드 GPU 사용량 추적
Informer를 통해 파드의 생성, 수정, 삭제 이벤트를 감지하고, 파드에 할당된 vGPU 사용량을 어노테이션에서 파싱하여 전체 사용량에 반영합니다.
func (mgr *ResourceTracker) handlePodCreation(obj interface{}) {
pod, valid := obj.(*corev1.Pod)
if !valid || pod.Annotations == nil {
return
}
assignedNode, exists := pod.Annotations["hami.io/assigned-node"]
if !exists || isPodTerminated(pod) {
return
}
allocatedDevices := decodeAllocatedDevices(pod.Annotations)
mgr.recordPodUsage(pod.Name, assignedNode, allocatedDevices)
}
스케줄링 구현: 필터링 및 바인딩
스케줄링은 크게 노드를 평가하고 선정하는 Filter 단계와 파드를 실제 노드에 할당하는 Bind 단계로 나뉩니다. HAMi는 스케줄링 결과를 완전히 통제하기 위해 Filter 단계에서 점수 계산(Score)을 통합하여 단 하나의 최적 노드만 반환하는 방식을 사용합니다.
1. Filter 및 Score 통합 로직
요청된 vGPU 리소스가 없는 경우 모든 노드를 통과시킵니다. 리소스가 요청된 경우, 각 노드의 잔여 GPU 코어와 메모리를 기반으로 점수를 계산합니다. Binpack 또는 Spread 전략에 따라 가중치를 적용하여 최종 노드를 선정합니다. 잔여 리소스가 적은 노드에 배정하는 Binpack 전략의 경우, 사용률이 높은 노드에 더 높은 점수를 부여합니다.
func (s *VirtualScheduler) evaluateAndSelectNode(args extenderv1.ExtenderArgs) (*extenderv1.ExtenderFilterResult, error) {
pod := args.Pod
requestedGPUs := extractGPURequests(pod)
if len(requestedGPUs) == 0 {
return &extenderv1.ExtenderFilterResult{NodeNames: args.NodeNames}, nil
}
nodeUsages, err := s.tracker.CalculateAvailableResources(args.NodeNames)
if err != nil {
return nil, err
}
bestNode, allocatedDevices := s.scoringEngine.FindOptimalNode(nodeUsages, requestedGPUs, pod.Annotations)
if bestNode == "" {
return &extenderv1.ExtenderFilterResult{
FailedNodes: map[string]string{"all": "insufficient vgpu resources"},
}, nil
}
patchAnnotations(pod, bestNode, allocatedDevices)
s.tracker.ReserveResources(pod.Name, bestNode, allocatedDevices)
return &extenderv1.ExtenderFilterResult{
NodeNames: &[]string{bestNode},
}, nil
}
2. Bind 로직
Filter에서 선정된 노드에 파드를 실제 바인딩하고, 노드 락(Lock)을 설정하여 동시성 문제를 방지합니다.
func (s *VirtualScheduler) commitPodBinding(args extenderv1.ExtenderBindingArgs) (*extenderv1.ExtenderBindingResult, error) {
podNamespace := args.PodNamespace
podName := args.PodName
targetNode := args.Node
binding := &corev1.Binding{
ObjectMeta: metav1.ObjectMeta{Name: podName, UID: args.PodUID},
Target: corev1.ObjectReference{Kind: "Node", Name: targetNode},
}
if err := s.lockManager.AcquireLock(targetNode); err != nil {
return &extenderv1.ExtenderBindingResult{Error: err.Error()}, nil
}
defer s.lockManager.ReleaseLock(targetNode)
updateBindingPhase(podNamespace, podName, "allocating")
err := s.kubeClient.CoreV1().Pods(podNamespace).Bind(context.Background(), binding, metav1.CreateOptions{})
if err != nil {
klog.Errorf("Binding failed for pod %s to node %s: %v", podName, targetNode, err)
return &extenderv1.ExtenderBindingResult{Error: err.Error()}, nil
}
return &extenderv1.ExtenderBindingResult{Error: ""}, nil
}