AI 算力基础设施深度系列(二):Kubernetes 深水区——底层原理与扩展机制全剖析

AI 算力基础设施深度系列(二):Kubernetes 深水区——底层原理与扩展机制全剖析

本文是《AI 算力基础设施深度系列》第 2 篇,共 6 篇。
系列目录:① 容器与 K8S 基础 → ② K8S 底层原理 → ③ GPU 与异构算力 → ④ AI 平台架构 → ⑤ 高性能网络与存储 → ⑥ 生产运维与成本优化

导语

上一篇我们建立了容器和 Kubernetes 的全景认知——你知道了 K8S 由哪些组件组成,Pod 是怎么被调度到节点上的,资源模型长什么样。

但那只是"使用者"视角。

真正的分水岭在于:你能不能深入到 K8S 的 API 机制、控制器模式、调度框架中去,理解它们为什么这样设计,以及如何利用这些机制构建新的平台能力?

比如:

  • HAMi GPU 虚拟化是怎么"劫持"K8S 的调度流程的?—— 你需要理解 Mutating Webhook + Scheduler Extender
  • Volcano 的 Gang 调度是怎么做到"全有或全无"的?—— 你需要理解 Scheduling Framework 的 Permit 扩展点
  • NVIDIA 的 DRA 驱动为什么能让调度器"看见" GPU 属性?—— 你需要理解 动态资源分配框架

这些 AI 算力场景中的核心能力,全部构建在 K8S 的底层机制之上。不理解底层,就只能用别人造好的轮子;理解底层,你才能造自己的轮子。

本文将带你进入 Kubernetes 的内部世界。我们不画"API Server 是中枢"这种高级别架构图——上一篇已经画过了。我们要拆开每个组件的引擎盖,看看里面的齿轮是怎么咬合的。


一、API Server 深度剖析:不只是一个 REST 接口

1.1 请求处理链:一个请求的完整旅程

当你执行 kubectl create -f pod.yaml 时,请求在 API Server 内部经历一条精密的处理链:

HTTP Request (kubectl / client-go / curl)
    │
    ▼
┌──────────────────┐
│  Authentication  │ ← 认证:你是谁?
│  (认证)          │   Bearer Token / X.509 证书 / OIDC / Webhook
└────────┬─────────┘
         ▼
┌──────────────────┐
│  Authorization   │ ← 鉴权:你能做什么?
│  (鉴权)          │   RBAC / ABAC / Webhook / Node Authorization
└────────┬─────────┘
         ▼
┌──────────────────┐
│ Mutating         │ ← 变更型准入:修改请求对象
│ Admission        │   注入 sidecar / 设默认值 / 打标签
└────────┬─────────┘
         ▼
┌──────────────────┐
│ Object Schema    │ ← 校验对象结构是否合法
│ Validation       │
└────────┬─────────┘
         ▼
┌──────────────────┐
│ Validating       │ ← 验证型准入:策略检查、安全合规
│ Admission        │
└────────┬─────────┘
         ▼
┌──────────────────┐
│  etcd Write      │ ← 持久化到 etcd
└────────┬─────────┘
         ▼
┌──────────────────┐
│  Watch Dispatch  │ ← 异步通知所有 Watcher
└──────────────────┘

关键细节

  1. Mutating 在 Validating 之前。这意味着 Mutating Webhook 修改后的对象会被 Validating Webhook 再次校验。常见陷阱:你的 Mutating Webhook 注入了一个字段,但被另一个 Validating Webhook 拒绝了。

  2. etcd 写入是同步的,Watch 分发是异步的kubectl create 返回 "created" 时,对象已经持久化到 etcd,但 Scheduler 和 Controller 可能还没有感知到。

  3. 每种 Admission 都可以有多个 Webhook 链式执行。执行顺序影响最终结果——这是设计多个 Webhook 时必须注意的。

1.2 RBAC 鉴权模型

RBAC(基于角色的访问控制)是 K8S 最常用的鉴权模型。理解 RBAC 有四个核心概念:

┌──────────┐     绑定      ┌──────────┐     引用      ┌──────────┐
│  Subject │ ←─────────── │ Binding  │ ──────────→  │   Role   │
│  (主体)  │              │  (绑定)   │              │  (角色)   │
│          │              │          │              │          │
│ • User   │              │ • Role   │              │ • Rules  │
│ • Group  │              │   Binding│              │   - API  │
│ • SA     │              │ • Cluster│              │     Groups│
└──────────┘              │   Role   │              │   - Verbs│
                          │   Binding│              │   - Res  │
                          └──────────┘              └──────────┘
# 为 AI 团队创建 GPU 资源的只读角色
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: ai-team
  name: gpu-reader
rules:
- apiGroups: [""]
  resources: ["pods", "pods/log"]
  verbs: ["get", "list", "watch"]
