soobook
KUBERNETES

Dynamic Resource Allocation

Device Plugin의 한계에서 출발해 DRA의 API 모델과 allocation 흐름을 정리

Dynamic Resource Allocation(DRA)은 GPU 같은 device를 개수가 아니라 속성을 가진 개별 객체로 다루는 Kubernetes의 resource 모델이다.

Intro

Kubernetes에서 GPU를 요청하는 가장 익숙한 방법은 이렇다.

resources:
  limits:
    nvidia.com/gpu: 1

이 방식은 GPU가 몇 장 필요한지만 표현할 수 있다.

  • 메모리가 80GiB 이상인 GPU
  • 같은 NVLink 도메인에 있는 GPU 두 장
  • 두 container가 함께 쓰는 GPU 한 장
  • GPU와 같은 PCIe root에 연결된 NIC

이러한 device의 속성이나 device 사이의 관계가 들어간 요구는 이 한 줄로 적을 수 없다.

DRA는 이런 요구를 표현하기 위해 device를 요청하고 할당하는 방식을 새로 설계한 API다.

Device Plugin Model

Device Plugin은 DRA 이전부터 device를 노출하는 표준 방식이다.

여기서 말하는 device plugin은 nvidia-device-plugin과 같은 k8s의 DevicePlugin을 사용하는 plugin이다.

Device Plugin은 node마다 DaemonSet으로 실행되고, kubelet에 gRPC로 등록한 뒤 “이 node에 nvidia.com/gpu가 4개 있다”고 보고한다.

Advertise한다고 많이 표현한다.

kubelet은 이 값을 Node의 status.capacity에 extended resource로 기록한다.

sequenceDiagram
    participant DP as Device Plugin
    participant KL as kubelet
    participant API as API Server
    participant SCH as Scheduler
    DP->>KL: Register + ListAndWatch (4 devices)
    KL->>API: Node.status.capacity nvidia.com/gpu=4
    SCH->>API: Pod requests nvidia.com/gpu=1
    SCH->>API: Bind Pod to node (count check only)
    KL->>DP: Allocate(device IDs)
    DP-->>KL: env, mounts, device nodes

이 구조에서 scheduler가 아는 정보는 정수 하나뿐이다.

scheduler는 node에 남은 GPU 개수만 확인하고 Pod를 배치하며, 실제로 어느 GPU를 줄지는 Pod가 node에 배치된 뒤 kubelet과 Device Plugin이 결정한다.

이 때문에 다음과 같은 한계가 생긴다.

Limitation

속성으로 고를 수 없다.

같은 node에 A100 40GB와 80GB가 함께 있어도 둘 다 같은 nvidia.com/gpu로 집계된다. 둘을 구분하려면 resource 이름을 따로 정의하거나, node label과 nodeSelector를 조합해 간접적으로 지정해야 한다.

scheduler가 topology를 모른다.

device를 node에서 선택하기 때문에 “같은 NUMA node에 있는 GPU 두 장” 같은 조건을 scheduling 단계에서 보장할 수 없다. node의 Topology Manager가 조건을 맞추지 못하면 Pod는 TopologyAffinityError로 실패한다.

TopologyManager는 노드의 하드웨어 리소스를 CPU Manager, Device Manager 같은 구성 요소의 topology hint를 모아서, 같은 NUMA node 안에서 할당이 이루어지도록 결정하는 kubelet 구성 요소이다.

공유를 표현할 수 없다.

extended resource는 정수 단위로만 할당되고 overcommit을 허용하지 않는다. 그래서 GPU 한 장을 두 container가 함께 쓰려면, time-slicing 설정처럼 물리 device 하나를 여러 개로 보고하는 방식을 써야 한다.

요청별 설정을 전달할 수 없다.

“이 GPU를 MIG 1g.10gb profile로 나눠서 할당해 달라” 같은 per-request 설정은 resource 요청에 포함할 수 없으므로, annotation이나 별도 CRD로 전달해야 한다.

할당이 Pod의 수명에 종속된다.

device는 container에 할당되었다가 Pod가 삭제되면 함께 해제된다. 따라서 여러 Pod가 같은 device를 동시에 쓰거나 차례로 이어 쓰는 경우를 표현하기 어렵다.

