课程目录(第 10 章 / 共 33 章)
课程/让应用可访问、可配置

存储卷与 PV/PVC:让数据活过 Pod 重启

10 章 / 共 33·18 分钟·入门存储PVCkubectl

从容器临时文件系统讲到 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

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: {}

创建并观察:

bash
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 reader

kubectl get pod 显示 READY 2/2,说明两个容器都跑起来了;logs -c reader 会打印出一行日期,并且每 5 秒更新一次,证明两个容器确实在看同一个目录。

然后删掉 Pod 重建,看数据会怎样:

bash
kubectl delete pod shared-dir -n demo
kubectl apply -f shared-dir.yaml
kubectl logs shared-dir -n demo -c reader

reader 依然能读到文件,但内容是从头开始写的——这是一个全新的 emptyDir。所以 emptyDir 解决的是「共享」,不是「持久」。

PVC、PV、StorageClass 三层关系

要持久化,可以让 Pod 直接写节点上的 hostPath,但那样会把 Pod 死死绑在某台节点上,换个集群就跑不起来,也不适合团队协作。Kubernetes 的做法是把「应用要多少存储」「存储由谁提供」解耦成三层:

text
   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-pathdriver.longhorn.io

kind 自带一个默认 StorageClass,名字叫 standard,由 local-path-provisioner 提供,实际数据落在节点的一个目录里。它开箱即用,但数据只在单节点本地,节点丢了数据就丢了,别拿它当生产存储。

bash
kubectl get storageclass
kubectl get sc standard -o yaml

输出里重点看这几个字段:

字段含义
provisioner谁来创建卷,kind 里是 rancher.io/local-path
reclaimPolicyPVC 被删除后 PV 怎么处理,Delete 表示一起删除
volumeBindingModeWaitForFirstConsumer 表示等 Pod 被调度后再创建卷
allowVolumeExpansion是否允许扩容,没有这一项就是不允许

如果 StorageClass 上带 storageclass.kubernetes.io/is-default-class: "true" 注解,那么没写 storageClassName 的 PVC 会自动使用它。

WaitForFirstConsumer 为什么有用

它让卷在 Pod 被调度之后才创建,这样调度器可以先按节点资源挑机器,再把卷建在那台机器上,避免出现「卷建在 A 节点、Pod 却排不上 A 节点」的死结。代价是:Pod 还没创建时 PVC 会短暂停在 Pending,这是正常现象,不是故障。

动手:给 redis 挂一块持久卷

现在把 redis 的数据目录挂到持久卷上。先写 PVC:

yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-data
  namespace: demo
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: standard
  resources:
    requests:
      storage: 1Gi

再写 Deployment。注意 strategy: RecreateReadWriteOnce 的卷同一时间只能被一个节点挂载,滚动更新时新旧 Pod 会同时抢卷,改成先删后建更稳妥。

yaml
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 重建:

bash
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

查看、描述与扩容

bash
kubectl get pvc -n demo
kubectl get pv
kubectl get sc
kubectl describe pvc redis-data -n demo

kubectl get pvc 输出里 STATUSBoundCAPACITY1Gi 就对了;kubectl get pv 会看到一块名字类似 pvc-<uuid> 的 PV,CLAIM 列指向 demo/redis-datakubectl describe pvcEvents 段落是排错的关键,动态供给失败的原因通常写在那里。

扩容要分两步看:先改 PVC 申请的容量,再由 CSI/插件真正把底层卷放大。能不能扩容取决于 StorageClass 的 `allowVolumeExpansion`

bash
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)

模式缩写含义常见用途
ReadWriteOnceRWO只能被一个节点读写挂载数据库、单副本应用,最常用
ReadOnlyManyROX可以被多个节点只读挂载共享的模型文件、静态资源
ReadWriteManyRWX可以被多个节点读写挂载多副本共享上传目录,需要 NFS/CephFS 等支持
ReadWriteOncePodRWOP只能被一个 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 等)。

