编程 K8s 1.36 ImageVolume 正式 GA:把 OCI 镜像当 Volume 挂载,AI 模型分发终于有了「官方正解」

2026-07-31 01:43:56 +0800 CST views 27

K8s 1.36 ImageVolume 正式 GA:把 OCI 镜像当 Volume 挂载,AI 模型分发终于有了「官方正解」

写在前面

如果你在 Kubernetes 上跑过大模型推理服务,大概率被同一个问题折磨过:模型权重文件怎么分发?

一个 7B 模型的权重动辄 15GB,70B 的量化版本也要 40GB 起步。把它打进业务镜像?镜像瞬间膨胀到几十 GB,每次改一行推理代码都要重新拉一遍完整镜像,CI/CD 直接瘫痪。用 PVC?PVC 是 Namespace 级别的资源,跨 Namespace 共享一个模型要么复制多份,要么在存储层面做各种脏 trick。用 initContainer 从对象存储下载?每个 Pod 启动都要下载一次,冷启动时间以分钟计,还得自己处理断点续传、校验、清理。

这个问题在社区里吵了好几年,各家有各家的野路子。而现在,Kubernetes 给出了官方答案:ImageVolume——在 v1.31 以 Alpha 形态引入,v1.33 进入 Beta,到了 v1.36 正式 GA。从此,OCI 镜像不再只能用来跑容器,它可以直接作为 Volume 挂载进 Pod。

这篇文章我会把 ImageVolume 从设计动机、底层机制、运行时链路,到构建模型镜像、生产落地的完整实战全部讲透。读完你应该能回答三个问题:

  1. ImageVolume 到底解决了什么 initContainer 和 PVC 解决不了的问题?
  2. 它在 kubelet 和容器运行时层面是怎么工作的?
  3. 我的集群现在能不能用、该怎么用、有哪些坑?

一、背景:OCI 镜像的「第二人生」

1.1 OCI 镜像不只是「容器的安装包」

先回到原点。OCI(Open Container Initiative)规范定义了两个核心标准:镜像格式(image spec)和运行时(runtime spec)。镜像本质上是什么?一组按内容寻址的 layer(tar 包)+ 一份 manifest 元数据。它有几个天然的好处:

  • 内容寻址:每个 layer 由 digest(SHA256)唯一标识,天然去重、天然防篡改
  • 分层复用:多个镜像共享相同 layer 时,节点上只存一份
  • 分发生态成熟:Registry、镜像加速、P2P 分发(Dragonfly、Kraken)、签名验证(cosign/notation)全套基础设施现成可用

注意到没有?这套能力描述的其实是一个通用的不可变 artifact 分发系统,跟「容器」没有必然绑定。模型权重、字体库、病毒特征库、地理数据、法律文本语料——任何「只读、大体积、需要在集群内广泛分发」的数据,都完美匹配这套机制。

OCI 社区早就意识到了这一点,于是有了 OCI Artifact 规范:允许 Registry 存储任意类型的内容,而不仅仅是可运行的容器镜像。ORAS(OCI Registry As Storage)项目就是干这个的。Hugging Face 上的模型、Helm Chart、WASM 模块、SBOM 文件,现在都可以推进 OCI Registry。

问题是:Kubernetes 一直没有原生的方式消费这些 artifact。你能把模型推到 Registry,但没法直接在 Pod 里「挂载」它——直到 KEP-4639。

1.2 KEP-4639:VolumeSource: OCI Artifact and/or Image

这个 KEP 由 SIG Node 和 SIG Storage 联合推进,核心诉求写得很直白:让用户可以在 Pod spec 里把一个 OCI 对象(镜像或 artifact)声明为 Volume 来源,由 kubelet 协调容器运行时完成拉取和挂载。

时间线如下:

  • v1.31(2024):Alpha 落地,需要手动开启 ImageVolume feature gate,且容器运行时必须支持(当时只有 CRI-O 1.31 能完整跑起来)
  • v1.33(2025):升级 Beta,containerd 跟进支持,增加 subPath 挂载能力
  • v1.36(2026):正式 GA,feature gate 默认开启并锁定,API 进入稳定状态

