1. 为什么需要动态供给?从手动创建PV的“苦”说起

在Kubernetes里用存储,很多朋友一开始都是从手动创建PV(PersistentVolume)和PVC(PersistentVolumeClaim)开始的。这就像你自己去仓库里找货架:你得先让管理员(集群运维)按照你的要求,搭好一个又一个的货架(PV),上面贴好标签,写明容量是10Gi、访问模式是ReadWriteOnce等等。然后你作为用户,写个申请单(PVC),说“我要一个10Gi能读写的货架”。系统帮你找到匹配的货架,绑定好,你的Pod就能用了。

听起来还行,对吧?但实际在稍微大点的项目里,这套流程很快就会让人头疼。我经历过一个项目,初期有几十个微服务,每个服务都可能需要自己的存储卷。运维同事那段时间不是在写YAML,就是在删YAML。更麻烦的是,当某个服务临时需要扩容、新建一个实例时,如果之前没预留好PV,新Pod就会卡住,报错“Pending”,因为PVC找不到合适的PV。开发同学跑过来说“我的服务起不来”,我们还得火急火燎地去检查是不是PV用完了,然后再手动补一个。

这种模式我们叫 静态供给(Static Provisioning)。它的核心问题是:存储资源的供给速度,跟不上应用的变化速度。PV是“死”的,需要预先创建好,而且生命周期独立于Pod。这在需要快速迭代、弹性伸缩的云原生环境里,非常不灵活。

那么,有没有一种方法,能让存储资源像云计算里申请虚拟机一样,按需自动创建呢?这就是 动态供给(Dynamic Provisioning) 要解决的问题。而实现动态供给的钥匙,就是 StorageClass(存储类)。你可以把StorageClass理解为一个“存储模板”或者“存储菜单”。它不直接代表一个具体的存储卷,而是定义了一类存储应该具有的属性(比如类型、性能、备份策略)以及最重要的:由谁来创建(Provisioner)

当用户提交一个指定了StorageClass的PVC时,Kubernetes不会再去现有的PV池里翻找,而是会直接拿着这个“菜单”去找对应的“厨师”(Provisioner),说:“按这个模板,现场做一个出来!” Provisioner接到指令,就会调用底层存储系统的API(比如在公有云上创建一块云硬盘,或者在NFS服务器上创建一个目录),瞬间生成一个完全符合PVC要求的PV,并自动完成绑定。整个过程完全自动化,无需人工干预。

从静态到动态,带来的改变是巨大的。运维人员从繁琐的PV生命周期管理中解放出来,只需要提前定义好几种StorageClass(例如“高速SSD类”、“标准HDD类”、“共享文件存储类”)。开发人员则在部署应用时,只需要在PVC里声明“我需要多大容量、什么类型的存储”,剩下的就全交给Kubernetes了。这极大地简化了存储管理,提升了整体的运维效率和敏捷性。

2. 核心概念再梳理:PVC、PV与StorageClass的三角关系

在深入实战前,我们有必要把PVC、PV和StorageClass这三个核心概念的关系再理一理。很多资料讲得比较抽象,我这里用一个更生活化的比喻帮你理解。

想象一下你去一个大型的、高度自动化的“云仓储中心”租用储物空间:

  • PVC(PersistentVolumeClaim):就是你填写的租赁申请表。你在表上写明你的需求:我要一个多大的空间(比如10平米)、我需要什么权限(只能我本人进出,还是我的团队都能进出)、我希望是什么类型的仓库(恒温仓还是普通仓)。这份申请表就是你(Pod)对存储的需求声明。
  • StorageClass:就是仓储中心提供的标准化服务套餐手册。手册里定义了不同等级的仓库类型,比如“经济型货架”、“标准型库房”、“高性能恒温仓”。每一类都说明了它的特性(存储介质、性能水平、是否带监控备份)以及最重要的——由哪个自动化建造机器人(Provisioner)来负责搭建
  • PV(PersistentVolume):就是最终根据你的申请和套餐手册,实际被建造出来并分配给你的那个实体仓库。它是集群中的一块具体存储资源。