정리하면 Device Plugin 모델에서 scheduler는 device 개수만 보고 node를 고르고, 어느 device를 줄지는 node에서 따로 결정된다. scheduler가 device의 속성을 모르기 때문에, 요구가 구체적일수록 scheduling 결과와 실제로 할당 가능한 device가 어긋나게 된다.

Core Idea

DRA의 구조는 PersistentVolume 모델과 같은 방식으로 요청과 할당을 분리한다.

storage를 쓸 때 Pod는 특정 디스크를 지정하지 않고, PersistentVolumeClaim에 용량, access mode, StorageClass를 적으면 control plane이 조건에 맞는 volume을 찾아 binding한다.

DRA에서도 Pod는 ResourceClaim에 원하는 device의 조건을 적고, control plane이 조건을 만족하는 구체적인 device를 골라 그 결과를 claim에 기록한다.

이 구조에서 달라지는 점은 두 가지다.

첫째, device를 선택하는 주체가 node가 아니라 scheduler로 바뀐다. scheduler가 device의 속성까지 확인하고 선택하므로, 조건을 만족하는 device가 없는 node에는 Pod를 배치하지 않는다.

둘째, 요청과 할당 결과가 Pod와 별개인 독립 객체(ResourceClaim)에 저장된다. 그래서 여러 Pod가 claim 하나를 참조해 같은 device를 공유할 수 있고, 어떤 device가 할당되었는지 API로 조회할 수 있다.

 

Device Plugin: count→node picksDRA: constraints→scheduler picks\textbf{Device Plugin: count} \rightarrow \textbf{node picks} \qquad \textbf{DRA: constraints} \rightarrow \textbf{scheduler picks}

 

API Objects

DRA는 네 종류의 API Object로 구성되며, 각 객체를 만드는 주체와 역할이 다르다.

객체누가 만드나역할
ResourceSliceDRA drivernode(또는 pool)에 어떤 device가 있고 각각 어떤 속성을 갖는지 공개
DeviceClasscluster 관리자device 종류를 정의하는 selector와 기본 설정 (StorageClass에 대응)
ResourceClaim사용자 또는 ResourceClaim Controller“이런 device가 필요하다”는 요청과, 할당 결과
ResourceClaimTemplate사용자Pod마다 ResourceClaim을 새로 생성하기 위한 template (volumeClaimTemplate에 대응)

DRA Driver도 DevicePlugin처럼 벤더나 프로젝트별로 따로 있다. NVIDIA DRA Driver가 대표적.

flowchart LR
    DRV[DRA driver] -->|publishes| RS[ResourceSlice<br/>devices + attributes]
    ADM[Cluster admin] -->|defines| DC[DeviceClass<br/>selector + config]
    USR[User] -->|writes| RCT[ResourceClaimTemplate]
    RCT -->|per Pod| RC[ResourceClaim]
    USR -->|or writes| RC
    RC -->|references| DC
    POD[Pod] -->|resourceClaims| RC
    SCH[Scheduler] -->|matches RC against RS<br/>writes status.allocation| RC

ResourceSlice

ResourceSlice는 DRA driver가 자신이 관리하는 device 목록을 API server에 공개하는 객체다.

apiVersion: resource.k8s.io/v1
kind: ResourceSlice
metadata:
  name: node-a-gpu.example.com-x7k2p
spec:
  driver: gpu.example.com
  nodeName: node-a
  pool:
    name: node-a
    generation: 1
    resourceSliceCount: 1
  devices:
  - name: gpu-0
    attributes:
      model:
        string: A100
      driverVersion:
        version: 550.54.15
      numaNode:
        int: 0
    capacity:
      memory: # VRAM
        value: 80Gi
  - name: gpu-1
    attributes:
      model:
        string: A100
      numaNode:
        int: 1
    capacity:
      memory:
        value: 40Gi

Device Plugin은 “GPU 4개”라는 개수만 보고했지만, ResourceSlice는 device마다 이름, attribute, capacity를 담은 목록을 제공한다.

attribute는 타입이 지정된 값(string, int, bool, version)이고 capacity는 quantity다.

