跳到主要内容

存储系统

容器本身的文件系统是临时的:容器一删,里面写的数据就没了。Docker 系列里我们用 volume 和 bind mount 解决单机持久化,到了 Kubernetes 问题更复杂——Pod 会重建、会被调度到别的节点,数据必须能「跟着走」。这篇从最简单的 Pod 级 Volume 讲起,一路到 PV/PVC、StorageClass 动态供给、CSI 插件体系,最后用一个完整可跑的 StatefulSet 示例收尾。

Volume​

K8s 的 Volume 和 Docker 的 volume 名字像、用法像,但最核心的差异在生命周期归属:

对比项Docker volumeKubernetes Volume
生命周期归属独立于容器,容器删了 volume 还在跟着 Pod 走:Pod 在卷就在,Pod 删卷就删
容器重启重新挂载即可同一 Pod 内的容器崩溃重启,卷里的数据原样保留
管理方Docker Enginekubelet(在节点上创建和挂载)
数据持久化落在宿主机目录,天然持久取决于卷类型:emptyDir 随 Pod 消失,持久化要靠 PV/PVC

一句话:K8s Volume 的生命周期跟 Pod 走,不跟容器走。Pod 里的容器无论重启多少次,看到的都是同一份卷;但只要 Pod 没了(删除、驱逐、节点故障后重建),emptyDir 这类 Pod 级卷就随之消失。想让数据跨过 Pod 重建仍然存在,就得用后面的 PV/PVC 体系,把存储的生命周期从 Pod 上解耦出去。

常用卷类型速查:

类型生命周期典型用途
emptyDir跟 Pod临时缓存、Pod 内容器间共享目录
hostPath跟节点系统级 agent 读写宿主机文件(日志、监控)
configMap / secret跟 Pod把配置、密钥以文件形式注入容器
downwardAPI / projected跟 Pod把 Pod 自身信息、多来源数据投射成文件
nfs独立于 Pod多节点共享的网络存储
persistentVolumeClaim独立于 Pod对接 PV/PVC 体系,业务数据持久化的标准做法
csi独立于 Pod直接引用 CSI 驱动提供的卷(一般较少直接用)

emptyDir​

emptyDir 是最简单的卷:Pod 被调度到节点时,kubelet 在节点上创建一个空目录挂进 Pod(「empty」就是指初始为空)。它有两个要点:

  • Pod 级共享:同一个 Pod 里的多个容器可以同时挂载同一个 emptyDir,是容器间共享文件的标准方式(比如主容器写日志、Sidecar 收集日志)。
  • 随 Pod 生灭:容器崩溃重启,emptyDir 数据还在;但 Pod 被删除、驱逐或在其他节点重建,emptyDir 就彻底消失,不可恢复。

一个两容器共享 emptyDir 的例子:

apiVersion: v1
kind: Pod
metadata:
name: cache-demo
spec:
containers:
- name: writer
image: busybox:1.36
command: ['sh', '-c', 'while true; do date >> /cache/tick.log; sleep 5; done']
volumeMounts:
- name: cache
mountPath: /cache
- name: reader
image: busybox:1.36
command: ['sh', '-c', 'while true; do tail -n 3 /cache/tick.log; sleep 10; done']
volumeMounts:
- name: cache
mountPath: /cache
volumes:
- name: cache
emptyDir: {}

注意 volumes 定义在 Pod 层(和 containers 平级),容器里用 volumeMounts 按名字引用——这是所有卷类型的通用写法,后面不再重复说明。

medium: Memory 内存盘​

把 medium 设为 Memory,emptyDir 就从节点磁盘变成 tmpfs 内存盘:

  volumes:
- name: cache
emptyDir:
medium: Memory
sizeLimit: 256Mi

读写速度极快,适合对 IO 敏感的临时数据。但代价是:

  • 占用的是节点内存,而且计入容器的内存用量——写太多会把容器顶到 memory limit,触发 OOMKilled。
  • 建议配合 sizeLimit 限定上限。
  • 节点重启,内存盘内容自然清空(反正它本来就活不过 Pod)。
什么时候用 emptyDir

只放「丢了不心疼」的数据:缓存、临时计算空间、checkpoint、容器间传递文件。凡是需要持久化的业务数据,直接跳到本篇的 PV/PVC 部分。

hostPath​

