我把 JuiceFS 替换 CephFS 做 K8s 共享存储后,Pod 启动从 47s 压到 3s
我把 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 启动时通常会做这几件事:
- 容器启动,加载模型文件骨架(大量 openat + readlink)
- 启动后第一次遍历数据集目录(大量 getattr + readdir)
- 训练过程中持续写 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 在容器化场景下有几个天生的优势:
- 客户端无状态。CephFS 的客户端要做 OSD 通信、MDS 通信、CRUSH 计算、PG 状态缓存等;JuiceFS 的客户端只做一件事:从 Redis 查元数据,根据元数据去对象存储拉数据。CSI 挂载时不需要 load kernel module,不需要和 kernel client 兼容,不需要等 FUSE 启动。
- 元数据可水平扩展。Redis Cluster 可以横向扩展到 200+ 节点,CephFS 的 MDS 横向扩展到 5 个已经是社区共识的上限。
- 数据本地缓存。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)作为挂载卷。
| 方案 | P50 | P95 | P99 |
|---|---|---|---|
| CephFS(Ceph 18.2 Reef) | 4.2s | 5.8s | 7.1s |
| JuiceFS(冷启动,无缓存) | 5.1s | 6.4s | 8.2s |
| JuiceFS(热缓存) | 1.6s | 2.1s | 2.8s |
冷启动下 JuiceFS 反而比 CephFS 慢一点,这是因为 JuiceFS 第一次挂载时要从 Redis 拉元数据 + 从 MinIO 拉数据块初始化缓存。但热缓存下 JuiceFS 优势明显。
实验 2:200 个 Pod 并发启动
这是关键场景,模拟一次作业调度高峰:
| 方案 | 平均 P50 | P95 | P99 | 元数据后端 |
|---|---|---|---|---|
| CephFS | 32s | 47s | 68s | MDS CPU 92% |
| JuiceFS(4GB 缓存) | 1.8s | 3s | 4.5s | Redis CPU 18% |
47s 压到 3s,就是这一组对比出来的。Redis 在 200 个并发客户端下的 CPU 占用只有 18%,完全没到瓶颈;CephFS 的 MDS 已经接近满载。
实验 3:读吞吐
单 Pod 用 fio 顺序读 4GB 数据:
| 方案 | 吞吐 | IOPS | 延迟 P99 |
|---|---|---|---|
| CephFS | 850 MB/s | 12k | 28ms |
| JuiceFS(本机缓存命中) | 2.3 GB/s | 35k | 3ms |
| JuiceFS(缓存未命中) | 480 MB/s | 8k | 45ms |
如果工作集能完全装进本机缓存,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_space、juicefs_inodes、juicefs_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 共享存储的坑,欢迎评论区交流。
更多推荐
所有评论(0)