K8s 1.36 ImageVolume GA:OCI 镜像不再只能跑容器
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 正式成为了一个通用的内容仓库。

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 挂载是只读的,如果需要在运行时修改挂载的文件,还是得用 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 默认值 | 说明 |
|---|---|---|---|---|
| Alpha | v1.31 | 2024-08 | false | 需要手动开启 Feature Gate |
| Beta(默认关) | v1.33 | 2025-04 | false | Beta 代码合入但仍默认关闭 |
| Beta(默认关) | v1.34 | 2025-08 | false | 移除 noexec 限制,仍默认关闭 |
| Beta(默认开) | v1.35 | 2025-12 | true | 首次默认启用 |
| GA | v1.36 | 2026-04 | true(锁定) | Feature Gate 锁定,v1.39 移除 |
Containerd v2.1.0 才正式支持 ImageVolume,而且没有回移到 v2.0.x 分支,所以用 containerd 的话必须升级到 v2.1.0+。

接下来过一遍每个阶段的变化。
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 | 需手动编译 PR | v2.1.0 原生支持 | v2.1.0+ |
| CRI-O | v1.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)。
更多推荐



所有评论(0)