PaaS 平台 GPU 资源复用与超卖技术深度解析

目录

  1. 背景与动机
  2. 技术架构概览
  3. 核心技术实现
  4. 关键数据结构
  5. 核心代码解析
  6. 模拟面试问答

背景与动机

业务需求

在PaaS平台的实际运营中,我们遇到了两类核心问题:

用户侧需求:

  • 推理服务实例资源利用率低:部分推理服务只占用1卡资源,但存在浪费
  • 开发机资源紧张:租户内资源有限,多用户开发机需要共享GPU

平台侧需求:

  • 提升集群资源利用率
  • 对已分配但空闲的GPU进行超卖
  • 增加平台收入和毛利率

技术选型

经过对市场现有方案的调研:

方案类型 代表方案 局限性
厂商官方解决方案 Nvidia MIG、MPS、vGPU 授权成本高;场景局限;不便于整合不同厂商
API劫持隔离方案 vCUDA、OrionX、cGPU 侵入业务层;开发维护难度大;存在性能损失
轻量调度共享方案 gpushare-device-plugin 依赖调度逻辑变更;与已有体系存在冲突

最终选择:自研轻量化共享方案

基于自定义Container Runtime、Device Plugin、节点资源管理组件,实现通用的容器间GPU设备共享,具有以下优势:

  • 对调度逻辑影响更小
  • 设备类型无关,通用性强
  • 核心功能:支持将一块卡同时挂载到多个容器

技术架构概览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
┌─────────────────────────────────────────────────────────────────────┐
│ 整体架构图 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ SharePod │ │ ShadowPod │ │ SpotPod │ │
│ │ (推理服务) │ │ (隐藏Pod) │ │ (低优先级) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Device Plugin Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ ShareGPU DP │ │ SpotGPU DP │ │ Real GPU DP │ │ │
│ │ │(虚拟设备) │ │(虚拟设备) │ │(NVIDIA/AMD/...) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ node-agent (核心组件) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ Detector │ │ Solver │ │ MappingCache │ │ │
│ │ │ (状态检测) │ │ (事件处理) │ │ (设备映射缓存) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Custom Container Runtime │ │
│ │ (设备同步、Spec文件修改、Annotation处理) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Physical GPU │ │
│ │ (NVIDIA / AMD / 国产芯片) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

核心技术实现

一、自定义Container Runtime实现容器间GPU共享

1.1 问题背景:Dind场景下的GPU独占

在PaaS平台的开发机场景中,用户使用Dind(Docker in Docker)功能时遇到问题:

1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────────────────┐
│ 开发机Pod │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Main容器 │ │ Dind容器 │ │
│ │ (用户环境) │ │ (Docker服务) │ │
│ │ ? │ │ GPU ✓ │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ 问题:GPU只能挂载给一个容器 │
└─────────────────────────────────────────┘

核心矛盾:K8s以容器为单位调度资源,不同容器间资源互不相干,一个GPU设备要么分配给主容器,要么分配给Dind容器。

1.2 解决思路:Hacking容器创建流程

K8s容器创建基本流程:

1
kubelet → containerd → spec文件(OCI标准) → runc → 容器

关键发现containerd在创建容器时会生成符合OCI标准的spec文件,runc根据这个spec文件创建容器。只要将容器A的spec中的设备挂载信息拷贝到容器B的spec中,就可以实现同一GPU同时挂载给多个容器。

1.3 自定义Runtime实现

参考nvidia-container-runtime的"包一层"方式,自定义runtime修改spec文件:

核心逻辑

  1. 检查Pod的annotation
  2. 如果存在设备同步标记,将来源容器的设备信息同步到目标容器spec
  3. 调用下层runtime创建容器

1.4 Annotation接口设计

使用annotation作为容器间设备同步的接口:

1
2
3
4
5
{
"annotations": {
"gpushare.k8s.io/container-devices-from.dind": "_/_/main"
}
}

格式说明

  • container-devices-from.dind:目标容器为dind,需要从其他容器同步设备
  • _/_/main:来源容器信息,两个下划线分别代表当前namespace和当前pod,main为来源容器名称

代码实现pkg/deviceplugin/share/stub.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Allocate will give annotation to the container according to the device id
func (stub *dpStub) Allocate(ctx context.Context, req *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
// ... 省略前置检查 ...

for _, req := range req.ContainerRequests {
// 解析设备ID获取ShadowPod信息
ns, shadowPodName := utils.ParseShadowPodFromDevice(req.DevicesIDs[0])

// 构建设备同步annotation
// e.g. "gpushare.k8s.io/container-devices-from.infer": "_/shadowPodName/main"
annoData := map[string]map[string][]string{
"_/" + shadowPodName + "/main": {
"*": req.DevicesIDs,
},
}
bytes, err := json.Marshal(annoData)
if err != nil {
return nil, fmt.Errorf("failed marshaling annotation value data: %v", err)
}

containerResponses = append(containerResponses, &pluginapi.ContainerAllocateResponse{
Annotations: map[string]string{
consts.ContainerRelationAnnotation + ".main": string(bytes)
},
})
}

return &pluginapi.AllocateResponse{
ContainerResponses: containerResponses,
}, nil
}

二、ShadowPod与虚拟设备实现Pod级GPU复用

2.1 核心概念

概念 说明
ShadowPod 隐藏的Pod,仅在后台可见,作用是占住GPU资源,作为设备同步中的"来源容器"
SharePod 开启了"显卡复用"开关的推理服务Pod,作为"目标容器"接收设备同步
虚拟资源 gpushare/share-gpu,在K8s调度框架内实现对应关系

2.2 工作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌─────────────────────────────────────────────────────────────────┐
│ ShareGPU工作流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户创建推理服务,开启"显卡复用"开关 │
│ │ │
│ ▼ │
│ 2. Shadow Controller根据需求创建ShadowPod │
│ │ │
│ ▼ │
│ 3. ShadowPod绑定真实GPU,生成虚拟设备ID │
│ │ 虚拟设备ID格式: {namespace}/{shadowpod-name}-{index} │
│ │ │
│ ▼ │
│ 4. ShareGPU Device Plugin向kubelet注册虚拟设备 │
│ │ │
│ ▼ │
│ 5. SharePod调度时请求gpushare/share-gpu资源 │
│ │ │
│ ▼ │
│ 6. Device Plugin Allocate返回设备同步annotation │
│ │ │
│ ▼ │
│ 7. Custom Runtime同步ShadowPod的GPU设备到SharePod │
│ │ │
│ ▼ │
│ 8. 多个SharePod共享同一块物理GPU │
│ │
└─────────────────────────────────────────────────────────────────┘

