前言

如果你是一名 Java 后端工程师,第一次把一个 Spring Boot 应用搬上 Kubernetes,大概率会觉得存储这件事很简单:容器挂了重启,镜像里的文件还在不就行了?官方文档在 Volumes 一节开篇就泼了冷水:容器内的磁盘文件是临时的(ephemeral),容器崩溃或被停止时状态不会被保存,容器生命周期内创建或修改的文件全部丢失,kubelet 重启容器时是以一个干净的状态重新开始的。这对任何「非平凡」的应用都是致命问题——MySQL 的数据目录、Kafka 的日志段、Elasticsearch 的索引段,无一不是写进容器可写层之后就再也找不回来。

官方文档把这个问题拆成了两类:一类是数据持久化,一类是共享存储。同一个 Pod 内的多个容器需要共享文件,或者两个不同节点上的 Pod 需要共享同一份数据,靠容器自身的可写层都做不到。Volume 抽象就是为了同时解决这两个问题而生的。再往上一层,当应用需要「跨 Pod 重启仍然保留」的数据时,单靠 Pod 内联声明的 Volume 就不够了,你需要 PersistentVolume、PersistentVolumeClaim、StorageClass 这一套完整的供给体系,以及 StatefulSet 这样的有状态工作负载控制器。

这篇文章我们逐段带读 Kubernetes 官方文档的存储章节(Volumes、Persistent Volumes、Storage Classes、Ephemeral Volumes、Volume Snapshots)与 StatefulSet 章节,把 Volume / PV / PVC / StorageClass 四层关系、访问模式与回收策略、动态供给与 CSI 驱动、有状态应用的部署与数据保护串成一条完整的主线。所有 API 名称、字段、配置项均以当前版本官方文档为准。

整体架构与核心概念

先建立全局图景。官方文档对 Volume 的定义非常朴素:一个 volume 本质上就是一个目录,里面可能有一些数据,Pod 内的容器可以访问它。这个目录如何产生、由什么介质支撑、里面有什么内容,完全取决于所使用的 volume 类型。Pod 通过 .spec.volumes 声明要用的卷,通过 .spec.containers[*].volumeMounts 声明把卷挂到容器的哪个路径。

Volume 分成两大类:临时卷(ephemeral volume)的生命周期与某个 Pod 绑定,Pod 不存在时 Kubernetes 会销毁它;持久卷(persistent volume)的生命周期独立于任何使用它的 Pod。无论哪种卷,数据在容器重启(container restart)期间都会保留——注意是容器重启,不是 Pod 被删除重建。

整个存储体系可以画成下面这张图:

                 ┌─────────────────────────────────────────────┐
                 │                 开发者 / 用户                 │
                 │  写 Pod YAML:.spec.volumes + volumeMounts   │
                 └──────────────────┬──────────────────────────┘
                                    │ 引用 claimName
                 ┌──────────────────▼──────────────────────────┐
                 │   PersistentVolumeClaim (PVC, 命名空间级)     │
                 │   请求:容量 / accessModes / storageClassName │
                 └──────────────────┬──────────────────────────┘
              动态供给               │ 绑定(一对一,ClaimRef)
   ┌───────────────┐     ┌──────────▼───────────┐
   │  StorageClass │────▶│ PersistentVolume(PV) │  集群级资源
   │ provisioner / │     │ capacity / volumeMode│  对应真实存储:
   │ reclaimPolicy │     │ / nodeAffinity ...   │  NFS、iSCSI、CSI...
   └───────────────┘     └──────────┬───────────┘
                                    │ 挂载
                 ┌──────────────────▼──────────────────────────┐
                 │  kubelet ──▶ 节点 ──▶ 容器内 mountPath        │
                 └─────────────────────────────────────────────┘

对照官方文档的概念定义:PersistentVolume(PV)是集群中的一块存储,可以由管理员预先供给(statically provisioned),也可以基于 StorageClass 动态供给。它跟节点一样是集群资源,生命周期独立于任何 Pod。PersistentVolumeClaim(PVC)是用户对存储的请求,概念上类似 Pod——Pod 消费节点资源,PVC 消费 PV 资源;Pod 可以请求 CPU 和内存级别,PVC 可以请求容量和访问模式(ReadWriteOnce、ReadOnlyMany、ReadWriteMany、ReadWriteOncePod)。StorageClass 则由管理员用来描述「存储的类别」,不同类别可以对应不同的服务质量等级或备份策略,官方文档直言 Kubernetes 本身并不规定 class 代表什么,它更像其他存储系统设计里的「profile」。

一个有后端经验的类比:PV 像物理机/云盘,PVC 像资源申请单,StorageClass 像云厂商控制台里「高性能 SSD / 普通云盘 / 本地盘」这类实例规格,而 Volume 则是进程最终看到的挂载点。理解了这层解耦——供给(provision)与消费(consume)分离——后面所有的设计选择都能推导出来。

主线一:Volume / PVC / PV / StorageClass 的四层关系

我们逐段读 Persistent Volumes 文档的「Lifecycle of a volume and claim」。PV 与 PVC 的交互遵循一个生命周期:Provisioning(供给)→ Binding(绑定)→ Using(使用)→ Reclaiming(回收)。