- apiGroups: ["batch"]
  resources: ["jobs"]
  verbs: ["get", "list", "watch", "create", "delete"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  namespace: ai-team
  name: gpu-reader-binding
subjects:
- kind: User
  name: alice
roleRef:
  kind: Role
  name: gpu-reader
  apiGroup: rbac.authorization.k8s.io

生产经验:AI 算力平台的 RBAC 设计需要特别注意 GPU 资源的权限控制。建议为不同团队创建独立的 Namespace,通过 ResourceQuota 限制 GPU 配额,通过 RBAC 限制谁能提交 GPU 训练任务。


二、Watch 与 Informer:控制循环的心脏

2.1 Watch 机制

K8S 的 Watch 机制是整个系统的神经系统。所有自动化行为——Pod 调度、副本扩缩、故障恢复——都依赖 Watch 来感知状态变化。

Client ──── HTTP GET /api/v1/pods?watch=true ────► API Server
       ◄─── Chunked Transfer-Encoding ────────────
       ◄─── {"type":"ADDED","object":{...}}
       ◄─── {"type":"MODIFIED","object":{...}}
       ◄─── {"type":"DELETED","object":{...}}
       ◄─── (keep-alive, 长连接持续推送)

API Server 内部使用 watchCache(环形缓冲区,默认 100 条事件)缓存最近的变更事件。Watch 请求首先尝试从缓存服务,只有缓存不命中时才从 etcd 发起 Watch。这就是为什么 API Server 能撑住成百上千个 Watch 连接而不压垮 etcd。

但直接使用 Watch 有三个致命问题

  1. 连接断开后怎么恢复? 需要记住 resourceVersion,断线后从该版本续接
  2. 事件洪泛怎么办? 大量中间状态事件可能淹没处理逻辑
  3. 启动时怎么获取全量? 需要先 List 再 Watch,衔接点可能有事件丢失

这就是为什么实际开发中,绝不应该直接使用 Watch API,而应该使用 Informer。

2.2 Informer 机制详解

Informer 是 client-go 提供的核心抽象,它优雅地解决了 Watch 的所有痛点:

                    ┌───────────────────────────────┐
                    │           Informer             │
                    │                                │
API Server ─────►  │  ┌───────────┐  ┌──────────┐  │
  (List+Watch)     │  │ Reflector │─►│  Store    │  │
                    │  │           │  │  (Cache)  │  │
                    │  └─────┬─────┘  └──────────┘  │
                    │        │                       │
                    │  ┌─────▼─────┐                 │   ┌──────────────┐
                    │  │  Delta    │                 │──►│  Indexer      │
                    │  │  FIFO     │                 │   │  (本地索引)   │
                    │  │  Queue    │                 │   └──────────────┘
                    │  └─────┬─────┘                 │
                    │        │                       │   ┌──────────────┐
                    │        └───────────────────────│──►│ EventHandler │
                    │                                │   │ (业务代码)    │
                    └────────────────────────────────┘   └──────────────┘

四个核心组件

① Reflector(反射器):负责 List + Watch 的自动化管理。

  • 启动时先做一次全量 List,获取所有对象
  • 然后切换到增量 Watch,实时接收变更事件
  • 如果 Watch 断开,自动重连并处理 resourceVersion 的衔接
  • 如果 resourceVersion 过期(410 Gone),自动执行 Re-list

② Delta FIFO Queue(增量队列):

  • 存储的是 delta(变化量),而非完整对象
  • 同一个对象的多次修改可能被 合并,减少不必要的处理
  • 先进先出保证处理顺序

③ Store / Indexer(本地缓存 + 索引):

  • 维护 API Server 数据的完整本地副本
  • 支持自定义索引(按 Namespace、Label 等建索引)
  • 读取操作直接查本地缓存,零网络开销

④ EventHandler(事件处理器):

  • 你的业务逻辑挂载点
  • 接收 Add / Update / Delete 事件
  • 通常将事件转为 key 放入 WorkQueue,由独立的 Worker 处理

2.3 关键设计原则

// ✅ 正确:通过 Lister 从本地缓存读取,零网络开销
pod, err := podLister.Pods("default").Get("my-pod")

// ❌ 错误:直接请求 API Server,高负载下会被限流
pod, err := clientset.CoreV1().Pods("default").Get(ctx, "my-pod", metav1.GetOptions{})

生产经验:一个常见的新手错误是在 Reconcile 函数里直接调用 API Server 的 Get/List。当你的 Operator 管理几千个对象时,大量 API 请求会触发 API Server 的限流(APF,API Priority and Fairness),导致控制循环变慢甚至停滞。永远优先从 Informer Cache 读取。


三、控制器模式:声明式系统的引擎

3.1 声明式 vs 命令式:不只是风格差异

K8S 选择声明式 API 不是一个"编程风格"的偏好,而是一个 系统可靠性层面的架构决策

命令式: "scaleUp(replicas=5)"
  → 执行到一半网络断了
  → 你不知道现在是 3 个还是 5 个
  → 重试?可能变成 7 个

声明式: "spec.replicas = 5"
  → 不管执行几次、中间断几次
  → 控制器总是对比"当前几个"和"期望几个"
  → 最终状态必定收敛到 5 个

3.2 Level-triggered vs Edge-triggered

这个概念来自硬件电路设计,但在 K8S 控制器中是核心设计原则:

  • Edge-triggered(边缘触发):对"事件"反应。收到"Pod 被删除"的事件,创建一个新 Pod。问题:事件丢了怎么办?
  • Level-triggered(电平触发):对"状态"反应。每次被唤醒时,比较"当前有几个 Pod"和"期望几个 Pod",差多少补多少。
// ❌ Edge-triggered 思维(脆弱)
func onPodDeleted(pod *v1.Pod) {
    createNewPod()  // 如果这个事件丢了,Pod 就永远少一个
}

// ✅ Level-triggered 思维(健壮)
func reconcile(deployment *appsv1.Deployment) {
    current := countRunningPods(deployment)
    desired := deployment.Spec.Replicas
    diff := desired - current
    if diff > 0 {
        createPods(diff)   // 不管调用几次,结果都是正确的
    } else if diff < 0 {
        deletePods(-diff)
    }
}

K8S 所有内建控制器都遵循 Level-triggered 设计。你写的 Operator 也必须如此。

3.3 Reconcile 函数的标准模式

一个生产级 Reconcile 函数的标准结构(使用 controller-runtime):

func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := r.Log.WithValues("resource", req.NamespacedName)

    // 1. 获取 CR 对象
    var myObj myv1.MyResource
    if err := r.Get(ctx, req.NamespacedName, &myObj); err != nil {
        if apierrors.IsNotFound(err) {
            return ctrl.Result{}, nil  // 对象已删除,无需处理
        }
        return ctrl.Result{}, err
    }

    // 2. 检查是否正在被删除(Finalizer 模式)
    if !myObj.DeletionTimestamp.IsZero() {
        if controllerutil.ContainsFinalizer(&myObj, myFinalizerName) {
            // 清理外部资源
            if err := r.cleanupExternalResources(&myObj); err != nil {
                return ctrl.Result{}, err
            }
            // 移除 Finalizer,让 K8S 完成删除
            controllerutil.RemoveFinalizer(&myObj, myFinalizerName)
            return ctrl.Result{}, r.Update(ctx, &myObj)
        }
        return ctrl.Result{}, nil
    }

    // 3. 确保 Finalizer 存在
    if !controllerutil.ContainsFinalizer(&myObj, myFinalizerName) {
        controllerutil.AddFinalizer(&myObj, myFinalizerName)
        if err := r.Update(ctx, &myObj); err != nil {
            return ctrl.Result{}, err
        }
    }

    // 4. 核心调谐逻辑:对比期望状态和实际状态,执行动作
    // ...(你的业务逻辑)

    // 5. 更新 Status 子资源
    myObj.Status.Phase = "Ready"
    myObj.Status.ObservedGeneration = myObj.Generation
    if err := r.Status().Update(ctx, &myObj); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

几个关键设计点

Finalizer 模式:K8S 的删除是"标记删除"而非"立即删除"。如果你的资源管理了外部资源(GPU 设备预留、DNS 记录、云资源),必须用 Finalizer 确保外部资源被正确清理。没有 Finalizer,K8S 删掉你的 CR 后,外部资源就成了孤儿。

Status 与 Spec 分离更新:Spec 由用户写入,Status 由控制器写入,走不同的 API 路径。这避免了用户和控制器同时修改同一个对象导致的冲突。

ObservedGenerationGeneration 在 Spec 变更时递增,ObservedGeneration 是控制器上次处理的 Generation。ObservedGeneration < Generation 表示控制器还没处理最新变更——这是非常有用的状态信号。

3.4 Owner Reference 与级联删除

// 设置所有权:myObj 拥有 pod
controllerutil.SetControllerReference(myObj, pod, r.Scheme)

当 owner 被删除时,owned 对象会被级联删除。三种策略:

  • Background(默认):先删父对象,子对象异步清理
  • Foreground:先删子对象,全部删完后删父对象
  • Orphan:删父对象,子对象变成孤儿

常见坑:如果你的 Operator 创建了 Pod 但忘了设 OwnerReference,删除 CR 时这些 Pod 会永远残留在集群中。


四、调度器:Scheduling Framework 全面剖析

4.1 为什么需要自定义调度

默认调度器在大多数场景下表现良好,但在 AI 场景中力不从心:

  • Gang Scheduling:训练任务的 N 个 Pod 必须同时调度,否则全部不调度
  • 拓扑感知:GPU 间的 NVLink/PCIe 互联拓扑直接影响性能
  • 公平共享:多团队共享 GPU 池,需要配额管理和优先级抢占
  • 协同调度:通信密集的 Pod 应调度到同一拓扑域

4.2 Scheduling Framework 架构

Kubernetes 1.19+ 的调度器采用 插件化框架(Scheduling Framework),几乎在调度的每个阶段都允许插入自定义逻辑:

                  调度一个 Pod 的完整流程

  ┌─────────────── Scheduling Cycle (串行,每次一个 Pod) ──────────────┐
  │                                                                     │
  │  ┌───────────┐  ┌────────┐  ┌──────────┐  ┌──────────┐  ┌───────┐ │
  │  │ PreFilter  │─►│ Filter │─►│PostFilter│─►│ PreScore │─►│ Score │ │
  │  │ (预处理)   │  │(过滤)  │  │(抢占)    │  │(预打分)  │  │(打分) │ │
  │  └───────────┘  └────────┘  └──────────┘  └──────────┘  └───┬───┘ │
  │                                                              │     │
  │  ┌────────────┐  ┌──────────┐                                │     │
  │  │ Normalize  │◄─┘          │                                │     │
  │  │ Score      │   │ Reserve │◄───────────────────────────────┘     │
  │  │(归一化)    │   │(预留)   │                                      │
  │  └────────────┘   └────┬────┘                                      │
  └────────────────────────┼───────────────────────────────────────────┘
                           │
  ┌────────────────────────┼──── Binding Cycle (并行) ─────────────────┐
  │                        ▼                                            │
  │  ┌──────────┐  ┌───────────┐  ┌────────┐                          │
  │  │  Permit  │─►│  PreBind  │─►│  Bind  │─►  PostBind              │
  │  │(审批)    │  │(预绑定)   │  │(绑定)  │                          │
  │  └──────────┘  └───────────┘  └────────┘                          │
  └────────────────────────────────────────────────────────────────────┘

各扩展点的用途与 AI 场景关联

扩展点 阶段 作用 AI 场景示例
PreFilter 调度前 预处理,计算聚合信息 Gang 调度:统计同组 Pod 数量
Filter 节点过滤 排除不满足条件的节点 GPU 拓扑检查、显存容量检查
PostFilter 过滤后 无可用节点时触发抢占 低优先级训练任务让位
PreScore 打分前 收集全局信息 收集节点 GPU 利用率数据
Score 节点打分 对候选节点评分 NVLink 拓扑打分、Binpack/Spread
NormalizeScore 归一化 分数归一化到 [0,100] 多个打分插件结果统一化
Reserve 预留资源 乐观标记资源已占用 预留 GPU 显存配额
Permit 审批 批准、拒绝或等待 Gang 调度核心:等待同组所有 Pod 就绪
PreBind 绑定前 绑定前准备 挂载分布式存储
Bind 绑定 将 Pod 绑定到节点 执行节点绑定
PostBind 绑定后 清理或通知 更新 GPU 监控数据

4.3 实现一个调度插件

拓扑感知打分插件 为例——让同一个训练 Job 的 Pod 尽量调度到同一个机架:

package topologyaware

import (
    "context"
    v1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/runtime"
    framework "k8s.io/kubernetes/pkg/scheduler/framework"
)

const Name = "TopologyAwareScheduling"

type TopologyAware struct {
    handle framework.Handle
}

var _ framework.ScorePlugin = &TopologyAware{}

func (t *TopologyAware) Name() string { return Name }

// Score: 同一 rack 上已有同 Job 的 Pod,则给高分
func (t *TopologyAware) Score(
    ctx context.Context,
    state *framework.CycleState,
    pod *v1.Pod,
    nodeName string,
) (int64, *framework.Status) {
    nodeInfo, err := t.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
    if err != nil {
        return 0, framework.AsStatus(err)
    }

    rack := nodeInfo.Node().Labels["topology.kubernetes.io/rack"]
    jobName := pod.Labels["job-name"]
    if rack == "" || jobName == "" {
        return 0, nil
    }

    score := int64(0)
    for _, p := range nodeInfo.Pods {
        if p.Pod.Labels["job-name"] == jobName {
            score += 10
        }
    }
    return score, nil
}

func (t *TopologyAware) ScoreExtensions() framework.ScoreExtensions { return t }

func (t *TopologyAware) NormalizeScore(
    ctx context.Context, state *framework.CycleState,
    pod *v1.Pod, scores framework.NodeScoreList,
) *framework.Status {
    var maxScore int64
    for _, s := range scores {
        if s.Score > maxScore {
            maxScore = s.Score
        }
    }
    if maxScore == 0 {
        return nil
    }
    for i := range scores {
        scores[i].Score = scores[i].Score * framework.MaxNodeScore / maxScore
    }
    return nil
}

func New(obj runtime.Object, h framework.Handle) (framework.Plugin, error) {
    return &TopologyAware{handle: h}, nil
}

注册到调度器

func main() {
    command := app.NewSchedulerCommand(
        app.WithPlugin(topologyaware.Name, topologyaware.New),
    )
    if err := command.Execute(); err != nil {
        os.Exit(1)
    }
}

配置文件

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: topology-aware-scheduler
  plugins:
    score:
      enabled:
      - name: TopologyAwareScheduling
        weight: 10

性能警告:调度器每个调度周期只有几毫秒预算(默认目标是每秒调度 100+ Pod)。你的插件如果做了复杂计算或外部调用,会成为整个集群的瓶颈。确保 Score 函数在微秒级完成。

4.4 Scheduler Extender vs Scheduling Framework

历史上还有一种 Scheduler Extender 模式(通过 HTTP Webhook 调用外部服务)。对比:

维度 Scheduler Extender Scheduling Framework
调用方式 HTTP Webhook(进程外) 进程内插件
延迟 毫秒级(网络开销) 微秒级
扩展点 Filter + Score + Bind 12 个扩展点
可靠性 Webhook 挂了影响调度 与调度器同生命周期
适用场景 简单场景、快速原型 生产级调度定制

注意:HAMi 等早期方案仍在使用 Scheduler Extender 模式。虽然不如 Framework 高效,但优势是不需要 fork 调度器代码。随着社区演进,越来越多方案迁移到 Scheduling Framework。


五、CRD + Operator:扩展 K8S API 的正确姿势

5.1 CRD 基础

CRD(Custom Resource Definition)让你定义新的 API 类型,扩展 Kubernetes 的 API 边界:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: trainingjobs.ai.example.com
spec:
  group: ai.example.com
  names:
    kind: TrainingJob
    plural: trainingjobs
    shortNames: ["tj"]
  scope: Namespaced
  versions:
  - name: v1
    served: true
    storage: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              framework:
                type: string
                enum: ["pytorch", "tensorflow", "jax"]
              workers:
                type: integer
                minimum: 1
                maximum: 512
              gpuPerWorker:
                type: integer
                minimum: 1
                maximum: 8
              image:
                type: string
            required: ["framework", "workers", "gpuPerWorker", "image"]
          status:
            type: object
            properties:
              phase:
                type: string
              activeWorkers:
                type: integer
              startTime:
                type: string
                format: date-time
    additionalPrinterColumns:
    - name: Framework
      type: string
      jsonPath: .spec.framework
    - name: Workers
      type: integer
      jsonPath: .spec.workers
    - name: Phase
      type: string
      jsonPath: .status.phase
    - name: Age
      type: date
      jsonPath: .metadata.creationTimestamp
    subresources:
      status: {}

注册后,你可以像使用原生资源一样操作自定义资源:

kubectl get trainingjobs
# NAME            FRAMEWORK   WORKERS   PHASE     AGE
# llm-pretrain    pytorch     128       Running   2h
# bert-finetune   pytorch     4         Complete  5h

5.2 CEL 验证(K8S 1.25+)

从 1.25 开始,CRD 支持 CEL(Common Expression Language) 做复杂验证,无需 Webhook:

x-kubernetes-validations:
- rule: "self.framework == 'pytorch' || self.workers >= 1"
  message: "workers must be at least 1"
- rule: "self.workers * self.gpuPerWorker <= 1024"
  message: "total GPU count cannot exceed 1024"
- rule: "self.framework != 'jax' || self.gpuPerWorker >= 2"
  message: "JAX training requires at least 2 GPUs per worker"

5.3 Operator 模式

CRD 定义了"长什么样",Operator 定义了"怎么工作"。 Operator 本质上就是一个自定义 Controller,watch 自定义资源的变化,通过 Reconcile 逻辑驱动底层资源(Pod、Service、ConfigMap 等)达到期望状态。

用户声明:                       Operator 自动创建:
┌──────────────────┐           ┌──────────────────┐
│  TrainingJob     │           │  StatefulSet      │
│  workers: 4      │    ──►    │  replicas: 4      │
│  gpuPerWorker: 8 │           │  (每个Pod 8卡GPU)  │
│  framework: pt   │           ├──────────────────┤
└──────────────────┘           │  Service          │
                               │  (Worker 通信)    │
                               ├──────────────────┤
                               │  ConfigMap        │
                               │  (训练配置)       │
                               └──────────────────┘

开发框架选型

框架 语言 适用场景
Kubebuilder Go 首选。K8S SIG 官方脚手架
Operator SDK Go/Ansible/Helm 需要 OperatorHub 分发时
kopf Python Python 团队快速原型
Metacontroller 任意语言 Webhook 模式,用任意语言写 Reconcile

选型建议:如果用 Go,Kubebuilder 是标准答案。它与 controller-runtime 深度集成,生成的代码结构清晰。

5.4 controller-runtime 核心机制

// Manager: 管理所有 Controller 的生命周期
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
    Scheme:             scheme,
    LeaderElection:     true,            // 高可用
    LeaderElectionID:   "my-operator",
})