2.3 核心代码实现

Shadow Controllerpkg/controllers/shadow/controller.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (c *Controller) doOneReconcile(ctx context.Context) {
snapshot := c.cache.Snapshot()

// 计算每个quota的ShadowPod需求
for _, quotaKey := range snapshot.ShareDevices.Keys() {
quotaDevices := snapshot.ShareDevices.Get(quotaKey)
delta := quotaDevices.Pipelining + quotaDevices.Ready
if delta < 0 {
// 需要创建ShadowPod
needShadow := (quotaKey.ShareSpec - 1 - delta) / quotaKey.ShareSpec
for i := 0; i < needShadow; i++ {
err := c.createShadow(ctx, quotaKey)
if err != nil {
logrus.Errorf("failed creating shadow: %v", err)
}
}
}
}

// 清理空闲超时的ShadowPod
for _, podInfo := range snapshot.Pods {
switch podInfo.DeviceType {
case apis.PodDeviceShadow:
c.doShadowPodClear(ctx, podInfo, snapshot.ShareDevices)
}
}
}

ShareGPU Device Pluginpkg/deviceplugin/share/deviceplugin.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func (dp *ShareGPUDevicePlugin) initDevices() error {
// 获取所有ShadowPod并生成虚拟设备
pods, err := dp.kubeClient.CoreV1().Pods(v1.NamespaceAll).List(context.Background(), metav1.ListOptions{
LabelSelector: fmt.Sprintf("%s=%s", shareapis.PodDescriptionLabelKey, shareapis.PodDescriptionLabelShadowPod),
})
if err != nil {
return err
}

for _, pod := range pods.Items {
if pod.Spec.NodeName != dp.nodeName {
continue
}
if pod.Status.Phase != v1.PodRunning {
continue
}
spec, err := utils.GetPodShareSpec(&pod)
if err != nil {
continue
}
// 每个ShadowPod生成spec个虚拟设备
for i := 0; i < spec; i++ {
dp.devs = append(dp.devs, &pluginapi.Device{
ID: fmt.Sprintf("%s/%s-%d", pod.Namespace, pod.Name, i),
Health: pluginapi.Healthy,
})
}
}
return nil
}

ShareGPU Solverpkg/solvers/sharegpu/sharegpu.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func (s *shareGPUSolver) Solve(e detectors.Event) {
s.Lock()
defer s.Unlock()

switch e.Type {
case consts.EventTypeShadow:
s.solveShadowPodEvent(e)
case consts.EventTypeShare:
s.solveSharePodEvent(e)
}
}

func (s *shareGPUSolver) solveSharePodEvent(e detectors.Event) {
if e.Clear {
s.unuse(e) // SharePod终止,释放虚拟设备
} else {
s.use(e) // SharePod运行,占用虚拟设备
}
}

func (s *shareGPUSolver) use(e detectors.Event) {
for _, d := range e.Devices {
ns, shadowpodName := utils.ParseShadowPodFromDevice(d.ID)
shadowpod := ns + "/" + shadowpodName

// 检查ShadowPod存活状态
pod, err := s.kubeClient.CoreV1().Pods(ns).Get(context.TODO(), shadowpodName, metav1.GetOptions{})
if err != nil || pod.DeletionTimestamp != nil {
// ShadowPod已不存在,驱逐SharePod
s.kubeClient.CoreV1().Pods(ns).Delete(context.Background(), sharePodName, metav1.DeleteOptions{})
return
}

// 更新缓存,建立设备使用关系
s.cache.Use(shadowpod, e.Target, d.ID)

// 更新ShadowPod的Condition,标记设备被使用
s.kubeClient.AddUsingCondition(shadowpod, d.ID, e.Target)

// 在SharePod上添加owner annotation
s.kubeClient.AddAnnotation(e.Target, shareapis.AnnoDeviceOwnerKey, shadowpod)
}
}

2.4 Device Plugin 与自定义 GPU 资源的设计

上面的"虚拟设备"在 K8s 里是通过自定义 device plugin 注册扩展资源实现的。这一层有几个关键设计,理解了它们就理解了 share-gpu / share-dev-gpu / spot-gpu 是怎么"骗"过 kubelet 调度的。

1. 资源定义:三种自定义扩展资源

全部是标准 K8s 扩展资源(<vendor>/<resource>):

资源名 含义
gpushare/share-gpu-{N} 一张物理 GPU 最多分给 N 个推理 pod(fan-out=N)
gpushare/share-dev-gpu-{N} 一张物理 GPU 最多分给 N 个开发机
gpushare/spot-gpu 偷卡超卖,与真实 GPU 一一对应、可偷可还

{N}共享份数(share spec)。推理默认 [2,3,4,5],开发机默认 [2,4,8,16](2 的幂)。每个资源名对应一个独立的 device plugin 实例,各自 unix socket、各自向 kubelet Register。所以一个节点上会同时跑十来个 device plugin(share-gpu-2/3/4/5 + share-dev-gpu-2/4/8/16 + spot + vcspot),由 DevicePluginManager 并行管理。

为什么开发机用 2 的幂而推理用连续小整数?开发机要的是"分数卡"(1/N 张卡),2 的幂让切分干净对齐:1 卡 = 2×½ = 4×¼ = 8×⅛ = 16×1/16,且开发机规格常按 1/2、1/4、1/8、1/16 卡售卖,二进制细分最自然、计费调度都好算,fan-out 也开得大(负载轻)。推理实例规格相对固定,常见一张卡跑 2~5 个副本,给连续小范围让业务按实际并发选最贴的粒度,不需要二进制对齐。

2. 虚拟设备的来源:两类完全不同

  • share-gpu / share-dev-gpu:设备不是真实 GPU,而是 ShadowPod 派生的。initDevices 列本节点所有匹配该资源名的 ShadowPod(label 过滤 + 资源名匹配),每个 ShadowPod 按 GetPodShareSpec() 取出 N,for i:=0; i<N; i++ 生成 N 个虚拟设备,ID 格式 {namespace}/{shadowpod-name}-{index}。设备数量随 ShadowPod 动态增减。
  • spot-gpu:设备来自真实 GPU device plugin(nvidia/metax/…)。initDevices 通过 gRPC ListAndWatch 调真实 DP 拿真实 GPU 列表,再用 OriginalID_To_SpotID-spot 后缀生成一一对应的 spot 虚拟设备。realResourceName 记下能连上的厂商资源名(如 nvidia.com/gpu),后面 Allocate 注解里要用。