它们三者的工作流程是这样的:

  1. 用户:提交一份租赁申请表(PVC),并在表上勾选自己想要的套餐类型(storageClassName: managed-nfs-storage)。
  2. 系统:收到申请表后,发现是指定了套餐类型的“动态申请”,于是立刻去查找对应的套餐手册(StorageClass)。
  3. Provisioner:系统根据手册里记载的机器人信息(provisioner: qgg-nfs-storage),呼叫对应的建造机器人。
  4. 创建与绑定:机器人根据套餐标准,在真实的仓储区(如NFS服务器)快速搭建一个符合要求的实体仓库(PV),然后把仓库钥匙交给系统,系统自动将你的申请表(PVC)和这个新仓库(PV)绑定在一起。
  5. 交付使用:你(Pod)凭绑定的申请表(PVC),就可以入驻并使用这个仓库了。

这个过程中,你作为用户,完全不用关心仓库具体建在哪个位置、怎么建的。你只需要关心自己的需求(PVC)和选择的套餐类型(StorageClass)。这种解耦的设计,正是Kubernetes存储管理的精妙之处。

3. 实战准备:搭建你的NFS服务器

动态供给需要一个“原材料基地”,也就是底层存储系统。为了让演示更通用、更容易在自建环境中复现,我们选择最经典的 NFS(网络文件系统) 作为后端存储。NFS本身并不支持动态创建目录(PV),所以我们需要一个“中间人”—— NFS Client Provisioner,它作为一个Pod运行在集群里,专门负责替StorageClass在NFS服务器上创建和删除目录。

首先,我们需要一个NFS服务器。如果你已经有现成的,可以跳过这一步。这里演示在一台Linux机器上快速搭建:

# 假设你的NFS服务器IP是192.168.1.100,数据目录为 /data/nfs
sudo apt-get update && sudo apt-get install -y nfs-kernel-server # Ubuntu/Debian
# 或者:sudo yum install -y nfs-utils # CentOS/RHEL

# 创建共享目录
sudo mkdir -p /data/nfs
sudo chown nobody:nogroup /data/nfs # 修改权限,根据实际情况调整
sudo chmod 777 /data/nfs # 为演示简化权限,生产环境请严格配置

# 配置NFS导出
echo "/data/nfs *(rw,sync,no_subtree_check,no_root_squash)" | sudo tee -a /etc/exports

# 使配置生效
sudo exportfs -a
sudo systemctl restart nfs-kernel-server
sudo systemctl enable nfs-kernel-server

# 在Kubernetes节点上测试挂载(可选,确保网络连通)
sudo mount -t nfs 192.168.1.100:/data/nfs /mnt/test && sudo umount /mnt/test

确保你的Kubernetes集群所有节点都能访问这个NFS服务器(防火墙放行2049端口等)。我们的目标是将NFS的 /data/nfs 目录作为动态供给的“总仓库”,Provisioner会在这个目录下为每个PV创建独立的子目录。

4. 部署NFS Provisioner:让存储“活”起来的关键组件

NFS Provisioner是一个特殊的控制器,它将以Pod的形式部署在你的集群中。它的核心职责有两个:

  1. 监听集群中所有指向它的PVC申请。
  2. 当有新的PVC申请时,在NFS服务器上创建对应的子目录,并自动创建PV与之绑定。

由于这个Provisioner需要在集群里创建PV、PVC等资源,所以我们需要先为它配置好相应的RBAC权限。别被RBAC吓到,它其实就是定义“这个服务账号能做什么”。

4.1 配置RBAC权限