bash
kubectl get crd volumesnapshots.snapshot.storage.k8s.io
kubectl get volumesnapshotclass
kubectl get csidrivers -o custom-columns=NAME:.metadata.name
text
redis-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)

VolumeSnapshotClassdriver 必须和 StorageClass 的 provisioner 是同一个;deletionPolicy: Retain 表示删掉快照对象后后端快照仍然保留:

yaml
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
  name: standard-snapclass
driver: <你的 CSI 驱动名>    # 与 StorageClass 的 provisioner 一致
deletionPolicy: Retain      # 删掉快照对象后,后端快照仍保留
yaml
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: redis-data-snap
  namespace: demo
spec:
  volumeSnapshotClassName: standard-snapclass
  source: {persistentVolumeClaimName: redis-data}
bash
kubectl -n demo apply -f snapshot.yaml
kubectl -n demo get volumesnapshot redis-data-snap -w   # 等 READYTOUSE 变成 true

从快照恢复:新 PVC 的容量不能小于快照,`storageClassName` 必须与快照兼容

yaml
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`

yaml
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}}
bash
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.mdreference/k8s-in-action/storage/ceph-snapshot/README.md

常见坑与排错

现象原因怎么确认怎么办
PVC 一直 Pending没有默认 StorageClass;storageClassName 名字写错;或 WaitForFirstConsumer 还没等到 Podkubectl describe pvcEventskubectl 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 resizedStorageClass 不允许扩容kubectl get sc standard -o jsonpath='{.allowVolumeExpansion}{"\n"}'换支持扩容的 StorageClass,或新建更大的 PVC 再迁数据
Deployment 多个副本只有一个能起来ReadWriteOnce 的卷只能挂在一个节点上kubectl describe pod 看挂载失败事件,并检查 PVC 的 accessModesstrategy: Recreate + replicas: 1,或改用支持 RWX 的存储

删除 PVC 前一定要看回收策略

Delete 会连底层存储一起删,Retain 会保留 PV 但状态变成 Released、需要管理员手工处理。kubectl get pvRECLAIMPOLICY 列就是答案,先看再删

PVC 一直 Pending 的排查顺序

按这个顺序查,不要跳步,每一步都能砍掉一半候选原因:

  1. kubectl describe pvc redis-data -n demo,直接看最后的 Events——动态供给失败的原因几乎都写在这里。
  2. kubectl get sc 确认 storageClassName 写的名字真实存在;写错时事件里会写 storageclass.storage.k8s.io "not-exist" not found
  3. 没写 storageClassName 的 PVC 依赖默认 StorageClass,看 kubectl get sc 里哪个带 (default) 标记。
  4. 确认 volumeBindingModeWaitForFirstConsumer 下 Pod 还没创建时 Pending 是正常的,建了 Pod 才会绑定。
  5. 看 provisioner 是否活着:kind 是 kubectl get pod -n local-path-storage,其他环境换成对应插件的命名空间。
  6. 手工创建 PV 的场景,核对 PV 与 PVC 的 capacityaccessModesstorageClassName 是否匹配,任一项不一致就永远绑不上。

另外,改完 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 决定,动手删之前先确认。

练习

  1. 把上面的 PVC 容量从 1Gi 改成 5Gi 再 apply,观察 kubectl get pvcCAPACITYkubectl describe pvc 的事件,说说你的集群为什么能或不能扩容。
  2. storageClassName 故意改成 not-exist,观察 PVC 的状态与事件,然后用 kubectl edit pvc 试着改回来,看看会发生什么。
  3. web(nginx)也挂一个 100Mi 的 PVC,把 /usr/share/nginx/html/index.html 换成自定义内容,再删掉 Pod 验证页面还在。

应用有了数据还不够——它挂了、变慢了、内存吃满了,集群怎么知道?下一章第 11 章 探针与资源管理会让 Kubernetes 理解你的应用在什么状态下才叫「健康」。

相关章节:第 9 章 ConfigMap 与 Secret(配置的两种注入方式)、第 15 章 排障手册ContainerCreating 的排查流程)、生产环境的存储选型