AI 训练任务对存储的要求,和传统 Web 服务几乎是两个物种。一个典型的分布式训练作业,可能在几秒钟内拉起几十个 Pod,每个 Pod 同时以高吞吐读取几万个小文件,训练中途还要周期性写 checkpoint,单文件动辄十几 GB。这些行为叠加在一起,会把集群里原本"够用"的存储方案压得很难看。

更麻烦的是,这些需求并不是静态的。数据预处理阶段希望共享读写、训练阶段希望本地缓存、推理阶段希望低延迟挂载、模型归档又希望走对象存储。一个集群里要同时容纳这些形态,靠早期 CSI 驱动的能力是不够的。

我把 CSI 这几年的版本演进重新过了一遍,整理出哪些能力真正解决了 AI 工作负载的痛点,哪些只是规范层面好看,落地还有距离。下面按"能力 → 场景 → 落地方式"的顺序展开。

CSI 到底在演进什么

CSI(Container Storage Interface)在 Kubernetes 1.13 进入 GA,之后规范独立于 Kubernetes 发布,目前由 CSI 社区维护,版本号从 1.0 一路走到 1.11(我写这篇文章时能查到的最新规范版本是 1.11,具体请以 csi-spec 仓库 README 为准)。它本质是一份 gRPC 接口定义,规定存储厂商要实现哪些方法、Kubelet 和外部组件会怎么调用。

早期 CSI 的痛点很集中:所有逻辑塞在同一个二进制里,升级驱动要动 DaemonSet,Sidecar 和驱动耦合,快照、扩容、卷健康这些能力要么没有,要么各家自己造轮子。后来社区把职责拆开,形成了今天大家熟悉的这一组 Sidecar:

组件职责对应的 CSI 能力
external-provisioner监听 PVC,调用 CreateVolumeController 侧
external-attacher处理 VolumeAttachment,调用 ControllerPublishController 侧
external-resizer处理扩容请求Controller 侧
external-snapshotter处理 VolumeSnapshotController 侧
external-health-monitor上报卷健康Node 侧 + Controller 侧
node-driver-registrar向 Kubelet 注册驱动Node 侧
livenessprobe健康探测通用

这个拆分对 AI 场景的意义在哪?在于能力可以独立升级。比如你需要快照能力,只要升级 external-snapshotter 和驱动对快照接口的实现,不需要动整个存储 DaemonSet。对一个跑着几十个训练作业的生产集群,这一点很关键。

规范演进中,几个对 AI 负载影响较大的节点:

  • CSI 1.1 引入 VolumeSnapshot 相关接口(ControllerCreateSnapshot 等),让快照从"厂商私有 CRD"变成标准能力。
  • CSI 1.3 加入 VolumeGroupSnapshot,可以一次性对多个卷做一致性快照。这个对分布式训练非常关键——参数服务器 + 多个 worker 的卷状态必须一致,否则恢复后数据错位。
  • CSI 1.5 之后 强化了 VolumeHealth,节点侧能上报卷异常,配合 Pod 事件做自动迁移。
  • CSI 1.9 / 1.10 附近 对拓扑、可访问性、单节点多卷并发做了细化。

【关键结论】AI 训练场景真正需要关注的是 CSI 1.3 以后的版本,因为 VolumeGroupSnapshot 之前,多卷一致性快照基本只能靠厂商自定义方案。

训练场景:为什么默认的 ReadWriteOnce 不够用

训练场景:为什么默认的 ReadWriteOnce 不够用

Kubernetes 的 PV 访问模式里,ReadWriteOnce(RWO)只能被单个节点挂载。对单机训练没问题,但分布式训练里,多个 worker 通常分布在不同节点,都要读同一份数据集。

很多人第一反应是用 ReadWriteMany(RWX),让所有 worker 挂同一个卷。这在 NFS 或 CephFS 上确实可行,但有个隐藏代价:共享文件系统的元数据操作在几十个 Pod 并发时会成为瓶颈。我见过一些场景,模型加载卡在 open() 上,原因是 NFS 的 attribute cache 被打爆。

