开始学习

Stage 04 · 生产 · 第 4 / 8

生产存储:CSI 选型与 Rook-Ceph

local-path、NFS、Ceph、GPFS 等 CSI 选型,Rook-Ceph 部署与快照。

55 分钟|全程第 27 / 37

存储基础讲了 PV、PVC、StorageClass 的关系。kind 里默认的 standard StorageClass 背后是 local-path,数据就在节点磁盘上,节点挂了数据也就没了。生产集群要回答的问题复杂得多:数据要不要多副本?Pod 漂到别的节点还能不能读到?多个 Pod 能不能同时写?要不要快照?

这些能力都由 CSI(Container Storage Interface,容器存储接口)驱动提供,不同驱动差别很大。这一课先给出团队的选型表,然后按从简单到复杂的顺序讲本地盘、NFS 和 Rook-Ceph。Rook-Ceph 是重点,会把部署、池设计、日常运维和排障完整走一遍。最后是快照、商业/云原生文件系统,以及上线前的性能测试。

选型:先问三个问题

  1. 访问模式:只需要单个 Pod 读写(RWO,块存储),还是要多个 Pod 共享(RWX,文件存储)?
  2. 高可用由谁负责:存储层多副本,还是应用自己复制(如 etcd、PostgreSQL 主从、Kafka)?
  3. 存储在集群内还是集群外:已经有 Ceph/NAS/并行文件系统,还是要在 K8s 节点上自建?
方案类型适用场景不适用
local-path本地盘,RWO缓存、自带复制的数据库、系统盘上的临时数据需要数据随 Pod 漂移的场景
nfs-csiNFS,RWX已有高可用 NAS、小规模、开发环境自建单点 NFS 跑生产
ceph-csi块 + 文件集群外已有独立 Ceph
Rook块 + 文件 + 对象在 K8s 节点上自建 Ceph(融合部署)节点少于 3 台
JuiceFS文件,RWX云上,或已有数据库 + 对象存储对元数据延迟极敏感的场景
GPFS / Weka并行文件系统AI 训练、HPC 高吞吐预算有限(商业产品)

local-path:本地 NVMe

local-path-provisioner 在节点的指定目录下为每个 PVC 建一个子目录。团队用 Kustomize 远程引用上游清单,再打补丁:

storage/local-storage/kustomization.yaml
resources:
  - github.com/rancher/local-path-provisioner/deploy?ref=v0.0.36
  - storageclass.yaml
patches:
  - path: patch.yaml
storage/local-storage/patch.yaml(节选)
apiVersion: v1
kind: ConfigMap
metadata:
  name: local-path-config
  namespace: local-path-storage
data:
  config.json: |-
    {
      "storageClassConfigs": {
        "local-path": {
          "nodePathMap": [{"node": "DEFAULT_PATH_FOR_NON_LISTED_NODES", "paths": ["/opt/local-path"]}]
        },
        "local-nvme": {
          "nodePathMap": [{"node": "DEFAULT_PATH_FOR_NON_LISTED_NODES", "paths": ["/data1/volumes"]}]
        }
      }
    }
storage/local-storage/storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-nvme
provisioner: rancher.io/local-path
parameters:
  configName: local-nvme
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete

WaitForFirstConsumer 是本地盘的关键:PVC 先不绑定,等 Pod 被调度到某个节点后再在那个节点上建卷。用 Immediate 的话,卷可能建在 A 节点,Pod 却被调度到 B 节点,永远起不来。

nfs-csi:共享 NAS

机房已有高可用 NAS 时,csi-driver-nfs 是最简单的 RWX 方案。它为每个 PVC 在共享目录下建一个子目录:

storage/nfs-csi/storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: shared-nfs
provisioner: nfs.csi.k8s.io
parameters:
  server: nas.example.com
  share: /export/k8s
reclaimPolicy: Delete
volumeBindingMode: Immediate
mountOptions:
  - vers=3
  - nconnect=16     # 同一挂载点建立 16 条 TCP 连接,大幅提升吞吐

Chart 的 values 里把 controller 设成 2 副本即可。注意 nfs-csi 同样不强制容量限制,配额要靠 NAS 侧实现。

Rook-Ceph:在集群内自建分布式存储

Ceph 同时提供块存储(RBD)、文件存储(CephFS)和对象存储(RGW)。Rook 是 Ceph 的 Kubernetes Operator,把 Ceph 的 mon、mgr、osd、mds 都变成 Pod,由 CephCluster 等 CR 声明式管理。

