--- tags: [k8s, storage, pv, pvc, statefulset, devops] create time: 2026-07-07 15:48 --- # Kubernetes 存储 — PV、PVC 与 StatefulSet ## 概述 容器的本质是短暂的(ephemeral):Pod 被删除后,其内部所有文件随之消散。这对无状态服务无关紧要,但对数据库、消息队列等有状态应用来说是致命的。Kubernetes 通过分层存储抽象 —— **Volume → PersistentVolume(PV)→ PersistentVolumeClaim(PVC)→ StorageClass** —— 将"存储供给"与"存储消费"解耦,让开发者无需关心底层基础设施,只需声明"我需要多大、什么访问模式的存储",即可自动获得持久化能力。配合 StatefulSet,K8s 还为有状态应用提供了稳定的网络标识和有序的生命周期管理。 ## 卷(Volume)基础 Volume 是 K8s 最早引入的存储概念,与 Pod 同生命周期(除非是持久卷类型)。它定义在 Pod spec 中,可被同一 Pod 内的多个容器共享。 ### 常见卷类型 | 卷类型 | 生命周期 | 典型用途 | 是否持久 | |--------|----------|----------|----------| | `emptyDir` | Pod 存续期间 | 容器间共享临时文件、缓存 | 否 | | `hostPath` | 节点存续期间 | 访问宿主机文件(如 Docker socket) | 节点级持久 | | `nfs` | 独立于 Pod | 多节点共享存储 | 是 | | `configMap` / `secret` | Pod 存续期间 | 注入配置和敏感信息 | 否 | | `persistentVolumeClaim` | 独立于 Pod | 绑定 PV,正式的持久化方案 | 是 | ### emptyDir 示例 ```yaml apiVersion: v1 kind: Pod metadata: name: shared-data spec: containers: - name: writer image: busybox command: ["sh", "-c", "echo hello > /data/msg && sleep 3600"] volumeMounts: - name: cache mountPath: /data - name: reader image: busybox command: ["sh", "-c", "cat /data/msg && sleep 3600"] volumeMounts: - name: cache mountPath: /data volumes: - name: cache emptyDir: {} ``` > [!tip] emptyDir 的 `medium: Memory` 配置可将其挂载为 tmpfs(RAM 磁盘),适用于对 I/O 延迟极敏感的场景,但会计入容器内存用量。 ### hostPath 注意事项 hostPath 直接暴露宿主机目录,存在安全风险。生产环境应优先使用 PVC。如果必须使用,建议设置 `type` 字段限制行为: ```yaml volumes: - name: docker-sock hostPath: path: /var/run/docker.sock type: Socket # 仅允许已存在的 socket 文件 ``` hostPath 的 `type` 可选值:`DirectoryOrCreate`、`Directory`、`FileOrCreate`、`File`、`Socket`、`CharDevice`、`BlockDevice`。 ## 持久卷(PersistentVolume) PersistentVolume(PV)是集群级别的存储资源,由管理员预先创建或由 StorageClass 动态供给。它与 Pod 完全解耦 —— Pod 删除后 PV 仍然存在。 ### PV 详解 ```yaml apiVersion: v1 kind: PersistentVolume metadata: name: pv-nfs-data spec: capacity: storage: 10Gi # 存储容量 accessModes: - ReadWriteMany # 访问模式 persistentVolumeReclaimPolicy: Retain # 回收策略 storageClassName: slow # 存储类名称 nfs: server: 192.168.1.100 path: /exports/data ``` 关键字段说明: | 字段 | 说明 | |------|------| | `capacity.storage` | PV 的存储容量,只能声明一种资源(目前仅支持 storage) | | `accessModes` | 定义卷的挂载方式,可声明多种,但实际只能使用一种 | | `persistentVolumeReclaimPolicy` | PVC 释放后 PV 的处理策略:Retain / Delete / Recycle | | `storageClassName` | 关联的 StorageClass 名称,为空字符串表示不关联任何类 | | `volumeMode` | `Filesystem`(默认)或 `Block`(原始块设备) | | `mountOptions` | 挂载选项,如 `["hard", "nfsvers=4.1"]` | > [!info] PV 的状态包括 `Available`(未绑定)、`Bound`(已绑定到 PVC)、`Released`(PVC 已删除但 PV 未回收)、`Failed`(自动回收失败)。 ### 回收策略(Reclaim Policy) 当 PVC 被删除后,PV 的回收策略决定了其后续行为。 | 策略 | 行为 | 适用场景 | |------|------|----------| | **Retain** | PV 状态变为 `Released`,数据保留,需管理员手动处理 | 有保留价值的数据 | | **Delete** | PV 及底层存储资源一并删除 | 动态供给的临时存储 | | **Recycle** | 执行 `rm -rf /volume/*`,PV 回到 `Available`(已废弃) | 仅兼容简单存储后端 | ```mermaid stateDiagram-v2 [*] --> Available: PV 创建 Available --> Bound: PVC 绑定 Bound --> Released: PVC 删除 Released --> Available: Recycle (rm -rf) Released --> [*]: Delete (删除底层存储) Released --> Released: Retain (等待手动处理) state "管理员手动操作" as manual Released --> manual: 需人工介入 manual --> Available: 修复后重新上线 manual --> [*]: 彻底删除 ``` > [!warning] Recycle 策略已被废弃,且仅对 NFS 和 hostPath 有效。生产环境应优先使用动态供给 + Delete 策略,或 Retain 策略配合手动运维。 ### 访问模式(Access Modes) | 模式 | 缩写 | 含义 | |------|------|------| | ReadWriteOnce | RWO | 单节点读写挂载 | | ReadOnlyMany | ROX | 多节点只读挂载 | | ReadWriteMany | RWX | 多节点读写挂载 | | ReadWriteOncePod | RWOP | 单 Pod 读写(K8s 1.22+) | 各存储后端对访问模式的支持情况: | 存储后端 | RWO | ROX | RWX | RWOP | |----------|-----|-----|-----|------| | 本地磁盘 (local) | 支持 | - | - | 支持 | | AWS EBS | 支持 | - | - | 支持 | | GCE PD | 支持 | 支持 | - | 支持 | | Azure Disk | 支持 | - | - | 支持 | | Azure File | 支持 | 支持 | 支持 | - | | Ceph RBD | 支持 | 支持 | - | - | | CephFS | 支持 | 支持 | 支持 | - | | NFS | 支持 | 支持 | 支持 | - | | Longhorn | 支持 | 支持 | 支持 | - | > [!tip] 为什么不是所有存储都支持 RWX?因为多节点同时写入需要分布式文件系统级别的并发控制(如分布式锁、一致性协议)。块存储(EBS、PD)天然不支持多写,必须通过 NFS/文件系统层封装才能实现 RWX。 ## 持久卷声明(PersistentVolumeClaim) PVC 是用户对存储资源的"请求"。用户不直接创建或管理 PV,而是通过 PVC 声明需求(容量、访问模式、存储类),K8s 负责将合适的 PV 绑定到 PVC。 ```yaml apiVersion: v1 kind: PersistentVolumeClaim metadata: name: mysql-data spec: accessModes: - ReadWriteOnce resources: requests: storage: 20Gi storageClassName: fast-ssd ``` ### PVC 绑定机制 PVC 与 PV 的绑定有两种模式: #### 静态供给(Static Provisioning) 管理员手动创建 PV 资源,用户创建 PVC 后,K8s 控制面中的 PV controller 根据容量、访问模式、存储类等条件进行匹配。 #### 动态供给(Dynamic Provisioning) 用户创建 PVC 后,若集群中没有匹配的 PV,且 PVC 指定了 StorageClass,K8s 会调用对应的 provisioner 自动创建 PV 并完成绑定。这是生产环境中最常用的方式。 ```mermaid flowchart TD A[用户创建 PVC] --> B{是否有匹配的 Available PV?} B -->|是| C[绑定 PV 到 PVC] B -->|否| D{PVC 指定了 StorageClass?} D -->|否| E[PVC 持续 Pending] D -->|是| F[StorageClass Provisioner 创建 PV] F --> C C --> G[Pod 挂载 PVC 使用] style E fill:#f96,stroke:#333 style G fill:#6f9,stroke:#333 ``` 绑定匹配规则: 1. PV 与 PVC 的 `storageClassName` 必须一致 2. PV 的 `accessModes` 必须包含 PVC 请求的模式 3. PV 的 `capacity.storage` 必须 >= PVC 请求的容量 4. PV 的 `volumeMode` 必须匹配 5. 若存在多个匹配 PV,优先选择容量最小的满足者(最小匹配原则) 6. 可通过标签选择器(`selector`)进一步筛选 > [!info] 一对一绑定:一个 PV 只能绑定一个 PVC,一个 PVC 也只能绑定一个 PV。绑定后即使 PV 的标签被修改,绑定关系也不会解除。 ### StorageClass StorageClass 定义了存储的"类别",是动态供给的核心。它告诉 K8s 使用哪个 provisioner、以什么参数创建存储。 ```yaml apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast-ssd provisioner: kubernetes.io/aws-ebs # 存储驱动 parameters: type: gp3 # 传递给 provisioner 的参数 fsType: ext4 iopsPerGB: "50" reclaimPolicy: Delete # PV 回收策略 volumeBindingMode: WaitForFirstConsumer # 绑定时机 allowVolumeExpansion: true # 允许扩容 mountOptions: - debug ``` 关键字段详解: | 字段 | 说明 | 常见值 | |------|------|--------| | `provisioner` | 存储驱动标识 | `kubernetes.io/aws-ebs`、`rancher.io/local-path`、`csi.hetzner.cloud` 等 | | `parameters` | 传递给 provisioner 的键值对,不同驱动参数不同 | EBS: `type`/`fsType`;Ceph: `pool`/`clusterID` | | `reclaimPolicy` | 动态创建的 PV 的回收策略,默认 `Delete` | `Retain`、`Delete` | | `volumeBindingMode` | PV 创建和绑定的时机 | `Immediate`(默认)、`WaitForFirstConsumer` | | `allowVolumeExpansion` | 是否允许 PVC 扩容,默认 `false` | `true`、`false` | | `mountOptions` | 动态创建的 PV 的挂载选项 | NFS: `["hard","nfsvers=4.1"]` | > [!warning] `volumeBindingMode: WaitForFirstConsumer` 的重要性:默认的 `Immediate` 模式会在 PVC 创建时立即绑定/创建 PV,这可能导致 PV 创建在与 Pod 不同的可用区(尤其在多可用区集群中),最终 Pod 调度失败。`WaitForFirstConsumer` 会延迟到 Pod 调度时才创建 PV,确保存储与计算在同一拓扑域。 ```mermaid sequenceDiagram participant User as 用户 participant API as API Server participant SC as StorageClass participant PV Controller participant Provisioner Note over User,Provisioner: Immediate 模式 User->>API: 创建 PVC API->>PV Controller: 协调 PVC PV Controller->>Provisioner: 创建 PV Provisioner-->>PV Controller: PV Ready PV Controller->>API: 绑定 PV ↔ PVC Note over User,Provisioner: WaitForFirstConsumer 模式 User->>API: 创建 PVC (Pending) User->>API: 创建 Pod API->>PV Controller: Pod 调度完成,触发绑定 PV Controller->>Provisioner: 在 Pod 所在节点/可用区创建 PV Provisioner-->>PV Controller: PV Ready PV Controller->>API: 绑定 PV ↔ PVC ``` ## StatefulSet StatefulSet 是 K8s 为有状态应用设计的工作负载控制器,解决了 Deployment 无法满足的三个核心需求: 1. **稳定的网络标识**:Pod 名称固定(`pod-0`、`pod-1`),通过 Headless Service 提供 DNS 记录(`pod-0.service-name.namespace.svc.cluster.local`) 2. **稳定的持久存储**:每个 Pod 拥有独立的 PVC,Pod 重建后 PVC 不变 3. **有序的部署/扩缩/删除**:Pod 按序号顺序操作,前一个就绪后才启动下一个 ### StatefulSet vs Deployment | 特性 | Deployment | StatefulSet | |------|-----------|-------------| | Pod 名称 | 随机后缀 | 有序编号(`-0`、`-1`、`-2`) | | 部署顺序 | 并行 | 顺序(前一个 Ready 后才启动下一个) | | 删除顺序 | 并行 | 逆序 | | 网络标识 | 不稳定 | 稳定,配合 Headless Service | | 存储 | 共享 PVC 或无存储 | 每个 Pod 独立 PVC(通过 `volumeClaimTemplates`) | | 缩容 | 随机删除 | 从最大序号开始删除 | ### volumeClaimTemplates `volumeClaimTemplates` 是 StatefulSet 的核心特性 —— 它会为每个 Pod 自动创建独立的 PVC。 ```yaml apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: serviceName: mysql # 必须关联 Headless Service replicas: 3 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: containers: - name: mysql image: mysql:8.0 volumeMounts: - name: data mountPath: /var/lib/mysql volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: fast-ssd resources: requests: storage: 20Gi ``` 上例中,K8s 会自动创建三个 PVC:`data-mysql-0`、`data-mysql-1`、`data-mysql-2`,每个绑定独立的 PV。 > [!info] StatefulSet 缩容时,PVC 不会被删除。这意味着缩容后再扩容,新 Pod 会重新挂载原来的 PVC,数据不丢失。这是 StatefulSet 最重要的特性之一。 ## CSI(Container Storage Interface) CSI 是 K8s 存储插件的标准化接口规范(自 K8s 1.13 GA),取代了早期的 in-tree volume plugin 方式。 ### 为什么需要 CSI 早期 K8s 的存储驱动代码直接存在于 K8s 主仓库(in-tree),导致: - 存储供应商必须将代码合入 K8s 仓库,发布周期受限于 K8s - K8s 仓库臃肿,维护负担重 - 驱动 bug 可能影响 K8s 核心组件稳定性 CSI 将存储驱动移出 K8s 核心(out-of-tree),通过 gRPC 接口标准化通信。 ### CSI 架构 ```mermaid graph TD subgraph "Kubernetes 组件" AK[API Server] KC[kube-controller-manager] KM[kubelet] end subgraph "CSI 组件(集群级别)" CP[CSI Controller Plugin] CS[CSI Driver 注册] end subgraph "CSI 组件(节点级别)" NP[CSI Node Plugin] end subgraph "存储后端" S[Cloud Provider / SAN / NFS] end AK --> KC KC -->|创建/删除/扩容| CP CP --> S KM -->|挂载/卸载| NP NP --> S CS -.->|注册| AK ``` CSI 的三个核心组件: - **CSI Controller Plugin**:集群级别,负责卷的创建、删除、快照、扩容等控制面操作 - **CSI Node Plugin**:节点级别,负责卷的挂载(NodePublishVolume)、卸载(NodeUnpublishVolume)等数据面操作 - **CSIDriver 对象**:在 K8s 中注册驱动,声明能力(如 `attachRequired`、`podInfoOnMount`) ### 常见 CSI 驱动 | 存储后端 | CSI 驱动名称 | 说明 | |----------|-------------|------| | AWS EBS | `ebs.csi.aws.com` | AWS 官方,替代 in-tree aws-ebs | | GCE PD | `pd.csi.storage.gke.io` | GCP 官方 | | Azure Disk | `disk.csi.azure.com` | Azure 官方 | | Ceph RBD | `rbd.csi.ceph.com` | Ceph 官方 | | Longhorn | `driver.longhorn.io` | Rancher 开源分布式存储 | | OpenEBS | `openebs.io/local` / `openebs.io/zfs-localpv` | 开源容器原生存储 | | Hetzner Cloud | `csi.hetzner.cloud` | Hetzner 云盘 | | 本地路径 | `rancher.io/local-path` | Local Path Provisioner,开发测试常用 | ### CSI Driver 部署示例 以 AWS EBS CSI Driver 为例,通常通过 Helm 安装: ```bash helm repo add aws-ebs-csi-driver https://kubernetes-sigs.github.io/aws-ebs-csi-driver helm install aws-ebs-csi-driver aws-ebs-csi-driver/aws-ebs-csi-driver \ --namespace kube-system ``` 安装后会在集群中创建 `ebs.csi.aws.com` CSIDriver 对象和对应的 DaemonSet(Node Plugin)+ Deployment(Controller Plugin)。 ## 实战示例 以下完整示例演示如何部署一个带持久化存储的 MySQL StatefulSet。 ### 1. StorageClass ```yaml apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: mysql-ssd provisioner: rancher.io/local-path # 本地开发环境使用 local-path reclaimPolicy: Retain # 数据库场景用 Retain 更安全 volumeBindingMode: WaitForFirstConsumer allowVolumeExpansion: true ``` ### 2. Headless Service ```yaml apiVersion: v1 kind: Service metadata: name: mysql labels: app: mysql spec: ports: - port: 3306 targetPort: 3306 name: mysql clusterIP: None # Headless Service,StatefulSet 必需 selector: app: mysql ``` ### 3. StatefulSet ```yaml apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: serviceName: mysql replicas: 1 # 单节点 MySQL,生产建议配合 Operator 做主从 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: containers: - name: mysql image: mysql:8.0 ports: - containerPort: 3306 name: mysql env: - name: MYSQL_ROOT_PASSWORD valueFrom: secretKeyRef: name: mysql-secret key: ROOT_PASSWORD volumeMounts: - name: mysql-data mountPath: /var/lib/mysql resources: requests: cpu: 250m memory: 512Mi limits: cpu: "1" memory: 1Gi readinessProbe: exec: command: - mysqladmin - ping - -h - localhost initialDelaySeconds: 10 periodSeconds: 5 volumeClaimTemplates: - metadata: name: mysql-data spec: accessModes: ["ReadWriteOnce"] storageClassName: mysql-ssd resources: requests: storage: 10Gi ``` ### 4. Secret(MySQL root 密码) ```yaml apiVersion: v1 kind: Secret metadata: name: mysql-secret type: Opaque stringData: ROOT_PASSWORD: "MyS3cur3P@ssw0rd" ``` ### 5. 客户端 Pod 测试连接 ```yaml apiVersion: v1 kind: Pod metadata: name: mysql-client spec: containers: - name: mysql-client image: mysql:8.0 command: ["sleep", "3600"] ``` 进入客户端 Pod 后,使用 StatefulSet 的稳定 DNS 名称连接: ```bash kubectl exec -it mysql-client -- bash mysql -h mysql-0.mysql.default.svc.cluster.local -u root -p ``` > [!tip] DNS 解析规则:`...svc.cluster.local`。`mysql-0.mysql` 即可解析,完整 FQDN 为 `mysql-0.mysql.default.svc.cluster.local`。 ## 常见陷阱与最佳实践 ### PVC 持续 Pending **现象**:PVC 长时间处于 Pending 状态,Pod 无法启动。 **排查步骤**: ```bash # 查看 PVC 事件 kubectl describe pvc # 查看是否有匹配的 PV kubectl get pv # 查看 StorageClass 是否存在 kubectl get storageclass ``` **常见原因**: - 没有匹配的 Available PV(静态供给场景下) - StorageClass 的 provisioner 未安装或配置错误 - `volumeBindingMode: WaitForFirstConsumer` 下,没有 Pod 消费该 PVC - 底层存储配额不足或权限不足 ### 动态供给配置错误 **现象**:PVC Pending,事件中显示 `waiting for first consumer to be created before binding`。 **排查**:确认 `volumeBindingMode` 设置是否符合预期。如果 Pod 已经存在但仍未绑定,检查: - Pod 是否成功调度到节点(Pod 事件) - CSI Node Plugin 是否在目标节点正常运行(DaemonSet Pod 状态) - Provisioner 的参数是否正确(如 `parameters.type` 是否为合法值) ### RWX 不支持的存储类型 **现象**:PVC 声明 `ReadWriteMany` 但一直 Pending。 **原因**:块存储(EBS、GCE PD、Azure Disk)不支持 RWX。解决方案: - 使用支持 RWX 的存储后端:NFS、CephFS、Azure File、Longhorn、OpenEBS - 使用 ReadWriteOnce(如果应用本身不支持多写,如 MySQL 单实例) - 引入分布式锁机制或应用层复制 ### PVC 扩容 扩容需要 StorageClass 设置 `allowVolumeExpansion: true`,且 CSI 驱动支持在线扩容: ```bash # 编辑 PVC 请求的容量 kubectl patch pvc mysql-data-mysql-0 -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}' # 查看扩容进度 kubectl describe pvc mysql-data-mysql-0 ``` > [!warning] PVC 扩容只能增大不能缩小。部分存储后端支持在线扩容(无需重启 Pod),部分需要 unmount 后才能扩容,具体取决于 CSI 驱动实现。 ### 数据备份策略 PV 中的数据没有内置的备份机制,需要额外配置: - **VolumeSnapshot**:K8s 1.20+ GA,通过 CSI 驱动创建卷快照 - **Velero**:开源的 K8s 备份恢复工具,支持卷快照 + 资源对象的完整备份 - **应用层备份**:对于数据库,使用 `mysqldump`、`pg_dump` 等原生工具定期备份 - **存储层快照**:利用云平台的 EBS Snapshot、Azure Snapshot 等定期快照 ```yaml # VolumeSnapshot 示例 apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: name: mysql-snapshot spec: volumeSnapshotClassName: csi-aws-ebs-snapclass source: persistentVolumeClaimName: mysql-data-mysql-0 ``` ### 最佳实践总结 1. **始终使用 `volumeBindingMode: WaitForFirstConsumer`**,避免多可用区集群中存储与计算分离 2. **数据库场景使用 `reclaimPolicy: Retain`**,防止误删 PVC 导致数据丢失 3. **为 StatefulSet 配置 Readiness Probe**,确保 K8s 在 Pod 真正就绪后才启动下一个 4. **生产环境使用 CSI 驱动替代 in-tree plugin**,获得更好的维护性和功能支持 5. **定期备份 PV 数据**,不要依赖 K8s 的存储抽象作为唯一的持久化保障 6. **监控 PV 使用率**,设置 PVC 扩容告警,避免磁盘满导致服务中断 7. **避免使用 `hostPath`** 作为生产存储方案,除非有明确的安全和隔离措施 ## 延伸阅读 - [[k8s-03-pod]] — Pod 基础与生命周期 - [[k8s-02-architecture]] — Kubernetes 整体架构