이 정보가 API server에 저장되므로 scheduler가 직접 읽고 할당 가능 여부를 판단할 수 있다.

Pool and Slice

pool은 같은 driver가 관리하는 device의 묶음이다. 일반적으로 node 하나의 device가 pool 하나를 이루지만, 네트워크로 연결되어 여러 node에서 접근할 수 있는 device는 nodeName 대신 nodeSelector나 allNodes로 접근 범위를 지정한다.

pool이라는 API 객체는 따로 없다. ResourceSlice의 spec.pool은 device를 담는 필드가 아니라 이 slice가 어느 pool에 속하는지 나타내는 이름이다. 같은 driver와 pool.name을 가진 ResourceSlice를 모은 것이 pool 하나가 되며, ResourceSlice 하나는 pool 하나에만 속한다.

pool과 ResourceSlice를 분리한 이유는 객체 하나에 담을 수 있는 device 수에 상한이 있기 때문이다. ResourceSlice 하나에는 device를 최대 128개(taint나 counter를 쓰는 device가 있으면 64개)까지 넣을 수 있다. MIG partition이나 SR-IOV VF처럼 device를 잘게 나눠 공개하면 이 상한을 넘기 쉬우므로, driver는 pool 하나를 여러 ResourceSlice에 나눠 담는다.

# One pool "node-a" split across two ResourceSlices
kind: ResourceSlice
metadata:
  name: node-a-gpu-1
spec:
  driver: gpu.example.com
  pool: {name: node-a, generation: 3, resourceSliceCount: 2}
  devices: [ ... ]   # up to 128 devices
---
kind: ResourceSlice
metadata:
  name: node-a-gpu-2
spec:
  driver: gpu.example.com
  pool: {name: node-a, generation: 3, resourceSliceCount: 2}
  devices: [ ... ]   # remaining devices

pool을 여러 객체에 나눠 담으면 갱신 시점이 어긋날 수 있다. Kubernetes API는 여러 객체를 한 번에 바꾸는 transaction을 제공하지 않으므로, driver는 GPU 고장이나 MIG 구성 변경을 반영할 때 slice를 하나씩 차례로 갱신한다.

그 사이에 scheduler가 slice를 읽으면 새 구성과 옛 구성이 섞인 상태를 보게 되고, 이미 없어진 device를 할당할 수 있다.

generation과 resourceSliceCount는 이 문제를 막는 필드다. driver는 pool 구성을 바꿀 때마다 generation을 올리고, 그 pool의 모든 slice에 같은 값을 쓴다. scheduler는 pool마다 가장 큰 generation을 가진 slice만 사용하고 나머지는 무시한다.

resourceSliceCount는 그 generation에 속한 slice의 총개수다. scheduler가 본 slice 수가 이 값보다 적으면 driver가 아직 갱신하는 중이라고 판단할 수 있다. 이런 pool을 incomplete pool이라고 하며, allocationMode: All처럼 pool 전체를 확인해야 하는 요청은 pool이 다시 완전해질 때까지 기다린다.

DeviceClass

DeviceClass는 cluster 관리자가 device 종류를 정의하는 객체다. 아래 예시는 gpu.example.com driver가 공개한 device를 gpu.example.com class로 정의한다.

apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
  name: gpu.example.com
spec:
  selectors:
  - cel:
      expression: device.driver == "gpu.example.com"

selector는 CEL(Common Expression Language) 식으로 작성한다. 사용자는 DeviceClass 이름만 참조하면 되므로, driver 이름이나 공통 필터 조건을 claim마다 반복해서 적을 필요가 없다. DeviceClass에 config를 지정하면, 이 class로 할당되는 모든 device에 공통 driver 설정을 전달할 수도 있다.

ResourceClaim

ResourceClaim은 device 요청과 할당 결과를 함께 저장한다. spec에는 필요한 device의 조건을 적고, status에는 scheduler가 실제로 할당한 device가 기록된다.

apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
  name: big-gpu
spec:
  devices:
    requests:
    - name: gpu
      exactly:
        deviceClassName: gpu.example.com
        selectors:
        - cel:
            expression: >-
              device.capacity["gpu.example.com"].memory.compareTo(quantity("64Gi")) >= 0
        count: 1