hostPath 把节点文件系统上的文件或目录直接挂进 Pod,相当于 K8s 版的 bind mount:

apiVersion: v1
kind: Pod
metadata:
name: hostpath-demo
spec:
containers:
- name: app
image: busybox:1.36
command: ['sh', '-c', 'ls /host-logs | head; sleep 3600']
volumeMounts:
- name: host-logs
mountPath: /host-logs
readOnly: true
volumes:
- name: host-logs
hostPath:
path: /var/log
type: Directory

type 字段控制挂载前的检查行为:

type 取值行为
DirectoryOrCreate目录不存在就自动创建(权限 0755),存在则直接使用
Directory目录必须已存在,否则 Pod 启动失败
FileOrCreate文件不存在就自动创建(权限 0644),存在则直接使用
File文件必须已存在
SocketUNIX socket 必须已存在
CharDevice字符设备必须已存在
BlockDevice块设备必须已存在

建议始终显式写 type:不写就等同于「不检查」,路径不存在或类型不符时要到容器读写时才报错,排查很绕。

hostPath 只该给系统级 agent 用

hostPath 有两大硬伤。一是数据绑死在节点上:Pod 重新调度到别的节点,同名路径下是另一份内容,多副本应用每个副本看到的数据都不一样。二是安全风险大:挂载 docker.sock、containerd.sock 或根目录这类路径,等于把节点 root 权限拱手让给容器,容器逃逸直奔宿主机。

它合理的场景基本只有 DaemonSet 形态的系统组件:日志采集器读 /var/log、监控 agent 读 /proc、/sys,CNI/CSI 插件管理节点目录。业务应用的持久化数据不要用 hostPath,单节点学习集群(kind、minikube)里凑合用一下可以,生产环境请走 PV/PVC。

ConfigMap Volume​

ConfigMap 除了以环境变量注入,更常用的方式是挂载为卷:每个 key 变成挂载目录下的一个文件,value 是文件内容。

先准备一个 ConfigMap,再挂进 Pod:

apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
app.properties: |
server.port=8080
log.level=info
welcome.txt: |
hello kubernetes
---
apiVersion: v1
kind: Pod
metadata:
name: config-demo
spec:
containers:
- name: app
image: busybox:1.36
command: ['sh', '-c', 'while true; do cat /etc/config/app.properties; sleep 30; done']
volumeMounts:
- name: config
mountPath: /etc/config
volumes:
- name: config
configMap:
name: app-config

容器里会看到 /etc/config/app.properties 和 /etc/config/welcome.txt 两个文件。卷定义里还可以用 items 只挑部分 key、自定义文件名,用 defaultMode 指定文件权限(默认 0644)。

更新传播有延迟​

修改 ConfigMap 之后,已挂载到 Pod 里的文件会自动更新,但不是即时的。延迟来自两段:kubelet 的 Pod 同步周期(默认 1 分钟),加上 kubelet 内部 ConfigMap 缓存的 TTL(默认也是 1 分钟),所以最坏情况下要等约两分钟才能看到新内容(新版本 kubelet 改用 watch 缓存后延迟明显缩短,但仍非实时)。

另外两个容易忽略的点:

  • 应用要重新读文件才生效。很多程序只在启动时读一次配置,文件更新了它也不知道,需要程序自己 watch 文件变化,或者干脆滚动重启 Pod。
  • 以环境变量注入的 ConfigMap 完全不更新,只能重建 Pod。

subPath 挂载不自动更新的经典坑​

如果只想要 ConfigMap 里的某一个文件,常见写法是用 subPath 挂单个文件:

      volumeMounts:
- name: config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf

坑在于:用 subPath 挂载的文件,ConfigMap 更新后内容不会跟着变。原因是整目录挂载时,K8s 通过符号链接的原子切换来推送更新;而 subPath 是对单个文件做 bind mount,绕开了这套机制,文件被「钉死」在旧版本上。

规避方法

要么挂载整个目录(不用 subPath),让应用从目录里读文件;要么接受「更新配置就滚动重启 Pod」的运维方式,很多 Helm Chart 会给 Deployment 加 ConfigMap 内容的 checksum 注解,配置一变就触发重建,就是这个思路。

Secret Volume​

Secret 卷和 ConfigMap 卷的用法几乎一模一样:

  volumes:
