Files
Qiniu/technical/k8s/k8s-07-storage.md
T

626 lines
21 KiB
Markdown
Raw Normal View History

2026-07-07 15:55:47 +08:00
---
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 解析规则:`<pod-name>.<headless-service-name>.<namespace>.svc.cluster.local`。`mysql-0.mysql` 即可解析,完整 FQDN 为 `mysql-0.mysql.default.svc.cluster.local`。
## 常见陷阱与最佳实践
### PVC 持续 Pending
**现象**:PVC 长时间处于 Pending 状态,Pod 无法启动。
**排查步骤**:
```bash
# 查看 PVC 事件
kubectl describe pvc <pvc-name>
# 查看是否有匹配的 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 整体架构