requests의 각 항목은 이름을 가지며, DeviceClass를 참조하고 CEL selector로 조건을 추가한다. 개수는 count로 지정하고, 조건을 만족하는 device 전부가 필요하면 allocationMode: All을 사용한다. Device Plugin 모델에서는 node label과 resource 이름을 조합해야 했던 “메모리 64GiB 이상인 GPU” 조건을 CEL 식 하나로 표현할 수 있다.

ResourceClaimTemplate

ResourceClaim을 직접 만들면 claim은 하나만 존재하고, 이 claim을 참조하는 Pod들은 같은 device를 공유한다. Deployment의 replica가 각자 GPU를 한 장씩 받아야 하는 경우처럼 Pod마다 별도의 device가 필요하면 ResourceClaimTemplate을 사용한다.

apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
  name: one-gpu
spec:
  spec:
    devices:
      requests:
      - name: gpu
        exactly:
          deviceClassName: gpu.example.com
---
apiVersion: v1
kind: Pod
metadata:
  name: trainer
spec:
  resourceClaims:
  - name: gpu
    resourceClaimTemplateName: one-gpu
  containers:
  - name: main
    image: trainer:latest
    resources:
      claims:
      - name: gpu

Pod의 spec.resourceClaims에 claim이나 template을 이름과 함께 선언하고, container의 resources.claims에서 그 이름을 참조한다. 선언과 참조가 분리되어 있으므로, Pod 안의 두 container가 같은 이름을 참조하면 두 container가 같은 device를 함께 쓴다. template으로 생성된 claim은 Pod를 owner로 가지므로 Pod가 삭제되면 함께 삭제된다.

Allocation Flow

Pod 하나가 device를 할당받기까지의 과정을 순서대로 살펴본다.

sequenceDiagram
    participant DRV as DRA driver (node)
    participant API as API Server
    participant RCC as ResourceClaim controller
    participant SCH as Scheduler
    participant KL as kubelet
    DRV->>API: Publish ResourceSlice
    Note over API: User creates Pod with claim template
    RCC->>API: Create ResourceClaim from template
    SCH->>API: Watch Pod, ResourceClaim, ResourceSlice, DeviceClass
    SCH->>SCH: Filter nodes: can claim be satisfied here?
    SCH->>API: Write claim.status.allocation + reservedFor
    SCH->>API: Bind Pod to node
    KL->>DRV: NodePrepareResources(claim)
    DRV-->>KL: CDI device IDs
    KL->>KL: Start container with CDI devices

Publish

node에서 실행되는 DRA driver(kubelet plugin)는 하드웨어를 조회해 ResourceSlice를 생성한다.

동시에 kubelet에 gRPC로 등록하는데, kubelet은 이후 이 연결을 통해 driver에 device 준비를 요청한다.

이 단계는 Pod 생성과 관계없이 미리 수행되며, device가 고장 나거나 추가되면 driver가 ResourceSlice를 갱신한다.

Claim Creation

사용자가 ResourceClaimTemplate을 참조하는 Pod를 생성하면, kube-controller-manager의 ResourceClaim controller가 template을 바탕으로 Pod 전용 ResourceClaim을 생성하고 그 이름을 Pod의 status.resourceClaimStatuses에 기록한다.

Pod가 ResourceClaim을 직접 참조하는 경우에는 이 단계를 거치지 않는다.

Pod가 PVC를 직접 참조하는 경우랑 비슷하다.

Scheduling

device 선택은 kube-scheduler의 DynamicResources plugin이 다음 순서로 수행한다.

  1. Pod가 참조하는 claim을 모두 조회한다. 이미 할당된 claim이 있으면, 후보 node는 그 할당이 유효한 node로 제한된다.
  2. 아직 할당되지 않은 claim에 대해서는 후보 node마다 계산을 수행한다. 그 node에서 접근할 수 있는 ResourceSlice의 device 중 다른 claim에 할당되지 않은 device만으로 모든 request를 만족할 수 있는지 확인하며, 이때 DeviceClass selector, claim의 selector, constraint를 모두 적용한다.
  3. request를 만족할 수 없는 node는 Filter 단계에서 후보에서 제외된다.
  4. node가 결정되면 Reserve 단계에서 선택한 device를 scheduler 메모리에 예약한다. 이어서 PreBind 단계에서 claim의 status.allocation에 할당 결과를 기록하고, status.reservedFor에 Pod를 추가한다.
  5. 마지막으로 Pod를 node에 bind한다.