- name: tls
secret:
secretName: my-tls-secret
defaultMode: 0400

同样是每个 key 一个文件,同样有更新传播延迟,subPath 不更新的坑也完全一样。区别在于数据本身:

  • Secret 的 data 字段要求 value 是 base64 编码(也可以用 stringData 写明文,提交时自动编码)。注意 base64 只是编码不是加密,任何能读 Secret 的人一条命令就能解码;真正的保护靠 RBAC 限制访问,以及给 etcd 开启静态加密。
  • Secret 卷在节点上用 tmpfs(内存文件系统)存储,不落盘,Pod 删除即清除,比普通目录更适合放密钥。
  • 权限通常收得更紧,比如 defaultMode: 0400 只允许属主读。
环境变量 vs 卷挂载

密码、证书这类敏感数据优先用卷挂载:环境变量会在 kubectl describe pod、crash 日志里直接暴露,卷挂载的文件则不会。配置类和密钥类数据的注入方式选择,「配置与安全」一篇还会展开。

PV​

PersistentVolume(持久卷)是集群里一块真实存储在 K8s 中的抽象——背后可以是云盘、NFS 目录、Ceph 卷、本地盘。它是集群级资源,不属于任何命名空间,生命周期独立于 Pod:Pod 删了,PV 和里面的数据都还在。

一个静态创建的 NFS 类型 PV:

apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-nfs-demo
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
volumeMode: Filesystem
nfs:
server: 192.168.1.10
path: /data/k8s

几个关键字段:

  • capacity:容量,静态供给时就是个声明,K8s 不会帮你校验底层存储真实大小。
  • volumeMode:Filesystem(默认,格式化成文件系统挂载)或 Block(裸块设备,交给应用自己用)。
  • accessModes:访问模式,见下表。
  • persistentVolumeReclaimPolicy:回收策略,见下文。

accessModes​

accessMode缩写含义
ReadWriteOnceRWO只允许一个节点挂载读写
ReadOnlyManyROX允许多个节点挂载,但只读
ReadWriteManyRWX允许多个节点同时挂载读写
ReadWriteOncePodRWOP只允许一个 Pod 挂载读写(1.29 起稳定,需要 CSI 驱动支持)
accessModes 是「能力声明」,不是强制隔离

RWO 的卷在技术上仍能被同一节点上的多个 Pod 使用,只是不能跨节点同时挂载。而且不是所有存储后端都支持全部模式:AWS EBS、各类云盘一般只支持 RWO;NFS、CephFS 才支持 RWX。选模式前先确认后端能力。

persistentVolumeReclaimPolicy​

PVC 删除后,PV 和底层数据怎么处理:

取值行为
Retain保留:PV 进入 Released 状态不再可用,数据留着,由管理员手动清理后重建 PV 复用
Delete删除:PV 连同底层存储(如云盘)一起删掉,动态供给的 PV 默认是这个策略
Recycle已废弃:早年会清空数据后复用,早已被动态供给取代,不要再使用
Delete 策略要谨慎

云厂商托管 K8s 的默认 StorageClass 大多用 Delete:删 PVC 等于删云盘,数据直接没了。生产环境的重要数据,要么把策略改成 Retain,要么确保有快照备份兜底。

静态供给流程​

上面这种「管理员先建好 PV」的方式叫静态供给,完整流程四步:

  1. 管理员准备好底层存储(比如开好 NFS 目录),创建 PV 对象。
  2. 用户创建 PVC,声明自己需要多大的存储。
  3. 控制面自动匹配合适的 PV,两者进入 Bound(绑定)状态。
  4. Pod 通过 PVC 引用这块存储。

静态供给的痛点很明显:每来一个存储需求都要管理员手工造 PV,规格还不一定刚好匹配。动态供给(StorageClass 一节)就是为了解决这个。

PVC​

PersistentVolumeClaim(持久卷声明)是用户对存储的「申请单」:声明要多大、什么访问模式、用哪个 StorageClass,剩下的交给系统匹配。

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-demo
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: manual

请求匹配规则​