一个细节值得玩味:这个特性从 Alpha 到 GA 走了大约两年,中间没有大的 API 变更。对 Kubernetes 这种体量的项目来说,这算是「设计一次就对了」的典范——因为它的 API 面非常克制,我们马上会看到。

二、核心机制:ImageVolume 是怎么工作的

2.2 API:克制到极致的设计

先看最小可用示例:

apiVersion: v1
kind: Pod
metadata:
  name: llm-inference
spec:
  containers:
    - name: server
      image: vllm/vllm-openai:v0.9.0
      args: ["--model", "/models/qwen3-8b"]
      volumeMounts:
        - name: model-weights
          mountPath: /models/qwen3-8b
          readOnly: true
  volumes:
    - name: model-weights
      image:
        reference: registry.example.com/models/qwen3-8b:v1
        pullPolicy: IfNotPresent

整个新增 API 就是 volumes[].image 这一个字段,下面只有两个子字段:

字段说明
reference镜像/artifact 引用,支持 tag 和 digest 两种形式
pullPolicy拉取策略:Always / IfNotPresent / Never,语义与容器镜像完全一致

几个关键行为约束:

  1. 挂载永远是只读的noexec + ro)。镜像是不可变 artifact,写入没有意义,这个约束直接从根上消灭了一票数据一致性问题
  2. Volume 内容是镜像所有 layer 合并后的 rootfs 视图,就像容器看到的文件系统一样,但不会执行任何 entrypoint
  3. 拉取失败的行为与容器镜像一致:Pod 卡在 Pending,事件里报 ErrImagePull / ImagePullBackOff
  4. 拉取凭证复用 Pod 的 imagePullSecrets,私有 Registry 不需要额外配置
  5. 支持 subPath/subPathExpr 挂载(Beta 阶段加入),可以只挂镜像里的某个子目录

2.2 kubelet 与容器运行时的分工

ImageVolume 的实现路径很聪明:它没有走 CSI,也没有发明新的存储插件体系,而是直接复用了 CRI 的镜像管理链路

完整流程是这样的:

Pod 创建
  │
  ▼
kubelet 解析 volumes,发现 image 类型 volume
  │
  ▼
kubelet 通过 CRI PullImage 拉取镜像
  (复用镜像 GC、凭证、并发限速等全部现有机制)
  │
  ▼
kubelet 在 CRI CreateContainer 请求中
  以 mount 形式传递 image volume 信息
  │
  ▼
容器运行时(containerd/CRI-O)负责:
  1. 将镜像 snapshot 以只读方式准备好
  2. bind mount 到容器的目标路径

这里有个容易被忽略的重点:镜像的解包和挂载是运行时的活,不是 kubelet 的活。containerd 用它的 snapshotter(默认 overlayfs)把镜像的所有 layer 组装成一个只读的合并视图,然后 bind mount 进容器。这意味着:

  • 同一节点上 N 个 Pod 挂载同一个模型镜像,磁盘上只有一份数据。overlayfs 的只读 lowerdir 天然共享,这是 initContainer 下载方案永远做不到的
  • 拉取走的是标准镜像链路,所有镜像加速手段(Registry mirror、Dragonfly P2P、containerd 的 lazy-pulling/eStargz、Nydus)对 ImageVolume 全部生效
  • 镜像 GC 统一管理。kubelet 的 imageGC 会把 image volume 使用的镜像纳入引用计数,有 Pod 在用就不会被回收

2.3 与 CRI 的版本配套要求

这是生产落地前必须确认的硬性条件。ImageVolume 需要容器运行时实现 CRI 中对应的 mount 语义:

运行时最低版本要求
CRI-O1.31+(最早支持)
containerd2.1+(完整支持,2.0 只有部分能力)
Docker Engine (cri-dockerd)不支持

