我把 JuiceFS 替换 CephFS 做 K8s 共享存储后,Pod 启动从 47s 压到 3s

上线 AI 训练平台那阵子,最让我头疼的不是 GPU 调度,而是存储

我们平台的训练任务大致分两类:一类是单机多卡的,不涉及到共享存储;另一类是分布式数据并行(DDP),需要多个 Pod 同时读同一份数据集。这种场景下,K8s 的 local PV 和 hostPath 都不顶用,必须用一个能让多个节点并发读的网络文件系统。

我们一开始选的是 CephFS。原因很简单:Ceph 是部门里推了多年的"标准答案",运维同学会配,监控告警齐全,出了问题大家都知道往哪找。

但上线第三周,一个数据科学团队反馈:他们的 Pod 启动一次要 47 秒

47 秒意味着什么?意味着作业调度延迟、数据加载慢、CI 上的 GPU 节点等待空转。我一开始以为是个例,去 NodePort 看了一下数据:训练任务 P50 启动延迟 32s,P95 47s,隔壁 Spark on K8s 集群 P50 启动 28s。

我们用了 4 周时间一步步定位,最后把存储换成了 JuiceFS。换成 JuiceFS 之后同样规模的训练任务,Pod 启动延迟从 P50 32s / P95 47s 压到了 P50 1.8s / P95 3s

这篇文章把这个过程的几个关键决策都写下来,包括为什么不是 CephFS 本身做错了、JuiceFS 究竟补上了什么短板,以及迁移过程中踩到的几个真实坑。

为什么 CephFS 在容器化场景下"卡"

先说清楚,CephFS 不是不能用,而是和容器化数据密集型负载的需求对不上

我们用的 Ceph 版本是 Reef 18.2,存储集群 12 个节点,3 副本 + 4+2 erasure coding 混合,OSD 用的 NVMe SSD。元数据池独立部署在 3 个 MDS 节点上。规模上看是不错的,但是在 K8s CSI 接入后,问题就来了:

第一个问题是 MDS 热点的写入放大。CephFS 的 MDS 是一个有状态的中心节点,处理所有文件的元数据操作(lookup、create、unlink、setattr)。在我们这个场景下,每个 Pod 启动时通常会做这几件事:

  1. 容器启动,加载模型文件骨架(大量 openat + readlink)
  2. 启动后第一次遍历数据集目录(大量 getattr + readdir)
  3. 训练过程中持续写 checkpoint(setattr + write,每 100 个 step 写一次)

第一步和第二步都是元数据密集型的。JuiceFS 的开发团队有一篇博客专门测过这个问题:在 1000 个客户端同时启动并读取 100 万个文件的场景下,CephFS 的 MDS 操作延迟从稳态的 5ms 飙升到 800ms。我们没有 1000 个客户端那么夸张,但 200 个 Pod 同时启动加载数据集时,监控上 MDS 的 CPU 飙到 90% 以上,元数据延迟 P99 超过 400ms。

第二个问题是 CSI 挂载的协议栈重。CephFS 的 Kubernetes CSI(csi-cephfs)走的是 kernel client,挂载模式分两种:

  • FUSE 用户态挂载:性能差,CPU 占用高
  • kernel client:性能好,但要求节点有 Ceph 维护的 kernel module

我们的节点用的是 Ubuntu 22.04,默认 kernel 5.15,自带的是旧版 ceph.ko。我们升级到 kernel 6.5 之后发现新内核的 ceph.ko 和 csi-cephfs 0.8 完全兼容,但是每次节点重启后第一次挂载要重新 load FUSE 模块——这一步在我们的 NodePort 上平均耗时 4-6 秒,看起来不起眼,但叠加在 Pod 启动流程里就被放大了。

第三个问题是 多副本元数据一致性 + 副本路由。CephFS 的客户端会根据文件 INODE 哈希路由到特定 MDS,在多 MDS 部署下大部分元数据操作是本地的。但如果某个文件被频繁访问(比如共享的数据集索引文件),会形成明显的热点。当时我们观察监控发现 MDS 0 的 CPU 占用 76%,MDS 1 是 41%,MDS 2 是 22%——这就是热点。

这里我要说句公道话:CephFS 在 VM / bare-metal 场景下仍然是合格的。它的问题是假设了"客户端数量有限 + 单次会话时间长",而容器化场景是"客户端数量爆炸 + 单次会话只有几分钟",负载特性完全不同。

为什么选 JuiceFS

调研替代方案时,我们对比了 4 个候选:NFS(v4.2)、Longhorn、CephFS(优化版)、JuiceFS。决策矩阵我列在这,供参考:

方案多节点并发读元数据性能运维复杂度容器友好度成本
NFS v4.2一般(单点)一般
Longhorn
CephFS中(视规模)
JuiceFS优(Redis 元数据)

JuiceFS 在我们这个场景下胜出的核心原因是 元数据和数据分离的架构