3. Allocate 的核心设计:不挂设备,只回写 annotation

这是整个方案最关键、最"反常规"的点。标准 device plugin 的 Allocate 应该返回 env / device mounts 让 kubelet 把设备挂进容器,但这里故意不挂,只在 ContainerAllocateResponse.Annotations 里写一条"设备同步指令":

  • share:gpushare.k8s.io/container-devices-from.main = JSON {"_/<shadowPodName>/main": {"*": [deviceIDs]}} —— 告诉自定义 runtime"把 ShadowPod 的 main 容器的这些设备同步到当前容器"。
  • spot:gpushare.k8s.io/container-devices-ids.main = JSON {<realResourceName>: [原始GPU IDs]} —— 告诉 runtime"把这几张真实 GPU 挂到当前容器"(spot ID 反解掉 -spot 还原原始 ID)。

为什么这么设计? kubelet 在分配 device plugin 资源时硬性保证"一个设备不能同时分给两个 pod",而 GPU 共享恰恰需要"一张卡同时给多个 pod"。于是 device plugin 被降级成"调度名额 + 选设备 + 下发指令"的三传手:用虚拟资源骗过 kubelet 的调度检查,真正共享由更底层的自定义 Container Runtime 在 OCI spec 阶段完成。ShadowPod 就是那个"先正常走调度把卡占住的来源容器"。

4. 后缀 = 资源类型标签,count 是动态上报的

这是最容易被误解的地方:gpushare/share-dev-gpu-8 是一个资源类型,调度器看到的是"该节点上有 K 个该类型"。K 不是后缀 8 本身,而是 device plugin 通过 ListAndWatch 上报的 Healthy 设备条数。计算公式:

1
2
K = (本节点该 spec 的 ShadowPod 数) × spec
= (被切成该粒度的物理 GPU 数) × spec

每个 ShadowPod 占一张物理 GPU,派生 spec 个虚拟设备。举例:

节点物理卡数 选用的 spec ShadowPod 数 device plugin 上报 gpushare/share-dev-gpu-N
1 张 8 1 1 × 8 = 8
8 张(全切成 spec=8) 8 8 8 × 8 = 64
8 张(全切成 spec=16) 16 8 8 × 16 = 128

所以"8 个 share-dev-gpu-8"恰好对应 1 张物理卡、spec=8 的情形;通用公式是 被切成该粒度的物理卡数 × 后缀。后缀既是类型标签,也是每张卡的裂分倍数;数量是 device plugin 根据当前 ShadowPod 实况动态上报的。

5. 调度侧到底看到什么 + 健康度作为调度杠杆

K8s 的机制串起来:

  1. device plugin gRPC ListAndWatch[]Device{ID, Health} 流式推给 kubelet;
  2. kubelet 把 Healthy 设备数累加成该扩展资源的 Capacity/Allocatable,写到 Node status;
  3. 调度器只看 Node 上的资源量做过滤/打分,不关心设备 ID;
  4. kubelet 在 bind 阶段调 device plugin 的 Allocate(DevicesIDs),这时才把具体设备 ID 交给 plugin,plugin 回写 annotation。

数量是动态的:ShadowPod 创建/删除、设备 Healthy/Unhealthy 翻转,都会通过 updateChan 或 1 分钟 ticker 重新推 ListAndWatch,kubelet 的可调度量随之变。spot-gpu 的"偷卡/还卡"就靠这个——开发机离开 → spot 设备 Healthy(可被新 spot 调度);用户回归 → Unhealthy(kubelet 不再分配,且 AllocategetUnHealthy 会拒绝已分配的)。GetPreferredAllocation 还会把候选设备分 idle(真正空闲可偷)/stoled(正在被偷),优先分配 idle,避免新 spot 调度到已在被偷的卡。


三、Steal GPU动态超卖机制

3.1 问题场景

某用户以包年包月方式购买了一个8卡节点,同时运行一个8卡开发机。但开发机只有白天工作时间运行GPU任务,大部分时间GPU利用率为0。能否将这部分空闲GPU时超卖?

3.2 "偷卡"机制设计

核心思路

  • 利用已有的"同一GPU挂载给多个Pod"能力
  • 准确判断用户"离开"状态 → 将开发机GPU重复挂载给Spot任务
  • 检测到用户"回归" → 立刻驱逐偷卡的Spot任务

状态定义

状态 判断条件 行为
离开 容器中超过一段时间没有对GPU设备的访问,且利用率为0 GPU可超卖
回归 处于"离开"状态的容器中出现对GPU设备的访问 驱逐Spot任务

3.3 虚拟资源映射

引入新的虚拟资源类型:gpushare/spot-gpu

1
真实GPU ←→ gpushare/spot-gpu (一一对应)

状态流转

场景 真实GPU状态 gpushare/spot-gpu状态 行为
Reserved任务分配 已分配 Unhealthy 不可用,如有Spot负载则驱逐
开发机"离开"状态 已分配但空闲 Healthy 可分配给新的Spot负载
用户"回归" 需要使用 触发驱逐 内核模块信号→驱逐Spot负载

3.4 核心代码实现

SpotGPU Device Pluginpkg/deviceplugin/spot/deviceplugin.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (dp *SpotGPUDevicePlugin) initDevices() error {
// 从真实GPU Device Plugin获取设备ID
var resp *pluginapi.ListAndWatchResponse
for name, c := range dp.realDPClients {
lwClient, err := c.ListAndWatch(context.Background(), &pluginapi.Empty{})
if err != nil {
continue
}
resp, err = lwClient.Recv()
if err == nil {
dp.realResourceName = name
break
}
}

// 将真实GPU ID转换为Spot GPU ID
devs := []*pluginapi.Device{}
for _, d := range resp.Devices {
devs = append(devs, &pluginapi.Device{
ID: utils.OriginalID_To_SpotID(d.ID), // 添加spot-前缀
Health: pluginapi.Healthy,
})
}
dp.devs = devs
return nil
}

SpotGPU Solverpkg/solvers/spotgpu/spotgpu.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
func (s *spotGPUSolver) Solve(e detectors.Event) {
s.Lock()
defer s.Unlock()

switch e.Type {
case consts.EventTypeLowPower:
s.solveLowPower(e) // 处理低功耗(离开)事件
case consts.EventTypeGeneric:
s.solveGeneric(e) // 处理Reserved任务
case consts.EventTypeSpot:
s.solveSpot(e) // 处理Spot任务
}
}