如果你的节点还在跑 containerd 1.7(大量存量集群的现状),即使 K8s 控制面升到了 1.36,Pod 也会因为运行时不认识 image mount 而创建失败。升级路径上运行时先行,这是铁律。

检查方法很简单:

# 确认 containerd 版本
containerd --version

# 创建一个测试 Pod,观察事件
kubectl apply -f imagevolume-test.yaml
kubectl describe pod imagevolume-test | grep -A5 Events
# 运行时不支持时会看到类似
# "failed to create containerd container: unsupported volume type"

三、实战一:把大模型权重打成 OCI 镜像

理论讲完,上手干活。第一步是把模型权重变成一个可挂载的镜像。有三种主流做法。

3.1 方法一:Dockerfile(最简单,但有坑)

# Dockerfile.model
FROM scratch
COPY qwen3-8b/ /
docker build -f Dockerfile.model -t registry.example.com/models/qwen3-8b:v1 .
docker push registry.example.com/models/qwen3-8b:v1

FROM scratch 保证镜像里除了模型文件什么都没有。这个方法的坑在于:docker build 会把整个模型目录打成一个巨型 layer。40GB 的单一 layer 在拉取时无法并行、失败重试代价巨大,Registry 和网关的单请求超时配置也容易被打爆。

3.2 方法二:分层构建(生产推荐)

把模型文件按 safetensors 分片拆成多个 layer,充分利用并行拉取和断点能力:

FROM scratch
# 每个分片一个 layer,拉取时可并行
COPY model-00001-of-00004.safetensors /
COPY model-00002-of-00004.safetensors /
COPY model-00003-of-00004.safetensors /
COPY model-00004-of-00004.safetensors /
COPY config.json tokenizer.json tokenizer_config.json /

containerd 默认并发拉取 3 个 layer(可通过 max_concurrent_downloads 调整),4 个 10GB 的 layer 比 1 个 40GB 的 layer 在万兆网卡上能快出一倍以上。

3.3 方法三:ORAS 直接推 artifact(最正统)

不想装 Docker?用 ORAS 把文件目录直接推成 OCI artifact:

# 安装 oras
brew install oras  # 或从 GitHub Releases 下载

# 推送模型目录
cd qwen3-8b/
oras push registry.example.com/models/qwen3-8b:v1 \
  --artifact-type application/vnd.example.model \
  ./:application/vnd.oci.image.layer.v1.tar

# 验证
oras manifest fetch registry.example.com/models/qwen3-8b:v1 | jq .

注意:ImageVolume 的 GA 范围主要覆盖标准 OCI 镜像。纯 artifact(非镜像 mediaType)的挂载支持取决于运行时实现,containerd 对「layer 是标准 tar」的 artifact 可以正常处理,但奇形怪状的自定义 mediaType 可能被拒。生产上稳妥的做法是始终产出标准 image manifest,用方法一或方法二。

3.4 用 digest 而非 tag 引用(安全强制项)

模型是推理结果的「源代码」,必须保证不可变。生产环境一律用 digest 引用:

volumes:
  - name: model-weights
    image:
      reference: registry.example.com/models/qwen3-8b@sha256:8f2e9d...
      pullPolicy: IfNotPresent

tag 是可以被覆盖推送的,digest 不能。配合 admission policy(Kyverno/OPA)强制所有 image volume 必须使用 digest,可以从制度上杜绝「线上模型被悄悄换掉」这种事故。Kyverno 规则示例:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-volume-digest
spec:
  validationFailureAction: Enforce
  rules:
    - name: image-volume-must-use-digest
      match:
        any:
          - resources:
              kinds: ["Pod"]
      validate:
        message: "image volume 必须使用 digest 引用"
        foreach:
          - list: "request.object.spec.volumes[?image]"
            deny:
              conditions:
                all:
                  - key: "{{ element.image.reference }}"
                    operator: NotEquals
                    value: "*@sha256:*"

四、实战二:vLLM 推理服务完整部署

把所有环节串起来,部署一个使用 ImageVolume 分发权重的 vLLM 服务。

4.1 Deployment 清单

