模型加载:storage-initializer 与节点级缓存

KServe 源码拆解系列 · 第 5 篇

第 4 篇建好了 Deployment、挂好了 volume------但 /mnt/models空的。140GB 权重谁搬进去的?Pod 重启、扩缩容要重搬吗?

本篇拆 storage-initializer 的下载机制和节点级缓存 LocalModel;下一篇看流量怎么进 Pod。


两条下载路径:init 容器与 sidecar

单模型路径 :Pod mutator(pkg/webhook/admission/pod/storage_initializer_injector.go)注入 storage-initializer 容器,参数 [storageUri, /mnt/models]。init 容器的语义恰好是我们要的:没下载完,引擎容器不启动

injector 顶部注释讲了一段历史:

go 复制代码
// This is a workaround because Knative does not
// support INIT containers: https://github.com/knative/serving/issues/4307

Knative 曾不支持 init 容器(issue #4307),早期 storage-initializer 只能以普通容器注入------这就是它被称为 sidecar 的历史由来;Knative 补齐后 KServe 改成了 init 容器。

多模型路径 必须常驻,至今仍是 sidecar:addAgentAnnotations 只在 predictor 是 MMS 时打 internal.serving.kserve.io/agent 注解,agent_injector 据此注入带 --enable-puller 的 agent 容器------盯着 models.json 变化随时热加载/卸载。取舍一句话:一次性任务 → init 容器;要响应事件的 → sidecar。


Downloader:SUCCESS 文件与协议分发

下载器在 pkg/agent/downloader.go

go 复制代码
sha256 := storage.AsSha256(modelSpec)
successFile := filepath.Join(d.ModelDir, modelName, "SUCCESS."+sha256)
_, err := os.Stat(successFile)
switch {
case os.IsNotExist(err):
	if err := d.download(modelName, modelSpec.StorageURI); err != nil { ... }
	file, _ := storage.Create(successFile)      // ← 下载完成,写成功标记
	encodedJson, _ := json.Marshal(modelSpec)   // ← 标记里存 ModelSpec JSON
	os.WriteFile(successFile, encodedJson, 0o644)
case err == nil:
	d.Logger.Infof("Model successFile exists already")  // ← 幂等:跳过

SUCCESS.{sha256} 一石二鸟:幂等 (hash 由 AsSha256 序列化 modelSpec 算得,标记在就不重下),状态持久化 (标记里存 ModelSpec JSON,syncer.goSyncModelDir 扫描它恢复"磁盘上有什么模型")。文件 0777------agent 和模型服务容器 UID 不同、共享 emptyDir,必须人人可读。

协议分发在 extractProtocol:正则 \w+?:// 确认带协议头,再和 SupportedProtocols 前缀匹配。S3 的实现(pkg/agent/storage/s3.go):先 SplitN(s3Uri, "/", 2) 拆出 bucket 和 prefix,ListObjectsV2Paginator 分页枚举,再 TransferClient.DownloadObject 逐个拉回本地、路径对齐远端结构。

心智模型:不是下载一个文件,是枚举 prefix 下所有对象逐个拉------权重切成几十上百个分片,URI 是目录语义。传输用 AWS transfermanager 分块并发;已存在对象先删后下(TODO 注释承认没有断点续传)。


四个 Provider:同一接口,四种对象存储

pkg/agent/storage/provider.go

go 复制代码
type Provider interface {
	DownloadModel(modelDir string, modelName string, storageUri string) error
	UploadObject(bucket string, key string, object []byte) error
}

四个协议常量 S3="s3://"GCS="gs://"AZURE="abfs://"HTTPS="https://",但 SupportedProtocols = [S3, GCS, HTTPS, HTTP]------AZURE 不在这里 。各家玩法:S3 如上;GCS 用 Objects(query) 迭代器 + io.Copy 流式(凭据 GOOGLE_APPLICATION_CREDENTIALS,无则匿名);Azure ListBlobsFlat 分页 + DownloadFileAZURE_STORAGE_ACCESS_KEY);HTTPS 单文件 GET,按 Content-Type 自动解 tar/zip(1GB 上限)------打包文件的 URL 可直接当 storageUri,headers 从 {uri}-headers 注解拿。

GetProvider惰性初始化 + map 缓存------首次遇到某协议才建客户端塞进 map 复用;客户端只读环境变量,env 来自第 4 篇 credentialBuilder。

Azure 是"存在但没挂上"的半成品:AZURE 常量有,GetProvider 也有 initializeAzureClient 分支,但不在 SupportedProtocols------写 abfs:// 会报 protocol not supported。azure.goTrimPrefix 掉的是 HTTPS 前缀------它期待的 https://...blob.core.windows.net/... 只会命中 HTTPSProvider。实现齐了,路由没接。


agent 流水线:Watcher → Puller → 下载 → 热加载

装配点在 cmd/agent/main.gostartModelPuller:先 SyncModelDir 恢复已有模型,再起 Puller 消费 Watcher 事件。

watcher.go 用 fsnotify 盯 ConfigMap 挂载的 models.json。ConfigMap 更新时 kubelet 换 ..data 软链接,代码只认 ..data 的 CREATE 事件,再 EvalSymlinks 追到真文件。parseConfig 的 diff 朴素有效:模型带 stale 标记,本轮出现置 false,最后还 stale 的------被删了。

puller.go 每模型一个 goroutine 串行处理,下载成功后:

go 复制代码
http.Post(fmt.Sprintf("http://localhost:8080/v2/repository/models/%s/load", modelName), ...)
// ← 文件到位,通知引擎热加载(KFServing v2 协议,Triton 实现)

Remove 对称,先删目录再调 /unload。恢复出的模型带 OnStartup 标记,Puller 用 WaitGroup 等下载完 watcher 才开 watch,避免两个下载源打架。models.json 谁写的、热加载完整故事留给第 8 篇。


LocalModel:把模型缓存到节点上

init 容器方案有个明显问题:每扩一个副本、每重建一次 Pod,都把 100GB 重新下载一遍 。KServe 用一组 CR 解决:模型提前下载到节点本地,推理 Pod 只挂载不下载。三个角色:LocalModelCache / LocalModelNamespaceCache(声明"把 sourceModelUri 缓存到哪些节点组")、LocalModelNodeGroup(节点亲和 + 存储上限 + PV/PVC 模板)、LocalModelNode(每节点一个)。

第 1 步,defaulter 匹配。 isvc 的 webhook defaulter(inference_service_defaults.gosetLocalModelLabel)发现 storageUri 命中某个缓存 CR(前缀匹配 + 节点组注解),就贴上 internal.serving.kserve.io/localmodel label 和 localmodel-pvc-name(值 {model}-{nodegroup})注解。

第 2 步,LocalModelCache reconciler 铺资源pkg/controller/v1alpha1/localmodel/reconcilers/):为节点组内每个 Ready 节点建 LocalModelNode;建下载 PV/PVC(PVC 名 {model}-{nodegroup},在 jobNamespace);再为每个用该模型的命名空间建服务 PV/PVC(PV 名 {model}-{nodegroup}-{namespace})。两者同一份 PersistentVolumeSpec 模板------同一块物理存储,下载一次,多处挂载