控制面给 PVC 挑 PV 时按这些规则来:

  • accessModes 和 storageClassName 必须一致(PVC 不写 storageClassName 时另有规则,见 StorageClass 一节)。
  • PV 容量必须大于等于请求容量;有多个满足条件的 PV 时,选容量最接近请求的(最小够用)。
  • 可以再加 selector 按标签精确筛选。
  • 暂时没有合适的 PV,PVC 停在 Pending 状态,等到有可用 PV(或动态供给造出 PV)再自动绑定。

一对一绑定​

PVC 和 PV 的绑定是一对一、独占的:一个 PVC 绑一个 PV,一个 PV 也只能被一个 PVC 绑。这带来一个反直觉的结果——即使 PV 有 10Gi 而 PVC 只要 5Gi,整个 PV 也归这个 PVC 独占,剩下的 5Gi 谁也用不了。

kubectl get pv,pvc
NAME                          CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM
persistentvolume/pv-nfs-demo 10Gi RWX Retain Bound default/data-demo

NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
persistentvolumeclaim/data-demo Bound pv-nfs-demo 10Gi RWX manual

注意 PVC 那行的 CAPACITY 显示的是 PV 的 10Gi,而不是请求的 5Gi。所以静态供给时 PV 的规格要尽量贴近实际需求,否则容量白白浪费。

Pod 里使用 PVC:

spec:
containers:
- name: app
image: busybox:1.36
command: ['sh', '-c', 'sleep 3600']
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: data-demo

从这里开始,Pod 不用再关心背后是 NFS 还是云盘——PV 描述「有什么存储」,PVC 描述「要什么存储」,两者解耦,这是整个 K8s 存储体系的核心设计。

StorageClass​

StorageClass(存储类)解决静态供给的痛点:管理员不再手工造 PV,而是定义「这类存储用什么 provisioner、什么参数创建」,用户 PVC 一提交,PV 自动造出来。

动态供给流程​

用户创建 PVC(指定 storageClassName)
→ StorageClass 里的 provisioner(CSI 插件)收到请求
→ provisioner 调用存储后端 API 创建真实存储(比如开一块云盘)
→ 自动创建对应的 PV 对象
→ PVC 与 PV 自动 Bound,Pod 可用

一个典型的 StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
type: gp3
fsType: ext4
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

provisioner 字段决定由谁来造存储,各环境差别很大:

环境provisioner 示例
AWS EBS 云盘ebs.csi.aws.com
GCE Persistent Diskpd.csi.storage.gke.io
阿里云云盘diskplugin.csi.alibabacloud.com
Ceph RBDceph-csi 的 rbd.csi.ceph.com
本地盘(学习、边缘场景)Rancher local-path-provisioner
NFS 目录nfs.csi.k8s.io 或 nfs-subdir-external-provisioner

parameters 下的参数完全由 provisioner 自己定义(云盘类型、文件系统、IOPS 档位等),写之前要查对应驱动的文档。

默认 StorageClass​

集群可以打一个「默认 StorageClass」的标记(注解 storageclass.kubernetes.io/is-default-class: "true",kind、minikube 等学习集群出厂就有)。它带来一条重要规则:

  • PVC 不写 storageClassName → 自动使用默认 SC,走动态供给。
  • PVC 写 storageClassName: ""(空字符串)→ 明确禁用动态供给,只匹配同样没有 class 的静态 PV。

这两个行为完全不同,是经典踩坑点:本想用静态 PV,结果忘了写空字符串,PVC 被默认 SC 接走造了个新 PV。

kubectl get sc
NAME                 PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION
local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false

volumeBindingMode: WaitForFirstConsumer​

volumeBindingMode 有两个取值,默认 Immediate:PVC 一创建,PV 立刻供给并绑定。对本地盘类存储,这个默认值会惹祸:

  • PV 在 PVC 创建时就造好,比如落在了 A 节点的磁盘上。
  • 之后 Pod 调度时却被分配到了 B 节点。
  • 卷在 A、Pod 在 B,拓扑永远不满足,Pod 卡在 Pending 动弹不得。

WaitForFirstConsumer 把供给和绑定延迟到有 Pod 引用这个 PVC 时才执行:调度器先选好节点,provisioner 再在那个节点上创建存储,从根上消除「盘和人分居两地」。多可用区的云盘同理——不用它,盘可能建错了可用区。

经验法则

本地盘(local-path-provisioner 等)必须用 WaitForFirstConsumer;跨可用区的云盘强烈推荐用;单可用区的网络存储用 Immediate 也没问题。