apiVersion: apps/v1
kind: Deployment
metadata:
  name: qwen3-8b-inference
  namespace: llm-serving
spec:
  replicas: 4
  selector:
    matchLabels:
      app: qwen3-8b
  template:
    metadata:
      labels:
        app: qwen3-8b
    spec:
      containers:
        - name: vllm
          image: vllm/vllm-openai:v0.9.0
          args:
            - "--model"
            - "/models/qwen3-8b"
            - "--served-model-name"
            - "qwen3-8b"
            - "--max-model-len"
            - "16384"
          ports:
            - containerPort: 8000
          resources:
            limits:
              nvidia.com/gpu: 1
          volumeMounts:
            - name: model-weights
              mountPath: /models/qwen3-8b
              readOnly: true
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 60
            periodSeconds: 10
      volumes:
        - name: model-weights
          image:
            reference: registry.example.com/models/qwen3-8b@sha256:8f2e9d1c...
            pullPolicy: IfNotPresent
      # 关键:让 Pod 倾向调度到已缓存该镜像的节点
      affinity:
        podAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app: qwen3-8b
                topologyKey: kubernetes.io/hostname

4.2 模型更新 = 改一行 digest

这是 ImageVolume 带来的最爽的工程体验。模型迭代时:

# 1. 推送新版本模型镜像
docker build -f Dockerfile.model -t registry.example.com/models/qwen3-8b:v2 .
docker push registry.example.com/models/qwen3-8b:v2
NEW_DIGEST=$(crane digest registry.example.com/models/qwen3-8b:v2)

# 2. 更新 Deployment 中的 digest,触发标准滚动更新
kubectl -n llm-serving patch deployment qwen3-8b-inference --type json -p "[
  {\"op\":\"replace\",
   \"path\":\"/spec/template/spec/volumes/0/image/reference\",
   \"value\":\"registry.example.com/models/qwen3-8b@${NEW_DIGEST}\"}
]"

模型版本从此进入 GitOps 管辖范围:digest 写在 YAML 里、进 Git、走 PR review、可一键回滚。对比一下过去「登录跳板机、往 NFS 上 rsync 新权重、逐个重启 Pod、祈祷没人在滚动中途读到半新半旧的文件」的黑暗时代,这就是文明的进步。

4.3 代码与模型的独立发布

注意看这个架构的解耦效果:

  • 推理代码更新:只改 containers[].image,模型镜像层在节点上已缓存,滚动更新秒级完成
  • 模型更新:只改 volumes[].image.reference,vLLM 镜像层已缓存,只需拉取新模型 layer
  • 两者彻底独立,各自有各自的版本线、发布节奏和回滚路径

在没有 ImageVolume 之前,很多团队把模型打进业务镜像,代码改一行就要重新分发 40GB。分层缓存理论上能缓解,但只要模型 layer 之前有任何一层变了(比如换了基础镜像),后面全部失效。现在这两个变更维度被物理拆开了。

五、横向对比:ImageVolume vs 传统方案

5.1 五种模型分发方案全景对比

维度打进业务镜像initContainer 下载PVC (RWX)CSI 镜像驱动*ImageVolume
冷启动速度慢(巨型镜像)慢(每 Pod 下载)快(节点级缓存)
节点磁盘去重是(layer 级)否(每 Pod 一份**)是(layer 级)
跨 Namespace 共享否/困难
版本不可变保证是(digest)需自建校验是(digest)
代码/模型独立发布
额外组件依赖下载脚本+对象存储存储系统第三方 CSI 驱动无(原生)
P2P/懒加载生态复用部分
GC/生命周期管理kubelet 原生自己写手动驱动自理kubelet 原生

* 指 warm-metal/csi-driver-image 这类社区方案,ImageVolume GA 后基本可以退役了。
** 除非 emptyDir 换成 hostPath 自建共享缓存,但那又是一堆运维债。

5.2 什么时候不该用 ImageVolume

