课程目录(第 10 章 / 共 33 章)
存储卷与 PV/PVC:让数据活过 Pod 重启
从容器临时文件系统讲到 PVC/PV/StorageClass 三层模型,动手给 redis 挂一块 1Gi 持久卷,并验证删掉 Pod 后数据还在。
学完这一章,你将能够
- ✓说清容器文件系统为什么是临时的,以及 Volume 与 emptyDir 的差别
- ✓用一张图讲明白 PVC、PV、StorageClass 三层各自负责什么
- ✓能独立编写 PVC 并把 Deployment 挂到持久卷上,会排查 Pending 与 ContainerCreating
容器里的数据为什么会消失
假设你已经把 redis 跑在集群里,往里面写了几条数据,然后删掉 Pod 让它重建。再连上去看,数据没了。这不是 redis 的 bug,而是容器文件系统天生就是临时的。
镜像的每一层都是只读的,容器启动时会在最上面叠一层可写的「容器层」。你 kubectl exec 进去创建的文件都落在这层里,而它的生命周期和容器严格绑定:
- 容器被重建(探针失败、镜像更新、被 OOM 杀掉),新容器拿到的是全新的可写层,旧文件一起消失。
- Pod 被删除或重新调度到另一台节点,数据不会跟着走。
- 只有镜像本身、Pod 日志和事件是留在集群里的。
想让数据活过 Pod,就得把数据写到 Pod 之外的地方,再挂载进容器。
这正是 Volume 要解决的问题。
Volume 与 emptyDir:先看最简单的共享
Kubernetes 的 Volume 定义在 Pod 级别:同一个 Pod 里的所有容器都可以挂载它,看到同一份文件。最简单的类型是 emptyDir——Pod 被调度到节点时创建一个空目录,Pod 消失时目录也一起被删掉。
它适合两类场景:同一个 Pod 里的多个容器交换数据(比如边车容器读主容器写出的日志),或者作为缓存与临时工作目录——丢了也不心疼。
下面这个例子用两个容器演示共享目录:一个每 5 秒写一次时间戳,另一个读出来。
动手:用 emptyDir 让两个容器共享目录
把下面内容保存成 shared-dir.yaml:
apiVersion: v1
kind: Pod
metadata:
name: shared-dir
namespace: demo
spec:
containers:
- name: writer
image: busybox:1.36
command: ["sh", "-c", "while true; do date > /data/heartbeat; sleep 5; done"]
volumeMounts:
- name: shared
mountPath: /data
- name: reader
image: busybox:1.36
command: ["sh", "-c", "sleep 6; tail -f /data/heartbeat"]
volumeMounts:
- name: shared
mountPath: /data
volumes:
- name: shared
emptyDir: {}创建并观察:
kubectl create namespace demo --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f shared-dir.yaml
kubectl get pod shared-dir -n demo
kubectl logs shared-dir -n demo -c readerkubectl get pod 显示 READY 2/2,说明两个容器都跑起来了;logs -c reader 会打印出一行日期,并且每 5 秒更新一次,证明两个容器确实在看同一个目录。
然后删掉 Pod 重建,看数据会怎样:
kubectl delete pod shared-dir -n demo
kubectl apply -f shared-dir.yaml
kubectl logs shared-dir -n demo -c readerreader 依然能读到文件,但内容是从头开始写的——这是一个全新的 emptyDir。所以 emptyDir 解决的是「共享」,不是「持久」。
PVC、PV、StorageClass 三层关系
要持久化,可以让 Pod 直接写节点上的 hostPath,但那样会把 Pod 死死绑在某台节点上,换个集群就跑不起来,也不适合团队协作。Kubernetes 的做法是把「应用要多少存储」和「存储由谁提供」解耦成三层:
Pod(volumes.persistentVolumeClaim.claimName)
│ 引用
▼
PVC(我要 1Gi、ReadWriteOnce)── 没有现成 PV 时,看 storageClassName
│ │
│ bind(一对一,绑上就是 Bound) │ 动态供给 dynamic provisioning
▼ ▼
PV(集群里一块真实的卷)◄────────── StorageClass(provisioner 模板)
│ 落盘
▼
后端存储(云盘 EBS / Ceph RBD / NFS / kind 的节点本地目录)三个角色的分工可以对照这张表:
| 资源 | 谁创建 | 作用 | 生命周期 |
|---|---|---|---|
| PVC | 你写在 YAML 里 | 声明「我要多大、怎么访问」,不关心底层是云盘还是本地目录 | 属于命名空间,删除前数据一直在 |
| PV | 由 StorageClass 动态创建,或管理员手工创建 | 集群里一块真实的存储资源 | 集群级资源,独立于 Pod 存在 |
| StorageClass | 集群管理员或发行版预装 | 描述「用哪个 provisioner、怎么创建卷」的模板 | 集群级资源,不属于任何命名空间 |
PVC 属于某个命名空间,而它绑定的 PV 是集群级资源,这个作用域差别在第 12 章 命名空间与 RBAC里会详细解释。PVC 和 PV 是一对一绑定的:绑上之后 PVC 的 STATUS 变成 Bound。如果集群里没有现成的 PV,PVC 会一直是 Pending——除非有 StorageClass 帮它动态供给(dynamic provisioning),也就是按 PVC 的要求现场造一个 PV 出来。
StorageClass 与动态供给
StorageClass 里最关键的字段是 provisioner,它决定谁真正去创建存储:云上可能是 ebs.csi.aws.com,本地集群可能是 rancher.io/local-path 或 driver.longhorn.io。
kind 自带一个默认 StorageClass,名字叫 standard,由 local-path-provisioner 提供,实际数据落在节点的一个目录里。它开箱即用,但数据只在单节点本地,节点丢了数据就丢了,别拿它当生产存储。
kubectl get storageclass
kubectl get sc standard -o yaml输出里重点看这几个字段:
| 字段 | 含义 |
|---|---|
| provisioner | 谁来创建卷,kind 里是 rancher.io/local-path |
| reclaimPolicy | PVC 被删除后 PV 怎么处理,Delete 表示一起删除 |
| volumeBindingMode | WaitForFirstConsumer 表示等 Pod 被调度后再创建卷 |
| allowVolumeExpansion | 是否允许扩容,没有这一项就是不允许 |
如果 StorageClass 上带 storageclass.kubernetes.io/is-default-class: "true" 注解,那么没写 storageClassName 的 PVC 会自动使用它。
WaitForFirstConsumer 为什么有用
它让卷在 Pod 被调度之后才创建,这样调度器可以先按节点资源挑机器,再把卷建在那台机器上,避免出现「卷建在 A 节点、Pod 却排不上 A 节点」的死结。代价是:Pod 还没创建时 PVC 会短暂停在 Pending,这是正常现象,不是故障。
动手:给 redis 挂一块持久卷
现在把 redis 的数据目录挂到持久卷上。先写 PVC:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: demo
spec:
accessModes:
- ReadWriteOnce
storageClassName: standard
resources:
requests:
storage: 1Gi再写 Deployment。注意 strategy: Recreate:ReadWriteOnce 的卷同一时间只能被一个节点挂载,滚动更新时新旧 Pod 会同时抢卷,改成先删后建更稳妥。
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: demo
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7.2
args: ["redis-server", "--appendonly", "yes"]
ports:
- name: redis
containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data应用并验证数据是否真的活过了 Pod 重建:
kubectl apply -f redis-pvc.yaml
kubectl apply -f redis-deploy.yaml
kubectl get pvc,pods -n demo
POD=$(kubectl get pod -n demo -l app=redis -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n demo "$POD" -- redis-cli set greeting hello
kubectl exec -n demo "$POD" -- redis-cli get greeting
kubectl delete pod -n demo "$POD"
kubectl wait --for=condition=Ready pod -n demo -l app=redis --timeout=60s
POD=$(kubectl get pod -n demo -l app=redis -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n demo "$POD" -- redis-cli get greeting最后一条命令仍然输出 "hello",说明数据落在了 PV 上,而不是容器里。这也解释了为什么 redis 要用 --appendonly yes:开启 AOF 后数据才会写到 /data。
查看、描述与扩容
kubectl get pvc -n demo
kubectl get pv
kubectl get sc
kubectl describe pvc redis-data -n demokubectl get pvc 输出里 STATUS 是 Bound、CAPACITY 是 1Gi 就对了;kubectl get pv 会看到一块名字类似 pvc-<uuid> 的 PV,CLAIM 列指向 demo/redis-data;kubectl describe pvc 的 Events 段落是排错的关键,动态供给失败的原因通常写在那里。
扩容要分两步看:先改 PVC 申请的容量,再由 CSI/插件真正把底层卷放大。能不能扩容取决于 StorageClass 的 `allowVolumeExpansion`:
kubectl get sc standard -o jsonpath='{.allowVolumeExpansion}{"\n"}'
kubectl patch pvc redis-data -n demo -p '{"spec":{"resources":{"requests":{"storage":"2Gi"}}}}'
kubectl get pvc redis-data -n demo如果 allowVolumeExpansion 为空或 false,上面的 patch 会直接报错,类似「only dynamically provisioned pvc can be resized and the storageclass that provisions the pvc must support resize」——kind 自带的 standard(local-path)通常就属于这种情况。另外缩容不支持,PVC 只能变大不能变小。
访问模式(Access Modes)
| 模式 | 缩写 | 含义 | 常见用途 |
|---|---|---|---|
| ReadWriteOnce | RWO | 只能被一个节点读写挂载 | 数据库、单副本应用,最常用 |
| ReadOnlyMany | ROX | 可以被多个节点只读挂载 | 共享的模型文件、静态资源 |
| ReadWriteMany | RWX | 可以被多个节点读写挂载 | 多副本共享上传目录,需要 NFS/CephFS 等支持 |
| ReadWriteOncePod | RWOP | 只能被一个 Pod读写挂载 | 需要严格独占的卷 |
两个容易搞混的点:RWO 限制的是「节点」而不是「Pod」,所以同一节点上的两个 Pod 可能同时写同一块卷;RWX 是否可用取决于你的存储插件,kind 的 local-path 并不支持。
快照与克隆:升级前的后悔药
PVC 用顺手之后,生产上还有两个高频动作:升级或改配置之前先留一份快照,出问题能退回;用现有卷复制一份数据给测试环境。这两件事分别由 VolumeSnapshot(快照)和 dataSource 克隆完成,它们不是 Kubernetes 核心 API,而是 external-snapshotter 装进来的 CRD。
先确认集群支持
kubectl get volumesnapshotclass 没有输出,就说明集群没装 external-snapshotter,或者你用的 CSI 驱动不支持快照——kind 自带的 `standard`(local-path)就不支持,本章这一段需要在支持快照的集群上做(Ceph RBD、Longhorn、云盘 CSI 等)。
kubectl get crd volumesnapshots.snapshot.storage.k8s.io
kubectl get volumesnapshotclass
kubectl get csidrivers -o custom-columns=NAME:.metadata.nameredis-data(PVC,Bound)
│ ① VolumeSnapshotClass 指定 driver,VolumeSnapshot 指向这个 PVC
▼
redis-data-snap(VolumeSnapshot,READYTOUSE=true 才能用)
│ ② 新 PVC 的 dataSource 指向 VolumeSnapshot → 快照时刻的数据
│ ③ 新 PVC 的 dataSource 指向 PVC(克隆) → 当前数据的一份拷贝
▼
redis-data-restored / redis-data-clone(新 PVC,Bound)VolumeSnapshotClass 的 driver 必须和 StorageClass 的 provisioner 是同一个;deletionPolicy: Retain 表示删掉快照对象后后端快照仍然保留:
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: standard-snapclass
driver: <你的 CSI 驱动名> # 与 StorageClass 的 provisioner 一致
deletionPolicy: Retain # 删掉快照对象后,后端快照仍保留apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: redis-data-snap
namespace: demo
spec:
volumeSnapshotClassName: standard-snapclass
source: {persistentVolumeClaimName: redis-data}kubectl -n demo apply -f snapshot.yaml
kubectl -n demo get volumesnapshot redis-data-snap -w # 等 READYTOUSE 变成 true从快照恢复:新 PVC 的容量不能小于快照,`storageClassName` 必须与快照兼容:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data-restored
namespace: demo
spec:
storageClassName: <支持快照的 StorageClass>
dataSource:
name: redis-data-snap
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 1Gi}}克隆则把 dataSource 换成另一个 PVC——注意 `kind` 是 `PersistentVolumeClaim` 且不写 `apiGroup`:
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: redis-data-clone, namespace: demo}
spec:
storageClassName: <支持克隆的 StorageClass>
dataSource: {name: redis-data, kind: PersistentVolumeClaim}
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 1Gi}}kubectl -n demo apply -f restored.yaml -f clone.yaml
kubectl -n demo get pvc redis-data-restored redis-data-clone # 两个都要 Bound生产上为什么要这么做:快照是「回退点」,克隆是「不碰生产数据的副本」。升级前打一个快照,出问题几分钟就能挂上恢复卷;测试环境要真实数据,克隆一份比导出导入快得多,也不会污染生产卷。但快照和源卷在同一个存储后端里,存储整体故障时两者一起丢,所以它不能替代备份——跨介质备份见备份与恢复。
参考:reference/k8s-in-action/storage/volumesnapshots/README.md、reference/k8s-in-action/storage/ceph-snapshot/README.md
常见坑与排错
| 现象 | 原因 | 怎么确认 | 怎么办 |
|---|---|---|---|
PVC 一直 Pending | 没有默认 StorageClass;storageClassName 名字写错;或 WaitForFirstConsumer 还没等到 Pod | kubectl describe pvc 的 Events;kubectl get sc | 按下面的排查顺序逐条过 |
Pod 卡在 ContainerCreating | 卷挂载失败:PVC 未绑定、RWO 卷被别的节点占用、provisioner 挂了 | kubectl describe pod 里的 FailedMount / Unable to attach or mount volumes | 先确认 PVC 是 Bound,再确认没有两个 Pod 跨节点抢同一块 RWO 卷 |
| 删了 PVC,数据还在(或直接没了) | PV 的回收策略是 Retain(保留)/ Delete(一起删) | kubectl get pv -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.persistentVolumeReclaimPolicy}{"\n"}{end}' | 删 PVC 前先确认策略;Retain 的 PV 状态变 Released,要管理员手工回收 |
patch 扩容报 only dynamically provisioned pvc can be resized | StorageClass 不允许扩容 | kubectl get sc standard -o jsonpath='{.allowVolumeExpansion}{"\n"}' | 换支持扩容的 StorageClass,或新建更大的 PVC 再迁数据 |
| Deployment 多个副本只有一个能起来 | ReadWriteOnce 的卷只能挂在一个节点上 | kubectl describe pod 看挂载失败事件,并检查 PVC 的 accessModes | 用 strategy: Recreate + replicas: 1,或改用支持 RWX 的存储 |
删除 PVC 前一定要看回收策略
Delete 会连底层存储一起删,Retain 会保留 PV 但状态变成 Released、需要管理员手工处理。kubectl get pv 的 RECLAIMPOLICY 列就是答案,先看再删。
PVC 一直 Pending 的排查顺序
按这个顺序查,不要跳步,每一步都能砍掉一半候选原因:
kubectl describe pvc redis-data -n demo,直接看最后的Events——动态供给失败的原因几乎都写在这里。kubectl get sc确认storageClassName写的名字真实存在;写错时事件里会写storageclass.storage.k8s.io "not-exist" not found。- 没写
storageClassName的 PVC 依赖默认 StorageClass,看kubectl get sc里哪个带(default)标记。 - 确认
volumeBindingMode:WaitForFirstConsumer下 Pod 还没创建时Pending是正常的,建了 Pod 才会绑定。 - 看 provisioner 是否活着:kind 是
kubectl get pod -n local-path-storage,其他环境换成对应插件的命名空间。 - 手工创建 PV 的场景,核对 PV 与 PVC 的
capacity、accessModes、storageClassName是否匹配,任一项不一致就永远绑不上。
另外,改完 PVC 的 storageClassName 不会重新绑定,这部分字段基本是不可变的;多个 Pod 挂同一块 RWO 卷时,第二台节点上的 Pod 会一直起不来,这属于预期行为。
自测题
自测:emptyDir 能让两个容器共享数据,为什么它还是不算「持久化」?(点击展开答案)
因为它共享的是同一份临时目录,而这个目录的生命周期和 Pod 严格绑定。Pod 还在时两个容器看到同一份文件,所以「共享」成立;但 Pod 一被删除或重新调度,emptyDir 就被销毁,新 Pod 拿到的是一个全新的空目录。判断是否持久化只看一件事:数据存放在 Pod 之外、Pod 消失后还存不存在。emptyDir 的目录在节点上,但归 Pod 所有,所以它只解决共享,不解决持久。
自测:为什么 PVC 是命名空间级的,而它绑定的 PV 却是集群级的?(点击展开答案)
因为两者的归属不同。PVC 是团队的应用声明,「我要 1Gi、RWO」,属于某个团队,所以放进命名空间。PV 是集群存储池里的一块资源,由管理员或 provisioner 统一管理,可以被任意命名空间的 PVC 绑定(同一时刻只绑定一个),回收策略也由集群层面决定。把 PV 放成集群级,才能让存储资源在多个命名空间之间被统一调度和回收。
自测:为什么给挂 RWO 卷的 Deployment 设 2 个副本,总有一个 Pod 起不来?(点击展开答案)
因为 ReadWriteOnce 限制的是节点级别的读写挂载,同一块卷同一时间只能被一个节点挂载。两个副本被调度到不同节点时,第二个节点上的 kubelet 拿不到卷,Pod 就卡在 ContainerCreating,事件里是挂载失败。正确做法是让这类应用保持单副本并用 strategy: Recreate,或者换一个支持 ReadWriteMany 的存储插件。
小结
- 容器可写层随容器消失,
emptyDir只在 Pod 生命周期内共享数据,两者都不算持久化。 - PVC 是「我要什么」,PV 是「实际的卷」,StorageClass 是「怎么造卷」的模板,三者通过动态供给自动串起来。
- 挂载只需两步:Deployment 里声明
volumes.persistentVolumeClaim.claimName,容器里写volumeMounts.mountPath。 Bound才是正常状态;扩容取决于allowVolumeExpansion,缩容不支持。- 删除 PVC 是否连带删除数据,由
reclaimPolicy决定,动手删之前先确认。
练习
- 把上面的 PVC 容量从
1Gi改成5Gi再 apply,观察kubectl get pvc的CAPACITY和kubectl describe pvc的事件,说说你的集群为什么能或不能扩容。 - 把
storageClassName故意改成not-exist,观察 PVC 的状态与事件,然后用kubectl edit pvc试着改回来,看看会发生什么。 - 给
web(nginx)也挂一个 100Mi 的 PVC,把/usr/share/nginx/html/index.html换成自定义内容,再删掉 Pod 验证页面还在。
应用有了数据还不够——它挂了、变慢了、内存吃满了,集群怎么知道?下一章第 11 章 探针与资源管理会让 Kubernetes 理解你的应用在什么状态下才叫「健康」。
相关章节:第 9 章 ConfigMap 与 Secret(配置的两种注入方式)、第 15 章 排障手册(ContainerCreating 的排查流程)、生产环境的存储选型。