OCI Artifacts

虽然 OCI 标准最初完全是围绕容器设计的,镜像格式里的 mediaType、config 结构都是为「跑容器」量身定做的。

但是如果我不想跑容器,只想把一堆文件打包分发呢?人们很快发现,OCI Registry 的分层存储和分发机制天然适合分发更多东西。

OCI 镜像的本质是什么?就是一堆只读的层(layer),加上一个 manifest 描述这些层的组织方式,再通过 Registry 的 API 完成分发。同时 OCI Registry 提供了一整套“可寻址、可校验、可去重、可控访问”的分发原语,然后这个模型并不绑定「容器」。

所以社区很自然地想到:能不能把 OCI Registry 当成一个通用的内容分发平台来用?

实际上社区很早就开始在 OCI Registry 里存非镜像内容了,但早期的做法都是 hack——把非镜像内容伪装成容器镜像塞进去,Registry 其实并不知道这些东西不是用来跑的:

  • Helm 从 3.0 开始支持把 Chart 推到 OCI Registry,算是最早的「Registry 当通用存储」的生产实践。
  • Cosign 直接把容器签名、SBOM 也存进 OCI Registry,用镜像层来承载签名数据。
  • ORAS(OCI Registry As Storage)更猛,WASM 模块、OPA 策略、Falco 规则都能往里塞,相当于把 OCI Registry 当成一个通用的对象存储来用。

OCI Artifacts 就是这么来的,把各种产物存进 OCI Registry、当成通用内容仓库来分发。

这些用法推动了 OCI 规范本身的演进。2024 年,image-spec v1.1.0 正式加入了 artifactType 字段,允许 Manifest 声明「我不是容器镜像,我是一个签名 / 一个 Helm Chart / 一个模型权重」。OCI 对非镜像内容的支持从社区 hack 变成了规范的一部分,OCI Registry 正式成为了一个通用的内容仓库。

OCI 格式演变时间线:从容器镜像到通用内容分发

K8s ImageVolume

现在 OCI Registry 已经变成了一个通用的内容仓库,但问题来了:Helm、Cosign、ORAS 这些工具都在往里塞东西,但到了 Kubernetes 这边,OCI 镜像还是只能拿来跑容器,缺少一个原生的消费方式。

ImageVolume 就是来补上这一块的。它允许在 Pod 中将 OCI 镜像直接作为 Volume 挂载,让 OCI Artifacts 在 K8s 里也能被原生消费,不再只是跑容器。就像这样:

kind: Pod
spec:
  containers:
    - …
      volumeMounts:
        - name: my-volume
          mountPath: /path/to/directory
  volumes:
    - name: my-volume
      image:
        reference: my-image:tag

ImageVolume 四大使用场景:共享配置、CI/CD 产物、安全签名、模型分发

不过有一点要注意,ImageVolume 挂载是只读的,如果需要在运行时修改挂载的文件,还是得用 PVC。后面会详细讨论这个限制。

ImageVolume 让 OCI Artifacts 在 K8s 里有了第一个原生的消费方式。不过这个能力并不是一步到位的,从 Alpha 到 GA 走了近两年。

2. ImageVolume 从 Alpha 到 GA

ImageVolume 这个特性来源于 KEP-4639,由 SIG Node 和 SIG Storage 共同推动。从 v1.31 Alpha 到 v1.36 GA 走了近两年,具体时间线如下:

阶段K8s 版本发布时间Feature Gate 默认值说明
Alphav1.312024-08false需要手动开启 Feature Gate
Beta(默认关)v1.332025-04falseBeta 代码合入但仍默认关闭
Beta(默认关)v1.342025-08false移除 noexec 限制,仍默认关闭
Beta(默认开)v1.352025-12true首次默认启用
GAv1.362026-04true(锁定)Feature Gate 锁定,v1.39 移除

Containerd v2.1.0 才正式支持 ImageVolume,而且没有回移到 v2.0.x 分支,所以用 containerd 的话必须升级到 v2.1.0+。

ImageVolume 概念图:容器镜像不再只跑容器,还能分发模型、配置、签名等各种只读数据

接下来过一遍每个阶段的变化。

2.1 subPath 支持(Alpha → Beta)

Alpha 阶段 ImageVolume 不支持 subPath,也就是说你只能挂载镜像的整个文件系统,没法只挂载其中的某个子目录。

Beta 阶段(v1.33)解除了这个限制,subPath 和 subPathExpr 都可以用了。对应的 CRI API 也新增了 image_sub_path 字段来支持这个功能。

现在你可以这样用:

# 只挂载镜像中的 models/Qwen2-0.5B 子目录到 /models/qwen2
containers:
  - name: app
    image: busybox:1.36
    volumeMounts:
      - name: model-volume
        mountPath: /models/qwen2
        subPath: Qwen2-0.5B        # 挂载镜像中的这个子路径
        readOnly: true