实际生产中更常见的做法是分层:

  1. 数据集放在对象存储或远端高性能文件系统(比如并行文件系统)。
  2. 每个训练 Pod 启动时,用 initContainer 把需要的分片拉到本地 NVMe。
  3. 训练过程只读本地盘,checkpoint 写回共享存储。

这个模式在 Kubeflow、Volcano、PyTorch Operator 里都有对应的实现思路。下面是一个简化的 initContainer 拉数据示例:

apiVersion: v1
kind: Pod
metadata:
  name: train-worker
spec:
  initContainers:
    - name: fetch-shard
      image: python:3.11-slim
      command:
        - python
        - -c
        - |
          import os, shutil
          src = "/remote/dataset/shard-${SHARD_ID}"
          dst = "/local-cache/shard"
          os.makedirs(dst, exist_ok=True)
          for name in os.listdir(src):
              shutil.copy2(os.path.join(src, name), dst)
          print("copy done:", len(os.listdir(dst)), "files")
      env:
        - name: SHARD_ID
          valueFrom:
            fieldRef:
              fieldPath: metadata.labels['shard-id']
      volumeMounts:
        - name: remote
          mountPath: /remote
        - name: local-cache
          mountPath: /local-cache
  containers:
    - name: trainer
      image: pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime
      command: ["python", "-m", "torch.distributed.run", "--nproc_per_node=8", "train.py"]
      volumeMounts:
        - name: local-cache
          mountPath: /data
  volumes:
    - name: remote
      persistentVolumeClaim:
        claimName: dataset-rwx
    - name: local-cache
      emptyDir:
        medium: ""

这里 emptyDir 用的是默认介质,落在节点磁盘上。如果节点有 NVMe,可以配合 local 类型的 PV 或者 hostPath 直接挂裸盘,但要自己处理调度和清理。emptyDir.medium: Memory 虽然快,但受 Pod 内存 limit 限制,几十 GB 数据集基本放不下。

【注意】emptyDir 的生命周期跟 Pod 绑定,Pod 重启数据就没了。训练任务如果频繁抢占重调度(比如用 Volcano gang scheduling),这个方案会反复拉数据。建议给数据集分片做本地缓存目录 + 一致性 hash 命名,重启后能命中一部分。

VolumeGroupSnapshot:分布式训练的救星还是半成品

VolumeGroupSnapshot:分布式训练的救星还是

前面提到 CSI 1.3 引入 VolumeGroupSnapshot,这是我认为对 AI 训练影响最大的一个能力。设想一个 8 worker 的作业,每个 worker 写自己的 checkpoint 分片,如果只用 VolumeSnapshot 逐个拍,拍完第一个和拍完第八个之间可能隔了几十秒,恢复时参数版本对不上,训练直接崩。

VolumeGroupSnapshot 通过一个 GroupSnapshotClass 和一组 PVC 列表,让驱动在底层做一致性点。规范里定义了 CreateVolumeGroupSnapshot、DeleteVolumeGroupSnapshot、GetVolumeGroupSnapshot 三个 Controller 接口,以及对应的 CRD。

一个典型的 VolumeGroupSnapshotClass 长这样(具体字段以你用的驱动文档为准,这里只是结构示意):

apiVersion: groupsnapshot.storage.k8s.io/v1alpha1
kind: VolumeGroupSnapshotClass
metadata:
  name: csi-group-snapclass
driver: your.csi.driver
deletionPolicy: Delete
parameters:
  # 驱动相关参数,各家不同
  consistencyLevel: "application"

对应的请求:

apiVersion: groupsnapshot.storage.k8s.io/v1alpha1
kind: VolumeGroupSnapshot
metadata:
  name: train-ckpt-group
spec:
  volumeGroupSnapshotClassName: csi-group-snapclass
  source:
    selector:
      matchLabels:
        app: train-ckpt

但这里我要说清楚:VolumeGroupSnapshot 在 Kubernetes 上游仍处于 alpha 阶段,对应的 CRD 属于 groupsnapshot.storage.k8s.io/v1alpha1,feature gate 和 API 版本可能随版本变化,生产使用前要确认你的集群版本和存储驱动是否支持。 我没有在 1.30 以上的集群实测过所有驱动的行为,这一点我没有验证。