创建一个文件叫 nfs-provisioner-rbac.yaml,内容如下。它定义了一个服务账号(ServiceAccount),并授予了它管理PV、PVC、StorageClass等资源所需的权限。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: nfs-client-provisioner
  namespace: default # 我们将其部署在default命名空间,可根据需要修改
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: nfs-client-provisioner-runner
rules:
  - apiGroups: [""]
    resources: ["persistentvolumes"]
    verbs: ["get", "list", "watch", "create", "delete"]
  - apiGroups: [""]
    resources: ["persistentvolumeclaims"]
    verbs: ["get", "list", "watch", "update"]
  - apiGroups: ["storage.k8s.io"]
    resources: ["storageclasses"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "update", "patch"]
  - apiGroups: [""]
    resources: ["endpoints"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: run-nfs-client-provisioner
subjects:
  - kind: ServiceAccount
    name: nfs-client-provisioner
    namespace: default
roleRef:
  kind: ClusterRole
  name: nfs-client-provisioner-runner
  apiGroup: rbac.authorization.k8s.io
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: leader-locking-nfs-client-provisioner
  namespace: default
rules:
  - apiGroups: [""]
    resources: ["endpoints"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: leader-locking-nfs-client-provisioner
subjects:
  - kind: ServiceAccount
    name: nfs-client-provisioner
    namespace: default
roleRef:
  kind: Role
  name: leader-locking-nfs-client-provisioner
  apiGroup: rbac.authorization.k8s.io

应用这个配置:

kubectl apply -f nfs-provisioner-rbac.yaml

这步完成后,集群里就有了一个名为 nfs-client-provisioner 的服务账号,并且它拥有了我们动态供给所必需的一系列权限。

4.2 创建StorageClass定义

接下来,我们要创建“套餐手册”——StorageClass。它的 provisioner 字段是关键,必须和后面部署的Provisioner Pod里声明的名字一致。

创建一个文件 nfs-storageclass.yaml

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: managed-nfs-storage
provisioner: qgg-nfs-storage # 这是Provisioner的标识名,可以自定义,但前后要一致
parameters:
  archiveOnDelete: "false" # 删除PVC时,PV对应的NFS目录是否归档(改为archived-前缀)而非删除
reclaimPolicy: Delete # 动态PV的回收策略,Delete或Retain
volumeBindingMode: Immediate # 绑定模式,Immediate立即绑定,WaitForFirstConsumer延迟绑定

应用它:

kubectl apply -f nfs-storageclass.yaml

现在,kubectl get sc 就能看到这个StorageClass了。reclaimPolicy: Delete 意味着当用户删除PVC时,这个动态创建的PV以及它在NFS服务器上的数据目录也会被自动清理。如果你希望保留数据,可以设置为 Retain,但之后就需要手动清理PV和目录了。

4.3 部署NFS Client Provisioner

现在是部署核心组件了。我们通过一个Deployment来运行Provisioner的Pod。注意镜像,这里使用一个社区常用的镜像。

创建文件 nfs-provisioner-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nfs-client-provisioner
  namespace: default
  labels:
    app: nfs-client-provisioner
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nfs-client-provisioner
  strategy:
    type: Recreate # 使用Recreate策略,确保单实例时数据一致性
  template:
    metadata:
      labels:
        app: nfs-client-provisioner
    spec:
      serviceAccountName: nfs-client-provisioner # 使用前面创建的服务账号
      containers:
        - name: nfs-client-provisioner
          image: k8s.gcr.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2 # 官方镜像
          # 如果无法拉取,可使用 quay.io/external_storage/nfs-client-provisioner:latest
          volumeMounts:
            - name: nfs-client-root
              mountPath: /persistentvolumes # Provisioner内部的工作目录
          env:
            - name: PROVISIONER_NAME
              value: qgg-nfs-storage # 必须与StorageClass中的provisioner名字完全一致!
            - name: NFS_SERVER
              value: 192.168.1.100 # 你的NFS服务器IP
            - name: NFS_PATH
              value: /data/nfs # NFS服务器的共享路径
      volumes:
        - name: nfs-client-root
          nfs:
            server: 192.168.1.100
            path: /data/nfs

仔细看这个配置:

  1. serviceAccountName 引用了我们刚创建的服务账号。
  2. PROVISIONER_NAME 环境变量是核心,它告诉这个Pod:“你的名字叫 qgg-nfs-storage”。StorageClass就是通过这个名字找到它的。
  3. NFS_SERVERNFS_PATH 告诉Provisioner你的NFS仓库在哪里。
  4. 这个Pod本身也需要挂载NFS目录到 /persistentvolumes,这样它才能在里面创建子目录。

应用部署:

kubectl apply -f nfs-provisioner-deployment.yaml

检查Pod是否运行成功:

kubectl get pods -l app=nfs-client-provisioner

你应该能看到一个状态为 Running 的Pod。至此,我们的动态供给“工厂”就搭建完毕了!

5. 初试锋芒:用动态供给快速创建一个Pod

理论说了那么多,是时候看看效果了。我们来创建一个最简单的Pod,让它使用动态供给的存储。

创建一个PVC,指定我们刚定义的StorageClass:

# test-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: test-dynamic-pvc
spec:
  storageClassName: managed-nfs-storage # 关键:指定使用哪个StorageClass
  accessModes:
    - ReadWriteMany # NFS支持多节点读写
  resources:
    requests:
      storage: 1Gi # 申请1GiB空间

应用PVC:

kubectl apply -f test-pvc.yaml

立刻检查PVC和PV的状态:

kubectl get pvc test-dynamic-pvc
kubectl get pv

你会看到神奇的一幕:PVC的状态几乎瞬间从 Pending 变成了 Bound。同时,在PV列表里,自动出现了一个新的PV,它的名字是自动生成的(如 pvc-<uuid>),容量是1Gi,并且CLAIM字段指向了 default/test-dynamic-pvc。这一切都没有我们手动创建PV!

现在,我们创建一个Pod来使用这个PVC:

# test-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: test-dynamic-pod
spec:
  containers:
  - name: busybox
    image: busybox:latest
    command: ["/bin/sh", "-c", "sleep 3600"]
    volumeMounts:
    - name: storage-volume
      mountPath: /data
  volumes:
  - name: storage-volume
    persistentVolumeClaim:
      claimName: test-dynamic-pvc

应用并检查:

kubectl apply -f test-pod.yaml
kubectl exec -it test-dynamic-pod -- ls -la /data

你可以在Pod的 /data 目录下创建文件,然后到你的NFS服务器上查看 /data/nfs 目录,会发现里面多了一个以 default-test-dynamic-pvc-pvc-<uuid> 格式命名的子目录,里面就是你创建的文件。这个子目录就是动态创建的PV在NFS上的实际存储位置。

6. 进阶实战:与StatefulSet集成,实现有状态应用的存储自动化

动态供给真正的威力,在于和有状态应用的代表—— StatefulSet 结合。StatefulSet的 volumeClaimTemplates 字段可以为每个Pod实例自动创建唯一的PVC,如果这些PVC都指向一个支持动态供给的StorageClass,那么每个Pod就能自动获得自己独立的、持久化的存储卷。这是部署数据库(如MySQL、Redis集群)、消息队列等应用的经典模式。

我们来部署一个简单的Nginx StatefulSet,每个Pod都有自己的存储。

# nginx-statefulset.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx-headless
  labels:
    app: nginx
spec:
  ports:
  - port: 80
    name: web
  clusterIP: None # Headless Service,用于StatefulSet网络标识
  selector:
    app: nginx
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  serviceName: "nginx-headless"
  replicas: 3 # 启动3个实例
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:alpine
        ports:
        - containerPort: 80
          name: web
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
  volumeClaimTemplates: # 核心:存储卷声明模板
  - metadata:
      name: www
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: managed-nfs-storage # 指向我们的动态StorageClass
      resources:
        requests:
          storage: 100Mi # 每个Pod申请100Mi空间

应用这个配置:

kubectl apply -f nginx-statefulset.yaml

然后观察资源创建过程:

# 观察Pod按序创建
kubectl get pods -l app=nginx -w
# 观察PVC自动创建
kubectl get pvc
# 观察PV自动创建
kubectl get pv

你会看到 web-0web-1web-2 三个Pod依次启动。同时,系统自动创建了三个PVC:www-web-0www-web-1www-web-2。紧接着,三个对应的PV也被动态创建出来,并分别与PVC绑定。

现在,你可以进入每个Pod,在其 /usr/share/nginx/html 目录下创建不同的 index.html 文件。即使删除某个Pod,StatefulSet控制器重建它时,新的Pod依然会挂载到原来那个PVC(以及背后的PV和NFS目录)上,数据不会丢失。

7. 生产环境注意事项与排坑指南

把动态供给跑起来只是第一步,要真正用到生产环境,有几个关键点和“坑”需要你特别注意。

1. StorageClass参数调优

  • reclaimPolicy: 默认是 Delete。对于生产数据库等关键数据,强烈建议设置为 Retain。这样删除PVC后,PV状态变为 Released,底层数据(NFS目录)不会被删,给你留出数据备份或迁移的时间。之后需要手动删除PV和清理数据。
  • volumeBindingMode: 默认是 Immediate(立即绑定)。对于**本地存储卷(Local PV)**或某些有拓扑限制的云存储(如AWS EBS不能跨可用区挂载),务必使用 WaitForFirstConsumer(等待第一个消费者)。这能确保PV在知道Pod被调度到哪个节点后才创建,避免PV创建在Pod无法访问的区域。
  • allowVolumeExpansion: 设为 true 可以允许后续通过修改PVC来动态扩容PV。但需要底层存储驱动支持(NFS Provisioner通常不支持在线扩容,需要特定驱动)。

2. NFS Provisioner的性能与高可用

  • 我们演示的Deployment只用了1个副本。在生产环境,如果存储请求量很大,这个Provisioner可能成为瓶颈。可以考虑适当增加副本数,但要注意NFS目录创建的并发控制(通常通过leader election机制,我们RBAC里的endpoints权限就是用于此)。
  • NFS服务器本身是单点。生产环境应使用高可用的NFS解决方案(如DRBD+Keepalived,或云厂商提供的高可用文件存储服务)。

3. 资源监控与清理

  • 动态创建的PV虽然方便,但也容易导致资源闲置。定期检查并清理那些 Released 状态的PV(特别是reclaimPolicy: Retain时)以及NFS服务器上对应的目录。
  • 监控NFS服务器的磁盘使用量。动态供给并不会魔法般地变出磁盘空间,它只是自动化了目录创建。如果NFS服务器磁盘满了,新的PVC将无法绑定,Pod会启动失败。

4. 权限与安全

  • 我们演示的RBAC权限范围较广。在生产中,应根据最小权限原则,进一步细化Provisioner服务账号的权限。
  • NFS的共享权限配置(/etc/exports)要严格,最好限制为Kubernetes节点IP段,并使用更安全的选项(如root_squash)。

5. 多StorageClass策略

  • 一个集群里可以定义多个StorageClass,对应不同性能或用途的存储。例如:
    • storageclass-slow-nfs: 用于备份、日志等冷数据。
    • storageclass-fast-nfs: 使用SSD后端,用于数据库等IO敏感型应用。
    • storageclass-retain: 回收策略为Retain的类,供关键应用使用。 开发人员根据应用特性选择合适的StorageClass,实现存储的精细化管理。

踩过几次坑之后,我的经验是:在测试环境充分验证你的StorageClass配置和Provisioner行为,特别是删除、重建的流程。将StorageClass的定义和Provisioner的部署通过GitOps进行版本化管理。对于关键数据,无论回收策略是什么,都要有独立的备份方案,不能完全依赖Kubernetes的存储生命周期。动态供给极大地提升了效率,但把存储管好,依然需要清晰的策略和细致的运维。

更多推荐