PaaS 平台 GPU 资源复用与超卖技术深度解析
PaaS 平台 GPU 资源复用与超卖技术深度解析
目录
背景与动机
业务需求
在PaaS平台的实际运营中,我们遇到了两类核心问题:
用户侧需求:
- 推理服务实例资源利用率低:部分推理服务只占用1卡资源,但存在浪费
- 开发机资源紧张:租户内资源有限,多用户开发机需要共享GPU
平台侧需求:
- 提升集群资源利用率
- 对已分配但空闲的GPU进行超卖
- 增加平台收入和毛利率
技术选型
经过对市场现有方案的调研:
| 方案类型 | 代表方案 | 局限性 |
|---|---|---|
| 厂商官方解决方案 | Nvidia MIG、MPS、vGPU | 授权成本高;场景局限;不便于整合不同厂商 |
| API劫持隔离方案 | vCUDA、OrionX、cGPU | 侵入业务层;开发维护难度大;存在性能损失 |
| 轻量调度共享方案 | gpushare-device-plugin | 依赖调度逻辑变更;与已有体系存在冲突 |
最终选择:自研轻量化共享方案
基于自定义Container Runtime、Device Plugin、节点资源管理组件,实现通用的容器间GPU设备共享,具有以下优势:
- 对调度逻辑影响更小
- 设备类型无关,通用性强
- 核心功能:支持将一块卡同时挂载到多个容器
技术架构概览
1 | ┌─────────────────────────────────────────────────────────────────────┐ |
核心技术实现
一、自定义Container Runtime实现容器间GPU共享
1.1 问题背景:Dind场景下的GPU独占
在PaaS平台的开发机场景中,用户使用Dind(Docker in Docker)功能时遇到问题:
1 | ┌─────────────────────────────────────────┐ |
核心矛盾: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文件:
核心逻辑:
- 检查Pod的annotation
- 如果存在设备同步标记,将来源容器的设备信息同步到目标容器spec
- 调用下层runtime创建容器
1.4 Annotation接口设计
使用annotation作为容器间设备同步的接口:
1 | { |
格式说明:
container-devices-from.dind:目标容器为dind,需要从其他容器同步设备_/_/main:来源容器信息,两个下划线分别代表当前namespace和当前pod,main为来源容器名称
代码实现(pkg/deviceplugin/share/stub.go):
1 | // Allocate will give annotation to the container according to the device id |
二、ShadowPod与虚拟设备实现Pod级GPU复用
2.1 核心概念
| 概念 | 说明 |
|---|---|
| ShadowPod | 隐藏的Pod,仅在后台可见,作用是占住GPU资源,作为设备同步中的"来源容器" |
| SharePod | 开启了"显卡复用"开关的推理服务Pod,作为"目标容器"接收设备同步 |
| 虚拟资源 | gpushare/share-gpu,在K8s调度框架内实现对应关系 |
2.2 工作流程
1 | ┌─────────────────────────────────────────────────────────────────┐ |
2.3 核心代码实现
Shadow Controller(pkg/controllers/shadow/controller.go):
1 | func (c *Controller) doOneReconcile(ctx context.Context) { |
ShareGPU Device Plugin(pkg/deviceplugin/share/deviceplugin.go):
1 | func (dp *ShareGPUDevicePlugin) initDevices() error { |
ShareGPU Solver(pkg/solvers/sharegpu/sharegpu.go):
1 | func (s *shareGPUSolver) Solve(e detectors.Event) { |
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通过 gRPCListAndWatch调真实 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 | K = (本节点该 spec 的 ShadowPod 数) × 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 的机制串起来:
- device plugin gRPC
ListAndWatch把[]Device{ID, Health}流式推给 kubelet; - kubelet 把 Healthy 设备数累加成该扩展资源的
Capacity/Allocatable,写到 Node status; - 调度器只看 Node 上的资源量做过滤/打分,不关心设备 ID;
- 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 不再分配,且 Allocate 里 getUnHealthy 会拒绝已分配的)。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 Plugin(pkg/deviceplugin/spot/deviceplugin.go):
1 | func (dp *SpotGPUDevicePlugin) initDevices() error { |
SpotGPU Solver(pkg/solvers/spotgpu/spotgpu.go):
1 | func (s *spotGPUSolver) Solve(e detectors.Event) { |
低功耗检测与处理(pkg/solvers/spotgpu/lowpower.go):
1 | func (s *spotGPUSolver) solveLowPower(e detectors.Event) error { |
驱逐机制(pkg/solvers/spotgpu/evict.go):
1 | func (s *spotGPUSolver) evictByDevice(e detectors.Event) error { |
四、多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 | type GpuAdapter interface { |
海光DCU适配器示例(pkg/metrics/collector/gpu_hygon.go):
1 | const ( |
关键数据结构
MappingCache - 设备映射缓存
1 | // pkg/apis/mapping_cache.go |
ShareDeviceCount - 共享设备计数
1 | // pkg/apis/share_device.go |
Event - 检测事件
1 | // pkg/detectors/types.go |
核心代码解析
设备ID转换工具函数
1 | // 设备ID格式转换,用于区分真实GPU和虚拟GPU |
模拟面试问答
Q1: 请介绍一下你在这个项目中的角色和主要贡献?
回答:
我在这个项目中负责GPU资源复用与超卖功能的核心设计与实现。具体包括:
-
技术方案设计:主导设计了基于自定义Container Runtime的容器间GPU共享方案,解决了Dind场景下的GPU独占问题。
-
核心代码开发:
- 实现了ShadowPod控制器,负责动态创建和管理隐藏Pod
- 开发了ShareGPU和SpotGPU两个Device Plugin,实现虚拟设备的注册和分配
- 实现了完整的设备映射缓存(MappingCache)和状态同步机制
-
多GPU适配:设计并实现了GPU适配器接口,完成了对NVIDIA、AMD以及海光、燧原、摩尔线程等国产芯片的适配。
-
线上落地:该方案已在生产环境稳定运行,显著提升了集群GPU利用率。
Q2: 为什么选择自定义Container Runtime的方式,而不是使用Nvidia官方的MIG或MPS?
回答:
选择自定义Container Runtime主要基于以下几点考虑:
-
通用性:我们的方案是设备无关的,一套代码可以支持NVIDIA、AMD以及各种国产芯片。而MIG和MPS是NVIDIA专有技术,无法覆盖其他厂商。
-
成本考虑:MIG需要A100/H100等高端卡支持,MPS也需要额外的授权费用。我们的方案不需要任何额外硬件或授权成本。
-
灵活性:MIG的划分粒度有限(如A100 80GB只能划分7个实例),而我们的方案可以灵活配置共享比例,比如一块卡可以分给4个甚至更多实例。
-
侵入性低:MPS需要业务侧配合设置环境变量,我们的方案对业务完全透明,用户无需修改任何代码。
-
便于扩展:基于这套机制,我们后续又实现了Spot GPU超卖功能,这是官方方案无法支持的。
Q3: ShadowPod的作用是什么?为什么不直接让多个SharePod绑定同一块GPU?
回答:
ShadowPod是一个关键的设计巧思,它解决了几个核心问题:
-
K8s调度限制:K8s的资源调度是以Pod为单位的,同一块GPU不能同时分配给两个不同的Pod。ShadowPod作为一个"占位符",先通过正常调度流程获得GPU资源。
-
设备同步来源:我们的设备同步机制需要一个"来源容器",ShadowPod就是提供GPU设备的来源。SharePod通过annotation指定从哪个ShadowPod同步设备。
-
生命周期管理:ShadowPod与GPU资源绑定,当ShadowPod被删除时,可以级联驱逐所有依赖它的SharePod。
-
状态追踪:通过ShadowPod的Condition,我们可以追踪哪些SharePod正在使用这块GPU。
如果直接让多个SharePod绑定同一块GPU,在K8s的调度框架内是无法实现的,因为kubelet在分配设备时会检查资源是否已被占用。
Q4: 如何判断用户"离开"和"回归"?如何保证Spot任务被及时驱逐?
回答:
用户状态的判断主要通过两个维度:
-
GPU访问检测:在内核层面劫持对GPU设备的ioctl系统调用。通过解析ioctl参数中的设备魔数判断是否为GPU访问,通过pid和pidnsid判断容器身份。
-
GPU利用率监控:通过DCGM或各厂商的监控接口获取GPU利用率。
状态判断:
- 离开:超过阈值时间(如10分钟)没有GPU访问,且利用率为0
- 回归:出现GPU访问行为
快速驱逐机制:
- 当检测到用户回归时,内核模块会阻塞用户的系统调用一小段时间
- 同时触发驱逐流程,并行Kill所有Spot容器进程
- 阻塞确保在Spot任务完全释放资源前,用户无法继续操作GPU
这里为什么使用内核模块而不是eBPF?因为eBPF不支持阻塞操作,而我们需要在检测到回归的瞬间阻塞用户操作,争取驱逐时间。
实现细节:gpukmod 内核模块如何感知 ioctl
上述"劫持 ioctl + 阻塞 + 上报"由独立的内核模块 gpukmod 完成,用户态侧(node-agent 的 pkg/gpukmod)通过 netlink 与之协作。整条链路如下:
1 | node-agent (Go) ──netlink "detect(pidnsid)"──▶ gpukmod 内核模块 |
1. 劫持方式:khook 内联 hook(非 eBPF、非 kprobe)
gpukmod 用的是经典的内核函数内联打补丁,实现在 khook/ 目录:
KHOOK_EXT(long, __x64_sys_ioctl, const struct pt_regs *)声明要 hook 的目标符号,宏把自定义处理函数khook___x64_sys_ioctl、目标名、stub 放进.data.khooksection。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_kernel里cli→关CR0.WP(必要时关CR4.CET)→写→恢复,全程在stop_machine上下文执行,避免其它核踩到半改的指令。
效果:任何进程调 ioctl,进内核走到 __x64_sys_ioctl 即被 jmp 转入我们的处理函数。
2. 感知逻辑本体:khook___x64_sys_ioctl
1 | static long khook___x64_sys_ioctl(const struct pt_regs *regs) |
文档里那段描述与代码的对应关系:
| 文档说法 | 代码实现 |
|---|---|
| 劫持对 GPU 设备的 ioctl 系统调用 | khook 改写 __x64_sys_ioctl 首指令 |
| 解析 ioctl 参数中的设备魔数判断是否 GPU 访问 | _IOC_TYPE(request) != 0x46 即放行 |
| 通过 pid 和 pidnsid 判断容器身份 | is_task_under_pid_ns + get_pid_ns_id(ns.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_message用nlmsg_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/lowpower、pkg/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填头 →memcpy把pidns_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"),上报方向就是一个 uint64 的 pidns_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 源码放到 /gpukmod、install_gpukmod.sh 放到 /gpukmod/lkm/。这个镜像本身不带编译产物,只带源码和工具链——编译在节点上发生。
Helm chart 侧(chart/templates/daemonset.yaml):node-agent 以 DaemonSet 部署(一节点一个)。加载内核模块放在一个 initContainer 里,受 enableAutoUpdateLKM 开关控制:
1 | initContainers: |
privileged: true+SYS_MODULEcapability:容器需要 root 权限执行insmod/rmmod。- 挂宿主机
/lib/modules和/usr/src:让容器内编译.ko时能 link 到该节点当前内核的 headers/symbols——这是"就地编译"的关键,保证.ko与节点内核版本严格匹配。 - 主容器
node-agent同样privileged: true、hostPID: true(开启 lowpower 时还hostNetwork: true),并挂/run、/var/lib/kubelet、/sys、/var/lib/node-agent等,用于感知容器状态、设备、持久化计数。
安装脚本(hack/install_gpukmod.sh)做了版本化的幂等加载,所以每次 Pod 重启或镜像更新都会自愈:
- 从
main.c的MODULE_VERSION("...")提取新版本号。 lsmod | grep gpukmod看是否已加载;若已加载,读/sys/module/gpukmod/version比对版本。- 版本相同 → 跳过(幂等,避免重启就重新编译/卸载)。
- 否则
rmmod旧模块 →make clean && make(针对节点内核编译)→sudo insmod gpukmod.ko。 lsmod | grep gpukmod校验加载成功。
整条部署链路:
1 | helm install → DaemonSet 调度到每个 GPU 节点 |
这样做的好处:节点重启、内核升级、镜像更新后,只要 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时的隔离性和安全性?
回答:
这是一个很好的问题。我们的方案目前主要解决的是设备共享问题,而非硬隔离问题。具体来说:
当前方案特点:
- 设备可见性共享:多个容器可以看到同一块GPU设备,共享设备文件和环境变量
- 资源竞争共存:多个容器进程在GPU上竞争执行,由GPU硬件和驱动层处理调度
隔离性保障:
- 显存隔离:可以通过CUDA_MPS_ACTIVE_THREAD_PERCENTAGE等机制限制每个进程的显存使用
- 算力隔离:可以通过cgroup或容器级别的资源限制来间接控制
- 错误隔离:一个进程崩溃不会影响其他进程(由GPU驱动保障)
适用场景:
- 推理服务:多个小模型实例共享一块卡,各自显存需求明确
- 开发机:用户自己管理资源,不存在恶意竞争
不适用场景:
- 训练任务:需要独占GPU资源
- 需要严格QoS保证的场景
如果需要更严格的隔离,可以结合MIG或cGPU等技术,但会增加复杂度和成本。
Q6: 这个方案在性能上有什么影响?设备同步会带来额外开销吗?
回答:
性能影响分析:
-
设备同步开销:
- 同步发生在容器创建阶段,属于一次性操作
- 只是在spec文件中添加设备挂载信息,开销可忽略
- 容器运行时没有额外开销,GPU访问路径与正常容器完全相同
-
运行时性能:
- GPU计算性能:无影响,各容器进程直接与GPU驱动交互
- 显存带宽:存在竞争,取决于实际工作负载
- 这与任何多进程共享GPU的场景一致
-
调度延迟:
- ShadowPod需要先调度,SharePod才能创建
- 典型增加约5-10秒的启动延迟
- 对于推理服务这种长运行任务,影响很小
监控开销:
- Detector每分钟检查一次Pod状态
- GPU指标采集复用现有的监控链路
- 整体CPU和内存占用在100MB以内
Q7: 如何处理节点故障或Pod被驱逐的情况?
回答:
我们设计了完善的故障处理机制:
ShadowPod故障:
- ShadowPod被删除或进入Terminating状态
- Detector检测到后发送Clear事件
- Solver收到事件后,驱逐所有依赖该ShadowPod的SharePod
- 代码逻辑(
sharegpu.go):
1 | func (s *shareGPUSolver) solveShadowPodEvent(e detectors.Event) { |
SharePod故障:
- SharePod终止时发送Clear事件
- 从MappingCache中移除设备使用关系
- 更新ShadowPod的Condition,标记设备空闲
- 虚拟设备变为可用,可分配给新的SharePod
节点故障:
- 节点NotReady后,kubelet会驱逐所有Pod
- 上层控制器会重新调度Pod到其他节点
- 我们的组件是节点级的,会随节点恢复而重建状态
Spot任务驱逐失败:
- 默认会重试驱逐
- 如果持续失败,可以通过运维手段介入
- 关键是保证用户的Reserved任务优先
Q8: 你们是如何实现多GPU厂商适配的?新增一个芯片需要做什么?
回答:
我们设计了适配器模式来实现多厂商支持:
核心抽象:
1 | type GpuAdapter interface { |
适配步骤:
-
指标映射:定义厂商特定指标名称到通用指标的映射
1
2
3
4
5const (
HYGON_GPU_USAGE = "dcu_utilizationrate" // 海光
NVIDIA_GPU_USAGE = "DCGM_FI_DEV_GPU_UTIL" // NVIDIA
METAX_GPU_USAGE = "metax_gpu_util" // 摩尔线程
) -
标签转换:处理厂商特定的标签名称
- 如海光使用
dcu_pod_name而非pod_name - 海光使用PCIE ID作为设备ID
- 如海光使用
-
注册适配器:
1
2
3func init() {
adapters[ADAPTER_HYGON] = &HygonAdapter{}
}
新增芯片只需:
- 实现GpuAdapter接口
- 在init()中注册适配器
- 节点上部署对应厂商的device-plugin和exporter
设备同步层面完全不需要修改,因为我们的Runtime是设备无关的,只处理设备文件路径和环境变量。
Q9: 这个方案有哪些局限性?未来有什么优化方向?
回答:
当前局限性:
-
软隔离问题:共享GPU的容器之间没有硬隔离,存在资源竞争
- 显存隔离依赖用户自觉或业务侧限制
- 算力隔离目前没有实现
-
调度耦合:SharePod依赖ShadowPod先调度,增加启动延迟
-
单节点限制:目前只支持同节点内的GPU共享,不支持跨节点
-
故障传播:ShadowPod故障会级联影响所有SharePod
未来优化方向:
-
增强隔离:
- 结合cGPU实现显存硬隔离
- 支持配置每个容器的GPU使用配额
-
调度优化:
- 预创建ShadowPod池,减少启动延迟
- 支持SharePod的亲和性调度,提高资源匹配效率
-
可观测性增强:
- 提供更细粒度的GPU使用监控
- 支持按容器维度的显存/算力使用统计
-
跨节点共享:
- 探索基于GPU虚拟化的跨节点共享方案
Q10: 如果让你重新设计这个系统,你会做哪些改进?
回答:
如果重新设计,我会考虑以下改进:
-
架构简化:
- 当前ShadowPod + SharePod的双层设计增加了复杂度
- 可以考虑直接在Device Plugin层面实现资源池化,对上层完全透明
-
状态管理优化:
- 当前MappingCache是内存状态,组件重启需要重建
- 可以引入持久化存储,加速状态恢复
-
更强的隔离支持:
- 内核模块层面支持显存隔离
- 提供更灵活的资源配额配置
-
更好的调度集成:
- 与K8s调度框架深度集成,避免二次调度
- 支持更复杂的调度策略(如负载感知调度)
-
可观测性优先:
- 设计阶段就考虑完善的监控和告警
- 提供用户友好的资源使用视图
不过任何设计都需要在功能完整性、系统复杂度和开发效率之间做权衡。当前方案的优势在于:快速落地、稳定运行、易于维护,这在商业项目中同样重要。
总结
本项目通过自定义Container Runtime、Device Plugin、节点资源管理组件的协同工作,实现了PaaS平台GPU资源的复用与超卖功能:
- 容器间GPU共享:解决了Dind场景下的GPU独占问题
- Pod级GPU复用:支持推理服务多实例共享同一GPU
- 动态超卖:利用空闲GPU资源提升集群利用率
- 多芯片适配:支持NVIDIA、AMD及多款国产芯片
方案已在生产环境稳定运行,为平台带来了显著的资源利用效率提升。
作者: [项目核心开发成员]
文档版本: v1.0
更新日期: 2024年
