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.go 的 SyncModelDir 扫描它恢复"磁盘上有什么模型")。文件 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 分页 + DownloadFile(AZURE_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.go 里 TrimPrefix 掉的是 HTTPS 前缀------它期待的 https://...blob.core.windows.net/... 只会命中 HTTPSProvider。实现齐了,路由没接。
agent 流水线:Watcher → Puller → 下载 → 热加载
装配点在 cmd/agent/main.go 的 startModelPuller:先 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.go 的 setLocalModelLabel)发现 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 前缀路由的规则又写在哪?