实际能不能用,取决于两件事:

  1. 你的 CSI 驱动是否实现了 Group 接口。Ceph CSI、部分企业存储驱动已经支持,但开源社区覆盖度不齐。
  2. 你的存储后端是否真的能保证跨卷一致性点。有些驱动只是把单卷快照循环调用了一遍,并没有真正的一致点,这种情况下 GroupSnapshot 只是"接口统一",语义上并不比逐个拍强。

判断方法很简单:看驱动的 release notes 里有没有提 “consistency group” 或 “atomic snapshot”,而不是只看它实现了哪个接口。

推理场景:低延迟挂载和拓扑感知

推理场景:低延迟挂载和拓扑感知

推理和训练的需求差别很大。模型文件通常是几个 GB 到几十 GB,加载一次后长期驻留内存。这时候存储的核心指标不是吞吐,而是首次挂载延迟和节点亲和性。

如果模型卷是通过网络文件系统挂载的,Pod 冷启动时可能要等几秒到几十秒。这在弹性扩缩容场景下非常难受。Kubernetes 的 volumeBindingMode: WaitForFirstConsumer 可以让 PV 在 Pod 调度后再绑定,避免跨节点拉数据,但前提是存储后端支持拓扑感知。

一个拓扑感知的 StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-nvme
provisioner: your.csi.driver
volumeBindingMode: WaitForFirstConsumer
allowedTopologies:
  - matchLabelExpressions:
      - key: topology.kubernetes.io/zone
        values: ["zone-a", "zone-b"]
reclaimPolicy: Retain

WaitForFirstConsumer 的效果是:PVC 不会立即绑定,等到有 Pod 使用它时,调度器把节点信息传给 provisioner,provisioner 在该节点所在拓扑域创建卷。这避免了"卷在 zone-a,Pod 调度到 zone-b"的跨区流量。

【踩坑提醒】WaitForFirstConsumer 和 ReadWriteMany 组合时,很多驱动的行为不一致。有些驱动在 RWX 下会忽略拓扑约束,直接创建全局可访问的卷。如果你的推理服务依赖本地 NVMe 的低延迟,得确认驱动在 RWX 下是否还遵守拓扑。

推理场景还有一点容易被忽略:模型卷通常是只读的。用 ReadOnlyMany(ROX)或者挂载时加 readOnly: true,可以让存储后端做更多优化,也避免误写。这个细节在 CSI 规范里没有强制,但大多数驱动都会对只读挂载做缓存优化。

并行文件系统与对象存储网关的取舍

并行文件系统与对象存储网关的取舍

说到高性能,绕不开并行文件系统(Lustre、GPFS、WekaFS、JuiceFS 等)。它们和 CSI 的关系是:CSI 只是挂载入口,真正的性能来自后端。

方案优点缺点适用场景
并行文件系统 + CSI高吞吐、POSIX 语义、支持 RWX部署运维成本高、需要专用网络大规模训练、数据预处理
对象存储 + CSI 网关弹性、成本低、容量近乎无限延迟高、POSIX 语义不完整数据集归档、模型仓库
本地 NVMe + CSI延迟最低、带宽最高容量受限、数据不共享推理、缓存层
分布式块存储 + CSI一致性快照、支持 GroupSnapshot元数据操作弱、不适合海量小文件checkpoint、数据库

实际架构里,这几种通常是组合使用。我梳理过的一个思路是:

  • 数据集冷存:对象存储
  • 数据集热存:并行文件系统
  • 训练缓存:本地 NVMe
  • checkpoint:分布式块存储 + VolumeGroupSnapshot
  • 模型仓库:对象存储 + 只读挂载

这套组合的问题在于数据要在层之间搬运,搬运逻辑容易写成一次性脚本,缺乏可观测性。我的建议是把搬运逻辑做成独立的 Job 或者 Operator,状态写到 CRD 里,而不是塞在 initContainer 里。

Python 侧怎么配合存储架构

存储架构最终要落到代码里。AI 训练代码对存储的感知,主要集中在三个地方:数据加载、checkpoint 写入、日志/指标上报。