供给分两种方式。静态供给(Static)由集群管理员预先创建一批 PV,携带真实存储的细节(NFS 路径、iSCSI target、云盘 ID 等),存在于 Kubernetes API 中等待消费。动态供给(Dynamic)发生在没有任何静态 PV 匹配用户的 PVC 时:集群会尝试专门为这个 PVC 动态创建一个卷,前提是 PVC 请求了某个 storage class 且管理员已经创建并配置好该类;把 storageClassName 设为 "" 的 claim 相当于声明自己禁用动态供给。要启用基于 storage class 的动态供给,管理员需要在 API Server 上启用 DefaultStorageClass 准入控制器(放在 --enable-admission-plugins 的逗号分隔有序列表里)。

绑定由控制平面中的一个控制循环完成:它监视新建的 PVC,寻找匹配的 PV 并把它们绑在一起。官方文档特别强调两点:其一,一旦绑定,PVC 与 PV 的绑定是排他的,一对一映射,通过 ClaimRef 实现双向绑定;其二,用户「总是至少得到他所请求的」,但卷可能超出请求——申请 8Gi 的 PVC 完全可能绑到一块 100Gi 的 PV 上。如果没有匹配的卷,claim 会一直停留在未绑定状态,直到有合适的 PV 出现——文档举的例子是:集群里都是 50Gi 的 PV,请求 100Gi 的 PVC 就绑不上,直到有人加入一块 100Gi 的 PV。

使用阶段,Pod 把 claim 当作 volume 来用,在 Pod spec 的 volumes 块里写 persistentVolumeClaim 段,声明 claimName。官方文档专门有一节「A Note on Namespaces」:PVC 是命名空间对象,而 PV 绑定是排他的,所以以 ROX、RWX 这类「Many」模式挂载 claim,只在同一个命名空间内可能。

回收阶段引出回收策略,我们在主线二单独展开。

还有一个重要的保护机制值得细读:Storage Object in Use Protection。如果用户删除一个正在被 Pod 使用的 PVC,这个 PVC 不会被立即移除,删除会被推迟到它不再被任何 Pod 活跃使用为止;管理员删除已绑定 PVC 的 PV 同理。判断方法很直接:kubectl describe pvc 看到 status 为 Terminating 且 Finalizers 列表里包含 kubernetes.io/pvc-protection(PV 对应的是 kubernetes.io/pv-protection)。这解释了为什么你 kubectl delete pvc 之后对象久久不消失——不是集群卡住了,是保护机制在工作。

你现在应该掌握了什么:PV 是集群级的存储资源,PVC 是命名空间级的存储请求,二者一对一绑定且绑定排他;供给可以静态也可以动态,动态供给依赖 StorageClass;使用中对象的删除会被 finalizer 推迟,这是数据安全的兜底。

主线二:访问模式与回收策略

访问模式

官方文档列出四种访问模式(access modes):

访问模式 含义 备注
ReadWriteOnce(RWO) 可被单个节点读写挂载 同一节点上的多个 Pod 仍可同时访问;要单 Pod 独占请用 RWOP
ReadOnlyMany(ROX) 可被多个节点只读挂载 —
ReadWriteMany(RWX) 可被多个节点读写挂载 —
ReadWriteOncePod(RWOP) 仅可被单个 Pod 读写挂载 v1.29 起 Stable;仅 CSI 卷支持

CLI 里的缩写是 RWO / ROX / RWX / RWOP。文档有一个非常容易被误读的告诫,必须逐字读:访问模式并不在存储挂载后强制执行写保护——即使 PV 被声明为 ReadOnlyMany,也不保证它真的只读;只有 ReadWriteOncePod 是受限的,只能挂载到单个 Pod。另外,一个卷同一时刻只能以其中一种访问模式挂载,即使它支持多种。

RWOP 是较新的模式(Kubernetes 1.22+,仅 CSI 卷,v1.29 Stable),官方建议需要确保全集群只有一个 Pod 读写某 PVC 时使用。它要求配套的 CSI sidecar 达到特定版本:csi-provisioner v3.0.0+、csi-attacher v3.3.0+、csi-resizer v1.3.0+。在 StatefulSet 文档的示例注释里,官方也写明:示例为简单起见用了 ReadWriteOnce,但生产环境推荐使用 ReadWriteOncePod。

不同卷插件对访问模式的支持差异很大,文档给出对照:NFS 支持 RWO/ROX/RWX,iSCSI、FC、RBD、HostPath 只支持 RWO/ROX,AzureFile 与 CephFS 三种都支持,CSI 则完全取决于驱动。Java 后端的读者可以把这理解成接口能力的差异:同样声明一个 PVC,底层是 NFS 还是云盘,直接决定你能不能跨节点多写。

回收策略

回收策略(reclaim policy)告诉集群在卷被 claim 释放之后该怎么做,目前有三种,对比如下:

回收策略 行为 数据命运 当前状态
Retain 手动回收:PVC 删除后 PV 仍在,进入 Released 状态 旧数据保留在存储资产上,需管理员手动清理 正式能力
Delete 删除 PV 对象同时删除外部存储资产 数据随底层卷一起删除 动态供给的默认策略
Recycle 基本擦除(rm -rf /thevolume/*)后重新可用 数据被擦除,卷复用 已废弃,仅 nfs、hostPath 支持

逐一带读三种策略的行为:

  • Retain:手动回收。PVC 删除后 PV 仍然存在,进入 Released 状态,但因为旧数据还在,不能直接给新 claim 用。管理员需要手动做四步:删除 PV 对象(外部存储资产仍在)→ 清理存储资产上的数据 → 手动删除存储资产 → 如果想复用,用同样的资产定义新建 PV。
  • Delete:对于支持该策略的插件,删除会同时移除 Kubernetes 里的 PV 对象和外部基础设施里的存储资产。动态供给的卷继承其 StorageClass 的回收策略,默认就是 Delete。官方提醒:管理员应按用户预期配置 StorageClass,否则只能在 PV 创建后再去 edit/patch 它。
  • Recycle:已废弃(deprecated),推荐改用动态供给。它执行一次基本擦除(rm -rf /thevolume/*)再让卷可用;在 Kubernetes 1.37 中只有 nfs 和 hostPath 类型还支持 recycling。

此外还有一个「PersistentVolume deletion protection finalizer」机制(v1.31 引入,v1.33 Stable):回收策略为 Delete 的 PV,会被加上 finalizer(CSI 卷是 external-provisioner.volume.kubernetes.io/finalizer,动态供给的 in-tree 插件卷是 kubernetes.io/pv-controller),确保底层存储真正删除之后 PV 对象才被移除,并且无论 PV 和 PVC 谁先删,存储后端的卷都会被删掉。

你现在应该掌握了什么:四种访问模式中只有 RWOP 提供真正的单 Pod 强约束,其余模式只是匹配和约束挂载行为,不构成写保护;回收策略三选一,动态供给默认 Delete,生产环境要按数据价值谨慎选择,想保留数据就用 Retain 并熟悉手动回收流程。

主线三:动态供给与 CSI 驱动

静态供给的问题是管理员要预先知道用户需要什么,动态供给把「创建真实存储」这一步交给了集群。它的完整链路是:用户创建 PVC(声明 storageClassName)→ 外部供给器(external provisioner)监听到该 PVC → 调用 CSI 驱动的 CreateVolume 在存储后端建卷 → 创建 PV 对象并与 PVC 绑定 → kubelet 在调度结果确定的节点上挂载。

StorageClass 是这条链路的配置载体。每个 StorageClass 包含 provisioner、parameters、reclaimPolicy 等字段,PV 需要动态供给时按这些字段执行。一个典型的 StorageClass 长这样:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: low-latency
  annotations:
    storageclass.kubernetes.io/is-default-class: "false"
provisioner: csi-driver.example-vendor.example
reclaimPolicy: Retain        # 默认值为 Delete
allowVolumeExpansion: true
mountOptions:
  - discard                  # 可能在块存储层开启 UNMAP / TRIM
volumeBindingMode: WaitForFirstConsumer
parameters:
  guaranteedReadWriteLatency: "true"   # 由具体 provisioner 定义

几个关键字段值得逐一带读:

  • Provisioner(必填):决定用哪个卷插件来供给 PV。除了名字带 kubernetes.io 前缀的内置供给器,还可以使用遵循 Kubernetes 规范的外部供给器。文档特别指出:Kubernetes 不包含内置的 NFS provisioner,要用 NFS 必须借助外部供给器(如 NFS subdir external provisioner)。
  • reclaimPolicy:动态创建的 PV 继承该值,只能是 Delete 或 Retain,缺省 Delete。
  • allowVolumeExpansion:置 true 后,用户可以编辑 PVC 请求更大的存储来扩容(只能扩容不能缩容;带文件系统的卷仅支持 XFS、Ext3、Ext4;且需要底层 CSI 驱动支持)。
  • volumeBindingMode:控制绑定与动态供给发生的时机。默认 Immediate 在 PVC 创建时就完成绑定/供给——对拓扑受限(不是全集群节点可访问)的存储后端,这可能在还不知道 Pod 调度需求的情况下建卷,导致 Pod 无法调度。WaitForFirstConsumer 把绑定和供给推迟到使用该 PVC 的 Pod 创建之后,按 Pod 的调度约束(资源需求、node selector、Pod 亲和/反亲和、污点与容忍)来选择或供给卷。文档还提醒:用了 WaitForFirstConsumer 就不要在 Pod spec 里用 nodeName 指定节点,那会绕过调度器,PVC 会一直 Pending;应该用针对 kubernetes.io/hostname 的 nodeSelector。
  • allowedTopologies:在需要时把供给限制在特定拓扑(如可用区),用来替代旧插件的 zone/zones 参数。
  • parameters:最多 512 个,总长度(含键值)不得超过 256 KiB,语义完全由 provisioner 决定。

默认 StorageClass 的机制也容易踩坑:给 StorageClass 打上注解 storageclass.kubernetes.io/is-default-class: "true" 即为默认;PVC 不显式写 storageClassName 时就会用默认类。若存在多个默认类,动态供给时使用最新创建的那个(官方建议只保留一个默认类,允许多个只是为了平滑迁移)。还有一个 v1.28 Stable 的行为:集群里原本没有默认类时创建的、未设 storageClassName 的 PVC,会在默认类出现后由控制平面自动补写;但显式设了 storageClassName: "" 的 PVC 不会被更新。

再说 CSI(Container Storage Interface)。官方文档对它的定位是:为容器编排系统暴露任意存储系统定义标准接口。历史上所有卷插件都是 in-tree 的——要支持一种新的存储系统,必须把代码签入 Kubernetes 核心仓库,跟核心二进制一起编译发布。CSI 让存储厂商可以独立于 Kubernetes 代码库开发插件,以扩展形式部署到集群上。目前 in-tree 插件正在大规模迁移:比如 gcePersistentDisk 自 v1.17 废弃、v1.28 移除,操作被重定向到 pd.csi.storage.gke.io CSI 驱动;awsElasticBlockStore 与 azureDisk 都在 v1.27 移除。CSIMigration 特性(v1.25 Stable)会把针对 in-tree 插件的操作转给对应的 CSI 插件,且迁移完成后旧的 PV 无需任何配置变更仍可继续使用——但前提是管理员必须自己安装并配置好对应的 CSI 驱动,Kubernetes 核心不会替你装。

一个 CSI PV 的核心字段包括:driver(必须与 CSI 驱动在 GetPluginInfoResponse 中返回的名字一致)、volumeHandle(唯一标识卷,对应 CreateVolumeResponse 里的 volume.id)、volumeAttributes(透传给驱动的静态属性)、若干 *SecretRef(在 ControllerPublish、NodeStage、NodePublish 等调用时把 Secret 传给驱动)。挂载选项(mountOptions)不被 Kubernetes 校验——写错了不会在类或 PV 层面报错,而是挂载直接失败。

你现在应该掌握了什么:动态供给 = PVC + StorageClass + provisioner 的协作;WaitForFirstConsumer 是拓扑受限存储的正确姿势;CSI 已是唯一方向,in-tree 插件不是废弃就是在迁移的路上,新集群选型只看 CSI 驱动。

主线四:StatefulSet 的稳定网络标识与有序扩缩

有了 PV/PVC,还要有一个控制器来替有状态应用管理 Pod。官方文档对 StatefulSet 的定义是:运行一组 Pod,并为每个 Pod 维持一个粘性身份(sticky identity)。与 Deployment 管理基于相同容器 spec 的 Pod 不同,StatefulSet 的 Pod 是不可互换的:每个 Pod 都有一个在任意重调度后都保持不变的持久标识符。如果你的应用不需要任何稳定标识或有序部署、删除、扩缩,官方建议直接用 Deployment 或 ReplicaSet。

StatefulSet 适合需要以下一项或多项的应用:稳定唯一的网络标识、稳定持久的存储、有序、优雅的部署与扩缩、有序、自动的滚动更新。这里「稳定」等价于「跨 Pod(重)调度持久」。

三个身份支柱

StatefulSet 的 Pod 身份由三部分组成:序号(ordinal)、稳定网络身份、稳定存储。

  • 序号:N 个副本的 StatefulSet,Pod 序号默认从 0 到 N-1,控制器还会打上 apps.kubernetes.io/pod-index 标签。从 v1.31 起可以通过 .spec.ordinals.start 自定义起始序号。
  • 稳定网络身份:Pod 主机名按 $(statefulset name)-$(ordinal) 构造,例如 web-0、web-1、web-2。StatefulSet 依赖一个 Headless Service(clusterIP: None)管理 Pod 域名,域名为 $(service name).$(namespace).svc.cluster.local,每个 Pod 得到 $(podname).$(governing service domain) 形式的 DNS 子域。这个 Service 需要你自己创建,并在 StatefulSet 的 serviceName 字段中引用。文档还提醒了一个 DNS 细节:负缓存(negative caching)意味着新建的 Pod 的 DNS 名可能不能立刻被解析,CoreDNS 默认缓存 30 秒;需要及时发现的场景可以直接 watch Kubernetes API,或调低 DNS 缓存时间。
  • 稳定存储:volumeClaimTemplates 中每一项都会为每个 Pod 生成一个 PVC,PVC 名按 <volumeClaimTemplate name>-<pod name> 组合。Pod 被(重)调度到某个节点时,挂载其 PVC 对应的 PV。关键点:删除 Pod 或删除整个 StatefulSet,这些 PV 都不会被自动删除——必须手动处理(v1.32 Stable 的 persistentVolumeClaimRetentionPolicy 可以改变这一点,见下文)。

一个最小可用的 StatefulSet 骨架如下(省略部分字段):

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  selector:
    matchLabels:
      app: nginx          # 必须与 .spec.template.metadata.labels 匹配
  serviceName: "nginx"    # 指向你创建的 Headless Service
  replicas: 3
  template:
    metadata:
      labels:
        app: nginx
    spec:
      terminationGracePeriodSeconds: 10   # 不要设为 0
      containers:
      - name: nginx
        image: registry.k8s.io/nginx-slim:0.24
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
  - metadata:
      name: www
    spec:
      accessModes: ["ReadWriteOnce"]      # 生产环境官方推荐 ReadWriteOncePod
      storageClassName: "my-storage-class"
      resources:
        requests:
          storage: 1Gi

有序部署与扩缩的精确语义

官方文档对部署和扩缩保证的描述值得逐字细读,每一句都对应一个生产事故场景:

  • N 副本的 StatefulSet 部署 Pod 时,按 0 到 N-1 的顺序串行创建;删除时逆序,从 N-1 到 0。
  • 在对某个 Pod 执行扩缩操作之前,它的所有前驱(predecessors)必须处于 Running and Ready;如果设置了 .spec.minReadySeconds,前驱必须可用(Ready 持续至少 minReadySeconds 秒)。
  • 在某个 Pod 被终止之前,它的所有后继(successors)必须已经完全关闭。
  • 举例:web-0 在 web-1 Running and Ready 之后、web-2 启动之前挂了,web-2 不会启动,直到 web-0 被成功重建并 Ready。

如果觉得有序性约束太严格,可以通过 .spec.podManagementPolicy: Parallel 放松:所有 Pod 并行启动/终止,不等待彼此;滚动更新时配合 .spec.updateStrategy.rollingUpdate.maxUnavailable(默认 1,可以是绝对数或百分比)还能实现「bursting」式并行更新——但文档警告,这可能让 Pod 乱序就绪,不适合有严格顺序要求的应用。

更新策略(.spec.updateStrategy.type)有三种:RollingUpdate(默认,从最大序号到最小序号逐个删除重建,等更新的 Pod Running and Ready 后再更新前驱,配合 partition 可做金丝雀/分阶段发布)、OnDelete(控制器不自动更新,需手动删 Pod 触发重建)、Recreate(v1.37 Alpha,需启用 StatefulSetRecreateStrategy 特性门,先删光所有 Pod 再按模板重建,期间有停机,适合不能两个版本并存的应用)。

有一个已知陷阱必须记住:RollingUpdate + 默认 OrderedReady 策略下,如果把 Pod 模板更新成一个永远不可能 Ready 的配置(坏二进制、配置错误),StatefulSet 会停住等待;此时只把模板回退到好配置是不够的——由于一个已知 issue,StatefulSet 会继续等待那个坏 Pod Ready,永远不执行回退。正确做法是回退模板后,再手动删除那些用坏配置运行过的 Pod,控制器才会用回退后的模板重建它们。

最后是 PVC 保留策略。默认情况下 StatefulSet 缩容或删除时 PVC 全部保留(数据安全优先)。.spec.persistentVolumeClaimRetentionPolicy 提供 whenDeleted 与 whenScaled 两个开关,各自可设 Retain(默认)或 Delete。设成 Delete 时,控制器会给 PVC 加 owner reference,在 Pod 终止后由垃圾回收器删除 PVC(进而可能按 StorageClass 回收策略删除底层卷)。注意边界:这个策略只在 Pod 因 StatefulSet 删除或缩容而移除时生效;Pod 因节点故障失败、控制平面重建替换 Pod 时,现有 PVC 会被保留并挂到新 Pod 上。文档还建议,如果控制器宕机期间有 Pod 被强删,操作员应核对 PVC 上的 owner reference 是否符合预期。

你现在应该掌握了什么:StatefulSet = 稳定序号 + Headless Service 提供的稳定 DNS + volumeClaimTemplates 提供的稳定存储;扩缩严格有序(0..N-1 升序创建、逆序删除),可用 Parallel 与 podManagementPolicy 放松;滚动更新有坏模板卡死的已知陷阱,回退后必须手动删除坏 Pod;PVC 默认随 Pod/Set 删除而保留,自动清理要显式配置 retention policy。

主线五:有状态数据迁移与备份

有状态应用绕不开两件事:数据备份与数据搬迁。官方文档在存储章节提供了三件标准化武器:Volume Snapshot(卷快照)、CSI Volume Cloning(卷克隆)、Volume Populators(卷填充器)。

Volume Snapshot

VolumeSnapshot 对应存储系统上某个卷在某一时间点的快照。与 PV/PVC 的关系完全同构:VolumeSnapshotContent 是集群中已供给的快照资源(类比 PV),VolumeSnapshot 是用户的快照请求(类比 PVC),VolumeSnapshotClass 用来指定快照的供应商相关属性(类比 StorageClass)。需要注意,这三个对象都是 CRD,不属于核心 API;快照只支持 CSI 驱动,且部署时需要 snapshot controller 和 CSI 驱动旁的 csi-snapshotter sidecar(前者 watch VolumeSnapshot/VolumeSnapshotContent 对象负责创建删除 VolumeSnapshotContent,后者 watch VolumeSnapshotContent 并触发 CreateSnapshot/DeleteSnapshot),这些组件的安装由 Kubernetes 发行版负责。

动态打快照的 YAML 如下:

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: new-snapshot-test
spec:
  volumeSnapshotClassName: csi-hostpath-snapclass
  source:
    persistentVolumeClaimName: pvc-test   # 动态供给快照时必填

删除行为由 VolumeSnapshotContent 的 deletionPolicy 控制:Delete 时删除底层存储快照连同 content 对象,Retain 时两者都保留。另一个保护机制:对正在被用作快照源的 PVC 发起删除,删除会被推迟到快照 readyToUse 或中止,防止快照拍到一半数据源没了。

跨可用区集群还有一个拓扑问题:某可用区卷的快照只能在该区恢复。开启 VolumeSnapshotTopology 后,CSI 驱动报告快照可用位置,由 csi-snapshotter 记录在 VolumeSnapshotContent.spec.nodeAffinity 上,从快照供给卷时会按此拓扑放置——这是 CSI sidecar 的 alpha 特性。

从快照恢复与卷克隆

用快照数据预填充新卷,是在 PVC 里写 dataSource:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: restore-pvc
spec:
  storageClassName: csi-hostpath-sc
  dataSource:
    name: new-snapshot-test
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi

卷克隆类似,把 dataSource 的 kind 换成 PersistentVolumeClaim,指向一个已存在的 PVC,就能得到一个内容相同的克隆卷——同样仅 CSI 插件可用。Volume Populators 进一步把 dataSourceRef 推广为任意自定义资源,让 PVC 可以从任意类型的数据源预填充(含跨命名空间数据源)。

此外 PVC 扩容(expansion)自 v1.24 Stable 后默认开启:编辑 PVC 请求更大容量即触发底层卷扩容(不会新建 PV);带文件系统的卷只在文件系统为 XFS、Ext3、Ext4 时可扩,且要等一个以读写模式使用该 PVC 的 Pod 存在时才会完成文件系统扩容。扩容失败(请求的容量超出后端能力)时控制器会无限重试,官方给出两条恢复路径:管理员把 PV 回收策略改为 Retain、删 PVC、清掉 PV 的 claimRef、用更小的新 PVC 通过 volumeName 绑回原 PV;或者直接把 PVC 的 .spec.resources 改成一个比上次请求小、但仍大于 .status.capacity 的值重试。

你现在应该掌握了什么:快照/恢复/克隆是一套与 PV/PVC 同构的 CRD 体系,只认 CSI 驱动;快照是数据库备份等场景的标准化入口;PVC 扩容是编辑即扩容,但缩容不支持,扩容失败有官方的恢复手册。

主线六:emptyDir 与 hostPath 的适用场景与陷阱

这两个最基础的卷类型,恰恰是最容易被用错的。

emptyDir

官方定义:Pod 被分配到节点时创建,最初为空;Pod 内所有容器可读写相同文件;Pod 因任何原因从节点上移除时,emptyDir 中的数据被永久删除。注意区分:容器崩溃并不会把 Pod 从节点上移除,所以 emptyDir 在容器崩溃时是安全的——这正是「数据在容器重启间保留」的体现。官方列的典型用途:磁盘归并排序的暂存空间(scratch space)、长计算的 checkpoint 以便崩溃恢复、内容管理容器抓取文件而 Web 容器对外提供服务的共享场景。

关键配置项:

  • emptyDir.medium:默认使用支撑节点的那类介质(磁盘、SSD 或网络存储,视环境而定)。设为 "Memory" 时挂载 tmpfs——非常快,但写入的文件计入容器的内存限制(memory limit),且如果不指定大小,内存支撑卷的容量按节点可分配内存(allocatable memory)计。
  • sizeLimit:对默认介质可以指定容量上限,存储从节点 ephemeral storage 分配;如果节点临时存储被其他来源(日志、镜像层)占满,emptyDir 可能提前耗尽。内存支撑的 emptyDir 在不设大小时按节点可分配内存计。
  • emptyDir.mode(v1.37 Alpha,需开启 EmptyDirVolumeMode 特性门):设置目录的 Unix 权限位,取值 0000 到 01777(八进制),默认 0777;设 fsGroup 时 fsGroup 的组权限会覆盖这里的 mode。
apiVersion: v1
kind: Pod
metadata:
  name: test-pd
spec:
  containers:
  - image: registry.k8s.io/test-webserver
    name: test-container
    volumeMounts:
    - mountPath: /cache
      name: cache-volume
  volumes:
  - name: cache-volume
    emptyDir:
      sizeLimit: 500Mi
      # medium: Memory   # 设为 Memory 即为 tmpfs,计入容器内存限额

陷阱在于资源语义:官方文档明确指出,emptyDir 与 hostPath 卷能消费多少空间没有限制,容器与 Pod 之间也没有隔离——emptyDir 的存储上限只有显式设了 sizeLimit 才存在,而 hostPath 连这个都没有。

hostPath

hostPath 把宿主机节点的文件或目录挂载进 Pod。官方文档的态度非常鲜明:能用别的方式就避免用 hostPath,更推荐的替代是定义一个 local 类型的 PersistentVolume。警告写得很具体:

  • 访问宿主文件系统可能暴露特权系统凭证(比如 kubelet 的凭证)或特权 API(比如容器运行时 socket),可用于容器逃逸或攻击集群其他部分。
  • 相同配置的 Pod(比如来自同一个 PodTemplate)在不同节点上行为可能不同,因为节点上的文件不同。
  • hostPath 用量不被视为临时存储用量,磁盘压力需要你自己监控。
  • 如果你用准入校验限制节点上特定目录的访问,只有当同时强制该 hostPath 挂载为只读时才有效;允许不受信 Pod 读写挂载任何宿主路径,容器可能借此颠覆这个读写挂载。

合法用途也有:访问节点级系统组件(如采集 /var/log 系统日志的容器,只读挂载);给静态 Pod(static Pod)提供宿主机上的配置文件——普通 Pod 能访问 ConfigMap,静态 Pod 不行。

type 字段的可选值值得记住:""(默认,挂载前不做任何检查)、DirectoryOrCreate(不存在则创建,权限 0755,属主属组与 kubelet 相同)、Directory(必须是已存在的目录)、FileOrCreate(不存在则创建文件,0644;注意它不会创建父目录,父目录不存在时 Pod 启动失败)、File、Socket、CharDevice(仅 Linux)、BlockDevice(仅 Linux)。

与 hostPath 密切相关的是 local 卷:代表节点上挂载的本地存储设备(磁盘、分区或目录),只能作为静态创建的 PV 使用,不支持动态供给。与 hostPath 相比,local 卷以持久、可移植的方式使用,系统通过 PV 上的 nodeAffinity 感知卷的节点约束从而完成调度——但代价是:节点不健康时 local 卷不可访问,使用它的 Pod 无法运行,应用必须容忍这种可用性下降和潜在数据丢失。使用 local 卷必须设置 PV 的 nodeAffinity,且官方建议配套创建 volumeBindingMode: WaitForFirstConsumer 的 StorageClass(用 provisioner: kubernetes.io/no-provisioner 表明该类不支持自动供给),让绑定延迟到 Pod 调度时再决策。local PV 的清理删除也需要人工或使用外部静态供给器完成。

你现在应该掌握了什么:emptyDir 是随 Pod 消亡的共享暂存,容器崩溃不丢、Pod 移除必丢;hostPath 是特权逃生舱,安全文档明确建议避免,日志采集等只读场景才是主业;要本地盘的性能和 PV 的调度语义,用 local 卷加 nodeAffinity 加 WaitForFirstConsumer。

主线七:数据库上 Kubernetes 的真实取舍

官方文档没有一节叫「数据库该不该上 K8s」,但把前面带读的所有约束拼起来,答案的轮廓是清楚的。这里把文档中的硬约束逐条映射到数据库场景:

第一,存储供给方式决定了你的可用性上限。 StatefulSet 文档在 Limitations 一节写明:给定 Pod 的存储必须由 PersistentVolume Provisioner 按请求的 storage class 供给,或由管理员预先供给。数据库的每一份副本对应一个 PVC,PVC 背后是 PV,PV 背后是真实存储。选 Immediate 绑定的拓扑受限存储(比如某可用区的云盘),可能出现卷建好了 Pod 却调度不过去的死锁;选 WaitForFirstConsumer 才能调度与供给联动。这是文档级的事实,不是经验之谈。

第二,访问模式决定架构形态。 RWO 允许单节点多 Pod 读写,RWOP 才保证全集群单 Pod 独占。文档在 StatefulSet 示例注释里建议生产用 RWOP——对数据库主副本而言,这是用声明式语义替代「主副本只能写一个节点」的手工约束。反过来,如果你的数据库要多副本跨节点共享同一份数据(shared-storage 架构),只有 NFS、AzureFile、CephFS 这类 RWX 插件可选,而文档明确 RWX 只是「允许多节点读写挂载」,不提供任何写保护语义——共享盘上的分布式锁、 fencing(防脑裂)仍然是你自己的责任。

第三,节点亲和与本地盘的可用性代价是明示的。 local 卷文档写得很直白:节点不健康时卷不可访问,Pod 无法运行,应用必须容忍可用性下降和潜在数据丢失。用本地 NVMe 换性能的数据库(类似云上本地盘实例的用法),等于把「节点故障」直接翻译成「该副本失联」,副本数与故障域规划必须按这个前提设计。

第四,扩容与故障恢复有明确的边界。 PVC 只能扩容不能缩容;扩容失败会无限重试,恢复要靠官方手册(Retain → 清 claimRef → 小容量 PVC 绑回原 PV)。这意味着容量规划要留余量,而不能指望先小后大无限扩。StatefulSet 的滚动更新会逐个重建 Pod,更新前驱要等后继 Ready;配合 minReadySeconds 可以给数据库留出「启动后恢复数据、通过探针」的时间窗口。而 Recreate 策略(v1.37 Alpha)天然停机,适合磁盘格式不兼容的双版本不可并存场景——这几乎就是在描述某些数据库的大版本升级。

第五,数据保护要组合使用文档给出的工具链。 快照(VolumeSnapshot)是「某一时间点的卷内容拷贝」的标准化方式,文档举例的第一个场景就是数据库管理员在编辑或删除前备份数据库。但要注意:CSI 快照是存储层的一致性拷贝,数据库有没有把脏页刷盘、有没有处于可恢复状态,快照本身不保证——配合数据库自身的备份工具(如 MySQL 的备份工具、PostgreSQL 的 pg_basebackup)仍是必要的。Pod 被删 PVC 默认保留、retention policy 显式 Delete 才清理、使用中 PVC/PV 删除被 finalizer 保护——这套设计默认假设「数据比资源贵」,运维自动化要尊重这个假设。

一句话总结取舍:Kubernetes 官方文档为有状态负载提供的是「身份稳定 + 存储解耦 + 供给自动化 + 快照标准化」的地基;数据库自身的高可用协议、一致性保障、备份验证仍然要由数据库系统和你来负责。把 StatefulSet 当作「稳定的命名与挂载机制」,而不是「数据库高可用方案」,预期就对了(具体能力边界以当前版本官方文档为准)。

你现在应该掌握了什么:数据库上 K8s 的可行性可以逐条对照文档验证——供给模式、访问模式、local 卷可用性、扩缩容边界、快照语义;文档没承诺的(写保护、强一致、备份有效性)都不要默认它有。

踩坑经验

以下每组都来自官方文档中的明确告诫,❌ 是常见错误做法,✅ 是文档推荐的正确做法。

❌ 错误做法 1:PVC 不写 storageClassName,以为这样可以绑定到任何 PV。
✅ 正确做法:理解三态语义。storageClassName: "" 是显式请求「无类 PV」并禁用动态供给;不写字段则看 DefaultStorageClass 准入插件——开了就绑默认类(多个默认类取最新创建的),没开就没有默认类的概念。v1.28 起还有追溯赋值:默认类出现后,未设该字段的旧 PVC 会被自动补上默认类,但显式设 "" 的不会被改。想精确控制绑定,显式写字段。

❌ 错误做法 2:拓扑受限的存储(如单可用区云盘)配 Immediate 绑定,结果 PVC 已绑定、Pod 却调度不到卷所在区,永久 Pending。
✅ 正确做法:StorageClass 设 volumeBindingMode: WaitForFirstConsumer,把供给推迟到 Pod 创建后,让调度器带着 Pod 的全部约束(资源、node selector、亲和/反亲和、污点容忍)去选卷;并且此时绝不用 Pod spec 的 nodeName 指定节点——那会绕过调度器,PVC 永远 Pending,要用 kubernetes.io/hostname 的 nodeSelector。

❌ 错误做法 3:以为声明了 ReadOnlyMany 或 ReadWriteOnce,挂载后就是只读或单写了。
✅ 正确做法:官方文档明确——访问模式不在挂载后强制执行写保护,ROX 不保证只读;只有 ReadWriteOncePod 是强约束(且仅 CSI 卷、v1.29+)。真正的只读要靠挂载选项和存储后端配置。

❌ 错误做法 4:StatefulSet 滚动更新发了一个坏模板,Pod 永远 NotReady,于是把模板改回来,坐等自愈。
✅ 正确做法:这是文档点名的已知陷阱。回退模板后必须手动删除那些已经带着坏配置运行过的 Pod,StatefulSet 才会用回退后的模板重建它们;否则它会永远等坏 Pod Ready。

❌ 错误做法 5:删除 StatefulSet 时期望顺带清理掉所有 PVC 和磁盘,或者反过来,缩容后纳闷残留的 PVC 为什么还在。
✅ 正确做法:默认行为是 PVC/PV 全保留(数据安全优先),清理必须手动;想自动化就配置 .spec.persistentVolumeClaimRetentionPolicy 的 whenDeleted/whenScaled 为 Delete,并理解它只在删除/缩容场景生效,节点故障重建 Pod 时 PVC 始终保留。

❌ 错误做法 6:用 hostPath 给应用做持久化存储,或者让不受信的 Pod 以读写方式挂载宿主目录。
✅ 正确做法:官方明确建议避免 hostPath;持久化请用 local PV 或网络存储。准入校验限制宿主目录时,必须同时强制该 hostPath 挂载为只读,否则读写挂载可以颠覆整个限制。

最佳实践清单

  1. 写可移植配置:官方「Writing Portable Configuration」一节建议:配置包里带 PVC、不带 PV(用户未必有权限建 PV);让用户可选地提供 storage class 名,提供就填进 storageClassName,不提供就留空(nil)走默认类;工具侧要监视长时间未绑定的 PVC 并提示用户。
  2. 生产 StatefulSet 用 ReadWriteOncePod:文档示例注释明确推荐,确保主副本全集群单 Pod 独占;同时确认 CSI sidecar 版本达标(csi-provisioner v3.0.0+、csi-attacher v3.3.0+、csi-resizer v1.3.0+)。
  3. 拓扑受限存储一律 WaitForFirstConsumer,local 卷必须配 nodeAffinity 且用 kubernetes.io/no-provisioner 的 StorageClass 延迟绑定;定期检查 Unused PVC(v1.37 Beta 起控制面会给 PVC 加 Unused condition),清理僵尸存储。
  4. 回收策略按数据价值选:StorageClass 默认 Delete 会连底层存储一起删;重要的数据用 Retain,并接受 Released PV 的手工回收流程。
  5. 不要手改 PV 容量:直接编辑 PV 的 capacity 再让 PVC 与之匹配,会让控制平面误以为容量已扩而跳过真实扩容;正确姿势是只编辑 PVC 的 .spec.resources,并确认 StorageClass allowVolumeExpansion: true。
  6. 滚动更新留缓冲:StatefulSet 设 minReadySeconds 与合理的 terminationGracePeriodSeconds(勿为 0),给数据库类应用留出启动恢复与优雅退出时间;需要灰度用 rollingUpdate.partition。
  7. 用通用临时卷(generic ephemeral volume)而非裸 CSI 内联卷做可限容的暂存:它能限容量、可快照可克隆可扩容,且 PVC 名确定(Pod 名 + 卷名);注意命名冲突(pod-a + 卷 scratch 与 Pod pod + 卷 a-scratch 都会生成 PVC pod-a-scratch)。
  8. 快照不等于备份:VolumeSnapshot 是 CSI 标准化快照,部署依赖发行版安装 snapshot controller 与 csi-snapshotter;快照配合数据库自身备份工具使用,并验证可恢复性。

总结

  1. Kubernetes 存储体系的核心是供给与消费的解耦:PV 是集群级存储资源,PVC 是命名空间级的存储请求,StorageClass 描述存储类别,Volume 是 Pod 内的最终挂载点;四者通过「供给→绑定→使用→回收」的生命周期串起来,绑定排他且一对一。
  2. 访问模式只在匹配和调度层面生效,不构成挂载后的写保护;需要全集群单 Pod 强约束就用 ReadWriteOncePod(CSI、v1.29+)。回收策略默认 Delete,数据重要的存储显式选 Retain。
  3. 动态供给是主流路径:StorageClass 的 provisioner、volumeBindingMode、allowVolumeExpansion、allowedTopologies 四个字段决定了供给的拓扑正确性、时机与弹性;拓扑受限存储务必 WaitForFirstConsumer。
  4. CSI 是存储集成的唯一未来:in-tree 插件大面积废弃或迁移,快照、克隆、RWOP、扩容等新能力全部只支持 CSI;选型只评估 CSI 驱动及其 sidecar 版本矩阵。
  5. StatefulSet 提供的是稳定身份(序号、Headless Service DNS、volumeClaimTemplates 存储),以及有序部署/扩缩/更新的保证;它不保证删除时清理存储(默认保留),也不替数据库解决一致性——坏模板卡死需手动删 Pod 的陷阱务必记住。
  6. 数据保护要组合快照、克隆、populator 与 PVC 扩容等标准化能力,同时尊重文档默认的「数据比资源贵」假设:使用中对象的删除被 finalizer 保护,删除前想清楚回收策略。

参考资料

  • Volumes: https://kubernetes.io/docs/concepts/storage/volumes/
  • Persistent Volumes: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
  • Storage Classes: https://kubernetes.io/docs/concepts/storage/storage-classes/
  • Ephemeral Volumes: https://kubernetes.io/docs/concepts/storage/ephemeral-volumes/
  • Volume Snapshots: https://kubernetes.io/docs/concepts/storage/volume-snapshots/
  • Storage(章节索引): https://kubernetes.io/docs/concepts/storage/
  • StatefulSets: https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/