低功耗检测与处理pkg/solvers/spotgpu/lowpower.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
func (s *spotGPUSolver) solveLowPower(e detectors.Event) error {
if e.Clear {
s.solveGenericTerminating(e)
return nil
}

if e.Devices[0].Using {
// 用户"回归",GPU开始使用
s.solveBusy(e)
} else {
// 用户"离开",GPU空闲
s.solveIdle(e)
}
return nil
}

func (s *spotGPUSolver) solveBusy(e detectors.Event) error {
// 用户回归,需要驱逐偷卡的Spot任务
evictedUsers := s.cache.GetOwnerUsers(e.Target)
s.updateEvictCounter(evictedUsers, consts.EvictReasonOwnerBack)

// 执行驱逐
s.evictProcessFor(e)
s.evictFor(e.Target)

// 更新缓存,删除设备映射
s.cache.Delete(e.Target)

// 设置Spot GPU为Unhealthy,防止新调度
s.updateDP(e, pluginapi.Unhealthy)

return nil
}

func (s *spotGPUSolver) solveIdle(e detectors.Event) error {
// 用户离开,GPU可以超卖
nvIDs := []string{}
for _, d := range e.Devices {
nvIDs = append(nvIDs, d.ID)
}

// 在缓存中创建设备映射
s.cache.Create(e.Target, utils.OriginalIDs_To_SpotIDs(nvIDs))

// 设置Spot GPU为Healthy,允许调度
s.updateDP(e, pluginapi.Healthy)

return nil
}

驱逐机制pkg/solvers/spotgpu/evict.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func (s *spotGPUSolver) evictByDevice(e detectors.Event) error {
needEvict := map[string]struct{}{}
for _, d := range e.Devices {
spotID := utils.OriginalID_To_SpotID(d.ID)
needEvict[spotID] = struct{}{}
}

// 从IdleSpace中查找使用这些设备的Spot任务
snapshot := s.cache.Snapshot()
idleStatus := snapshot[consts.IdleSpace]
users := map[string]struct{}{}
for _, ds := range *idleStatus {
if _, need := needEvict[ds.ID]; need && ds.SharingPod != "" {
users[ds.SharingPod] = struct{}{}
// 从缓存中移除映射关系
s.cache.UnUse(consts.IdleSpace, ds.SharingPod, ds.ID)
}
}

// 驱逐Spot Pod
for user := range users {
ns, name, _ := cache.SplitMetaNamespaceKey(user)
// 先Kill容器进程
for _, container := range pod.Status.ContainerStatuses {
id := strings.TrimPrefix(container.ContainerID, "containerd://")
s.taskClient.Kill(context.Background(), id)
}
// 再删除Pod
s.kubeClient.CoreV1().Pods(ns).Delete(context.Background(), name, metav1.DeleteOptions{})
}
return nil
}

四、多GPU类型适配

4.1 设计理念

底层Runtime进行容器间设备同步的机制是设备无关的,适用于所有类型的GPU设备。

4.2 已适配芯片

类别 厂商 资源名称
国际 NVIDIA nvidia.com/gpu
国际 AMD amd.com/gpu
国产 燧原 enflame.com/gpu
国产 摩尔线程 metax.com/gpu
国产 天数智芯 iluvatar.com/gpu
国产 沐曦 metax.com/gpu
国产 壁仞 bilibili.com/gpu
国产 海光 hygon.com/dcu
国产 华为 huawei.com/npu
国产 昆仑芯 kunlunxin.com/xpu
国产 清微 qingwei.com/gpu

4.3 GPU Collector适配器实现

适配器接口定义pkg/metrics/collector/gpu.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type GpuAdapter interface {
ConvertLabels(*model.Metric, *GpuMetric) bool
Convert(context.Context, PlainMetricModel) (ConvertedGpuMetricModel, error)
}

func init() {
adapters = map[string]GpuAdapter{
ADAPTER_NVIDIA: &NvidiaAdapter{},
ADAPTER_METAX: &MetaxAdapter{},
ADAPTER_HUAWEI: &HuaweiAdapter{},
ADAPTER_ENFLAME: &EnflameAdapter{},
ADAPTER_KUNLUNXIN: &KunlunxinAdapter{},
ADAPTER_QINGWEI: &QingweiAdapter{},
ADAPTER_HYGON: &HygonAdapter{},
}
}

海光DCU适配器示例pkg/metrics/collector/gpu_hygon.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const (
ADAPTER_HYGON = "dcu"
HYGON_GPU_USAGE = "dcu_utilizationrate"
HYGON_GPU_MEM_USED = "dcu_usedmemory_bytes"
HYGON_GPU_MEM_TOTAL = "dcu_memorycap_bytes"
HYGON_GPU_POWER_USAGE = "dcu_power_usage"
)

type HygonAdapter struct{}

func (na *HygonAdapter) ConvertLabels(mt *model.Metric, data *GpuMetric) bool {
for _, lbl := range mt.Label {
switch lbl.GetName() {
case "dcu_pod_namespace":
data.Namespace = lbl.Value
case "dcu_pod_name":
data.PodName = lbl.Value
case "container":
data.Container = lbl.Value
case "minor_number":
data.DeviceName = lbl.Value
case "pcieBus_number": // 海光芯片使用PCIE ID作为设备ID
data.DeviceId = lbl.Value
}
}
return true
}

func (na *HygonAdapter) Convert(ctx context.Context, pm PlainMetricModel) (ConvertedGpuMetricModel, error) {
model := NewConvertedGpuMetricModel()
for name, mf := range pm {
switch name {
case HYGON_GPU_USAGE:
walkMetricFamily(na, mf, func(data *GpuMetric) {
model.AddMetric(MetricGpuUsage, data)
})
case HYGON_GPU_MEM_USED:
walkMetricFamily(na, mf, func(data *GpuMetric) {
model.AddMetric(MetricGpuMemUsedBytes, data)
})
// ... 其他指标处理
}
}
return model, nil
}

关键数据结构

MappingCache - 设备映射缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// pkg/apis/mapping_cache.go

type MappingCache struct {
cache map[string]*PodStatus // owner pod key => PodStatus
sync.Mutex
}

type PodStatus map[string]*DeviceStatus // device id => DeviceStatus

type DeviceStatus struct {
ID string // 设备ID
SharingPod string // 当前使用该设备的Pod
}

// 核心方法
func (mc *MappingCache) Create(owner string, devices []string) // 创建owner与设备的映射
func (mc *MappingCache) Use(owner, user, deviceID string) // 标记设备被user使用
func (mc *MappingCache) UnUse(owner, user, deviceID string) error // 解除设备使用关系
func (mc *MappingCache) GetOwnerUsers(owner string) []string // 获取owner的所有使用者
func (mc *MappingCache) GetDeviceOwner(deviceID string) string // 获取设备的owner