工具没有银弹,这几种场景请绕行:

  1. 需要写入的数据。ImageVolume 强制只读,训练 checkpoint、日志、缓存请继续用 PVC/emptyDir
  2. 高频变化的数据。镜像的构建+推送+拉取链路适合「天级/周级」更新的 artifact。分钟级更新的配置请用 ConfigMap 或配置中心
  3. 超大単体文件且节点磁盘吃紧。镜像模式要求节点本地有完整副本。如果模型 300GB 而节点盘只有 500GB,网络文件系统(如 JuiceFS、Fluid)的按需读取可能更合适——或者上 Nydus/eStargz 懒加载,只拉真正被读到的 chunk
  4. 运行时版本无法升级的存量集群。containerd < 2.1 就是硬门槛,别硬上

六、性能与生产调优

6.1 拉取提速:让 40GB 模型镜像 2 分钟就绪

调大 containerd 并发下载/etc/containerd/config.toml):

[plugins."io.containerd.grpc.v1.cri"]
  max_concurrent_downloads = 8

配合前面说的分层构建,万兆内网从本地 Registry 拉 40GB 可以压到 1-2 分钟。

上 P2P 分发。百节点规模同时拉同一个模型镜像会把 Registry 打挂,Dragonfly 是标准解法:

[plugins."io.containerd.grpc.v1.cri".registry.mirrors."registry.example.com"]
  endpoint = ["http://127.0.0.1:65001", "https://registry.example.com"]

节点间互为 peer 分发 layer,Registry 出口带宽压力下降一个数量级。这套东西原本就是为容器镜像建的,ImageVolume 直接白嫖,一行额外配置都不用加。

懒加载(可选进阶)。Nydus/eStargz 格式允许容器/挂载点在 layer 未完全下载时就绪,按需读取文件 chunk。对「模型文件很大但推理框架只顺序读一遍」的场景,可以把 Pod Ready 时间从分钟级压到秒级。代价是需要转换镜像格式并部署 snapshotter,评估好运维成本再上。

6.2 节点镜像缓存与 GC 策略

模型镜像动辄几十 GB,kubelet 默认的镜像 GC 阈值(imageGCHighThresholdPercent=85)很容易被触发,导致模型镜像被回收后下次调度又要重拉。建议:

# kubelet 配置
imageGCHighThresholdPercent: 90
imageGCLowThresholdPercent: 80
imageMinimumGCAge: 24h   # 至少保留 24 小时

更进一步,用 DaemonSet 做「镜像预热 + 钉住」:在模型节点池上跑一个挂载了模型镜像的 pause Pod,只要它活着,引用计数就不为零,GC 永远不会碰这个镜像:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: model-cache-pinner
spec:
  selector:
    matchLabels: {app: model-pinner}
  template:
    metadata:
      labels: {app: model-pinner}
    spec:
      nodeSelector:
        node-pool: gpu-inference
      containers:
        - name: pause
          image: registry.k8s.io/pause:3.10
          volumeMounts:
            - name: model
              mountPath: /pinned
              readOnly: true
      volumes:
        - name: model
          image:
            reference: registry.example.com/models/qwen3-8b@sha256:8f2e9d...
            pullPolicy: IfNotPresent

新节点加入节点池时 DaemonSet 自动预热,业务 Pod 调度过来时模型已在本地——冷启动问题被前置消化掉了。

6.3 监控指标

重点盯三个信号:

# 镜像拉取耗时分布(模型镜像会显著拉高长尾)
histogram_quantile(0.99,
  rate(kubelet_image_pull_duration_seconds_bucket[5m]))

# 节点镜像文件系统使用率(防止模型镜像撑爆磁盘)
(node_filesystem_size_bytes{mountpoint="/var/lib/containerd"}
 - node_filesystem_avail_bytes{mountpoint="/var/lib/containerd"})
/ node_filesystem_size_bytes{mountpoint="/var/lib/containerd"}

# ErrImagePull 事件(Registry 或凭证问题的第一信号)
sum(rate(kube_pod_container_status_waiting_reason{reason=~"ErrImagePull|ImagePullBackOff"}[5m])) by (namespace)