// Builder 模式注册 Controller
err = ctrl.NewControllerManagedBy(mgr).
    For(&myv1.TrainingJob{}).          // 主资源
    Owns(&appsv1.StatefulSet{}).       // owned 的 StatefulSet
    Owns(&v1.Service{}).               // owned 的 Service
    Watches(                           // 外部资源变更也触发 Reconcile
        &v1.Secret{},
        handler.EnqueueRequestsFromMapFunc(findRelatedJobs),
    ).
    WithOptions(controller.Options{
        MaxConcurrentReconciles: 5,    // 并发 Reconcile 数
    }).
    Complete(&TrainingJobReconciler{})

六、Admission Webhook:集群的守门人

6.1 两种 Webhook 类型

                  API 请求处理顺序
                        │
          ┌─────────────▼──────────────┐
          │  Mutating Admission Webhook │  ← 可以修改请求对象
          │  • 注入 sidecar 容器         │
          │  • 设置默认值               │
          │  • 注入环境变量             │
          └─────────────┬──────────────┘
                        ▼
          ┌─────────────────────────────┐
          │  Schema Validation           │
          └─────────────┬───────────────┘
                        ▼
          ┌─────────────────────────────┐
          │ Validating Admission Webhook │  ← 只能批准或拒绝
          │  • 策略检查                  │
          │  • 安全合规审计              │
          │  • GPU 配额验证             │
          └─────────────────────────────┘

