# K8s 1.36 ImageVolume GA：OCI 镜像不再只能跑容器


![k8s-1.36-image-volume-ga.jpg](https://img.lixueduan.com/kubernetes/cover/k8s-1.36-image-volume-ga.jpg)

**OCI 规范建立的目的就是将容器镜像格式标准化，正如其名，OCI 镜像在之前一直用来跑容器，但现在它还能干更多事。**

Kubernetes v1.36 里 **ImageVolume** 特性正式 GA 了，该特性允许我们把 OCI 镜像直接作为 Volume 挂载到 Pod 里。现在 OCI 镜像在 K8s 里不再只能跑容器了，模型权重、配置文件、安全签名、CI/CD 工件，只要是只读数据，都可以打包成 OCI 镜像供 Pod 挂载使用。

<!--more-->

这个特性从 v1.31 以 Alpha 引入，经过 v1.33、v1.35 的 Beta 阶段，终于在 v1.36 正式 GA。GA 之后不用再手动开 Feature Gate，containerd 也原生支持了，开箱即用。

## 1. 背景

OCI（Open Container Initiative）是 2015 年在 Linux 基金会支持下成立的开放项目，Docker、CoreOS（后来被 Red Hat 收购）和容器行业的主要厂商一起，围绕容器格式和运行时制定了开放标准。Docker 捐出了自己的镜像格式作为基础，社区在此基础上逐步形成了 Runtime、Image 和 Distribution 三大规范。

2017 年 image-spec v1.0 发布，容器镜像的格式算是定下来了。在 OCI 标准下，运行一个容器的过程就是：从 Registry 下载 OCI 镜像 → 解压到 OCI Bundle → OCI Runtime 运行这个 Bundle。整个流程标准化之后，不同 Runtime、不同 Registry 之间可以互操作，不用强行绑定 Docker。

> Docker 也把 libcontainer 的实现移动到 runC 捐给了 OCI，社区有了第一个 OCI Runtime 的参考实现。

### 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 格式演变时间线：从容器镜像到通用内容分发](https://img.lixueduan.com/kubernetes/oci/oci-evolution.jpg)

### K8s ImageVolume

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

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

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

![ImageVolume 四大使用场景：共享配置、CI/CD 产物、安全签名、模型分发](https://img.lixueduan.com/kubernetes/oci/image-volume-scenarios.jpg)

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

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


## 2. ImageVolume 从 Alpha 到 GA

ImageVolume 这个特性来源于 [KEP-4639](https://github.com/kubernetes/enhancements/issues/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+。

![ImageVolume 概念图：容器镜像不再只跑容器，还能分发模型、配置、签名等各种只读数据](https://img.lixueduan.com/kubernetes/oci/image-volume-concept.jpg)

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

### 2.1 subPath 支持（Alpha → Beta）

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

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

现在你可以这样用：

```yaml
# 只挂载镜像中的 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](https://github.com/kubernetes/enhancements/pull/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，想玩的话只能自己动手。参考我之前的文章 [K8s V1.31 新特性：ImageVolume](https://www.lixueduan.com/posts/kubernetes/27-feature-image-volume/)，
我当时手动 checkout 了 containerd 的 PR [#10579](https://github.com/containerd/containerd/pull/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](https://www.lixueduan.com/posts/kubernetes/59-kubeclipper-release-1.6.0/#3-%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B%E4%BD%93%E9%AA%8C) 安装的 K8s 集群，版本如下：

* Kubernetes v1.36.1
* containerd v2.2.4

### 3.2 构建目标镜像

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

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

```bash
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 如下：

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

构建并推送到镜像仓库：

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



### 3.3 基本挂载

创建 Pod 挂载这个镜像：

```yaml
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 后查看挂载内容：

```bash
$ 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 就行：

```yaml
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 的内容：

```bash
$ 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 指定的路径在镜像中不存在，容器创建会直接报错：

```bash
$ 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`：

```bash
$ 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）。

OCI Artifacts 从社区变通变成了标准的一部分。ImageVolume GA 等于 Kubernetes 也跟上了，OCI Artifacts 在 K8s 里有了第一个原生的消费方式。

往后看，读写支持可能是下一个方向，目前只读挂载还是限制了些场景，社区已经有相关讨论。另外 `ImageVolumeWithDigest` 子特性在 v1.36 也 GA 了，Pod Status 里能直接看到挂载镜像的 digest，版本追溯方便不少。


---

> 作者: [意琦行](https://github.com/lixd)  
> URL: https://www.lixueduan.com/posts/kubernetes/61-k8s1.36-image-volume-ga/  