第 3 步,LocalModelNode 控制器干活pkg/controller/v1alpha1/localmodelnode/controller.go,DaemonSet 每节点一个,NODE_NAME 来自 downward API)。它先过滤:req.Name != nodeName 直接返回------每节点各管各的。对 spec 里每个模型:算 storageKey = GetStorageKey(uri)(sha256 前 16 位,同 URI 同目录、多 CR 去重);本地 {MountPath}/models/{storageKey} 不存在就 launchJob,NodeSelector 钉在本节点、TTLSecondsAfterFinished 默认 1 小时自清理。容器还是 storage-initializer 镜像,args 仍是 [sourceModelUri, /mnt/models],只是 volume 换成下载 PVC、SubPath models/{storageKey}------同一个下载器,这次写进 PVC 而不是 emptyDir。Job 结果(Succeeded→ModelDownloaded,Failed→ModelDownloadError)写进 LocalModelNode.Status,LocalModelCache reconciler 汇总成 ModelCopies。控制器每 60s RequeueAfter 兜底查文件夹------Job 被 TTL 清掉也不怕,文件夹在状态就在。

第 4 步,推理 Pod 免下载。 Pod 创建时 mutator 看到 localmodel label,把 URI 改写:

go 复制代码
srcURI = "pvc://" + pvcName + "/models/" + storageKey + subPath  // ← 下载变成了挂载