6.2 AI 场景中的典型 Webhook

示例:GPU Pod 注入 Webhook

HAMi 的核心机制之一就是 Mutating Webhook——当用户提交 GPU Pod 时,自动修改 Pod 规范:

func (m *GPUPodMutator) Handle(ctx context.Context, req admission.Request) admission.Response {
    pod := &v1.Pod{}
    if err := m.decoder.Decode(req, pod); err != nil {
        return admission.Errored(http.StatusBadRequest, err)
    }

    // 检测是否请求了 GPU 资源
    if !hasGPURequest(pod) {
        return admission.Allowed("no GPU request")
    }

    // 1. 修改调度器名称
    pod.Spec.SchedulerName = "hami-scheduler"

    // 2. 注入 LD_PRELOAD 环境变量(用于 CUDA API 劫持)
    for i := range pod.Spec.Containers {
        pod.Spec.Containers[i].Env = append(pod.Spec.Containers[i].Env, v1.EnvVar{
            Name:  "LD_PRELOAD",
            Value: "/usr/local/vgpu/libvgpu.so",
        })
    }

    // 3. 注入 RuntimeClassName
    runtimeClass := "nvidia"
    pod.Spec.RuntimeClassName = &runtimeClass

    marshaledPod, _ := json.Marshal(pod)
    return admission.PatchResponseFromRaw(req.Object.Raw, marshaledPod)
}