ShareDeviceCount - 共享设备计数

1
2
3
4
5
6
7
// pkg/apis/share_device.go

type ShareDeviceCount struct {
Pipelining int // 管道中(已请求但未调度)
Ready int // 就绪(已调度并运行)
Releasing int // 释放中
}

Event - 检测事件

1
2
3
4
5
6
7
8
9
10
// pkg/detectors/types.go

type Event struct {
Type string // 事件类型
Target string // 目标Pod key
Devices []DeviceStatus // 设备状态列表
SpecCode string // 规格编码
ShareInfo ShareInfo // 共享信息
Clear bool // 是否为清除事件
}

核心代码解析

设备ID转换工具函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 设备ID格式转换,用于区分真实GPU和虚拟GPU

// OriginalID_To_SpotID: 将真实GPU ID转换为Spot GPU ID
func OriginalID_To_SpotID(id string) string {
return "spot-" + id
}

// SpotID_To_OriginalID: 将Spot GPU ID还原为真实GPU ID
func SpotID_To_OriginalID(spotID string) string {
return strings.TrimPrefix(spotID, "spot-")
}

// ParseShadowPodFromDevice: 从虚拟设备ID解析ShadowPod信息
// 输入: "namespace/shadowpod-xxx-0"
// 输出: namespace="namespace", shadowPodName="shadowpod-xxx"
func ParseShadowPodFromDevice(gpuName string) (namespace, shadowPodName string) {
idx := strings.LastIndex(gpuName, "-")
if idx == -1 {
return "", ""
}
parts := strings.SplitN(gpuName, "/", 2)
if len(parts) != 2 {
return "", ""
}
return parts[0], gpuName[:idx]
}

模拟面试问答

Q1: 请介绍一下你在这个项目中的角色和主要贡献?

回答

我在这个项目中负责GPU资源复用与超卖功能的核心设计与实现。具体包括:

  1. 技术方案设计:主导设计了基于自定义Container Runtime的容器间GPU共享方案,解决了Dind场景下的GPU独占问题。

  2. 核心代码开发

    • 实现了ShadowPod控制器,负责动态创建和管理隐藏Pod
    • 开发了ShareGPU和SpotGPU两个Device Plugin,实现虚拟设备的注册和分配
    • 实现了完整的设备映射缓存(MappingCache)和状态同步机制
  3. 多GPU适配:设计并实现了GPU适配器接口,完成了对NVIDIA、AMD以及海光、燧原、摩尔线程等国产芯片的适配。

  4. 线上落地:该方案已在生产环境稳定运行,显著提升了集群GPU利用率。


Q2: 为什么选择自定义Container Runtime的方式,而不是使用Nvidia官方的MIG或MPS?

回答

选择自定义Container Runtime主要基于以下几点考虑:

  1. 通用性:我们的方案是设备无关的,一套代码可以支持NVIDIA、AMD以及各种国产芯片。而MIG和MPS是NVIDIA专有技术,无法覆盖其他厂商。

  2. 成本考虑:MIG需要A100/H100等高端卡支持,MPS也需要额外的授权费用。我们的方案不需要任何额外硬件或授权成本。

  3. 灵活性:MIG的划分粒度有限(如A100 80GB只能划分7个实例),而我们的方案可以灵活配置共享比例,比如一块卡可以分给4个甚至更多实例。

  4. 侵入性低:MPS需要业务侧配合设置环境变量,我们的方案对业务完全透明,用户无需修改任何代码。

  5. 便于扩展:基于这套机制,我们后续又实现了Spot GPU超卖功能,这是官方方案无法支持的。


Q3: ShadowPod的作用是什么?为什么不直接让多个SharePod绑定同一块GPU?

回答

ShadowPod是一个关键的设计巧思,它解决了几个核心问题:

  1. K8s调度限制:K8s的资源调度是以Pod为单位的,同一块GPU不能同时分配给两个不同的Pod。ShadowPod作为一个"占位符",先通过正常调度流程获得GPU资源。

  2. 设备同步来源:我们的设备同步机制需要一个"来源容器",ShadowPod就是提供GPU设备的来源。SharePod通过annotation指定从哪个ShadowPod同步设备。

  3. 生命周期管理:ShadowPod与GPU资源绑定,当ShadowPod被删除时,可以级联驱逐所有依赖它的SharePod。

  4. 状态追踪:通过ShadowPod的Condition,我们可以追踪哪些SharePod正在使用这块GPU。

如果直接让多个SharePod绑定同一块GPU,在K8s的调度框架内是无法实现的,因为kubelet在分配设备时会检查资源是否已被占用。


Q4: 如何判断用户"离开"和"回归"?如何保证Spot任务被及时驱逐?

回答

用户状态的判断主要通过两个维度:

  1. GPU访问检测:在内核层面劫持对GPU设备的ioctl系统调用。通过解析ioctl参数中的设备魔数判断是否为GPU访问,通过pid和pidnsid判断容器身份。

  2. GPU利用率监控:通过DCGM或各厂商的监控接口获取GPU利用率。

状态判断

  • 离开:超过阈值时间(如10分钟)没有GPU访问,且利用率为0
  • 回归:出现GPU访问行为

快速驱逐机制

  1. 当检测到用户回归时,内核模块会阻塞用户的系统调用一小段时间
  2. 同时触发驱逐流程,并行Kill所有Spot容器进程
  3. 阻塞确保在Spot任务完全释放资源前,用户无法继续操作GPU

这里为什么使用内核模块而不是eBPF?因为eBPF不支持阻塞操作,而我们需要在检测到回归的瞬间阻塞用户操作,争取驱逐时间。

实现细节:gpukmod 内核模块如何感知 ioctl

上述"劫持 ioctl + 阻塞 + 上报"由独立的内核模块 gpukmod 完成,用户态侧(node-agent 的 pkg/gpukmod)通过 netlink 与之协作。整条链路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
node-agent (Go)  ──netlink "detect(pidnsid)"──▶  gpukmod 内核模块
│ container_map[pidnsid].need_notice = true

[容器内进程调 ioctl(fd, …)] │
│ __x64_sys_ioctl 被 khook 劫持 │
▼ │
khook___x64_sys_ioctl: │
type==0x46(GPU) && in pid-ns && need_notice ─────┤
│ │
├─ send_netlink_message(pidns_id) ───────────┘
│ └─▶ Go: Recvfrom → noticeChans[id] ← 触发驱逐 spot
└─ msleep(3000) ← 阻塞用户,给驱逐争取时间
最后 KHOOK_ORIGIN 调真正的 ioctl