text
                ┌───────────── rook-ceph-operator ─────────────┐
                │  监听 CephCluster / CephBlockPool / CephFS CR │
                └──────────────────────┬───────────────────────┘
                                       ▼
  控制平面节点:mon × 3、mgr × 2        存储节点(node-role.kubernetes.io/storage)
                                       ├─ osd.0(/dev/nvme0n1)
  CSI:rbd / cephfs provisioner        ├─ osd.1(/dev/nvme1n1)
       + 每个节点的 csi plugin          └─ mds(CephFS 元数据)

部署

准备:至少 3 个存储节点,每台至少一块没有分区、没有文件系统的空盘,并打上标签:

bash
kubectl label node sn-192-168-201-1 sn-192-168-201-2 sn-192-168-201-3 node-role.kubernetes.io/storage=true
lsblk -f        # 数据盘的 FSTYPE 列必须为空

Operator 同样用 Kustomize 引用上游 examples,截至本文写作时为 v1.19.x:

storage/rook/kustomization.yaml
resources:
  - https://raw.githubusercontent.com/rook/rook/v1.19.4/deploy/examples/crds.yaml
  - https://raw.githubusercontent.com/rook/rook/v1.19.4/deploy/examples/common.yaml
  - https://raw.githubusercontent.com/rook/rook/v1.19.4/deploy/examples/csi-operator.yaml
  - https://raw.githubusercontent.com/rook/rook/v1.19.4/deploy/examples/operator.yaml
patches:
  - path: operator-patch.yaml   # operator 2 副本;CSI provisioner 放控制平面

然后是集群本身:

storage/rook/cephcluster.yaml(节选)
apiVersion: ceph.rook.io/v1
kind: CephCluster
metadata:
  name: rook-ceph
  namespace: rook-ceph
spec:
  cephVersion:
    image: quay.io/ceph/ceph:v20.2.1
  dataDirHostPath: /var/lib/rook
  mon:
    count: 3
  network:
    provider: host
    addressRanges:
      public: ["192.168.201.0/24"]    # 客户端访问网
      cluster: ["192.168.202.0/24"]   # OSD 间复制网;只有一张网卡时删掉整个 network 段
  placement:
    all:
      nodeAffinity:
        requiredDuringSchedulingIgnoredDuringExecution:
          nodeSelectorTerms:
            - matchExpressions:
                - key: node-role.kubernetes.io/storage
                  operator: Exists
    # mon、mgr 另设 placement 放到控制平面节点,此处省略
  resources:
    osd:
      requests: { cpu: "4", memory: 8Gi }
      limits: { memory: 16Gi }
  storage:
    useAllNodes: true
    useAllDevices: true
bash
kubectl apply -k storage/rook
kubectl apply -f storage/rook/cephcluster.yaml
kubectl -n rook-ceph get cephcluster -w       # 等待 HEALTH_OK

# 安装 kubectl 插件,之后可以直接执行 ceph 命令
kubectl krew install rook-ceph
kubectl rook-ceph ceph -s
kubectl rook-ceph ceph osd tree

network.provider: host 让 Ceph 直接使用宿主机网络,绕过 CNI,性能明显更好,也能用上独立的复制网。

块存储:RBD

团队用"副本池存元数据 + 纠删码池存数据"的组合:纠删码(Erasure Coding,EC)8+3 的空间利用率约 73%,远高于三副本的 33%。

storage/rook/rbd.yaml(节选)
apiVersion: ceph.rook.io/v1
kind: CephBlockPool
metadata: { name: replicapool, namespace: rook-ceph }
spec:
  replicated: { size: 3, requireSafeReplicaSize: true }
---
apiVersion: ceph.rook.io/v1
kind: CephBlockPool
metadata: { name: ec-pool, namespace: rook-ceph }
spec:
  erasureCoded: { dataChunks: 8, codingChunks: 3 }
  parameters: { bulk: "true" }
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: ceph-block
provisioner: rook-ceph.rbd.csi.ceph.com
parameters:
  clusterID: rook-ceph
  pool: replicapool          # RBD 镜像元数据
  dataPool: ec-pool          # 实际数据
  imageFeatures: layering
  csi.storage.k8s.io/fstype: ext4
  csi.storage.k8s.io/provisioner-secret-name: rook-csi-rbd-provisioner
  csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
  csi.storage.k8s.io/node-stage-secret-name: rook-csi-rbd-node
  csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
  csi.storage.k8s.io/controller-expand-secret-name: rook-csi-rbd-provisioner
  csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph
allowVolumeExpansion: true
reclaimPolicy: Delete

文件存储:CephFS

CephFS 提供 RWX 卷,适合多个 Pod 共享数据集、模型文件。它需要一个元数据池、一个或多个数据池,以及 MDS(元数据服务器):

