Skip to main content

7. 存储管理

Pod 重启后文件去哪了​

你写了一个 MySQL Pod,跑了一天,数据存进去了。然后 Pod 被驱逐重新调度到另一台机器——数据没了。

Pod 的文件系统是临时的。容器一停,overlayfs 的可写层就没了。这是设计如此:Pod 被设计成随时可替换的,持久化不是 Pod 自己的责任。

所以你需要的不是"让 Pod 的 /var/lib/mysql 更可靠",而是把持久化这件事从 Pod 的生命周期中抽离出来。

K8S 的答案是 PV(PersistentVolume)和 PVC(PersistentVolumeClaim)。

PV 和 PVC:存储管理的接口抽象​

PV 和 PVC 的关系,像招聘网站上的"岗位"和"投递":

  • PV 是管理员提前准备好的存储池——"我有一块 100G 的 SSD"
  • PVC 是应用申请的存储请求——"我需要一块 10G 的盘"
  • K8S 做匹配——把符合要求的 PV 绑给这个 PVC
# 管理员视角:创建一块 100G 的 NFS 存储
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-pv
spec:
capacity:
storage: 100Gi
accessModes:
- ReadWriteMany
nfs:
server: 192.168.1.100
path: /exports/data
# 应用视角:我要 10G,但我不关心是谁给的
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi

应用 Pod 只管把 PVC 挂上去:

spec:
containers:
- name: mysql
image: mysql:8
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumes:
- name: data
persistentVolumeClaim:
claimName: mysql-pvc

关键区别:Pod 挂的是 PVC,不是 PV。PV 的 NFS IP 地址 Pod 永远不需要知道。Pod 只看 PVC——我需要 10G——至于这 10G 是从 NFS 来的、Ceph 来的、还是云盘来的,Pod 不关心。

思考题:注意看上面的 YAML——PV 没有 namespace 字段,PVC 有。为什么?PV 和 PVC,哪个是集群级资源(cluster-scoped),哪个是命名空间级(namespace-scoped)?想一下背后的设计理由——如果 PV 也限定在某个 namespace 里,会有什么问题?

accessModes:几个字决定了你的架构​

accessModes 只有三种可能,但很关键:

模式含义场景
ReadWriteOnce单节点读写数据库的数据盘
ReadOnlyMany多节点只读共享配置文件
ReadWriteMany多节点读写共享文件存储、日志聚合

单节点的意思是被单个 Node 上的 Pod 挂载——同一个 Deployment 的多个 Pod 散布在不同 Node 上,只有其中一个能访问 ReadWriteOnce 的 PV。

回收策略:删了 PVC 之后怎么办​

PV 和 PVC 解除绑定后,PV 怎么处理?三种策略:

spec:
persistentVolumeReclaimPolicy: Retain # 保留数据(默认),需手动清理
# Delete(删 PVC 时连存储一起删)
# Recycle(rm -rf 一遍再给别人用,已废弃)

生产环境一般用 Retain——数据无价,不要自动删。Delete 在开发和临时环境比较方便。

StorageClass:告别手动建 PV​

前面说你得先手动建 PV。那 100 个应用要存储呢,每个建一次?

StorageClass + 动态供给 解决这个问题。你只定义 Provisioner(谁负责创建存储),每次 PVC 申请时自动创建 PV:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: kubernetes.io/aws-ebs
parameters:
type: gp3
encrypted: "true"
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer

然后 PVC 只需要指这个 StorageClass 的名字,不需要提前建 PV:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-pvc
spec:
storageClassName: fast-ssd # 就是这个
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi

Provisioner 需要实现 CSI 接口(第 13 章聊细节)。K8S 本身不创建存储,它通过 CSI 调外部 Controller 去创建。

volumeBindingMode 的坑​

volumeBindingMode 有两个值,在你用区域云盘的时候必须弄清楚:

  • Immediate:PVC 一创建就立刻找 PV 绑定(默认)
  • WaitForFirstConsumer:等到有 Pod 真正用这个 PVC 时才建存储

为什么需要等?因为你的 Pod 可能在 zone A,而存储被创建到了 zone B——跨可用区挂不上。

volumeBindingMode: WaitForFirstConsumer

这行配置让 Provisioner 等到 Scheduler 决定了 Pod 跑在哪个 zone,再在那个 zone 创建存储。这对云盘来说至关重要。

