التخصيص الديناميكي لموارد GPU لأعباء عمل Kubernetes

التخصيص الديناميكي لموارد GPU لأعباء عمل Kubernetes

حاليًا، لجدولة Pods GPU في Kubernetes (k8s)، يتم تطبيق حلول توسعة مختلفة، بما في ذلك Device Plugin و Extended Resource و scheduler extender و scheduler framework، أو تطوير جدولة جديدة. غالبًا ما تعتمد هذه الحلول على nodeSelector أو nodeAffinity لاختيار العقد التي توفر نماذج GPU محددة. بالإضافة إلى ذلك، لاكتشاف نماذج GPU تلقائيًا على كل عقدة، من الضروري نشر Node Feature Discovery (NFD) أو GPU Feature Discovery (GFD).

لاحقًا، لتوحيد طريقة عرض الأجهزة الطرفية للحاويات، تم تقديم مواصفات وقت التشغيل الجديدة CDI (Container Device Interface). بالطبع، يوفر CDI حلاً فقط على جانب CRI.

لحسن الحظ، بدءًا من k8s v1.26، يتم دعم التخصيص الديناميكي للموارد (DRA)، مما يوفر حلاً متسقًا لطلبات الموارد وتخصيصها على جانب الجدولة. ومع ذلك، كان للنهج الأولي لـ DRA مع Control Plane Controller بعض العيوب، والتي تم تحسينها في k8s v1.30 من خلال تقديم DRA مع المعلمات المنظمة (Structured Parameters)، وهو محور هذه المقالة.

قام k8s 1.31 بإجراء تغييرات أخرى على هذا الأساس، وستقدم هذه المقالة DRA مع المعلمات المنظمة بناءً على إصدار k8s 1.31.

(لن تتناول هذه المقالة تفاصيل حلول التوسعة السابقة؛ إذا سمح الوقت، سأكتب مقالة أخرى لتقديمها لاحقًا).

الآن، دعنا ننتقل مباشرة إلى الموضوع الرئيسي لهذه المقالة، DRA مع المعلمات المنظمة.

DRA مع المعلمات المنظمة

يُستخدم DRA بشكل رئيسي لطلب ومشاركة الموارد بين Pods أو بين حاويات متعددة داخل Pod واحد، ولا يقتصر على موارد GPU فقط. بينما هو أكثر تعقيدًا بشكل ملحوظ من نهج Device Plugin السابق، فإن ثلاثية ResourceClaimTemplate/ResourceClaim/DeviceClass، المشابهة لـ PVC/PV/SC، توفر المرونة ومجموعة شاملة من تعريفات الموارد التي يمكنها استيعاب مجموعة متنوعة من الموارد غير المتجانسة رسميًا.

بطبيعة الحال، لا يزال يتعين على المستخدمين تطوير برامج تشغيل الموارد الخاصة بهم للتعامل مع إعداد وصيانة هذه الموارد.

المفاهيم الأساسية

ResourceClaim

يُستخدم ResourceClaim لطلبات الوصول إلى الموارد. تقوم الجدولة (Scheduler) بتحديد العقد المتاحة بناءً على ResourceClaim المُكون في Pod.

---
apiVersion: resource.k8s.io/v1alpha3
kind: ResourceClaim
metadata:
  name: small-white-cat-claim
spec:
  devices:
    requests:
    - name: req-0
      deviceClassName: resource.example.com
      selectors:
      - cel:
         expression: |-
            device.attributes["resource-driver.example.com"].color == "white" &&
            device.attributes["resource-driver.example.com"].size == "small"

ResourceClaimTemplate

يُستخدم ResourceClaimTemplate لإنشاء ResourceClaims. تتم إدارته بواسطة وحدة تحكم resourceclaim في kube-controller-manager. إليك مقتطف الكود الخاص بإنشائه:

func (ec *Controller) handleClaim(ctx context.Context, pod *v1.Pod, podClaim v1.PodResourceClaim, newPodClaims *map[string]string) error {
    claimName, mustCheckOwner, err := resourceclaim.Name(pod, &podClaim)
    switch {
    case errors.Is(err, resourceclaim.ErrClaimNotFound):
    ......
    }
    // Before we create a new ResourceClaim, check if there is an orphaned one.
    // This covers the case that the controller has created it, but then fails
    // before it can update the pod status.
    claim, err := ec.findPodResourceClaim(pod, podClaim)
    if err != nil {
        return fmt.Errorf("finding ResourceClaim for claim %s in pod %s/%s failed: %v", podClaim.Name, pod.Namespace, pod.Name, err)
    }

    if claim == nil {
        template, err := ec.templateLister.ResourceClaimTemplates(pod.Namespace).Get(*templateName)
        if err != nil {
            return fmt.Errorf("resource claim template %q: %v", *templateName, err)
        }

        // Create the ResourceClaim with pod as owner, with a generated name that uses
        // <pod>-<claim name> as base.
        isTrue := true
        annotations := template.Spec.ObjectMeta.Annotations
        if annotations == nil {
            annotations = make(map[string]string)
        }
        annotations[podResourceClaimAnnotation] = podClaim.Name
        generateName := pod.Name + "-" + podClaim.Name + "-"
        maxBaseLen := 57 // Leave space for hyphen and 5 random characters in a name with 63 characters.
        if len(generateName) > maxBaseLen {
            // We could leave truncation to the apiserver, but as
            // it removes at the end, we would loose everything
            // from the pod claim name when the pod name is long.
            // We can do better and truncate both strings,
            // proportional to their length.
            generateName = pod.Name[0:len(pod.Name)*maxBaseLen/len(generateName)] +
                "-" +
                podClaim.Name[0:len(podClaim.Name)*maxBaseLen/len(generateName)]
        }
        claim = &resourceapi.ResourceClaim{
            ObjectMeta: metav1.ObjectMeta{
                GenerateName: generateName,
                OwnerReferences: []metav1.OwnerReference{
                    {
                        APIVersion:         "v1",
                        Kind:               "Pod",
                        Name:               pod.Name,
                        UID:                pod.UID,
                        Controller:         &isTrue,
                        BlockOwnerDeletion: &isTrue,
                    },
                },
                Annotations: annotations,
                Labels:      template.Spec.ObjectMeta.Labels,
            },
            Spec: template.Spec.Spec,
        }
        metrics.ResourceClaimCreateAttempts.Inc()
        claimName := claim.Name
        claim, err = ec.kubeClient.ResourceV1alpha3().ResourceClaims(pod.Namespace).Create(ctx, claim, metav1.CreateOptions{})
        if err != nil {
            metrics.ResourceClaimCreateFailures.Inc()
            return fmt.Errorf("create ResourceClaim %s: %v", claimName, err)
        }
        logger.V(4).Info("Created ResourceClaim", "claim", klog.KObj(claim), "pod", klog.KObj(pod))
        ec.claimCache.Mutation(claim)
    }
    

لماذا تقديم ResourceClaimTemplate؟

ضع في اعتبارك المثال التالي داخل Pod Spec: يسمح لك حقل resourceClaims بالإشارة إما إلى ResourceClaim أو ResourceClaimTemplate، ولكن ليس كليهما - فهما حصريان لبعضهما البعض. عندما تشير إلى ResourceClaim عبر resourceClaimName، فإن جميع Pods التي تستخدم Pod Spec هذا (مثل تلك الموجودة في Deployment أو StatefulSet) تشترك في نفس مثيل ResourceClaim. يتوافق هذا المثيل مع ResourceClaim الذي يحمل الاسم المحدد بواسطة resourceClaimName داخل نفس Namespace الخاص بـ Pod.

على العكس من ذلك، عندما تشير إلى ResourceClaimTemplate عبر resourceClaimTemplateName، يحصل كل Pod على مثيل ResourceClaim خاص به ومتميز. وذلك لأن وحدة تحكم resourceclaim في kube-controller-manager تنشئ تلقائيًا ResourceClaim منفصل لكل Pod.

إليك مثال مبسط لـ Pod Spec:

–--
apiVersion: v1
kind: Pod
metadata:
  name: pod-with-cats
spec:
  containers:
  - name: container0
    image: ubuntu:20.04
    command: ["sleep", "9999"]
    resources:
      claims:
      - name: cat-0
  - name: container1
    image: ubuntu:20.04
    command: ["sleep", "9999"]
    resources:
      claims:
      - name: cat-1
  resourceClaims:
  - name: cat-0
    resourceClaimName: small-white-cat-claim
  - name: cat-1
    resourceClaimTemplateName: large-black-cat-claim-template

DeviceClass

يشمل DeviceClass التكوين والمُحدد (selector) لجهاز (Device)، كما هو موضح أدناه.

// +genclient
// +genclient:nonNamespaced
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +k8s:prerelease-lifecycle-gen:introduced=1.31

// DeviceClass is a vendor- or admin-provided resource that contains
// device configuration and selectors. It can be referenced in
// the device requests of a claim to apply these presets.
// Cluster scoped.
//
// This is an alpha type and requires enabling the DynamicResourceAllocation
// feature gate.
type DeviceClass struct {
    metav1.TypeMeta `json:",inline"`
    // Standard object metadata
    // +optional
    metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`

    // Spec defines what can be allocated and how to configure it.
    //
    // This is mutable. Consumers have to be prepared for classes changing
    // at any time, either because they get updated or replaced. Claim
    // allocations are done once based on whatever was set in classes at
    // the time of allocation.
    //
    // Changing the spec automatically increments the metadata.generation number.
    Spec DeviceClassSpec `json:"spec" protobuf:"bytes,2,name=spec"`
}

// DeviceClassSpec is used in a [DeviceClass] to define what can be allocated
// and how to configure it.
type DeviceClassSpec struct {
    // Each selector must be satisfied by a device which is claimed via this class.
    //
    // +optional
    // +listType=atomic
    Selectors []DeviceSelector `json:"selectors,omitempty" protobuf:"bytes,1,opt,name=selectors"`

    // Config defines configuration parameters that apply to each device that is claimed via this class.
    // Some classses may potentially be satisfied by multiple drivers, so each instance of a vendor
    // configuration applies to exactly one driver.
    //
    // They are passed to the driver, but are not considered while allocating the claim.
    //
    // +optional
    // +listType=atomic
    Config []DeviceClassConfiguration `json:"config,omitempty" protobuf:"bytes,2,opt,name=config"`

    // Only nodes matching the selector will be considered by the scheduler
    // when trying to find a Node that fits a Pod when that Pod uses
    // a claim that has not been allocated yet *and* that claim
    // gets allocated through a control plane controller. It is ignored
    // when the claim does not use a control plane controller
    // for allocation.
    //
    // Setting this field is optional. If unset, all Nodes are candidates.
    //
    // This is an alpha field and requires enabling the DRAControlPlaneController
    // feature gate.
    //
    // +optional
    // +featureGate=DRAControlPlaneController
    SuitableNodes *v1.NodeSelector `json:"suitableNodes,omitempty" protobuf:"bytes,3,opt,name=suitableNodes"`
}

عندما ينشئ المستخدمون ResourceClaim أو ResourceClaimTemplate، يحتاجون إلى تحديد DeviceClass المقابل من خلال deviceClassName. تقوم الجدولة (Scheduler) بتعيين العقد لـ Pods عن طريق مطابقة الأجهزة باستخدام مُحدد DeviceClass، وفي النهاية، تقوم بتعبئة تكوين الجهاز من DeviceClass في حالة (Status) مثيل ResourceClaim.


    if requestData.class != nil {
        match, err := alloc.selectorsMatch(r, device, deviceID, requestData.class, requestData.class.Spec.Selectors)
        if err != nil {
            return false, err
        }
        if !match {
            alloc.deviceMatchesRequest[matchKey] = false
            return false, nil
        }
    }

    request := &alloc.claimsToAllocate[r.claimIndex].Spec.Devices.Requests[r.requestIndex]
    match, err := alloc.selectorsMatch(r, device, deviceID, nil, request.Selectors)
        

    for claimIndex, allocationResult := range alloc.result {
        claim := alloc.claimsToAllocate[claimIndex]

        // Populate configs.
        for requestIndex := range claim.Spec.Devices.Requests {
            class := alloc.requestData[requestIndices{claimIndex: claimIndex, requestIndex: requestIndex}].class
            if class != nil {
                for _, config := range class.Spec.Config {
                    allocationResult.Devices.Config = append(allocationResult.Devices.Config, resourceapi.DeviceAllocationConfiguration{
                        Source:              resourceapi.AllocationConfigSourceClass,
                        Requests:            nil, // All of them...
                        DeviceConfiguration: config.DeviceConfiguration,
                    })
                }
            }
        }

مسار الكود: pkg/scheduler/framework/plugins/dynamicresources/dynamicresources.go

func (pl *dynamicResources) bindClaim(ctx context.Context, state *stateData, index int, pod *v1.Pod, nodeName string) (patchedClaim *resourceapi.ResourceClaim, finalErr error) {
        if allocation != nil {
            if claim.Status.Allocation != nil {
                return fmt.Errorf("claim %s got allocated elsewhere in the meantime", klog.KObj(claim))
            }
......
            claim.Status.Allocation = allocation
        }
        claim.Status.ReservedFor = append(claim.Status.ReservedFor, resourceapi.ResourceClaimConsumerReference{Resource: "pods", Name: pod.Name, UID: pod.UID})
        updatedClaim, err := pl.clientset.ResourceV1alpha3().ResourceClaims(claim.Namespace).UpdateStatus(ctx, claim, metav1.UpdateOptions{})

ResourceSlice

يقدم Kubernetes 1.30 ResourceSlice كميزة تحسين رئيسية لـ DRA مع المعلمات المنظمة. يتم تحديد إنشاء وصيانة مثيلات ResourceSlice بواسطة kubelet وبرامج تشغيل الموارد المقدمة من البائعين. بالإضافة إلى ذلك، تحدد الجدولة (Scheduler) عقدة الجدولة بناءً على ResourceClaim و ResourceSlice أثناء جدولة Pod، مما يتحمل فعليًا مسؤولية تخصيص الموارد.

يكون kubelet مسؤولاً بشكل أساسي عن المسح التلقائي لـ ResourceSlice على العقدة أثناء بدء التشغيل أو إعادة التشغيل أو عند إزالة مكون إضافي (plugin) لـ kubelet.

// NewPluginHandler returns new registration handler.
//
// Must only be called once per process because it manages global state.
// If a kubeClient is provided, then it synchronizes ResourceSlices
// with the resource information provided by plugins.
func NewRegistrationHandler(kubeClient kubernetes.Interface, getNode func() (*v1.Node, error)) *RegistrationHandler {
    handler := &RegistrationHandler{
        // The context and thus logger should come from the caller.
        backgroundCtx: klog.NewContext(context.TODO(), klog.LoggerWithName(klog.TODO(), "DRA registration handler")),
        kubeClient:    kubeClient,
        getNode:       getNode,
    }

    // When kubelet starts up, no DRA driver has registered yet. None of
    // the drivers are usable until they come back, which might not happen
    // at all. Therefore it is better to not advertise any local resources
    // because pods could get stuck on the node waiting for the driver
    // to start up.
    //
    // This has to run in the background.
    go handler.wipeResourceSlices("")

    return handler
}

// DeRegisterPlugin is called when a plugin has removed its socket,
// signaling it is no longer available.
func (h *RegistrationHandler) DeRegisterPlugin(pluginName string) {
    if p := draPlugins.delete(pluginName); p != nil {
        logger := klog.FromContext(p.backgroundCtx)
        logger.V(3).Info("Deregister DRA plugin", "endpoint", p.endpoint)

        // Clean up the ResourceSlices for the deleted Plugin since it
        // may have died without doing so itself and might never come
        // back.
        go h.wipeResourceSlices(pluginName)

        return
    }

    logger := klog.FromContext(h.backgroundCtx)
    logger.V(3).Info("Deregister DRA plugin not necessary, was already removed")
}

برنامج تشغيل الموارد (Resource Driver)، المعروف أيضًا باسم DRA driver

يحتاج برنامج تشغيل الموارد، المعروف أيضًا باسم DRA driver، إلى التطوير من قبل البائعين أو المستخدمين أنفسهم. وهو مسؤول بشكل أساسي عن نشر موارد ResourceSlice، وكإضافة kubelet، يتعامل مع طلبات إعداد الموارد التي يتم تشغيلها بواسطة kubelet. في النهاية، يتم استخدام الموارد من خلال CDI.

أدناه الكود ذو الصلة لبدء kubelet لطلبات إعداد الموارد.

// PrepareResources attempts to prepare all of the required resources
// for the input container, issue NodePrepareResources rpc requests
// for each new resource requirement, process their responses and update the cached
// containerResources on success.
func (m *ManagerImpl) PrepareResources(pod *v1.Pod) error {
    batches := make(map[string][]*drapb.Claim)
    resourceClaims := make(map[types.UID]*resourceapi.ResourceClaim)
    for i := range pod.Spec.ResourceClaims {
        podClaim := &pod.Spec.ResourceClaims[i]
        klog.V(3).InfoS("Processing resource", "pod", klog.KObj(pod), "podClaim", podClaim.Name)
        claimName, mustCheckOwner, err := resourceclaim.Name(pod, podClaim)
......       
        resourceClaim, err := m.kubeClient.ResourceV1alpha3().ResourceClaims(pod.Namespace).Get(
            context.TODO(),
            *claimName,
            metav1.GetOptions{})
......
        // Atomically perform some operations on the claimInfo cache.
        err = m.cache.withLock(func() error {
            claimInfo, exists := m.cache.get(resourceClaim.Name, resourceClaim.Namespace)
......
            // Loop through all drivers and prepare for calling NodePrepareResources.
            claim := &drapb.Claim{
                Namespace: claimInfo.Namespace,
                UID:       string(claimInfo.ClaimUID),
                Name:      claimInfo.ClaimName,
            }
            for driverName := range claimInfo.DriverState {
                batches[driverName] = append(batches[driverName], claim)
            }

            return nil
        })
        if err != nil {
            return fmt.Errorf("locked cache operation: %w", err)
        }
    }

    // Call NodePrepareResources for all claims in each batch.
    // If there is any error, processing gets aborted.
    // We could try to continue, but that would make the code more complex.
    for driverName, claims := range batches {
        // Call NodePrepareResources RPC for all resource handles.
        client, err := dra.NewDRAPluginClient(driverName)
        if err != nil {
            return fmt.Errorf("failed to get gRPC client for driver %s: %w", driverName, err)
        }
        response, err := client.NodePrepareResources(context.Background(), &drapb.NodePrepareResourcesRequest{Claims: claims})
......
}

عيوب نهج DRA مع Control Plane Controller

الجانب السلبي لنهج DRA مع Control Plane Controller هو أنه أثناء جدولة Pod، تقوم الجدولة بإنشاء PodSchedulingContext، ويقوم برنامج تشغيل الموارد بتخصيص الموارد. نظرًا لأن جدولة Pod تتم بشكل تسلسلي، فإن ذلك يزيد حتمًا من التأخير لـ Pods الأخرى المنتظرة في قائمة الانتظار للجدولة.

لتحسين ذلك، تم تقديم المعلمات المنظمة، مما يسمح لبرنامج تشغيل الموارد بالإبلاغ المسبق عن ResourceSlices، مما يمكن الجدولة من اتخاذ قرارات الجدولة مباشرة من ResourceSlices أثناء جدولة Pod.

الخلاصة

حاليًا، يمكن جدولة تدريب واستدلال AI بواسطة Kubernetes مثل حاويات CPU العادية، ولكن يتم تحقيق ذلك من خلال آليات توسعة Kubernetes الحالية بدون حل موحد. علاوة على ذلك، كلما كانت سيناريوهات تخصيص موارد GPU أكثر تعقيدًا، كلما كانت الوظائف التي يحتاج المستخدمون إلى تنفيذها بأنفسهم أكثر تعقيدًا. يوفر Kubernetes DRA حلاً موحدًا حيث يحتاج البائعون أو المستخدمون فقط إلى تنفيذ برنامج تشغيل الموارد وتوفير تكوينات مثيل DriverClass، وهو مدعوم رسميًا، لذلك لا داعي للقلق بشأن توقف حل مفتوح المصدر لجدولة GPU عن الصيانة.

DRA ليس ناضجًا بعد ويتم تحسينه باستمرار. دعنا نتابع أحدث التطورات في المجتمع.