storage/rook/cephfs.yaml(节选)
apiVersion: ceph.rook.io/v1
kind: CephFilesystem
metadata: { name: shared-ceph, namespace: rook-ceph }
spec:
  metadataPool:
    deviceClass: ssd
    replicated: { size: 3 }
  dataPools:
    - name: replicated
      replicated: { size: 3 }
    - name: ec
      erasureCoded: { dataChunks: 4, codingChunks: 2 }
  preserveFilesystemOnDelete: true   # 误删 CR 时保留数据
  metadataServer:
    activeCount: 1
    activeStandby: true
    resources:
      limits: { memory: 32Gi }

MDS 会把元数据缓存在内存里,每个文件或目录约占几 KB。千万级小文件的场景,MDS 内存给少了会频繁换出,ls 一个目录都要好几秒,所以团队至少给 32 GiB。多租户时每个租户一个独立的文件系统或数据池,方便做配额和隔离。

日常运维

操作做法
加盘插入空盘后 operator 自动创建 OSD(useAllDevices: true 时)
节点离线Ceph 默认 10 分钟后把 OSD 标记为 out 并开始数据恢复;计划内维护先 ceph osd set noout
加速恢复ceph config set osd osd_mclock_override_recovery_settings true,再调大 osd_max_backfills,恢复完记得还原
PG 数不涨ceph osd pool autoscale-status 为空时,多半是 .mgr 池和其他池用了不同的 CRUSH 规则,用 ceph osd pool set .mgr crush_rule <规则> 统一
Dashboardkubectl -n rook-ceph port-forward svc/rook-ceph-mgr-dashboard 8443,admin 密码在 rook-ceph-dashboard-password Secret 里

移除一台存储节点上的 OSD:

bash
kubectl drain sn-192-168-201-3 --ignore-daemonsets --delete-emptydir-data
kubectl -n rook-ceph scale deploy rook-ceph-operator --replicas=0   # 防止 operator 把 OSD 拉回来
kubectl -n rook-ceph scale deploy rook-ceph-osd-5 --replicas=0
kubectl rook-ceph ceph osd down osd.5
kubectl rook-ceph rook purge-osd 5 --force
kubectl -n rook-ceph scale deploy rook-ceph-operator --replicas=2
kubectl rook-ceph ceph -s          # 等数据恢复完成、HEALTH_OK 后再处理下一个

彻底删除

bash
kubectl rook-ceph destroy-cluster          # 按提示输入 yes-really-destroy-cluster
kubectl delete -k storage/rook
# 每个存储节点上:
rm -rf /var/lib/rook
sgdisk --zap-all /dev/nvme0n1
dd if=/dev/zero of=/dev/nvme0n1 bs=1M count=100 oflag=direct
blkdiscard /dev/nvme0n1                    # SSD 额外执行

忘了擦盘是重装后 OSD 起不来的头号原因。

快照:VolumeSnapshot

CSI 快照是标准 API,但默认不安装。需要先装 external-snapshotter 的 CRD 和 snapshot-controller(截至本文写作时 v8.x):

bash
kubectl apply -k "https://github.com/kubernetes-csi/external-snapshotter/client/config/crd?ref=v8.2.0"
kubectl apply -k "https://github.com/kubernetes-csi/external-snapshotter/deploy/kubernetes/snapshot-controller?ref=v8.2.0"
snapshot.yaml
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
  name: ceph-block-snap
driver: rook-ceph.rbd.csi.ceph.com
parameters:
  clusterID: rook-ceph
  csi.storage.k8s.io/snapshotter-secret-name: rook-csi-rbd-provisioner
  csi.storage.k8s.io/snapshotter-secret-namespace: rook-ceph
deletionPolicy: Delete
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: data-snap-1
spec:
  volumeSnapshotClassName: ceph-block-snap
  source:
    persistentVolumeClaimName: data
---
# 从快照恢复成一个新 PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-restore
spec:
  storageClassName: ceph-block
  dataSource:
    name: data-snap-1
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io
  accessModes: [ReadWriteOnce]
  resources:
    requests: { storage: 10Gi }

快照和数据在同一个存储集群里,存储集群坏了快照也一起没,所以它不能代替备份。跨集群备份(如 Velero)放在 Day-2 运维

更多选择:GPFS 与 JuiceFS

IBM Storage Scale(GPFS) 是 AI 训练集群常见的并行文件系统,通过 IBM 的 CSI Operator 接入,StorageClass 的 provisioner 是 spectrumscale.csi.ibm.com。接入前的准备比一般 CSI 多:在 GPFS GUI 上创建属于 CsiAdmin 组的用户;文件系统开启配额(mmchfs -Q yes)和 --filesetdf,让每个 PVC 对应的 fileset 显示自己的容量;需要挂载的节点打上 scale=true 标签,并且在 GPFS 管理 API 里状态为 HEALTHY。