6.3 生产注意事项

可用性是生命线:Webhook 挂了,默认会拒绝所有匹配的请求(failurePolicy: Fail)。你的 Webhook 宕机 = 集群无法创建 GPU Pod。

failurePolicy: Fail    # 安全优先(Webhook 挂了就拒绝)
failurePolicy: Ignore  # 可用性优先(Webhook 挂了就放行)

最佳实践

  • 至少 2 副本部署
  • 超时时间不超过 5 秒
  • namespaceSelector 排除 kube-system,避免"鸡生蛋"问题
  • 用 cert-manager 自动管理 TLS 证书

6.4 ValidatingAdmissionPolicy(K8S 1.30+ GA)

新一代验证策略,内置 CEL 引擎,无需部署 Webhook 服务:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-gpu-limits
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["pods"]
  validations:
  - expression: >
      !object.spec.containers.exists(c,
        has(c.resources.requests) &&
        'nvidia.com/gpu' in c.resources.requests &&
        (!has(c.resources.limits) || !('nvidia.com/gpu' in c.resources.limits))
      )
    message: "所有请求 GPU 的容器必须同时设置 GPU limits"

趋势:ValidatingAdmissionPolicy 会逐步取代大量简单的 Validating Webhook——它不需要额外的服务,没有网络调用开销,可靠性更高。


