В настоящее время для планирования 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, поддерживается Dynamic Resource Allocation (DRA), предлагающая единое решение для запросов и выделения ресурсов на стороне планировщика. Однако первоначальный подход DRA с контроллером плоскости управления (Control Plane Controller) имел некоторые недостатки, которые были устранены в k8s v1.30 с введением DRA со структурированными параметрами (Structured Parameters), что и является темой данной статьи.
k8s 1.31 внесла дополнительные изменения на этой основе, и в данной статье будет представлен DRA со структурированными параметрами на базе версии k8s 1.31.
(Предыдущие решения-расширения в этой статье подробно рассматриваться не будут; при наличии времени я напишу отдельную статью, чтобы представить их позже.)
Теперь давайте сразу перейдем к основной теме этой статьи — DRA со структурированными параметрами.
DRA со структурированными параметрами

DRA в основном используется для запроса и разделения ресурсов между подами или между несколькими контейнерами в одном поде, охватывая не только GPU-ресурсы. Хотя это значительно сложнее, чем предыдущий подход Device Plugin, трио ResourceClaimTemplate/ResourceClaim/DeviceClass, аналогичное PVC/PV/SC, обеспечивает гибкость и полный набор определений ресурсов, который может официально поддерживать разнообразные гетерогенные ресурсы.
Естественно, пользователи по-прежнему должны разрабатывать собственные драйверы ресурсов для управления подготовкой и обслуживанием этих ресурсов.
Ключевые концепции
ResourceClaim
ResourceClaim используется для запросов доступа к ресурсам. Планировщик выбирает доступные узлы на основе ResourceClaim, настроенного в поде.
---
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?
Рассмотрим следующий пример в спецификации пода: поле resourceClaims позволяет ссылаться либо на ResourceClaim, либо на ResourceClaimTemplate, но не на оба одновременно — они взаимоисключающие. Когда вы ссылаетесь на ResourceClaim через resourceClaimName, все поды, использующие эту спецификацию (например, в Deployment или StatefulSet), совместно используют один и тот же экземпляр ResourceClaim. Этот экземпляр соответствует ResourceClaim с именем, указанным в resourceClaimName, в том же пространстве имен, что и под.
И наоборот, когда вы ссылаетесь на ResourceClaimTemplate через resourceClaimTemplateName, каждый под получает собственный отдельный экземпляр ResourceClaim. Это происходит потому, что контроллер resourceclaim в kube-controller-manager автоматически генерирует отдельный ResourceClaim для каждого пода.
Вот упрощенный пример спецификации пода:
–--
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 включает конфигурацию и селектор для устройства, как определено ниже.
// +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. Планировщик назначает узлы поду, сопоставляя устройства с помощью селектора DeviceClass, и, наконец, заполняет конфигурацию устройства из DeviceClass в статус экземпляра 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 и драйверами ресурсов, предоставляемыми вендорами. Кроме того, планировщик определяет узел для планирования на основе ResourceClaim и ResourceSlice во время планирования пода, фактически беря на себя ответственность за выделение ресурсов.
Kubelet в основном отвечает за автоматическую очистку ResourceSlice на узле во время запуска, перезапуска или при удалении плагина 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-драйвер
Resource Driver, также известный как DRA-драйвер, должен разрабатываться вендорами или самими пользователями. Он в первую очередь отвечает за публикацию ресурсов 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 с контроллером плоскости управления
Недостаток подхода DRA с контроллером плоскости управления заключается в том, что во время планирования пода планировщик создает PodSchedulingContext, а драйвер ресурсов выделяет ресурсы. Поскольку планирование подов происходит последовательно, это неизбежно увеличивает задержку для других подов, ожидающих в очереди на планирование. Для оптимизации этого были введены структурированные параметры, позволяющие драйверу ресурсов активно сообщать ResourceSlices, что дает планировщику возможность принимать решения о планировании непосредственно на основе ResourceSlices во время планирования пода.
Заключение
В настоящее время обучение и инференс ИИ могут планироваться Kubernetes как обычные CPU-контейнеры, но это достигается с помощью существующих механизмов расширения Kubernetes без единого решения. Более того, чем сложнее сценарии выделения GPU-ресурсов, тем более сложные функции пользователям приходится реализовывать самостоятельно. Kubernetes DRA предоставляет единое решение, где вендоры или пользователи должны только реализовать драйвер ресурсов и предоставить конфигурации экземпляров DriverClass, и это официально поддерживается, поэтому не нужно беспокоиться о прекращении поддержки какого-либо решения с открытым исходным кодом для планирования GPU. DRA еще не зрелый и постоянно улучшается. Будем следить за последними обновлениями в сообществе.