┌────────────────────────────────────────────────┐
│            JuiceFS Architecture                │
├────────────────────────────────────────────────┤
│  Client (FUSE / CSI)                           │
│      ↓                                         │
│  ┌──────────────┐  ┌────────────────────────┐ │
│  │  元数据引擎    │  │  数据存储(S3/OSS/MinIO)│ │
│  │  Redis/TiKV   │  │  分块存储(4MB/64MB)    │ │
│  └──────────────┘  └────────────────────────┘ │
└────────────────────────────────────────────────┘

元数据走 Redis。Redis 本身就是内存 KV,元数据操作的延迟是 sub-millisecond 量级,比 CephFS 的 MDS 元数据操作低 1-2 个数量级。

数据走对象存储。在我们自己的 MinIO 集群上(12 节点,NVMe 缓存 + HDD 容量池),数据读写延迟稳定在 5-10ms。

这种"元数据内存化 + 数据对象化"的拆分让 JuiceFS 在容器化场景下有几个天生的优势:

  1. 客户端无状态。CephFS 的客户端要做 OSD 通信、MDS 通信、CRUSH 计算、PG 状态缓存等;JuiceFS 的客户端只做一件事:从 Redis 查元数据,根据元数据去对象存储拉数据。CSI 挂载时不需要 load kernel module,不需要和 kernel client 兼容,不需要等 FUSE 启动。
  2. 元数据可水平扩展。Redis Cluster 可以横向扩展到 200+ 节点,CephFS 的 MDS 横向扩展到 5 个已经是社区共识的上限。
  3. 数据本地缓存。JuiceFS 客户端有内置的本地缓存(默认 /var/jfsCache),热数据直接走本地 SSD 读。这点对我们这种"每次训练任务重复读同一份数据集"场景太友好了。

我们的 JuiceFS 部署架构

JuiceFS 的部署结构很简洁,由 3 部分组成:元数据引擎(Redis)对象存储(MinIO)客户端(CSI)

# minio-tenant.yaml
apiVersion: minio.min.io/v2
kind: Tenant
metadata:
  name: juicefs-data
  namespace: storage
spec:
  image: minio/minio:RELEASE.2024-08-26T15-33-02Z
  secrets:
    consoleSecret:
      name: minio-creds
  pools:
    - servers: 12
      name: pool-0
      volumesPerServer: 4
      size: 4Ti
      storageClassName: nvme-ssd
      resources:
        requests:
          cpu: "8"
          memory: "16Gi"
configuration:
  accessKey:
    secret:
      name: minio-creds
      key: accesskey
  secrets:
    provider: Kubernetes

元数据 Redis 我们用的是 Sentinel 模式(3 主 3 从),没有用 Cluster 模式,因为我们的元数据量级(30 万个文件、2 亿个 INODE)单 Redis 主就可以承担。

JuiceFS CSI 的部署按官方 Helm chart,没什么特殊的:

helm repo add juicefs https://juicedata.github.io/charts/
helm install juicefs-csi juicefs/juicefs-csi-driver \
  -n juicefs-system --create-namespace \
  --set kubeletDir=/var/lib/kubelet

CSI 驱动起来后,我们创建了一个静态的 PV + 预格式化好的文件系统(这一步是 JuiceFS 命令预先完成的):

# 格式化 JuiceFS(一次性)
juicefs format \
  --storage minio \
  --bucket http://minio.storage.svc:9000/juicefs-data \
  --access-key $ACCESS_KEY \
  --secret-key $SECRET_KEY \
  redis://redis-sentinel.storage.svc:26379/1 \
  aispace

# 挂载验证
juicefs mount -d redis://redis-sentinel.storage.svc:26379/1 /mnt/jfs
ls /mnt/jfs/datasets/  # 验证数据集就绪

格式化完成后,创建对应的 PV 和 PVC:

# pvc.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: jfs-datasets
spec:
  capacity:
    storage: 100Ti
  volumeMode: Filesystem
  accessModes:
    - ReadOnlyMany
  csi:
    driver: csi.juicefs.com
    fsType: juicefs
    volumeHandle: aispace
    nodePublishSecretRef:
      name: jfs-secret
      namespace: storage
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: datasets
  namespace: ml-train
spec:
  accessModes:
    - ReadOnlyMany
  resources:
    requests:
      storage: 100Ti
  storageClassName: ""
  volumeName: jfs-datasets

训练任务的 Pod 引用这个 PVC 即可,不需要任何客户端配置。

性能对比:47s → 3s 是怎么来的

我们做了 3 组对比实验,保证其他变量一致:

实验 1:单 Pod 启动延迟

测量从 kubectl apply 到 Pod 进入 Running 状态的时间,使用 ImageNet 数据集(1.28M 张图片,13GB)作为挂载卷。

方案P50P95P99
CephFS(Ceph 18.2 Reef)4.2s5.8s7.1s
JuiceFS(冷启动,无缓存)5.1s6.4s8.2s
JuiceFS(热缓存)1.6s2.1s2.8s