七、CSI / CNI / CRI:三大运行时插件接口

K8S 的设计哲学是 核心精简,能力外挂。存储、网络、容器运行时都通过标准接口解耦。

7.1 CRI(Container Runtime Interface)

kubelet ──── gRPC ────► CRI Runtime
                        ├── containerd(主流)
                        ├── CRI-O(Red Hat 主推)
                        └── 其他(Kata, gVisor...)

CRI 定义了两组接口:

  • RuntimeService:管理 Pod Sandbox 和 Container 生命周期
  • ImageService:管理镜像的拉取、查询和删除

AI 场景关联:NVIDIA Container Runtime 就是通过 containerd 的 runtime handler 机制,在容器创建时注入 GPU 设备。通过 RuntimeClass 可以指定不同的运行时:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: nvidia
handler: nvidia    # 对应 containerd 配置中的 runtime handler

7.2 CNI(Container Network Interface)

CNI 插件负责容器的网络配置——分配 IP、设置路由、配置网络策略。

// CNI 配置 (/etc/cni/net.d/10-calico.conflist)
{
  "cniVersion": "1.0.0",
  "name": "k8s-pod-network",
  "plugins": [
    {
      "type": "calico",          // 主插件
      "ipam": { "type": "calico-ipam" }
    },
    {
      "type": "bandwidth",       // 链式插件:限流
      "capabilities": { "bandwidth": true }
    }
  ]
}