어떤 device가 사용 중인지 별도로 저장하는 객체는 없다. scheduler는 모든 ResourceClaim의 status.allocation을 읽어서 사용 중인 device를 계산하므로, ResourceClaim의 할당 기록이 device 사용 현황의 유일한 기준이 된다. 할당 결과는 다음과 같이 기록된다.

status:
  allocation:
    devices:
      results:
      - request: gpu
        driver: gpu.example.com
        pool: node-a
        device: gpu-0
    nodeSelector:
      nodeSelectorTerms:
      - matchFields:
        - key: metadata.name
          operator: In
          values: ["node-a"]
  reservedFor:
  - resource: pods
    name: trainer
    uid: 6f0c...

nodeSelector는 이 할당이 유효한 node를 나타낸다. 같은 claim을 참조하는 다른 Pod도 이 selector에 따라 같은 node에 배치된다.

Prepare

Pod가 node에 배치되면 kubelet은 container를 시작하기 전에 DRA driver의 NodePrepareResources를 호출한다. driver는 claim의 할당 결과를 읽고, MIG instance 생성, device 초기화, 설정 파일 생성처럼 device를 사용하는 데 필요한 작업을 수행한다.

준비가 끝나면 driver는 CDI(Container Device Interface) device ID를 반환한다. kubelet이 이 ID를 container runtime(containerd, CRI-O)에 전달하면, runtime은 CDI spec 파일에 정의된 device node, mount, 환경 변수를 container에 추가한다.

이 과정은 Device Plugin의 Allocate와 비슷하지만, DRA에서는 scheduler가 이미 device를 선택했으므로 driver는 선택된 device를 준비하는 작업만 수행한다.

Release

Pod가 종료되면 kubelet은 NodeUnprepareResources를 호출하고, driver는 준비 단계에서 만든 설정을 정리한다.

이후 claim의 reservedFor에서 Pod가 제거되고, claim을 참조하는 Pod가 더 이상 없으면 ResourceClaim controller가 status.allocation을 비운다.

그러면 scheduler는 다음 계산부터 해당 device를 할당 가능한 device로 취급한다. template으로 생성된 claim은 Pod와 함께 삭제된다.

Expressing Requirements

Device Plugin 모델에서 표현하기 어려웠던 요구는 ResourceClaim의 필드로 표현할 수 있다.

Attribute Selection

CEL selector에서는 attribute와 capacity를 모두 참조할 수 있다.

selectors:
- cel:
    expression: >-
      device.attributes["gpu.example.com"].model == "A100" &&
      device.attributes["gpu.example.com"].driverVersion.compareTo(semver("550.0.0")) >= 0

attribute 이름 앞에는 driver의 domain이 붙기 때문에, 서로 다른 driver가 같은 이름의 attribute를 정의해도 충돌하지 않는다.

Constraints Across Devices

여러 device를 요청할 때는 constraints로 device 사이의 조건을 지정할 수 있다.

spec:
  devices:
    requests:
    - name: gpus
      exactly:
        deviceClassName: gpu.example.com
        count: 2
    - name: nic
      exactly:
        deviceClassName: nic.example.com
    constraints:
    - requests: ["gpus", "nic"]
      matchAttribute: resource.kubernetes.io/pcieRoot

matchAttribute는 지정한 request들에 할당된 모든 device가 해당 attribute에서 같은 값을 가져야 한다는 조건이다.

위 예시는 GPU 두 장과 NIC가 같은 PCIe root에 연결되어 있어야 한다는 조건을 scheduling 단계에서 보장한다.

Device Plugin 모델에서는 Pod가 node에 배치된 뒤 Topology Manager가 이 조건을 확인했지만, DRA에서는 조건을 만족할 수 없는 node가 scheduling 단계에서 제외된다.

Sharing