volumes:
  - name: model-volume
    image:
      reference: registry.example.com/models/all-models:v1
      pullPolicy: IfNotPresent
# 如果 subPath 指定的路径在镜像中不存在,容器创建会报错

很多时候一个镜像里会放多个目录,有了 subPath 就不用把整个镜像都挂进来了。

2.2 noexec 限制移除(Alpha → Beta)

Alpha 阶段 ImageVolume 挂载时强制加了 noexec 选项,挂载进来的文件不能被执行。

这个限制在 Beta 阶段(2025-06,PR #5354)被移除了。社区讨论后觉得 noexec 限制过于严格,ImageVolume 的主要用途是分发只读数据,强制 noexec 没有必要,反而限制了某些合理的使用场景,比如挂载包含可执行工具的镜像。

不过 ImageVolume 仍然是只读挂载(ro),读写支持还得等后续的 KEP。

2.3 Kubelet 监控指标(Alpha → Beta)

Beta 阶段新增了 3 个 Kubelet 指标,方便监控 ImageVolume 的使用情况。

  • kubelet_image_volume_requested_total — 请求的 ImageVolume 数量
  • kubelet_image_volume_mounted_succeed_total — ImageVolume 挂载成功的数量
  • kubelet_image_volume_mounted_errors_total — ImageVolume 挂载失败的数量

GA 阶段这些指标提升到了 BETA 稳定性级别,可以在 Prometheus 里配告警了。

2.4 Feature Gate 锁定(GA)

v1.36 GA 后,ImageVolume Feature Gate 被锁定为默认开启,没法关了。按照 K8s 的惯例,Feature Gate 会在 GA 后 3 个版本移除,也就是 v1.39 会彻底删掉这个 Gate。

所以现在的状态就是:

  • 不再需要手动开启 Feature Gate 了,v1.36 集群开箱即用
  • API 字段上的 +featureGate=ImageVolume 注解也被移除了
  • E2E 测试提升为 Conformance 级别,这是 GA 的标志之一

2.5 containerd 原生支持

这个虽然不是 K8s 代码的变化,但可能是对使用者影响最大的变化。

Alpha 阶段,containerd 不支持 ImageVolume,想玩的话只能自己动手。参考我之前的文章 xxx ,
我当时手动 checkout 了 containerd 的 PR #10579,编译替换二进制文件。编译倒是不难,但是那个 PR 还有个 bug,kubelet 没有把 readOnly 参数透传到 CRI mounts 配置中,导致 containerd 校验 readOnly 失败一直报错。。。没办法,还得手动注释掉校验逻辑,整个流程折腾下来真的挺崩溃的。

现在 containerd v2.1.0 已经原生支持 ImageVolume,直接用就行,不需要任何 hack。

CRI-O 的话从 v1.31 就支持了,v1.34 还增加了 subPath 支持,一直走在前面。

2.6 变化总结

整理一下:

变化项Alpha (v1.31)Beta (v1.33-v1.35)GA (v1.36)
subPath❌ 不支持✅ 支持✅ 支持
noexec 限制强制 noexec移除无限制
监控指标Alpha 级别BETA 级别
Feature Gate默认关,需手动开v1.33/34 默认关,v1.35 默认开锁定开启
containerd需手动编译 PRv2.1.0 原生支持v2.1.0+
CRI-Ov1.31 支持v1.34 支持 subPath同左
挂载模式只读只读只读

3. 现在怎么用

变化聊完了,实际用起来是什么感觉呢。GA 之后用起来比 Alpha 阶段简单太多了,不用再折腾 Feature Gate 和手动编译 containerd 了。

3.1 环境要求

  • Kubernetes >= v1.36
  • Container Runtime:
    • containerd >= v2.1.0
    • CRI-O >= v1.31(subPath 需要 >= v1.34)

就这么简单,不需要额外配置任何 Feature Gate。

本次验证环境是使用 KubeClipper 安装的 K8s 集群,版本如下:

  • Kubernetes v1.36.1
  • containerd v2.2.4

3.2 构建目标镜像

使用方式和 Alpha 阶段基本一致。先构建一个包含模型文件的 OCI 镜像,用 FROM scratch 就行,不需要任何基础镜像。

为了后面演示 subPath,这里在镜像里放两个模型目录,再放一个配置文件:

mkdir -p models/Qwen2-0.5B models/Llama2-7B
echo "qwen2 model weights" > models/Qwen2-0.5B/model.bin
echo "qwen2 config" > models/Qwen2-0.5B/config.json
echo "llama2 model weights" > models/Llama2-7B/model.bin
echo "llama2 config" > models/Llama2-7B/config.json
echo "app config v1" > app.conf

目录结构如下:

image-builder/
├── Dockerfile
├── app.conf
└── models/
    ├── Qwen2-0.5B/
    │   ├── config.json
    │   └── model.bin
    └── Llama2-7B/
        ├── config.json
        └── model.bin

Dockerfile 如下:

FROM scratch
COPY ./models /models
COPY ./app.conf /app.conf

构建并推送到镜像仓库:

docker build -t registry.example.com/demo/image-volume:v1 .
docker push registry.example.com/demo/image-volume:v1

3.3 基本挂载

创建 Pod 挂载这个镜像:

apiVersion: v1
kind: Pod
metadata:
  name: image-volume-demo
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sleep", "3600"]
      volumeMounts:
        - name: model-volume
          mountPath: /models
          readOnly: true
  volumes:
    - name: model-volume
      image:
        reference: registry.example.com/demo/image-volume:v1
        pullPolicy: IfNotPresent

应用到集群,等 Pod Running 后查看挂载内容:

$ kubectl apply -f pod.yaml
pod/image-volume-demo created

$ kubectl get pod image-volume-demo
NAME                 READY   STATUS    RESTARTS   AGE
image-volume-demo    1/1     Running   0          30s

$ kubectl exec image-volume-demo -- ls -la /models/
total 16
drwxr-xr-x    1 root     root          4096 Jun 16 13:03 .
drwxr-xr-x    1 root     root          4096 Jun 16 13:03 ..
-rw-r--r--    1 root     root            14 Jun 16 13:03 app.conf
drwxr-xr-x    2 root     root          4096 Jun 16 13:03 Qwen2-0.5B
drwxr-xr-x    2 root     root          4096 Jun 16 13:03 Llama2-7B

$ kubectl exec image-volume-demo -- cat /models/app.conf
app config v1

$ kubectl exec image-volume-demo -- cat /models/Qwen2-0.5B/config.json
qwen2 config

镜像里的文件都挂载进来了,跟预期一致。

3.4 subPath 挂载

上面那个镜像里放了两个模型目录,如果 Pod 只需要 Qwen2-0.5B,不需要把整个镜像都挂进来,用 subPath 就行:

apiVersion: v1
kind: Pod
metadata:
  name: image-volume-subpath
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sleep", "3600"]
      volumeMounts:
        - name: model-volume
          mountPath: /models/qwen2
          subPath: Qwen2-0.5B
          readOnly: true
  volumes:
    - name: model-volume
      image:
        reference: registry.example.com/demo/image-volume:v1
        pullPolicy: IfNotPresent

验证一下,挂载目录里只有 Qwen2-0.5B 的内容:

$ kubectl exec image-volume-subpath -- ls -la /models/qwen2/
total 12
drwxr-xr-x    2 root     root          4096 Jun 16 13:03 .
drwxr-xr-x    3 root     root          4096 Jun 16 13:03 ..
-rw-r--r--    1 root     root            14 Jun 16 13:03 config.json
-rw-r--r--    1 root     root            22 Jun 16 13:03 model.bin

$ kubectl exec image-volume-subpath -- cat /models/qwen2/config.json
qwen2 config

只挂载了 Qwen2-0.5B 目录,Llama2-7B 和 app.conf 都不在。如果 subPath 指定的路径在镜像中不存在,容器创建会直接报错:

$ kubectl get pod image-volume-subpath-err -o jsonpath='{.status.containerStatuses[0].state.waiting.message}'
failed to mount image volume: ImageVolumeMountFailed: failed to ensure image subpath "not-exist-dir" in "...": openat not-exist-dir: no such file or directory

3.5 只读挂载验证

ImageVolume 挂载是只读的,尝试写入会报 Read-only file system

$ kubectl exec image-volume-demo -- sh -c 'echo test > /models/test.txt'
sh: can't create /models/test.txt: Read-only file system

最后提几个实际使用中的注意事项。

ImageVolume 挂载是只读的,如果需要运行时修改文件还是得用 PVC,目前没有读写支持的 KEP。Pod 重建时 ImageVolume 会重新解析远端镜像,所以生产环境建议用 digest 而不是 tag 引用镜像,避免 Pod 重建后 tag 被覆盖导致拿到非预期版本。

镜像层共享能省磁盘,两个 ImageVolume 引用的镜像有相同层的话 containerd 只存一份,但大模型镜像多了也要注意节点磁盘压力。

4. 小结

OCI 从 2017 年 image-spec v1.0 发布到今天,走了一条挺清晰的路:先是容器镜像格式标准化,然后 OCI Registry 被社区 hack 成通用内容仓库,接着 image-spec v1.1.0 和 distribution-spec v1.1.0 把这种用法正式写入规范(artifactType + referrers API)。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