思考题:你在 AWS 上 kubectl apply 一个 PVC,几分钟后一块 EBS 盘就挂到 Pod 上了。K8S 本身不调 AWS API——那是谁调的?从 PVC 创建到 EBS 盘挂载,中间经过了哪些组件和接口?

思考题:PVC 删了、PV 还在(Retain 策略),上面是上一个 Pod 的数据。怎么把这个 PV"洗白"给下一个 PVC 用?手动清理完之后,PV 的状态怎么从 Released 变回 Available?

思考题:数据库备份——你能像给 EBS 盘做 snapshot 一样给 PV 做快照吗?不需要跑到云控制台操作——K8S 里有没有原生方式?

小结​

  • PV 是管理员准备的存储资源,PVC 是应用的存储请求
  • Pod 只认识 PVC,不需要知道存储来自哪里
  • accessModes 的 RWO/ROX/RWX 决定了你的部署拓扑
  • StorageClass + 动态供给省去手动建 PV
  • WaitForFirstConsumer 解决可用区冲突

存储管理的本质还是接口分层:应用层只声明需求(PVC),供给层只管实现(CSI),K8S 做中间的匹配。下一章看看谁有权限读写这些资源——安全。


解答​

PV 和 PVC,哪个是全局概念?

PV 是集群级资源(cluster-scoped),PVC 是命名空间级(namespace-scoped)。

验证很简单——kubectl api-resources 看 NAMESPACED 列:

kubectl api-resources | grep -E "persistentvolume|persistentvolumeclaim"
# persistentvolumes pv v1 false PersistentVolume
# persistentvolumeclaims pvc v1 true PersistentVolumeClaim

false = 不加 namespace,true = 限定 namespace。

为什么这样设计?

看一个场景就知道。公司有 3 个团队(team-a、team-b、team-c),每个团队有自己的 namespace。管理员提前准备了 10 块 100G 的 NFS 存储(10 个 PV)。如果 PV 也限定在 namespace 里,这些 PV 就必须被"放进去"——那该放哪个 namespace?team-a 的 namespace 里放了 5 块,team-c 只放了 1 块,team-c 的应用申请 10G 发现没 PV 可用——但集群整体上是有空闲的,只是被锁在了别的 namespace。

PV 跨 namespace 的匹配逻辑:K8S 的 PV Controller 在看到一个新的 PVC 时,从所有 PV 中查找匹配的(capacity >= request、accessModes 一致、StorageClass 一致),不关心 PV 叫什么名字。一个 team-a namespace 里的 PVC,能匹配到一个没归属的 PV(因为 PV 没有 namespace 归属)。这才是存储池的应有之义——资源是全局的,谁需要给谁。

反过来,PVC 必须在 namespace 里——因为 PVC 代表"team-a 的应用在申请存储"。你不能把 team-a 的 PVC 挂给 team-b 的 Pod(至少默认不允许),namespace 隔离在这里起作用。

资源级别谁管设计含义
PV集群级管理员全局资源池,任何 namespace 的 PVC 都能匹配
PVC命名空间级应用团队限定在 namespace 内,防止跨团队资源抢占
StorageClass集群级管理员全局的"供应策略",跟 PV 一样跨 namespace

2. PV 如何与云服务关联?​

从 PVC 创建到云盘挂载,整条链路分内外两层:

外层:CSI Provisioner(创建云盘)

当你 kubectl apply 一个 PVC 时,K8S 的 PV Controller 看到 PVC 的 storageClassName: fast-ssd,去找对应的 StorageClass 对象。StorageClass 里有一个 provisioner 字段,告诉 K8S "谁来替这个需求创建存储":

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3-encrypted
provisioner: ebs.csi.aws.com # ← 这个字符串决定了谁会收到"建盘"请求
parameters:
type: gp3
encrypted: "true"

K8S 理解这个字符串——ebs.csi.aws.com 对应一个运行在集群里的 CSI Driver Pod(AWS EBS CSI Driver 部署在 kube-system namespace)。CSI Driver 实现了标准的 CSI gRPC 接口。PV Controller 通过 kubelet 的 CSI socket 调它的 ControllerServer.CreateVolume 方法:

// AWS EBS CSI Driver 内部逻辑简化
func (d *controllerServer) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
// 1. 解析 StorageClass 的 parameters(type=gp3, encrypted=true)
// 2. 调 AWS API 创建 EBS 盘
vol, _ := d.cloud.CreateDisk(ctx, &DiskOptions{
CapacityGB: req.CapacityRange.RequiredBytes / 1024 / 1024 / 1024,
VolumeType: "gp3",
Encrypted: true,
Zone: topologyZone, // WaitForFirstConsumer 场景下 zone 已知
})
// 3. 等 EBS 状态变 available
d.cloud.WaitForVolume(vol.VolumeID, "available")
// 4. 返回卷信息(volumeHandle = vol-xxxx 这个 AWS ID)
return &csi.CreateVolumeResponse{
Volume: &csi.Volume{
VolumeId: vol.VolumeID, // "vol-08d4..." ← AWS 的唯一 ID
CapacityBytes: req.CapacityRange.RequiredBytes,
},
}
}

PV Controller 拿到返回值后,自动创建一个 PV 对象,把 volumeHandle: vol-08d4... 写入 PV 的底层字段,然后把 PV 绑给这个 PVC。

内层:CSI Node Plugin(挂载云盘到 Pod)

当 Pod 所在的 Node 上的 kubelet 准备起容器时,它调用同一个 CSI Driver 的 NodeServer.NodePublishVolume:

func (d *nodeServer) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
volumeID := req.VolumeId // vol-08d4... ← PV Controller 之前写下的 AWS 盘 ID

// 1. 调 AWS API 把这块 EBS 盘 attach 到这个 Node 的 EC2 实例
d.cloud.AttachDisk(d.nodeID, volumeID)

// 2. 等内核识别出新块设备(/dev/nvme1n1 或 /dev/xvdf)
devicePath := waitForDevice(volumeID)

// 3. format 文件系统(如果是新盘)
formatAndMount(devicePath, req.TargetPath, req.VolumeCapability.GetMount().FsType)
}

完整的调用链路:

kubectl apply pvc
│
├─ PV Controller: "有个 PVC 要存储 → 看 StorageClass → provisioner=ebs.csi.aws.com"
│ └─ 调 ebs-csi-controller Pod(CSI CreateVolume gRPC)
│ └─ AWS API: ec2.CreateVolume(type=gp3, size=10, zone=us-east-1a)
│ └─ 返回 VolumeId: vol-08d4...
│ └─ PV Controller 创建 PV,绑到 PVC
│
├─ Scheduler: 把 Pod 分配到 Node(zone us-east-1a)
│
└─ kubelet(该 Node 上): Pod 要挂这个 PVC
└─ 调 ebs-csi-node DaemonSet Pod(CSI NodePublishVolume gRPC)
└─ AWS API: ec2.AttachVolume(vol-08d4..., instance=i-xxxx)
└─ 等 /dev/nvme1n1 出现 → mkfs → mount → 容器看到 /var/lib/mysql

全程 K8S 核心没有调过一次 AWS API。PVC → StorageClass → CSI Driver → 云 API,每一层只做自己的事情。

3. Retain 策略下,怎么回收 PV 给下一个 PVC 用?​

PV 的 persistentVolumeReclaimPolicy: Retain 意味着 PVC 删了之后,PV 不解绑也不自动删云盘。此时 PV 状态是 Released——它还绑着上一个 PVC 的信息,不能被其他 PVC 匹配。

你需要两步手动回收:

# 1. 删掉 PV(可选——旧的绑定信息才能清掉)
kubectl delete pv nfs-pv

# 2. 用同样的 yaml 重新创建
kubectl apply -f pv.yaml
# 新 PV 状态 = Available,可以被新 PVC 匹配

为什么不能直接改状态? K8S API Server 不允许你直接 patch PV 的 status.phase 从 Released 改回 Available——那个状态只由 PV Controller 基于实际绑定情况计算。删 PV 再重建是官方推荐的做法。云盘不受影响——Retain 策略下底层云盘不会被删。

4. PV 快照怎么做?​

K8S 1.17 引入了 VolumeSnapshot CRD,允许你给 PV 做快照——不需要跑云控制台:

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: ebs-snapshot
driver: ebs.csi.aws.com
deletionPolicy: Delete
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: mysql-backup
spec:
volumeSnapshotClassName: ebs-snapshot
source:
persistentVolumeClaimName: mysql-pvc # 给这个 PVC 的 PV 做快照

CSI Driver 收到 CreateSnapshot gRPC 后,调云 API 创建真实快照(AWS EBS Snapshot、GCP PD Snapshot)。

更实用的是——从快照恢复一个新 PV:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-restore
spec:
dataSource:
name: mysql-backup # 从这个快照创建
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Gi

CSI Driver 从快照创建一块新云盘,里面的数据跟快照时间点一模一样。完整的备份恢复流程不需要离开 kubectl。