数据加载层面,PyTorch 的 DataLoader 如果直接从网络文件系统读,num_workers 一开大就会打爆元数据。一个常见的优化是先把数据映射到本地,再用 DataLoader:

# 需要 Python 3.10+ 和 torch 2.3 及以上
import os
import torch
from torch.utils.data import Dataset, DataLoader
from pathlib import Path

class LocalShardDataset(Dataset):
    def __init__(self, cache_dir: str):
        self.files = sorted(Path(cache_dir).rglob("*.bin"))
        if not self.files:
            raise RuntimeError(f"no shard found in {cache_dir}")

    def __len__(self):
        return len(self.files)

    def __getitem__(self, idx):
        # 本地文件,直接 mmap 读取,避免走网络
        return torch.load(self.files[idx], map_location="cpu")

def build_loader(cache_dir: str, batch_size: int = 64) -> DataLoader:
    ds = LocalShardDataset(cache_dir)
    return DataLoader(
        ds,
        batch_size=batch_size,
        num_workers=min(8, os.cpu_count() or 1),
        pin_memory=True,
        persistent_workers=True,
        prefetch_factor=4,
    )

这里几个参数值得说明:persistent_workers=True 让 worker 进程在 epoch 之间复用,避免反复 fork;prefetch_factor=4 是每个 worker 预取 4 个 batch,配合本地盘能压住 IO 抖动;pin_memory=True 让数据在送入 GPU 前锁页,减少 H2D 拷贝开销。这些参数在官方文档里都有,但组合效果和存储层强相关——如果你的数据还在 NFS 上,num_workers 越大反而越慢。

checkpoint 写入这块,建议先写本地再异步搬到共享存储。直接写网络文件系统,训练进程会阻塞在 fsync 上:

import os
import shutil
import threading
from pathlib import Path

def save_checkpoint(state: dict, local_path: str, remote_path: str) -> None:
    local = Path(local_path)
    local.parent.mkdir(parents=True, exist_ok=True)
    # 先落本地,确保训练不阻塞
    torch.save(state, local)  # noqa: F821

    def _upload():
        remote = Path(remote_path)
        remote.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(local, remote)

    threading.Thread(target=_upload, daemon=True).start()

【注意】这个异步上传线程是 daemon,训练进程退出时可能来不及上传完。生产里更稳妥的是用队列 + 独立进程,或者干脆让 checkpoint 落到本地 PV,由外部 sidecar 负责搬运。异步线程只是示意,不要直接抄进生产代码。

一些我还没验证清楚的地方

写到这里,有几个问题我确实没有实测结论,明确列出来避免误导:

  1. VolumeGroupSnapshot 在不同 CSI 驱动上的实际一致性语义。规范定义了接口,但驱动实现差异很大。我只确认了它在上游是 alpha,没有验证过 Ceph CSI、Longhorn 等具体驱动的行为。
  2. WaitForFirstConsumer 与 RWX 组合的拓扑遵守情况。理论上调度器会传拓扑,但驱动是否遵守,取决于实现。我没有逐一测试。
  3. CSI 1.11 相对 1.10 的破坏性变更清单。规范演进很快,我只关注了几个和 AI 负载相关的接口,没有完整比对 changelog。

这些点如果要做生产选型,建议直接看目标驱动的 release notes 和 issue tracker,不要只看 CSI 规范。

落地时的判断顺序

如果让我给一个落地顺序,大概是:

  1. 先明确工作负载类型(训练 / 推理 / 数据预处理),确定核心指标是吞吐、延迟还是容量。
  2. 再确认 CSI 驱动是否支持对应能力(RWX、拓扑感知、GroupSnapshot),拿真实负载压测。
  3. 最后才考虑组合方案(本地缓存 + 共享存储 + 对象归档),因为组合的复杂度远高于单点能力。

存储架构这件事,没有银弹。CSI 提供了标准接口,但接口背后的语义一致性、性能特征、运维成本,还是要一个个场景去验证。规范能帮你少踩接口层面的坑,语义层面的坑,得自己趟。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