之后走第 4 篇的 pvc:// 分支:直接挂载、不注入 init 容器。服务 PV 带节点亲和------Pod 只能调度到缓存了模型的节点,冷启动从"下载 100GB"变成"挂载 + 引擎加载权重"。


完整旅程:从 apply 到引擎加载

less 复制代码
kubectl apply InferenceService (storageUri: s3://bucket/llama-70b)
        │
        ▼
[InferenceService Controller] 生成 Deployment
        │
        ▼
[defaulter] storageUri 命中 LocalModelCache?
        │
        ├─ 是 ─▶ LocalModelCache reconciler ─▶ LocalModelNode 控制器 ─▶ Job 钉本节点写 PVC
        │        推理 Pod:pvc:// 直挂,免下载 ──────────────────────────┐
        │                                                                │
        └─ 否 ─▶ [pod mutator] 注入 init 容器                            │
                 extractProtocol → GetProvider                           │
                 ListObjectsV2 + transfermanager ─▶ 写 /mnt/models ──────┤
                                                                         ▼
                                 [kserve-container] 启动:引擎读 /mnt/models → 权重进 GPU → Ready

一句话总结

模型加载是两条路径的叠加:单模型走 storage-initializer init 容器(SUCCESS 幂等、\w+?:// 协议分发、四 Provider 统一接口),多模型走 agent sidecar(watch models.json → /load);LocalModel 系列 CR 用"节点级 PV 缓存 + DaemonSet 下载 Job + pvc:// 直挂"把冷启动里的下载环节整个省掉。从对象存储到 GPU 显存,每一步都被安排得明明白白。

下一篇预告:第 6 篇 流量路由。Pod Ready 之后,请求怎么穿过 Istio VirtualService 和 Knative 的 queue-proxy,抵达 kserve-container 的 8080?canary 切分和 URL 前缀路由的规则又写在哪?

相关推荐
SakitamaX2 小时前
pod管理及其相关实验
运维·kubernetes
天蓝不会忘记022 小时前
pod的控制器
容器·kubernetes
吴声子夜歌2 小时前
Java面试——设计模式(一)
java·设计模式·面试
DevOps老兵2 小时前
Kubernetes生产运维02:只会get pods还不够,kubectl排障工具箱怎么组合使用
运维·容器·kubernetes·kubectl·devops·故障排查·sre
陈聪.3 小时前
Kubernetes 中 Pod 的全面知识
云原生·容器·kubernetes
SakitamaX3 小时前
k8s中的控制器的使用
云原生·容器·kubernetes
IT 小阿姨(数据库)3 小时前
K8s v1.24.17 完整搭建文档(CentOS7 + containerd1.6.33 + Calico)
java·容器·kubernetes
进哥AI研习社4 小时前
WebGL 与视频背景——首屏视觉层的性能博弈
性能优化·webgl·gpu·canvas·shader·帧率·视频背景
蒲锘6 小时前
DevOps 实验项目笔记三 —— Kubernetes 集群部署阶段
笔记·kubernetes·devops·rbac