JuiceFS 把元数据放数据库、数据放对象存储,适合云上或者已有对象存储的环境。元数据引擎按规模选:千万级文件以内用 Redis,十亿级用 PostgreSQL,更大规模用 TiKV。它的 CSI 以"Mount Pod"方式挂载,可以通过 mountPodPatch 调整 --buffer-size--cache-size 等参数,本地缓存盘对读性能影响很大。

上线前做性能测试

存储上线前一定要测出基线,出问题时才有对比。团队用 elbencho,它一个工具覆盖了 fio(块)、mdtest(元数据)、ior(多节点吞吐)的场景:

bash
# 裸盘顺序写:48 线程、4M 块、直接 I/O,同时在另一个终端看 iostat -xm 1
elbencho -w -b 4M -t 48 --direct -s 100g /dev/nvme0n1

# 在文件系统上测多节点带宽:先在每个客户端节点启动服务
elbencho --service
# 再从一台机器统一发起
elbencho --hosts cn-192-168-3-50,cn-192-168-3-51 -w -r -b 4M -t 16 --direct \
  -s 20g -n 1 -N 4 --base10 /mnt/shared/bench
# 4K 随机 IOPS
elbencho --hosts cn-192-168-3-50,cn-192-168-3-51 -r -b 4k --rand --iodepth 10 \
  -t 16 --direct --timelimit 120 -s 20g -n 1 -N 4 /mnt/shared/bench

一块企业级 NVMe 顺序写一般能到几 GB/s。某块盘明显偏低时,先查 PCIe 链路有没有降速:

bash
lspci -vvv -s <设备地址> | grep -E 'LnkCap|LnkSta'   # LnkSta 出现 downgraded 说明降速

对象存储(RGW、MinIO 等 S3 接口)用 warp 测试。

动手练习

  1. 在 kind 里查看 kubectl get sc standard -o yaml,确认 provisioner 和 volumeBindingMode。创建一个 PVC,观察它在 Pod 创建前一直是 Pending,然后用 docker exec 进入对应的 kind 节点,找到卷在节点上的实际目录。
  2. 在 kind 里安装 csi-driver-host-path(仓库的 deploy/kubernetes-latest/deploy.sh 会一并安装快照 CRD 和 controller),用它的 StorageClass 创建 PVC 并写入文件,打一个 VolumeSnapshot,再从快照恢复出新 PVC,验证文件内容一致。
  3. 有一台 Linux 虚拟机的话,装 NFS 服务端和 nfs-csi,创建一个 RWX 的 PVC,让两个 Pod 同时挂载并写入同一个文件,观察结果。
  4. 有 3 台带空盘的虚拟机的话,按本文部署 Rook-Ceph(副本数可以降到 2,EC 换成副本池),用 kubectl rook-ceph ceph -s 确认 HEALTH_OK,然后故意删掉一个 OSD Pod,观察 Ceph 状态的变化和恢复过程。
  5. 用 elbencho 或 fio 分别测试本机磁盘的 4M 顺序写带宽和 4K 随机读 IOPS,记录下来作为基线。

自测

local-path 的 StorageClass 为什么要用 WaitForFirstConsumer?

本地卷只存在于某一个节点上。Immediate 会在 Pod 调度之前就选节点建卷,调度器随后可能把 Pod 放到另一个节点,导致 Pod 永远无法挂载。WaitForFirstConsumer 让调度器先选好 Pod 的节点,再在该节点上创建卷。

Rook 部署后 OSD 数量比磁盘少,怎么排查?

rook-ceph-osd-prepare Pod 中 provision 容器的日志,通常是磁盘上有残留的分区、文件系统、LVM 或者旧 Ceph 集群的标签,Rook 为保护数据跳过了它。确认数据可以丢弃后擦盘(sgdisk --zap-alldd、SSD 再 blkdiscard),然后重启 operator 触发重新扫描。

纠删码 8+3 的池至少需要多少台存储节点?为什么?

至少 11 台。每个对象被切成 8 个数据块和 3 个校验块,默认故障域是主机,11 块必须分布在 11 台不同主机上。主机不够时 PG 无法达到 active+clean

有了 VolumeSnapshot,还需要备份吗?

需要。快照和原始数据在同一个存储集群里,存储集群故障、误删存储池时快照会一起丢失。快照适合快速回滚和克隆,备份要把数据复制到独立的存储系统或异地。

为什么数据库通常不放在 Ceph 上?

数据库自己有主从复制做高可用,再放在三副本 Ceph 上会产生多份冗余写入,延迟也比本地 NVMe 高很多。更常见的做法是数据库用本地盘,高可用交给数据库 Operator,分布式存储留给无法自己做复制的应用。

参考资料

学完了吗?

进度保存在本机浏览器中,可在学习路线页查看。