CSI​

CSI(Container Storage Interface)是 K8s 定义的存储插件标准接口,如今所有存储对接都走它。一套典型的 CSI 驱动在集群里分两侧部署:Controller 侧以 Deployment 形式运行,Pod 里除了厂商自己的 driver 容器,还配着几个官方出品的 sidecar——external-provisioner 负责 watch PVC 并调用 driver 的 CreateVolume 造卷、external-attacher 负责把卷 attach 到节点、external-snapshotter 负责快照、external-resizer 负责扩容;Node 侧以 DaemonSet 形式跑在每个节点上,node-driver-registrar 负责把驱动注册给 kubelet,driver 的 node 容器负责在节点上把卷格式化、挂载进 Pod 的目录。kubelet 本身不懂任何存储实现,全部通过 CSI 的 gRPC 接口调用驱动,各 sidecar 也是通用组件,驱动作者只需要实现接口规定的几个方法。

CSI 插件架构

为什么 in-tree 插件都迁出去了

早年的存储插件(AWS EBS、GCE PD、OpenStack Cinder……)代码直接写在 K8s 核心仓库里,叫 in-tree 插件。问题随之而来:每支持一个新存储就要改 K8s 源码、跟着 K8s 的发版节奏走,bug 修复也要等大版本发布,核心仓库越来越臃肿。CSI 把存储实现整体移到核心之外(out-of-tree):厂商在自己的仓库里维护驱动、按自己的节奏发版,K8s 核心只保留接口。in-tree 插件从 1.26 起陆续删除,到 1.31 前后基本清完。对用户来说变化是透明的——PV/PVC 的用法不变,只是 provisioner 名字从 kubernetes.io/aws-ebs 变成了 ebs.csi.aws.com。

Snapshot​

K8s 的卷快照体系由三个对象组成,和 PV 体系一一对应:

快照体系对应 PV 体系角色
VolumeSnapshotPVC用户侧的「快照申请」,指定给哪个 PVC 打快照
VolumeSnapshotContentPV集群侧实际的快照资源,指向底层存储的真实快照
VolumeSnapshotClassStorageClass定义用哪个 CSI 驱动、删除策略等参数

前提是 CSI 驱动支持快照,且集群里装了快照相关的 CRD 和 snapshot-controller(主流发行版和托管集群一般自带)。

给 PVC 打快照:

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: data-demo-snapshot
spec:
volumeSnapshotClassName: csi-snapclass
source:
persistentVolumeClaimName: data-demo

从快照恢复 PVC​

恢复靠新 PVC 的 dataSource 字段指向快照:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-restored
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: fast-ssd
dataSource:
apiGroup: snapshot.storage.k8s.io
kind: VolumeSnapshot
name: data-demo-snapshot

要点:

  • 恢复出的 PVC 容量不能小于快照来源的 PVC。
  • 常见用途:备份恢复、故障前存档、给数据分析复制一份生产数据的副本。
  • 快照是否「崩溃一致」(应用层面的数据完整性)取决于应用本身,对数据库类应用,打快照前最好先 flush 或停写。

Clone​

克隆比快照更直接:新 PVC 的 dataSource 指向同命名空间下已有的 PVC,新卷拿到源卷的一份完整副本:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-demo-clone
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: fast-ssd
dataSource:
apiGroup: ""
kind: PersistentVolumeClaim
name: data-demo

要点:

  • 需要 CSI 驱动支持克隆能力,主流驱动(云盘、Ceph 等)基本都支持。
  • 写法和快照恢复几乎一样,区别在于 kind 是 PersistentVolumeClaim,且 apiGroup 是空字符串 ""(核心 API 组)。
  • 只能克隆同命名空间的 PVC;请求容量必须大于等于源 PVC。
  • 克隆出的卷和源卷从此完全独立,互不影响,适合「复制一份数据做实验」「给测试环境造数据」这类场景。

Volume Expansion​

业务跑着跑着盘不够用了,K8s 支持在线扩容 PVC,不用重建 Pod。

前提两个:StorageClass 里设了 allowVolumeExpansion: true,且 CSI 驱动支持扩容(支持列表见各驱动文档,主流云盘和 Ceph 都支持)。

操作就是改大 PVC 的请求容量——只能改大,不能改小:

kubectl patch pvc data-demo --type merge -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'