冷启动下 JuiceFS 反而比 CephFS 慢一点,这是因为 JuiceFS 第一次挂载时要从 Redis 拉元数据 + 从 MinIO 拉数据块初始化缓存。但热缓存下 JuiceFS 优势明显。

实验 2:200 个 Pod 并发启动

这是关键场景,模拟一次作业调度高峰:

方案平均 P50P95P99元数据后端
CephFS32s47s68sMDS CPU 92%
JuiceFS(4GB 缓存)1.8s3s4.5sRedis CPU 18%

47s 压到 3s,就是这一组对比出来的。Redis 在 200 个并发客户端下的 CPU 占用只有 18%,完全没到瓶颈;CephFS 的 MDS 已经接近满载。

实验 3:读吞吐

单 Pod 用 fio 顺序读 4GB 数据:

方案吞吐IOPS延迟 P99
CephFS850 MB/s12k28ms
JuiceFS(本机缓存命中)2.3 GB/s35k3ms
JuiceFS(缓存未命中)480 MB/s8k45ms

如果工作集能完全装进本机缓存,JuiceFS 比 CephFS 快 2-3 倍。我们的训练任务的数据集大小从 50GB 到 200GB 不等,节点 NVMe 缓存盘 1TB,命中率稳定在 78%-92%。

迁移过程中踩到的 5 个坑

迁移不是一帆风顺的,下面 5 个坑记录一下,给想上 JuiceFS 的同学提个醒:

坑 1:客户端缓存目录 IO 抖动

我们第一次部署时,缓存目录默认在 /var/jfsCache,发现容器写日志时偶发卡顿。查了一下,/var/jfsCache 和 docker/containerd 的 storage driver 共享同一个磁盘,写压力叠加后出现 IO 抖动。改成单独挂一块 NVMe SSD 给缓存目录:

# juicefs-csi helm values
daemon:
  cacheDir: /mnt/nvme/jfsCache
  cacheSize: 102400  # 100GB

坑 2:Redis 主从切换导致元数据短暂不可用

我们的 Redis Sentinel 模式在故障切换时(一般 15-25 秒),JuiceFS 客户端会短暂断开元数据连接,正在运行的训练任务不会中断(数据已经加载到本机缓存),但新 Pod 启动会卡住。解决办法是 Redis 客户端配置重连退避,加上副本拓扑感知:

# juicefs format option
redis://redis-sentinel.storage.svc:26379/1?max-retries=10&retry-backoff=2s

坑 3:FUSE 设备文件权限

K8s 节点上 /dev/fuse 默认权限 0666 root:root,JuiceFS CSI 驱动的 DaemonSet 需要访问,handle 了好几次 DaemonSet 启动失败。解决方法是节点初始化时:

# /etc/rc.local
chmod 666 /dev/fuse

或者在 DaemonSet 里加 initContainer 改权限:

initContainers:
  - name: fix-fuse-perms
    image: busybox
    command: ["sh", "-c", "chmod 666 /dev/fuse"]
    securityContext:
      privileged: true

坑 4:快照和 PVC 复用冲突

JuiceFS 的 PV 用静态绑定,所有 PVC 共享同一个底层 volumeHandle 时,如果用户在 Pod 里 mount --bind 重新挂载或者做 snapshot 操作,会触发 CSI 驱动的幂等性检查失败。教训是:不要给 JuiceFS PV 做 K8s 层的 Snapshot,用 JuiceFS 自己的同步镜像功能做备份。

坑 5:监控指标缺口

我们从 CephFS 切到 JuiceFS 后,Prometheus 的 ceph_ 指标全部失效(Grafana 上一片空白)。需要新建 JuiceFS 的监控面板:

# servicemonitor.yaml
apiVersion: monitoring.coreapis.com/v1
kind: ServiceMonitor
metadata:
  name: juicefs-exporter
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: juicefs-exporter
  endpoints:
    - port: http

JuiceFS 官方有 juicefs-exporter,采集指标包括 juicefs_used_spacejuicefs_inodesjuicefs_cache_hit_ratio 等。juicefs_cache_hit_ratio 这个指标必须重点关注,低于 60% 时缓存容量可能不够。

写在最后

把 CephFS 换成 JuiceFS 不是"全面碾压"的胜利,而是一个场景适配的胜利:

  • 如果你的存储场景是 VM / bare-metal 上的关系型数据库 / 长期稳定的服务,CephFS 仍然优秀;
  • 如果你的场景是 K8s 上的数据分析 / 训练 / CI 短任务,JuiceFS 的容器友好度 + Redis 元数据性能优势会非常明显。

最后给两个数字:

  • 我们用 JuiceFS 跑了 3 个月,月度存储成本从 $4800 降到了 $2600(MinIO 用 EC 替代 3 副本,节省 50% 容量成本);
  • 训练任务 P50 启动延迟从 32s 压到 1.8s,每天节省的 GPU 节点等待时间加起来约 18 小时。

如果你也在踩 K8s 共享存储的坑,欢迎评论区交流。

更多推荐