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,调用 CreateVolume | Controller 侧 |
| external-attacher | 处理 VolumeAttachment,调用 ControllerPublish | Controller 侧 |
| external-resizer | 处理扩容请求 | Controller 侧 |
| external-snapshotter | 处理 VolumeSnapshot | Controller 侧 |
| 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 不够用
Kubernetes 的 PV 访问模式里,ReadWriteOnce(RWO)只能被单个节点挂载。对单机训练没问题,但分布式训练里,多个 worker 通常分布在不同节点,都要读同一份数据集。
很多人第一反应是用 ReadWriteMany(RWX),让所有 worker 挂同一个卷。这在 NFS 或 CephFS 上确实可行,但有个隐藏代价:共享文件系统的元数据操作在几十个 Pod 并发时会成为瓶颈。我见过一些场景,模型加载卡在open()上,原因是 NFS 的 attribute cache 被打爆。
实际生产中更常见的做法是分层:
- 数据集放在对象存储或远端高性能文件系统(比如并行文件系统)。
- 每个训练 Pod 启动时,用 initContainer 把需要的分片拉到本地 NVMe。
- 训练过程只读本地盘,checkpoint 写回共享存储。
这个模式在 Kubeflow、Volcano、PyTorch Operator 里都有对应的实现思路。下面是一个简化的 initContainer 拉数据示例:
apiVersion:v1kind:Podmetadata:name:train-workerspec:initContainers:-name:fetch-shardimage:python:3.11-slimcommand:-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_IDvalueFrom:fieldRef:fieldPath:metadata.labels['shard-id']volumeMounts:-name:remotemountPath:/remote-name:local-cachemountPath:/local-cachecontainers:-name:trainerimage:pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtimecommand:["python","-m","torch.distributed.run","--nproc_per_node=8","train.py"]volumeMounts:-name:local-cachemountPath:/datavolumes:-name:remotepersistentVolumeClaim:claimName:dataset-rwx-name:local-cacheemptyDir:medium:""这里emptyDir用的是默认介质,落在节点磁盘上。如果节点有 NVMe,可以配合local类型的 PV 或者hostPath直接挂裸盘,但要自己处理调度和清理。emptyDir.medium: Memory虽然快,但受 Pod 内存 limit 限制,几十 GB 数据集基本放不下。
【注意】emptyDir的生命周期跟 Pod 绑定,Pod 重启数据就没了。训练任务如果频繁抢占重调度(比如用 Volcano gang scheduling),这个方案会反复拉数据。建议给数据集分片做本地缓存目录 + 一致性 hash 命名,重启后能命中一部分。
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/v1alpha1kind:VolumeGroupSnapshotClassmetadata:name:csi-group-snapclassdriver:your.csi.driverdeletionPolicy:Deleteparameters:# 驱动相关参数,各家不同consistencyLevel:"application"对应的请求:
apiVersion:groupsnapshot.storage.k8s.io/v1alpha1kind:VolumeGroupSnapshotmetadata:name:train-ckpt-groupspec:volumeGroupSnapshotClassName:csi-group-snapclasssource:selector:matchLabels:app:train-ckpt但这里我要说清楚:VolumeGroupSnapshot 在 Kubernetes 上游仍处于 alpha 阶段,对应的 CRD 属于 groupsnapshot.storage.k8s.io/v1alpha1,feature gate 和 API 版本可能随版本变化,生产使用前要确认你的集群版本和存储驱动是否支持。我没有在 1.30 以上的集群实测过所有驱动的行为,这一点我没有验证。
实际能不能用,取决于两件事:
- 你的 CSI 驱动是否实现了 Group 接口。Ceph CSI、部分企业存储驱动已经支持,但开源社区覆盖度不齐。
- 你的存储后端是否真的能保证跨卷一致性点。有些驱动只是把单卷快照循环调用了一遍,并没有真正的一致点,这种情况下 GroupSnapshot 只是"接口统一",语义上并不比逐个拍强。
判断方法很简单:看驱动的 release notes 里有没有提 “consistency group” 或 “atomic snapshot”,而不是只看它实现了哪个接口。
推理场景:低延迟挂载和拓扑感知
推理和训练的需求差别很大。模型文件通常是几个 GB 到几十 GB,加载一次后长期驻留内存。这时候存储的核心指标不是吞吐,而是首次挂载延迟和节点亲和性。
如果模型卷是通过网络文件系统挂载的,Pod 冷启动时可能要等几秒到几十秒。这在弹性扩缩容场景下非常难受。Kubernetes 的volumeBindingMode: WaitForFirstConsumer可以让 PV 在 Pod 调度后再绑定,避免跨节点拉数据,但前提是存储后端支持拓扑感知。
一个拓扑感知的 StorageClass:
apiVersion:storage.k8s.io/v1kind:StorageClassmetadata:name:local-nvmeprovisioner:your.csi.drivervolumeBindingMode:WaitForFirstConsumerallowedTopologies:-matchLabelExpressions:-key:topology.kubernetes.io/zonevalues:["zone-a","zone-b"]reclaimPolicy:RetainWaitForFirstConsumer的效果是: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 及以上importosimporttorchfromtorch.utils.dataimportDataset,DataLoaderfrompathlibimportPathclassLocalShardDataset(Dataset):def__init__(self,cache_dir:str):self.files=sorted(Path(cache_dir).rglob("*.bin"))ifnotself.files:raiseRuntimeError(f"no shard found in{cache_dir}")def__len__(self):returnlen(self.files)def__getitem__(self,idx):# 本地文件,直接 mmap 读取,避免走网络returntorch.load(self.files[idx],map_location="cpu")defbuild_loader(cache_dir:str,batch_size:int=64)->DataLoader:ds=LocalShardDataset(cache_dir)returnDataLoader(ds,batch_size=batch_size,num_workers=min(8,os.cpu_count()or1),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上:
importosimportshutilimportthreadingfrompathlibimportPathdefsave_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: F821def_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 负责搬运。异步线程只是示意,不要直接抄进生产代码。
一些我还没验证清楚的地方
写到这里,有几个问题我确实没有实测结论,明确列出来避免误导:
- VolumeGroupSnapshot 在不同 CSI 驱动上的实际一致性语义。规范定义了接口,但驱动实现差异很大。我只确认了它在上游是 alpha,没有验证过 Ceph CSI、Longhorn 等具体驱动的行为。
WaitForFirstConsumer与 RWX 组合的拓扑遵守情况。理论上调度器会传拓扑,但驱动是否遵守,取决于实现。我没有逐一测试。- CSI 1.11 相对 1.10 的破坏性变更清单。规范演进很快,我只关注了几个和 AI 负载相关的接口,没有完整比对 changelog。
这些点如果要做生产选型,建议直接看目标驱动的 release notes 和 issue tracker,不要只看 CSI 规范。
落地时的判断顺序
如果让我给一个落地顺序,大概是:
- 先明确工作负载类型(训练 / 推理 / 数据预处理),确定核心指标是吞吐、延迟还是容量。
- 再确认 CSI 驱动是否支持对应能力(RWX、拓扑感知、GroupSnapshot),拿真实负载压测。
- 最后才考虑组合方案(本地缓存 + 共享存储 + 对象归档),因为组合的复杂度远高于单点能力。
存储架构这件事,没有银弹。CSI 提供了标准接口,但接口背后的语义一致性、性能特征、运维成本,还是要一个个场景去验证。规范能帮你少踩接口层面的坑,语义层面的坑,得自己趟。