扩容分两层:先扩底层块设备,再扩上面的文件系统。观察进度:

kubectl describe pvc data-demo
Conditions:
Type Status Reason
---- ------ ------
FileSystemResizePending True Waiting for user to (re-)start a pod to finish file system resize of volume on node.

在线扩容的条件​

  • 1.27 起「卷正在使用中也能扩」成为稳定特性:块设备扩完后,文件系统在 Pod 不重启的情况下在线扩完。旧版本或驱动不支持时,会停在 FileSystemResizePending 状态,等 Pod 重启才完成文件系统扩容。
  • 文件系统得支持在线扩容:ext4、xfs 都可以(这也是云盘默认用它们的原因)。
  • volumeMode: Block 的块设备卷没有文件系统这一层,块设备扩完即完成。
  • 扩容不可逆,云盘类后端还会立刻按新容量计费,扩之前想清楚。

验证结果:

kubectl get pvc data-demo
kubectl exec redis-0 -- df -h /data

容量字段变成新值、df 看到的文件系统也变大,才算整个流程走完。

Stateful 数据实践​

把前面所有东西串起来的终极场景:有状态应用。需求很典型——每个副本要有自己独立、稳定的存储,Pod 挂了重建数据不丢。这正是 StatefulSet 的 volumeClaimTemplates 干的事:为每个副本按模板自动创建一个 PVC。

一份完整可运行的 Redis 示例(依赖集群有默认 StorageClass,kind、minikube 都满足):

apiVersion: v1
kind: Service
metadata:
name: redis
labels:
app: redis
spec:
clusterIP: None
ports:
- port: 6379
name: redis
selector:
app: redis
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
spec:
serviceName: redis
replicas: 3
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7.2
ports:
- containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
labels:
app: redis
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

创建并观察:

kubectl apply -f redis-sts.yaml
kubectl get pods -l app=redis -w
NAME      READY   STATUS    RESTARTS   AGE
redis-0 1/1 Running 0 40s
redis-1 1/1 Running 0 35s
redis-2 1/1 Running 0 30s
kubectl get pvc
NAME           STATUS   VOLUME                                     CAPACITY   STORAGECLASS
data-redis-0 Bound pvc-1a2b3c4d-0001 1Gi local-path
data-redis-1 Bound pvc-1a2b3c4d-0002 1Gi local-path
data-redis-2 Bound pvc-1a2b3c4d-0003 1Gi local-path

PVC 随 Pod 名稳定​

注意 PVC 的命名规则:模板名-StatefulSet名-序号,于是 redis-0 永远绑定 data-redis-0,redis-1 永远绑定 data-redis-1。这份绑定关系写进了设计的骨子里:

  • Pod 被删除重建(哪怕漂移到别的节点),新的 redis-0 挂的还是 data-redis-0,数据原样回来。
  • 缩容时 Pod 按序号从大到小删除,PVC 保留;之后重新扩容,redis-2 回来时发现 data-redis-2 还在,直接挂回旧数据。

亲手验证一下「删 Pod 不丢数据」:

# 写入一个 key 并强制落盘
kubectl exec redis-0 -- redis-cli set hello k8s
kubectl exec redis-0 -- redis-cli save

# 删掉 redis-0,等 StatefulSet 自动重建
kubectl delete pod redis-0

# 重建后再读
kubectl exec redis-0 -- redis-cli get hello
"k8s"

删 Pod 不删 PVC 的设计​

这里有个刻意的安全设计:删 Pod 不删 PVC,甚至删掉整个 StatefulSet,PVC 也依然保留。StatefulSet 控制器不替用户做「删数据」这种不可逆的决定——副本数可以随便调,数据必须由人显式清理。真要删干净,得自己动手:

kubectl delete statefulset redis
kubectl delete pvc -l app=redis

(PVC 上的 app=redis 标签来自上面 volumeClaimTemplates 里 metadata.labels 的声明,不声明就只能按名字逐个删。)

一句话总结本篇

Pod 内的临时数据用 emptyDir;配置密钥用 ConfigMap/Secret 卷;业务持久化数据走 PV/PVC,生产上几乎总是配合 StorageClass 动态供给;有状态应用交给 StatefulSet 加 volumeClaimTemplates,让「稳定的网络标识 + 稳定的存储」成对出现。