1. 劫持方式:khook 内联 hook(非 eBPF、非 kprobe)

gpukmod 用的是经典的内核函数内联打补丁,实现在 khook/ 目录:

  • KHOOK_EXT(long, __x64_sys_ioctl, const struct pt_regs *) 声明要 hook 的目标符号,宏把自定义处理函数 khook___x64_sys_ioctl、目标名、stub 放进 .data.khook section。
  • khook_resolve 通过 kallsyms_lookup_name 找到 __x64_sys_ioctl 地址(符号未导出时用 kprobe 临时注册再读 probe.addr 兜底)。
  • khook_arch_sm_init_one 用内核内置长度反汇编器(insn_init/insn_get_length)算出目标函数首部若干字节的完整指令长度(≥5 字节),然后:
    • khook_arch_create_orig:把原始首指令拷贝到 orig 缓冲,后接 jmp 回原函数继续处,这就是 KHOOK_ORIGIN(...) 调用的"原始函数"。
    • khook_arch_create_stub:构造 stub,末尾跳到我们的 khook___x64_sys_ioctl
    • x86_put_jmp:在目标函数起始处写一条 E9 jmp 到 stub。
  • 写内核代码段需关写保护:khook_arch_write_kernelcli→关 CR0.WP(必要时关 CR4.CET)→写→恢复,全程在 stop_machine 上下文执行,避免其它核踩到半改的指令。

效果:任何进程调 ioctl,进内核走到 __x64_sys_ioctl 即被 jmp 转入我们的处理函数。

2. 感知逻辑本体:khook___x64_sys_ioctl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static long khook___x64_sys_ioctl(const struct pt_regs *regs)
{
int fd = regs->di; // ioctl(int fd, ...)
unsigned long request = regs->si; // 命令码
unsigned long arg = regs->dx;

unsigned int cmd = _IOC_NR(request);
unsigned int type = _IOC_TYPE(request); // ← 设备魔数
unsigned int size = _IOC_SIZE(request);
unsigned int dir = _IOC_DIR(request);

// 只关心容器里的进程:pid namespace 不是 init_pid_ns 的才算
if (!is_task_under_pid_ns(current))
return KHOOK_ORIGIN(__x64_sys_ioctl, regs);

// 通过 ioctl 命令码里的设备魔数判断是不是 GPU 设备
if (type != 0x46) // 0x46 = 'F',GPU 魔数
return KHOOK_ORIGIN(__x64_sys_ioctl, regs);

// 拿到当前进程所在 pid namespace 的 id(容器身份)
u64 pidns_id = get_pid_ns_id(current); // task_active_pid_ns(current)->ns.inum

// 查 container_map:这个容器当前是否需要上报
hash_for_each_possible(container_map, entry, node, pidns_id) {
if (entry->pidns_id == pidns_id && entry->need_notice == true) {
// 命中 → 上报用户态 + 阻塞一会儿
send_netlink_message(pidns_id);
msleep(3000); // ← "阻塞一小段时间"
return KHOOK_ORIGIN(__x64_sys_ioctl, regs);
}
}
return KHOOK_ORIGIN(__x64_sys_ioctl, regs);
}

文档里那段描述与代码的对应关系:

文档说法 代码实现
劫持对 GPU 设备的 ioctl 系统调用 khook 改写 __x64_sys_ioctl 首指令
解析 ioctl 参数中的设备魔数判断是否 GPU 访问 _IOC_TYPE(request) != 0x46 即放行
通过 pid 和 pidnsid 判断容器身份 is_task_under_pid_ns + get_pid_ns_idns.inum
检测到回归后阻塞一小段时间争取驱逐 send_netlink_message(pidns_id); msleep(3000)

注意:内核模块负责统计每个容器的访问次数或判断"离开"(那部分由 节点组件 侧用利用率 + 时间窗完成)。它只做一件具体的事——当某个被标记为"需要上报"的容器重新发起 GPU ioctl 时,立刻把它卡住 3 秒并通知用户态去驱逐 spot,也就是"回归 → 阻塞 + 触发驱逐"这一环。

3. container_map / need_notice 由谁设置