AI 场景关联:标准 CNI 只能配一个网卡。但 AI 训练需要 管理网络 + RDMA 训练网络 双平面。Multus CNI 解决了这个问题——它作为"元 CNI",允许 Pod 同时接入多个网络:

# Pod 同时接入默认网络 + RDMA 网络
metadata:
  annotations:
    k8s.v1.cni.cncf.io/networks: rdma-network

7.3 CSI(Container Storage Interface)

CSI 定义了存储的 Provision → Attach → Mount 三阶段生命周期:

CSI 三阶段:

1. Provision (Controller)
   PVC Created → CSI Driver 创建底层存储卷

2. Attach (Controller)
   Pod 调度到节点 → CSI Driver 将卷挂载到节点

3. Mount (Node)
   容器启动前 → CSI Driver 将卷挂载到容器路径

AI 场景关联:高性能存储是 AI 训练的刚需。CSI 让 K8S 能对接各种存储后端:

  • Lustre / GPFS:分布式并行文件系统,提供 100+ GB/s 聚合读吞吐
  • 本地 NVMe:通过 Local PV 暴露高速本地存储
  • 对象存储:通过 CSI Driver 挂载 S3/GCS/OSS

八、DRA(动态资源分配):资源模型的革命

8.1 Device Plugin 的根本局限

传统 Device Plugin 将设备抽象为整数计数器:

resources:
  limits:
    nvidia.com/gpu: 2   # "我要 2 个 GPU"
                        # 但:什么型号?多少显存?什么拓扑?全不知道

调度器看不到设备的任何属性,只知道数量。一块 H100 和一块 T4 都是 nvidia.com/gpu: 1

8.2 DRA 的设计理念

DRA(Dynamic Resource Allocation,K8S 1.31+ Beta)借鉴 PVC/PV 的设计模式,引入 声明式资源请求

apiVersion: resource.k8s.io/v1beta1
kind: ResourceClaim
metadata:
  name: training-gpus
spec:
  devices:
    requests:
    - name: gpu
      deviceClassName: gpu.nvidia.com
      count: 8
      selectors:
      - cel:
          expression: >
            device.attributes["gpu.nvidia.com"].model == "H100" &&
            device.capacity["gpu.nvidia.com"].memory.compareTo(
              quantity("80Gi")) >= 0
    constraints:
    - requests: ["gpu"]
      matchAttribute: "gpu.nvidia.com/numa-node"

8.3 DRA 核心组件

┌───────────────┐     ┌────────────────┐     ┌───────────────┐
│ ResourceClaim │     │  DeviceClass   │     │ ResourceSlice │
│ (用户请求:    │     │ (设备类定义:   │     │ (节点设备清单:│
│  要什么样的   │     │  什么是GPU)    │     │  有哪些设备)  │
│  设备)        │     │                │     │               │
└──────┬────────┘     └───────┬────────┘     └──────┬────────┘
       │                      │                      │
       └──────────┬───────────┘                      │
                  │                                   │
           ┌──────▼──────┐                   ┌───────▼───────┐
           │  Scheduler  │◄──────────────────│  DRA Driver   │
           │ (属性匹配   │                   │ (设备发现     │
           │  调度决策)  │                   │  资源分配)    │
           └─────────────┘                   └───────────────┘
  • ResourceSlice:DRA Driver 在每个节点发布该节点的设备清单,包含详细属性(型号、显存、计算能力、互联拓扑)
  • DeviceClass:定义一类设备的选择规则(类似 StorageClass)
  • ResourceClaim:用户声明需要什么样的设备(类似 PVC)

8.4 DRA 的革命性价值

维度 Device Plugin DRA
资源表示 整数计数器 属性+能力
调度可见性 调度器只看到数量 调度器看到属性
设备选择 随机分配 基于属性选择
拓扑感知 不支持 原生支持约束
共享模型 不支持 支持 MIG/分时
GPU 在线迁移 不支持 支持

趋势判断:2026 年 3 月,NVIDIA 正式将 GPU DRA 驱动捐赠给 CNCF。这标志着 GPU 调度从"厂商绑定"走向"社区共治"。DRA 是 K8S 资源模型的下一代演进,如果你在做异构设备管理相关的开发,DRA 是必须跟进的方向。


九、二次开发工程实践

9.1 开发一个 Operator 的标准流程

# 1. 初始化项目
kubebuilder init --domain example.com --repo github.com/example/training-operator

# 2. 创建 API(CRD + Controller)
kubebuilder create api --group ai --version v1 --kind TrainingJob

# 3. 编辑类型定义
# api/v1/trainingjob_types.go

# 4. 生成代码和 CRD
make generate   # 生成 DeepCopy 方法
make manifests  # 生成 CRD YAML 和 RBAC 规则