6.4 安全清单

  • 强制 digest 引用(前文 Kyverno 规则)
  • 签名验证:模型镜像与业务镜像一视同仁,cosign 签名 + admission 校验,防止供应链投毒。模型文件被替换的后果不比代码被替换轻——想象一下被植入后门的 LoRA
  • Registry 鉴权imagePullSecrets 对 image volume 同样生效,模型属于核心资产,私有 Registry + 最小权限 robot 账号
  • 只读语义是安全特性:不用担心容器内进程篡改共享模型影响同节点其他 Pod,overlayfs 只读 lowerdir 在内核层面保证了这一点

七、ImageVolume 之外:这个设计撬动的想象空间

把视野从模型分发拉远一点,「OCI 镜像作为通用只读数据载体」这个范式还能玩出很多花样:

配置与规则库分发。WAF 规则、GeoIP 库、病毒特征库、风控模型——这类「全集群共享、定期更新、必须版本一致」的数据,过去要么塞 ConfigMap(1MB 上限,尴尬)、要么自建分发服务。现在打个镜像 tag 就完事,还自带版本管理和回滚。

多租户函数计算。FaaS 平台可以把用户代码打成镜像作为 volume 挂进统一的 runtime 容器,代码分发与运行时解耦,冷启动只需 bind mount 而不是启动新容器镜像。

边缘场景。边缘节点带宽金贵,OCI layer 去重 + P2P + 增量分发的组合拳,比裸文件同步高效得多。KubeEdge 社区已经在跟进相关集成。

与 Job/批处理生态联动。基因测序的参考基因组、CFD 仿真的网格数据、渲染农场的素材库——批处理作业的「输入数据版本化」一直是老大难,ImageVolume + digest 让「作业 spec 完整描述全部输入」成为可能,可复现性直接拉满。

社区下一步的方向也值得关注:对 OCI artifact(非镜像 mediaType)更完整的支持、volume 级别的镜像拉取进度暴露、以及与 In-Place Pod Update 配合实现「不重启 Pod 更换挂载镜像」的探索。最后这个如果落地,模型热更新就真的一行 YAML 的事了。

八、总结

ImageVolume 是那种「看起来很小、实际很大」的特性。API 只加了两个字段,但它补齐了 Kubernetes 作为 AI 基础设施最缺的一块拼图:用云原生世界最成熟的分发机制(OCI 镜像),去分发云原生世界增长最快的数据类型(模型权重)

划重点:

  1. v1.36 GA,生产可用,但 containerd 必须 ≥ 2.1(或 CRI-O ≥ 1.31),运行时先行
  2. 本质是复用 CRI 镜像链路:拉取、缓存、GC、加速、P2P、签名全部白嫖现有生态
  3. 只读、layer 级去重、digest 不可变,天生适合模型权重、规则库等大体积只读 artifact
  4. 生产三板斧:分层构建 + digest 引用 + DaemonSet 预热钉住
  5. 不适合写场景、高频更新、节点磁盘装不下的超大数据

如果你的团队还在用 initContainer 从 S3 拽模型,或者维护着一套摇摇欲坠的 NFS 共享目录——是时候把这条链路推倒重来了。这一次,官方方案真的够好。

参考资料

  • KEP-4639: VolumeSource: OCI Artifact and/or Image(kubernetes/enhancements #4639)
  • Kubernetes 官方博客:Image Volumes Alpha/Beta/GA 系列公告
  • containerd 2.1 Release Notes
  • ORAS 项目文档(oras.land)

推荐文章

前端开发中常用的设计模式
2024-11-19 07:38:07 +0800 CST
Vue3中怎样处理组件引用?
2024-11-18 23:17:15 +0800 CST
Go 接口:从入门到精通
2024-11-18 07:10:00 +0800 CST
一个简单的打字机效果的实现
2024-11-19 04:47:27 +0800 CST
html一份退出酒场的告知书
2024-11-18 18:14:45 +0800 CST
程序员茄子在线接单