内核维护哈希表 container_map(key=pidns_id),每个 entry 有 need_notice bool。增删改由 netlink 命令驱动(nl_recv_msg):

  • add(N):id1 id2 … → 把容器登记进表(need_notice=false
  • delete(N):… → 移除
  • detect(N):…need_notice=true,开始盯着这个容器,一旦有 GPU ioctl 就上报(对应"开发机离开、把卡借给 spot"后等待用户回归)
  • undetect(N):…need_notice=false

只有 need_notice==true 的容器,其 GPU ioctl 才会被上报+阻塞。这正是"处于离开状态的容器出现 GPU 访问 = 回归"的精确实现。

4. 用户态对端:pkg/gpukmod/user.go

  • 启动时建 netlink socket(协议号 NETLINK_USER = 31),向内核发 "Register"。内核收到后记下 user_pid,之后 send_netlink_messagenlmsg_unicast(nl_sk, skb, user_pid) 把消息打回这个进程。
  • Add/Delete/Detect/Undetect 把容器 pidns_id 列表拼成 add(2):123 456 之类字符串发往内核,内核据此维护 container_map
  • 一个 goroutine Recvfrom 专门收内核上报:解出一个 uint64(即命中的 pidns_id),按 pidns_id 找到 noticeChans[pidnsid] 通道塞进去。

下游(detectors/lowpowerpkg/detectors/spot 等)拿到该通道事件即知"某容器的用户回来了",进而触发 spot pod 驱逐;而 detect/undetect 的调用时机则由"离开/回归"状态机(利用率 + 时间窗,节点组件 侧)决定。

5. netlink 是什么

netlink 是 Linux 内核提供的内核 ↔ 用户态进程间的异步双向通信机制,本质是一组基于 socket 的 IPC。它最初为网络子系统设计(替换老的 ioctl 网络配置),后来泛化成内核与用户态交换任意数据的通用通道——audit、route、netfilter、SELinux 事件、kobject 事件等都走它。

为什么这里用它:gpukmod 是内核模块,而触发驱逐的逻辑在用户态 Go 程序里。内核 hook 到 ioctl 后,需要把"某容器(pidns_id)回来了"这个事件异步推给用户态去驱逐 spot pod——这正是 netlink 最擅长的场景。对比:

  • ioctl/sysfs/procfs:用户态主动问内核,内核难以主动推。
  • 信号 signal:能主动推,但只能带一个 int,语义粗糙。
  • netlink:内核可主动 nlmsg_unicast/多播,能带任意 payload,支持多对多、多播组、非阻塞。

在本方案里协议号自定义 NETLINK_USER = 31(21~31 是给自定义/实验性协议保留的段)。两端各持一个 socket:

  • 内核侧(lkm/main.c):netlink_kernel_create(&init_net, NETLINK_USER, &cfg) 创建 socket,.input = nl_recv_msg 收用户态命令;发消息用 nlmsg_new 建 skb → nlmsg_put 填头 → memcpypidns_id 当 payload → nlmsg_unicast(nl_sk, skb, user_pid) 单播给注册过的用户进程。
  • 用户态侧(pkg/gpukmod/user.go):syscall.Socket(AF_NETLINK, SOCK_RAW, 31),bind 后发 "Register",内核据此记下 user_pid;之后一边发命令(detect/undetect…),一边 Recvfrom 收内核上报的 pidns_id

报文是头(nlmsghdr)+ payload 结构。这里非常简化:命令方向是字符串("Register""add(2):123 456"),上报方向就是一个 uint64pidns_id

netlink 在本方案承担的两个方向:

方向 用途 谁发起
用户态 → 内核 Register(登记接收方 pid)、add/delete(维护 container_map)、detect/undetect(开关 need_notice node-agent 主动
内核 → 用户态 命中 GPU ioctl 的容器的 pidns_id 上报(触发 spot 驱逐) gpukmod 主动 nlmsg_unicast

一句话:netlink 是内核模块 gpukmod 和用户态 node-agent 之间的那根"电话线"——用户态用它下达"盯哪些容器"的指令,内核用它上报"这个容器回来了,赶紧驱逐"的事件。

6. gpukmod 内核模块如何部署到集群节点

gpukmod 不是手动在每台节点上 insmod 的,而是打进一个镜像,通过 Helm chart 以 DaemonSet 的 initContainer 在每个节点上自动编译+加载。关键在于:内核模块和内核版本强绑定,所以必须在每个节点上针对该节点当前运行的内核就地编译,而不是预编译一份 .ko 全集群分发。

镜像侧dist/gpukmod.dockerfile):基于 ubuntu:22.04,装 build-essential kmod libelf-dev sudo,把 gpukmod 源码放到 /gpukmodinstall_gpukmod.sh 放到 /gpukmod/lkm/。这个镜像本身不带编译产物,只带源码和工具链——编译在节点上发生。

Helm chart 侧chart/templates/daemonset.yaml):node-agent 以 DaemonSet 部署(一节点一个)。加载内核模块放在一个 initContainer 里,受 enableAutoUpdateLKM 开关控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
initContainers:
- name: kernel-module-installer
image: "{{ .Values.image.repositoryInit }}" # gpukmod 镜像
securityContext:
privileged: true
capabilities:
add: ["SYS_MODULE"]
command: ["/gpukmod/lkm/install_gpukmod.sh"]
volumeMounts:
- name: kernel-headers
mountPath: /lib/modules # 宿主机内核模块目录
- name: kernel-src
mountPath: /usr/src # 宿主机内核源码/headers
- name: sysfs
mountPath: /sys
  • privileged: true + SYS_MODULE capability:容器需要 root 权限执行 insmod/rmmod
  • 挂宿主机 /lib/modules/usr/src:让容器内编译 .ko 时能 link 到该节点当前内核的 headers/symbols——这是"就地编译"的关键,保证 .ko 与节点内核版本严格匹配。
  • 主容器 node-agent 同样 privileged: truehostPID: true(开启 lowpower 时还 hostNetwork: true),并挂 /run/var/lib/kubelet/sys/var/lib/node-agent 等,用于感知容器状态、设备、持久化计数。

安装脚本hack/install_gpukmod.sh)做了版本化的幂等加载,所以每次 Pod 重启或镜像更新都会自愈:

  1. main.cMODULE_VERSION("...") 提取新版本号。
  2. lsmod | grep gpukmod 看是否已加载;若已加载,读 /sys/module/gpukmod/version 比对版本。
  3. 版本相同 → 跳过(幂等,避免重启就重新编译/卸载)。
  4. 否则 rmmod 旧模块 → make clean && make(针对节点内核编译)→ sudo insmod gpukmod.ko
  5. lsmod | grep gpukmod 校验加载成功。

整条部署链路

1
2
3
4
5
6
7
8
9
helm install → DaemonSet 调度到每个 GPU 节点

└─ initContainer(kernel-module-installer, privileged + SYS_MODULE)
│ 挂宿主机 /lib/modules、/usr/src
│ install_gpukmod.sh: 版本比对 → make → insmod gpukmod.ko

主容器 node-agent(privileged, hostPID)
│ netlink(31) 连上内核里的 gpukmod
│ 下发 detect/undetect、收回归上报、触发 spot 驱逐

这样做的好处:节点重启、内核升级、镜像更新后,只要 DaemonSet Pod 重新调度,initContainer 就会按当前内核重新编译并加载对应版本的 gpukmod,无需人工介入;而版本号比对又避免了无谓的重复卸载加载。

综上,“感知 ioctl”= khook 在内核文本段插 jmp 劫持 __x64_sys_ioctl,再用 _IOC_TYPE 魔数(0x46)+ pid namespace inum 过滤出"被监听容器的 GPU 访问",命中即阻塞 3 秒并通过 netlink(31) 上报 pidns_id 给用户态。eBPF 被弃用的根因也在此:eBPF 不能在 handler 里 msleep 阻塞,而这里恰恰要靠阻塞给驱逐争取时间。


Q5: 如何保证多容器共享GPU时的隔离性和安全性?

回答

这是一个很好的问题。我们的方案目前主要解决的是设备共享问题,而非硬隔离问题。具体来说:

当前方案特点

  1. 设备可见性共享:多个容器可以看到同一块GPU设备,共享设备文件和环境变量
  2. 资源竞争共存:多个容器进程在GPU上竞争执行,由GPU硬件和驱动层处理调度

隔离性保障

  1. 显存隔离:可以通过CUDA_MPS_ACTIVE_THREAD_PERCENTAGE等机制限制每个进程的显存使用
  2. 算力隔离:可以通过cgroup或容器级别的资源限制来间接控制
  3. 错误隔离:一个进程崩溃不会影响其他进程(由GPU驱动保障)