# 5. 编写 Reconcile 逻辑
# internal/controller/trainingjob_controller.go

# 6. 测试
make test       # 单元测试 (envtest)
make run        # 本地运行

# 7. 部署
make docker-build docker-push IMG=myrepo/training-operator:v1
make deploy IMG=myrepo/training-operator:v1

9.2 常见踩坑指南

坑 1:ResourceVersion 冲突

// ❌ Get 之后做了耗时操作,Update 时对象已被别人修改
obj, _ := client.Get(ctx, key, &myObj)
doExpensiveWork()           // 期间对象可能被修改
client.Update(ctx, &myObj) // Conflict! 409

// ✅ 返回 error 让 WorkQueue 自动重试

坑 2:Informer Cache 最终一致性

// ❌ 刚创建的对象在缓存中可能还不存在
client.Create(ctx, newPod)
pod, err := lister.Get("new-pod")  // 可能 NotFound

// ✅ 设计 Reconcile 逻辑容忍缓存延迟
// 下一轮 Reconcile 时缓存就同步了

坑 3:Status 更新风暴

// ❌ 每次 Reconcile 都更新 Status,即使没变化
myObj.Status.Phase = "Running"
client.Status().Update(ctx, &myObj)  // 触发 Watch 事件风暴

// ✅ 只在 Status 真正变化时才更新
if myObj.Status.Phase != "Running" {
    myObj.Status.Phase = "Running"
    client.Status().Update(ctx, &myObj)
}

9.3 生产部署 Checklist

□ Leader Election 已启用(高可用至少 2 副本)
□ RBAC 遵循最小权限原则
□ 资源 Requests/Limits 已设置
□ Liveness/Readiness Probe 已配置
□ Prometheus 指标已暴露
□ 结构化日志(不用 fmt.Println)
□ Finalizer 正确处理外部资源清理
□ 优雅退出(处理 SIGTERM)
□ Rate Limiting 合理配置
□ 测试覆盖(单元 + envtest + e2e)

十、总结与下一篇预告

本篇核心要点回顾

1. API Server 请求链: 认证→鉴权→Mutating Admission→验证→Validating Admission→etcd→Watch

2. Informer 机制: Reflector(List+Watch) → Delta FIFO → Indexer(本地缓存)
   └── 永远从缓存读,不要直接调 API Server

3. 控制器模式:
   └── 声明式 + Level-triggered + 幂等 Reconcile
   └── Finalizer 处理外部资源清理

4. Scheduling Framework: 12 个扩展点
   └── Filter/Score 用于 GPU 拓扑感知
   └── Permit 用于 Gang 调度

5. CRD + Operator: 扩展 K8S API 的标准方式
   └── Kubebuilder + controller-runtime

6. Admission Webhook: Mutating(修改) + Validating(校验)
   └── HAMi 的核心入口就是 Mutating Webhook

7. CSI/CNI/CRI: 三大可插拔接口
   └── 存储/网络/运行时 的扩展基座

8. DRA: 下一代资源模型
   └── 基于属性的设备调度,替代 Device Plugin 的整数计数器

下一篇预告

现在你理解了 K8S 的底层机制和扩展方式。下一个问题是:GPU 和异构加速卡在 K8S 上是怎么被管理的?

第 3 篇《GPU 与异构算力:让 Kubernetes 驾驭每一块加速卡》 将深入 GPU 算力管理:

  • GPU 的硬件架构是什么样的?SM、CUDA Core、HBM、NVLink 各是什么?
  • Device Plugin 是怎么把 GPU 注册为 K8S 资源的?
  • MIG、MPS、Time-Slicing 三种 NVIDIA 原生方案各有什么优劣?
  • HAMi 是怎么通过 LD_PRELOAD + CUDA API 劫持实现显存隔离的?
  • 华为昇腾、寒武纪 MLU 等国产加速卡怎么纳入统一管理?
  • 在你的集群中该选哪个方案?

从"理解 K8S 机制"到"掌握 GPU 管理",我们离构建 AI 算力平台又近了一步。


参考资料

  1. Kubernetes API Concepts - kubernetes.io - API 机制官方文档
  2. Writing Controllers - kubernetes.io - 控制器模式设计
  3. Scheduling Framework - kubernetes.io - 调度框架官方文档
  4. Custom Resources - kubernetes.io - CRD 官方文档
  5. Dynamic Resource Allocation - kubernetes.io - DRA 文档
  6. Kubebuilder Book - Operator 开发权威指南
  7. client-go under the hood - GitHub - Informer 机制详解
  8. Admission Webhooks - kubernetes.io - Webhook 开发指南
  9. ValidatingAdmissionPolicy - kubernetes.io - 新一代验证策略
  10. NVIDIA DRA Driver - CNCF - GPU DRA 捐赠 CNCF

关注公众号「coft」,获取更多 AI 实战干货和 AI-Infra 深度教程。

posted @ 2026-03-27 19:55  warm3snow  阅读(17)  评论(0)    收藏  举报