Spot 资源实现深度解析:从虚拟设备到偷卡超卖的完整链路
1. 什么是 Spot 资源
Spot 资源(在 GangJob 中对应 spot-queue 队列)是一种服务质量不被保证的低优先级 GPU 资源:
- 随时可以被高优先级的 reserved 任务驱逐抢占
- 计费便宜
- 适用于短时调试任务、可被打断保留进度的任务
传统 spot 的调度范围是集群中所有空闲未分配的资源(未售出的 + 已售出但暂未分配的)。但本文要解析的是更深层的 spot 实现——偷卡超卖:即使资源已被 reserved 任务分配(如用户开发机占着 8 卡),只要用户"离开"(GPU 利用率为 0),就能把这块卡的算力偷给 spot 任务,用户"回归"时再驱逐 spot、还回资源。
2. 三层架构总览
Spot 资源的实现横跨三个组件,各层职责清晰:
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
| ┌─────────────────────────────────────────────────────────┐ │ gang-scheduler │ │ (调度器 spot 插件) │ │ - spot-gpu 虚拟资源调度 │ │ - 优先级排序 (spothigh/spotmid/spotlow) │ │ - 抢占驱逐决策 │ └──────────────────────────┬──────────────────────────────┘ │ 调度 spot 任务到 platform/spot-gpu ▼ ┌─────────────────────────────────────────────────────────┐ │ spot-manager │ │ (节点设备管理组件) │ │ - spot-gpu 虚拟设备 Device Plugin │ │ - 低功耗检测 (lowpower detector) │ │ - 设备状态管理 (Healthy/Unhealthy) │ │ - 偷卡: 检测用户离开/回归 │ │ - 驱逐: Kill spot 容器进程 + 删 Pod │ │ - khook: 内核态 ioctl 劫持 │ └──────────────────────────┬──────────────────────────────┘ │ Annotation 指示设备同步 ▼ ┌─────────────────────────────────────────────────────────┐ │ node-runtime │ │ (自定义 Container Runtime) │ │ - 劫持容器创建流程 (containerd spec 修改) │ │ - 将 ShadowPod/reserved Pod 的 GPU 设备同步给 Spot Pod │ │ - prestart hook: 容器启动前注入设备 │ └─────────────────────────────────────────────────────────┘
|
3. 底层基础:自定义 Container Runtime(node-runtime)
3.1 问题:K8s 不允许同一 GPU 分配给两个 Pod
K8s 以容器为单位调度资源,一个 GPU 设备要么分配给 Pod A,要么分配给 Pod B。但在容器层面,同一块 GPU 可以被多个容器挂载——这只是设备文件(/dev/nvidia0)的 bind mount。
3.2 解决:自定义 Runtime 拦截容器创建
K8s 容器创建流程:kubelet → containerd → spec 文件(OCI) → runtime → 容器
containerd 生成符合 OCI 标准的 spec 文件,runc 根据 spec 创建容器。spec 文件里定义了容器的设备挂载(devices)、环境变量(env)、挂载点(mounts)等。
关键发现:只要将容器 A 的 spec 中的设备挂载信息拷贝到容器 B 的 spec,就能让两个容器共享同一块 GPU。
node-runtime 仿照 nvidia-container-runtime 的"包一层"方式,在 containerd → runc 之间插入一层自定义 runtime。调用链变为:
1
| containerd → node-runtime(拦截 spec)→ nvidia-container-runtime → runc → 容器
|
3.3 Spec 复制的具体流程
不是字节级拷贝来源容器的 spec——而是通过 annotation + kubelet + Device Plugin 的结构化流程:
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
| annotation 告诉 runtime"从哪同步" │ ▼ ┌─────────────────────────────────────────────────────────┐ │ ① 解析 annotation │ │ platform.k8s.io/container-devices-from.main = │ │ {"_/shadowPod-xxx/main": {"nvidia.com/gpu": ["GPU-..."]}}│ │ → 目标容器: main │ │ → 来源容器: shadowPod-xxx/main │ │ → 资源: nvidia.com/gpu │ └──────────────────────┬────────────────────────────────────┘ │ 拿到来源 Pod/容器名 ▼ ┌─────────────────────────────────────────────────────────┐ │ ② 向 kubelet 查来源容器的已分配设备 │ │ kubeletClient.RequestContainer(ns, podName, container) │ │ → kubelet 返回来源容器分配了哪些设备 ID │ │ → GetDeviceMap() 提取: {"nvidia.com/gpu": ["GPU-111"]} │ └──────────────────────┬────────────────────────────────────┘ │ 拿到设备 ID ▼ ┌─────────────────────────────────────────────────────────┐ │ ③ 调 Device Plugin Allocate(gRPC) │ │ deviceInterpreter.Request(deviceIDs) │ │ → Device Plugin 返回 ContainerAllocateResponse: │ │ - Envs: NVIDIA_VISIBLE_DEVICES=GPU-xxx │ │ - Mounts: /dev/nvidia0, driver libs... │ │ - Devices: /dev/nvidia0 (c 195:0) │ │ - Annotations: container-devices-from... │ └──────────────────────┬────────────────────────────────────┘ │ Allocate 响应 ▼ ┌─────────────────────────────────────────────────────────┐ │ ④ 合并到目标容器的 spec │ │ - mergeEnv(): 把 env 追加到 spec.Process.Env │ │ - spec.Linux.Devices += 响应里的设备 │ │ - spec.Mounts += 响应里的挂载(驱动库等) │ │ - MergeDevices(): 设备 ID 列表写入 annotation │ └──────────────────────┬────────────────────────────────────┘ │ spec 修改完成 ▼ nvidia-container-runtime → runc → 容器创建 (容器里有了来源容器的 GPU 设备)
|
关键代码路径:
1 2 3 4 5 6 7 8 9 10 11 12 13
| func newContainerRuntime(logger, cfg, argv, options) (Runtime, error) { ociSpec, err := oci.NewSpec(logger, argv) specs, err := ociSpec.Load()
specModifier, err := options.NewSpecModifier(logger, cfg)
return NewModifyingRuntimeWrapper(lowLevelRuntime, ociSpec, specModifier) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| func (m *DevicesModifier) Modify(spec *specs.Spec) error { dm, err := m.provider(spec)
MergeDevices(spec, dm)
for resName, deviceids := range dm { containerInfo, err := m.InterpretDevice(resName, deviceids)
mergeEnv(containerInfo.Envs, &spec.Process.Env) } }
|
provider 有三种来源(providers/merge.go Merge 合并):
| Provider |
来源 |
适用场景 |
| ContainerSync |
从 annotation 解析来源容器 → 查 kubelet 获取该容器分配的设备 |
容器间共享(Dind 场景、spot 偷卡) |
| PodSync |
从 annotation 解析来源 Pod → 查 kubelet 获取该 Pod 所有容器分配的设备 |
Pod 间共享(推理服务复用) |
| Direct |
直接从 annotation 中的设备 ID 分配 |
直接指定设备 |
ContainerSync 解析 annotation 的格式:
1 2 3 4 5 6 7 8 9 10
|
{ "_/shadowPod-xxx/main": { "nvidia.com/gpu": ["GPU-111", "GPU-222"] } }
|
为什么不是直接拷贝来源容器的 spec:
因为设备挂载到容器需要的不只是 /dev/nvidia0 这一个文件,还有:
- 驱动库(
/usr/lib/x86_64-linux-gnu/libnvidia-xxx.so)
- 环境变量(
NVIDIA_VISIBLE_DEVICES)
- 可能的 RDMA 设备(
/dev/infiniband/uverbs0)
这些信息由 Device Plugin 的 Allocate 响应提供——不同设备类型(NVIDIA/Metax/AMD/RDMA)的 Device Plugin 返回不同的挂载信息。通过 Device Plugin 接口,runtime 实现了设备类型无关的设备同步。
4. 中间层:节点设备管理(spot-manager)
spot-manager 是运行在每个节点上的守护进程,负责 spot-gpu 虚拟设备的管理、偷卡检测、驱逐。
4.1 spot-gpu 虚拟设备
为 spot 任务引入虚拟资源类型 platform/spot-gpu:
1 2
| 真实 GPU: nvidia.com/gpu ← reserved 任务申请的资源 虚拟 GPU: platform/spot-gpu ← spot 任务申请的资源
|
一一对应关系:节点上每块真实 GPU 对应一个 platform/spot-gpu 虚拟设备。
状态流转:
| 状态 |
含义 |
spot 能否调度 |
| Healthy |
真实 GPU 空闲 / reserved 用户已离开 |
✅ 可以调度 spot |
| Unhealthy |
真实 GPU 被 reserved 任务分配且用户在使用 |
❌ 不可调度,已有 spot 被驱逐 |
1 2 3 4 5 6 7
|
idleDevices := []string{} for _, d := range dps[0].GetDevice() { idleDevices = append(idleDevices, d.ID) } cache.Create(consts.IdleSpace, idleDevices)
|
4.2 低功耗检测(Lowpower Detector)
spot-manager 的 lowpowerDetector 负责检测 reserved 用户是否"离开":
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| func (d *lowpowerDetector) detect(ctx context.Context) (*detectors.Event, error) { utilActive, err := d.isUtilActive() processActive, err := d.isProcessActive(ctx) if utilActive || processActive { d.idle = false d.lastActiveTime = time.Now() return nil, nil } if time.Since(d.lastActiveTime) > d.maxInactiveTime && !d.idle { d.idle = true return &detectors.Event{ Type: consts.EventTypeLowPower, Target: d.target, Devices: []detectors.DeviceStatus{{Using: false}}, }, nil } return nil, nil }
|
两个检测维度:
-
利用率检测(isUtilActive):从 DCGM(NVIDIA GPU 监控)或各厂商 exporter 拉 GPU 利用率,> 0 则 active。
-
进程检测(isProcessActive):用 NVML 检查 GPU 上是否有 compute 进程在跑:
1 2 3 4 5 6 7 8 9 10 11 12
| func (d *lowpowerDetector) isProcessActive(ctx context.Context) (bool, error) { processes, err := d.getContainerProcesses(ctx) for i := 0; i < gpuNum; i++ { device, _ := nvml.DeviceGetHandleByIndex(i) dProcessList, _ := device.GetComputeRunningProcesses() } return false, nil }
|
4.3 偷卡机制(Steal GPU)
当 lowpower detector 判定用户"离开"时:
1 2 3 4 5 6
| func (s *spotGPUSolver) solveIdle(e detectors.Event) error { }
|
当 lowpower detector 检测到用户"回归"(GPU 利用率从 0 变 > 0,或有新进程):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| func (s *spotGPUSolver) solveBusy(e detectors.Event) error { evictedUsers := s.cache.GetOwnerUsers(e.Target) s.updateEvictCounter(evictedUsers, consts.EvictReasonOwnerBack) s.evictProcessFor(e) s.evictFor(e.Target) s.cache.Delete(e.Target) }
|
驱逐流程(evict.go):
1 2 3 4 5 6 7 8 9 10 11 12 13
| func (s *spotGPUSolver) evictProcessFor(e detectors.Event) error { users := s.cache.GetOwnerUsers(e.Target) for _, user := range users { pod, _ := s.kubeClient.CoreV1().Pods(ns).Get(...) for _, container := range pod.Status.ContainerStatuses { id := strings.TrimPrefix(container.ContainerID, "containerd://") s.taskClient.Kill(context.Background(), id) } } s.evictFor(e.Target) }
|
关键:先 Kill 进程再删 Pod——Pod 删除需要走 K8s graceful deletion(默认 30s grace period),太慢;直接 Kill containerd 进程能立即释放 GPU。
4.4 khook:内核态 ioctl 劫持
eBPF 无法阻塞系统调用,因此用内核模块实现:
- 劫持对 GPU 设备的
ioctl 系统调用
- 解析参数中的设备参数判断是否是 GPU 设备
- 通过 pid 和 pidnsid 判断容器身份
- 用户"回归"时:阻塞其 ioctl 系统调用,直到 spot 任务被驱逐、GPU 资源彻底释放
这是偷卡的核心安全网:用户回归后,内核模块阻塞其对 GPU 的访问,等 spot 驱逐完成后才放行——用户感觉不到中间过程。
4.5 检测机制:离开用轮询,回归用事件触发
device-manager 的检测是非对称设计——"离开"轮询、"回归"事件触发,原因如下:
| 方向 |
机制 |
为什么 |
| 离开 |
轮询(detectInterval 周期查利用率 + 进程) |
用户不操作 GPU 就没有事件可触发,只能主动查;晚几分钟发现无所谓 |
| 回归 |
事件触发(内核模块 khook 劫持 ioctl) |
必须秒级响应——轮询周期内用户会看到别人的进程/显存被占;khook 检测到 ioctl 后立即阻塞,驱逐完成后才放行 |
离开检测流程(轮询):
1 2 3 4 5 6 7
| 每 detectInterval 周期: ① isUtilActive() → 拉 DCGM/exporter 指标查 GPU 利用率 ② isProcessActive() → NVML 查 GPU 上有没有 compute 进程 ③ 如果 active → 刷新 lastActiveTime,继续轮询 ④ 如果 inactive 且超过 maxInactiveTime → 判定"离开" → 发 Event{Using: false} → solveIdle → 激活 spot-gpu → 通知 khook: Detect(pidNsIds) → 开始监控回归
|
回归检测流程(事件触发):
1 2 3 4 5 6 7 8
| 用户操作 GPU → ioctl 系统调用 → khook 内核模块立即捕获(不等轮询周期) → 阻塞该 ioctl(用户感知不到延迟) → device-manager 收到"回归"信号 → solveBusy → 驱逐 spot(Kill 进程 + 删 Pod) → spot-gpu 变回 Unhealthy → khook: Undetect → 放行用户 ioctl → 用户无感
|
关键:离开后才会启动 khook 监控(Detect),回归驱逐完后停止监控(Undetect)。khook 不是常驻监控,而是按需启停——只在 spot 偷卡期间监控用户回归。
5. 调度层:gang-scheduler 的 spot 插件
5.1 spot-gpu 虚拟资源调度
调度器的 spot 插件负责 spot 任务的调度和抢占决策:
1 2 3 4 5 6 7 8 9 10 11 12
| const SpotGPUResourceName = "platform/spot-gpu"
const ( ExclusiveSpot SpotGPUType = 1 SharedSpot SpotGPUType = 2 )
const ( SpotGPUStrategyExclusiveFirst SpotGPUStrategy = "idle" SpotGPUStrategSharedFirst SpotGPUStrategy = "lowpower" )
|
5.2 优先级体系
spot 任务有三个优先级级别,对应不同权重:
1 2 3 4
| SpotHighPriorityWeight = 3000 SpotMidPriorityWeight = 2000 SpotLowPriorityWeight = 1000
|
驱逐决策时按权重排序,低优先级先被驱逐:
1 2 3 4 5 6 7
| type pluginParams struct { PriorityClassPolicyWeight int TaskNumberPolicyWeight int TaskResourcePolicyWeight int RunningDurationPolicyWeight int RunningDurationEvictionPolicy string }
|
5.3 节点状态与调度策略
节点 annotation 控制 spot 调度策略:
1 2 3 4
| NodeAnnoShareGPU = spotpis.AnnoNodeLowPowerSpotKey NodeAnnoGPUStrategy = spotpis.AnnoSpotPreferKey PodAnnoSpotGPUOwner = spotpis.AnnoDeviceOwnerKey
|
5.4 驱逐决策(podgroups.go)
当 reserved 任务需要资源时,spot 插件计算驱逐哪些 spot 任务:
1 2 3 4 5 6 7 8 9
| func newEvictPodGroupInfos(evictSpotTasks []*spotTaskInfo) evictPodGroupInfos { for _, t := range evictSpotTasks { evictPgs[t.PodGroup.Name].evictResource += t.RequestGPUMilli[ExclusiveSpot] } }
|
6. 完整工作流:从用户离开到偷卡到回归驱逐
6.1 偷卡启动(用户离开)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 用户开发机(reserved, 8卡)→ 白天不用了 → GPU 利用率 = 0 持续 > maxInactiveTime
spot-manager lowpower detector: ① isUtilActive() → false(DCGM 显示 GPU 利用率 0) ② isProcessActive() → false(NVML 无 compute 进程) ③ 判定 idle → 发 Event{Type: LowPower, Using: false}
spotGPUSolver.solveIdle(): ④ 激活 spot-gpu 虚拟设备 → Healthy ⑤ 更新节点 annotation: 可调度 spot ⑥ khookUser.Detect(pidNsIds) → 内核模块开始监控用户回归
gang-scheduler: ⑦ 看到 Healthy 的 platform/spot-gpu → 调度 spot GangJob 到该节点 ⑧ spot Pod 申请 platform/spot-gpu → Device Plugin Allocate
node-runtime: ⑨ runtime 检查 annotation → 将 reserved Pod 的 GPU 设备同步到 spot Pod ⑩ spot Pod 容器启动 → 用户开发机的 GPU 被偷用
|
6.2 偷卡驱逐(用户回归)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| 用户回到开发机 → 执行 nvidia-smi / torch 脚本 → 对 GPU 的 ioctl 系统调用
内核模块 (khook): ① 检测到目标 pid namespace 对 GPU 设备的 ioctl → 用户"回归" ② 阻塞该 ioctl 系统调用 → 用户感知不到延迟
spot-manager lowpower detector: ③ isUtilActive() → true 或 isProcessActive() → true ④ 判定 busy → 发 Event{Type: LowPower, Using: true}
spotGPUSolver.solveBusy(): ⑤ 更新驱逐 metrics (evictReason: OwnerBack) ⑥ evictProcessFor(): → containerd taskClient.Kill(spot 容器 ID) → 立即杀进程 ⑦ evictFor(): → kubeClient.CoreV1().Pods().Delete(spot Pod) → 删 Pod ⑧ cache.Delete(target) → 清除设备映射 ⑨ spot-gpu 设备变回 Unhealthy ⑩ khookUser.Undetect(pidNsIds) → 内核模块放行用户 ioctl
用户: ⑪ ioctl 系统调用返回 → GPU 上没有其他人的进程 → 感觉什么都没发生
|
7. 数据结构
7.1 MappingCache(设备映射缓存)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| type MappingCache struct { cache map[string]*PodStatus sync.Mutex }
type PodStatus map[string]*DeviceStatus
type DeviceStatus struct { ID string SharingPod string }
func (mc *MappingCache) Create(owner string, devices []string) func (mc *MappingCache) Use(owner, user, deviceID string) func (mc *MappingCache) UnUse(owner, user, deviceID string) error func (mc *MappingCache) GetOwnerUsers(owner string) []string func (mc *MappingCache) GetDeviceOwner(deviceID string) string
|
7.2 设备 ID 转换
真实 GPU ID 和 spot-gpu 虚拟设备 ID 的转换:
1 2 3 4 5
|
func OriginalID_To_SpotID(id string) string { return "spot-" + id }
|
7.3 Pod 类型
1 2 3 4 5 6
| const ( PodDeviceShadow PodDeviceType = "shadow" PodDeviceShare PodDeviceType = "share" )
|
8. 与 GangJob spot-queue 队列的关系
在 alcor-chaos 的 GangJob case 中,我们使用 --queue spot-queue 创建 GangJob:
1
| chaos inject workload gangjob --name dev-23 ... --queue spot-queue
|
spot-queue 是一个 QueueType=spot 的队列:
1 2 3
| case string(v1alpha1.QueueTypeSpot): taskType = api.SpotTask
|
这意味着:
- GangJob 提交到
spot-queue 队列 → 调度器识别为 SpotTask。
- 调度器按 spot 优先级和 platform/spot-gpu 可用性调度。
- GangJob 的 Pod 申请
platform/spot-gpu(而非 nvidia.com/gpu)。
- node-runtime runtime 将来源容器(ShadowPod 或 reserved 开发机)的 GPU 同步给 spot Pod。
- spot GangJob 可以被 reserved 任务驱逐(资源不够时)。
这就是为什么我们造的 GangJob case 用 spot-queue 队列:它绕开了 tenant 100000 的 quota 队列(po-dcsldksmdrryl5wv),走 spot 通道调度到 a9 的 GPU 上,即使 a9 的 GPU 已被分配。
9. 多 GPU 厂商适配
spot-manager 的 GPU Collector 适配器接口是设备无关的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| type GpuAdapter interface { ConvertLabels(*model.Metric, *GpuMetric) bool Convert(context.Context, PlainMetricModel) (ConvertedGpuMetricModel, error) }
var adapters = map[string]GpuAdapter{ ADAPTER_NVIDIA: &NvidiaAdapter{}, ADAPTER_METAX: &MetaxAdapter{}, ADAPTER_HUAWEI: &HuaweiAdapter{}, ADAPTER_ENFLAME: &EnflameAdapter{}, ADAPTER_KUNLUNXIN: &KunlunxinAdapter{}, ADAPTER_HYGON: &HygonAdapter{}, }
|
底层的 runtime 设备同步机制不依赖任何厂商的特定 API——只是把 spec 里的设备文件挂载信息拷贝过去,任何 /dev/xxx 类型的设备都能同步。
10. 总结
| 组件 |
职责 |
核心机制 |
| node-runtime |
自定义 Container Runtime |
劫持 containerd spec 文件,通过 annotation 同步 GPU 设备到多个容器 |
| spot-manager |
节点设备管理 |
spot-gpu 虚拟设备 Device Plugin + lowpower 检测 + 偷卡/驱逐 + khook 内核模块 |
| gang-scheduler |
调度器 spot 插件 |
spot-gpu 调度 + 优先级排序 + 抢占驱逐决策 |
核心技术链路:
1 2 3 4 5 6 7 8 9 10 11 12 13
| reserved 用户离开 → spot-manager lowpower detector(GPU 利用率 0 + 无进程) → spot-gpu 设备 Healthy → gang-scheduler 调度 spot 任务 → node-runtime runtime 同步 GPU 设备到 spot Pod → spot 任务偷用 GPU
reserved 用户回归 → khook 内核模块阻塞 ioctl → spot-manager 驱逐 spot(Kill 进程 + 删 Pod) → spot-gpu 设备 Unhealthy → 内核模块放行用户 → 用户无感
|
三个关键设计:
- 虚拟设备(
platform/spot-gpu):与真实 GPU 一一对应,状态 Healthy/Unhealthy 控制可调度性,不侵入 K8s 原生 GPU 分配逻辑。
- 内核态劫持(khook):通过内核模块劫持 GPU 的 ioctl 系统调用,精确感知用户"回归"并阻塞直到驱逐完成——这是偷卡安全性的关键。
- 自定义 Runtime(node-runtime):在容器创建层面同步 GPU 设备,设备类型无关,适配所有 GPU 厂商。