适用场景

  • 推理服务:多个小模型实例共享一块卡,各自显存需求明确
  • 开发机:用户自己管理资源,不存在恶意竞争

不适用场景

  • 训练任务:需要独占GPU资源
  • 需要严格QoS保证的场景

如果需要更严格的隔离,可以结合MIG或cGPU等技术,但会增加复杂度和成本。


Q6: 这个方案在性能上有什么影响?设备同步会带来额外开销吗?

回答

性能影响分析

  1. 设备同步开销

    • 同步发生在容器创建阶段,属于一次性操作
    • 只是在spec文件中添加设备挂载信息,开销可忽略
    • 容器运行时没有额外开销,GPU访问路径与正常容器完全相同
  2. 运行时性能

    • GPU计算性能:无影响,各容器进程直接与GPU驱动交互
    • 显存带宽:存在竞争,取决于实际工作负载
    • 这与任何多进程共享GPU的场景一致
  3. 调度延迟

    • ShadowPod需要先调度,SharePod才能创建
    • 典型增加约5-10秒的启动延迟
    • 对于推理服务这种长运行任务,影响很小

监控开销

  • Detector每分钟检查一次Pod状态
  • GPU指标采集复用现有的监控链路
  • 整体CPU和内存占用在100MB以内

Q7: 如何处理节点故障或Pod被驱逐的情况?

回答

我们设计了完善的故障处理机制:

ShadowPod故障

  1. ShadowPod被删除或进入Terminating状态
  2. Detector检测到后发送Clear事件
  3. Solver收到事件后,驱逐所有依赖该ShadowPod的SharePod
  4. 代码逻辑(sharegpu.go):
1
2
3
4
5
6
7
func (s *shareGPUSolver) solveShadowPodEvent(e detectors.Event) {
if e.Clear {
s.evictFor(e.Target) // 驱逐所有SharePod
s.cache.Delete(e.Target)
s.updateDevicePlugin()
}
}

SharePod故障

  1. SharePod终止时发送Clear事件
  2. 从MappingCache中移除设备使用关系
  3. 更新ShadowPod的Condition,标记设备空闲
  4. 虚拟设备变为可用,可分配给新的SharePod

节点故障

  1. 节点NotReady后,kubelet会驱逐所有Pod
  2. 上层控制器会重新调度Pod到其他节点
  3. 我们的组件是节点级的,会随节点恢复而重建状态

Spot任务驱逐失败

  1. 默认会重试驱逐
  2. 如果持续失败,可以通过运维手段介入
  3. 关键是保证用户的Reserved任务优先

Q8: 你们是如何实现多GPU厂商适配的?新增一个芯片需要做什么?

回答

我们设计了适配器模式来实现多厂商支持:

核心抽象

1
2
3
4
type GpuAdapter interface {
ConvertLabels(*model.Metric, *GpuMetric) bool // 标签转换
Convert(context.Context, PlainMetricModel) (ConvertedGpuMetricModel, error) // 指标转换
}

适配步骤

  1. 指标映射:定义厂商特定指标名称到通用指标的映射

    1
    2
    3
    4
    5
    const (
    HYGON_GPU_USAGE = "dcu_utilizationrate" // 海光
    NVIDIA_GPU_USAGE = "DCGM_FI_DEV_GPU_UTIL" // NVIDIA
    METAX_GPU_USAGE = "metax_gpu_util" // 摩尔线程
    )
  2. 标签转换:处理厂商特定的标签名称

    • 如海光使用dcu_pod_name而非pod_name
    • 海光使用PCIE ID作为设备ID
  3. 注册适配器

    1
    2
    3
    func init() {
    adapters[ADAPTER_HYGON] = &HygonAdapter{}
    }

新增芯片只需

  1. 实现GpuAdapter接口
  2. 在init()中注册适配器
  3. 节点上部署对应厂商的device-plugin和exporter

设备同步层面完全不需要修改,因为我们的Runtime是设备无关的,只处理设备文件路径和环境变量。


Q9: 这个方案有哪些局限性?未来有什么优化方向?

回答

当前局限性

  1. 软隔离问题:共享GPU的容器之间没有硬隔离,存在资源竞争

    • 显存隔离依赖用户自觉或业务侧限制
    • 算力隔离目前没有实现
  2. 调度耦合:SharePod依赖ShadowPod先调度,增加启动延迟

  3. 单节点限制:目前只支持同节点内的GPU共享,不支持跨节点

  4. 故障传播:ShadowPod故障会级联影响所有SharePod

未来优化方向

  1. 增强隔离

    • 结合cGPU实现显存硬隔离
    • 支持配置每个容器的GPU使用配额
  2. 调度优化

    • 预创建ShadowPod池,减少启动延迟
    • 支持SharePod的亲和性调度,提高资源匹配效率
  3. 可观测性增强

    • 提供更细粒度的GPU使用监控
    • 支持按容器维度的显存/算力使用统计
  4. 跨节点共享

    • 探索基于GPU虚拟化的跨节点共享方案

Q10: 如果让你重新设计这个系统,你会做哪些改进?

回答

如果重新设计,我会考虑以下改进:

  1. 架构简化

    • 当前ShadowPod + SharePod的双层设计增加了复杂度
    • 可以考虑直接在Device Plugin层面实现资源池化,对上层完全透明
  2. 状态管理优化

    • 当前MappingCache是内存状态,组件重启需要重建
    • 可以引入持久化存储,加速状态恢复
  3. 更强的隔离支持

    • 内核模块层面支持显存隔离
    • 提供更灵活的资源配额配置
  4. 更好的调度集成

    • 与K8s调度框架深度集成,避免二次调度
    • 支持更复杂的调度策略(如负载感知调度)
  5. 可观测性优先

    • 设计阶段就考虑完善的监控和告警
    • 提供用户友好的资源使用视图

不过任何设计都需要在功能完整性系统复杂度开发效率之间做权衡。当前方案的优势在于:快速落地、稳定运行、易于维护,这在商业项目中同样重要。


总结

本项目通过自定义Container Runtime、Device Plugin、节点资源管理组件的协同工作,实现了PaaS平台GPU资源的复用与超卖功能:

  1. 容器间GPU共享:解决了Dind场景下的GPU独占问题
  2. Pod级GPU复用:支持推理服务多实例共享同一GPU
  3. 动态超卖:利用空闲GPU资源提升集群利用率
  4. 多芯片适配:支持NVIDIA、AMD及多款国产芯片

方案已在生产环境稳定运行,为平台带来了显著的资源利用效率提升。


作者: [项目核心开发成员]
文档版本: v1.0
更新日期: 2024年