device 공유 여부는 claim을 참조하는 대상의 수로 결정된다.

  • 한 Pod 안의 container 두 개가 같은 claim을 참조하면, 두 container가 그 device를 함께 쓴다.
  • 여러 Pod가 직접 생성한 ResourceClaim 하나를 참조하면, 여러 Pod가 device를 공유하고 reservedFor에 그 Pod들이 모두 기록된다.
  • Pod마다 별도의 device가 필요하면 ResourceClaimTemplate을 사용한다.

Device Plugin 모델처럼 device 개수를 늘려서 보고할 필요가 없고, 공유 여부를 객체 사이의 참조 관계로 확인할 수 있다.

Alternatives

exactly 대신 firstAvailable을 사용하면 우선순위가 있는 대안 목록을 지정할 수 있다.

requests:
- name: gpu
  firstAvailable:
  - name: big
    deviceClassName: gpu.example.com
    selectors:
    - cel:
        expression: device.capacity["gpu.example.com"].memory.compareTo(quantity("80Gi")) >= 0
  - name: small
    deviceClassName: gpu.example.com
    count: 2

위 claim은 메모리 80GiB 이상인 GPU 한 장을 우선 요청하고, 그런 GPU가 없으면 GPU 두 장을 요청한다. scheduler는 목록의 첫 항목부터 확인해 만족할 수 있는 항목을 선택한다.

Configuration

claim에 config를 지정하면 driver에 request별 설정을 전달할 수 있다.

spec:
  devices:
    requests:
    - name: gpu
      exactly:
        deviceClassName: gpu.example.com
    config:
    - requests: ["gpu"]
      opaque:
        driver: gpu.example.com
        parameters:
          apiVersion: gpu.example.com/v1
          kind: GpuConfig
          sharing:
            strategy: TimeSlicing

scheduler는 opaque 설정을 해석하지 않는다. 이 설정은 할당된 device를 어떻게 준비할지에 관한 정보이며, NodePrepareResources 단계에서 driver가 읽는다.

DRA는 할당 결과에 영향을 주는 정보를 attribute와 selector로, device 준비 방식에 관한 정보를 opaque config로 구분한다. 이렇게 구분하기 때문에 scheduler는 driver의 도움 없이 할당을 계산할 수 있다.

Device Plugin vs DRA

Device Plugin 모델에서 scheduler는 node의 정수 capacity만 보고 Pod를 배치한다. 어느 GPU를 할당할지와 topology 조건은 Pod가 node에 배치된 뒤에 결정되므로, 그 단계에서 조건을 맞추지 못하면 Pod가 실패한다. 공유와 요청별 설정은 API 모델 밖의 설정 방식으로 처리해야 한다.

DRA에서 scheduler는 device별 속성을 확인하고, claim의 조건과 device 사이의 constraint를 모두 만족하는 node와 device를 함께 선택한다. 할당 결과는 ResourceClaim에 기록되어 공유 여부와 할당 해제 시점을 판단하는 기준이 되고, node의 driver는 scheduler가 선택한 device를 준비한다.

대신 DRA에서는 관리해야 할 객체가 늘어난다. GPU 개수만 지정하면 되는 workload라면 extended resource 한 줄로 요청하는 편이 더 간단하다. DRA는 device의 속성, device 사이의 관계, 공유, 요청별 설정이 필요한 workload에 적합하다.

Summary

  • Device Plugin 모델에서 scheduler는 device를 개수로만 알고, 실제 device 선택은 node에서 이루어진다. 그래서 속성 조건, topology, 공유, 요청별 설정을 표현하기 어렵다.
  • DRA에서는 driver가 ResourceSlice로 device와 속성을 공개하고, 사용자가 ResourceClaim에 조건을 적으면, scheduler가 조건에 맞는 device를 선택해 할당한다.
  • DeviceClass는 StorageClass처럼 device 종류를 정의하고, ResourceClaimTemplate은 Pod마다 ResourceClaim을 생성한다.
  • 할당 결과는 claim의 status.allocation에 기록되며, scheduler는 이 기록으로 사용 중인 device를 계산하고 공유 여부를 판단한다.
  • node의 driver는 NodePrepareResources에서 할당된 device를 준비하고, container runtime은 CDI spec에 따라 device를 container에 추가한다.

References