阶段一 · Kubernetes 核心

存储系统

前置回顾

  • 第 03 篇 StatefulSet 用了 volumeClaimTemplates,当时只说了"每 Pod 独占一块存储",没展开原理
  • 第 06 篇 ConfigMap/Secret 用了 Volume 挂载,但那只是配置文件场景

本篇讲解:容器的文件系统为什么是"一次性"的,Volume 怎么解决这个问题,PV / PVC / StorageClass 三者怎么配合,以及生产环境给数据库等有状态应用挂盘的标准做法.

容器文件系统的本质

先理解一个基本事实:容器内的文件系统是临时的.

容器 = 镜像层(只读) + 容器层(可写,用 OverlayFS 叠加)

┌─────────────────────────────────┐
│ 容器层(可写 Upper Layer) │ ← 容器运行时的所有写入都在这里
│ /tmp/data.txt ← 你写的文件 │
├─────────────────────────────────┤
│ 镜像层 3(只读) │
├─────────────────────────────────┤
│ 镜像层 2(只读) │ ← 镜像构建时的各层
├─────────────────────────────────┤
│ 镜像层 1(只读)- Base Image │
└─────────────────────────────────┘

问题:容器层随容器生命周期存在
容器被删除(Pod 重建/重启) → 容器层消失 → 你写的所有文件全部丢失

Docker 章节讲过 OverlayFS 的原理.在 K8s 语境下,关键结论是:

  • 容器内直接写文件 → 写入容器层 → 容器重启就丢
  • Pod 被重建(节点故障,滚动更新)→ 新容器从头开始 → 数据彻底消失
  • 同一个 Pod 内的多个容器各自有独立的容器层 → 默认不能共享文件

这对无状态应用(如 Web 服务)没问题--每次请求独立处理,不依赖本地文件.但对有状态应用(数据库,缓存,文件存储),这是致命的.

Volume - 给 Pod 挂一块"外部存储"

Volume 是 K8s 解决容器存储问题的基础抽象:一块独立于容器层的存储空间,挂载到容器内的某个路径.

没有 Volume:
容器文件系统 = 镜像层 + 容器层(临时)
容器重启 → 数据丢失

有 Volume:
容器文件系统 = 镜像层 + 容器层 + Volume(挂载到 /data)
容器重启 → 容器层丢失,但 /data 目录的内容来自 Volume → 数据保留

Volume 的生命周期取决于类型:
emptyDir → 随 Pod 存在(Pod 删除则丢失)
hostPath → 随节点存在(Pod 调度到其他节点则看不到)
PVC → 独立于 Pod 和节点(真正的持久化)

Volume 在 YAML 中的位置

apiVersion: v1
kind: Pod
metadata:
name: app
spec:
volumes: # 1. 在 Pod 级别声明"有哪些 Volume"
- name: data-vol # 给 Volume 起个名字
emptyDir: {} # Volume 类型 + 配置

containers:
- name: app
image: my-app:v1
volumeMounts: # 2. 在容器级别声明"把哪个 Volume 挂到哪个路径"
- name: data-vol # 引用上面声明的 Volume 名
mountPath: /data # 容器内的挂载路径

两步操作:volumes 声明"有什么存储可用",volumeMounts 声明"把它挂到容器内哪个目录".
这两步分开的原因是:一个 Pod 有多个容器时,同一个 Volume 可以被多个容器同时挂载(实现容器间文件共享).

常用 Volume 类型

emptyDir - 临时共享空间

最简单的 Volume 类型.Pod 创建时在节点上新建一个空目录,Pod 删除时清空.

spec:
volumes:
- name: shared-data
emptyDir: {} # 默认存在节点磁盘上

# 或者存在内存中(tmpfs),速度快但占 Pod 内存配额
- name: cache
emptyDir:
medium: Memory # 存内存
sizeLimit: 256Mi # 限制大小,超出则 Pod 被驱逐
生命周期:
Pod 创建 → emptyDir 创建(空的)
容器写入 /data → 实际写入节点的 /var/lib/kubelet/pods/<pod-id>/volumes/...
容器崩溃重启 → emptyDir 内容保留(因为 Pod 还在)
Pod 被删除 → emptyDir 一起删除 → 数据丢失

适用场景:
✓ 同一 Pod 内多个容器需要共享文件(如 sidecar 模式)
✓ 临时缓存/中间计算结果(丢了也没关系)
✓ 排序算法需要的临时磁盘空间
✗ 需要持久化的数据(数据库,用户上传文件)

典型用法--sidecar 共享日志:

spec:
volumes:
- name: log-volume
emptyDir: {}

containers:
# 主容器:写日志到 /var/log/app/
- name: app
image: my-app:v1
volumeMounts:
- name: log-volume
mountPath: /var/log/app

# Sidecar:读取日志并转发到 Elasticsearch
- name: log-shipper
image: filebeat:latest
volumeMounts:
- name: log-volume
mountPath: /var/log/app # 同一个 Volume,同一个目录
readOnly: true # 只读访问

hostPath - 直接使用节点路径

将节点(宿主机)上的文件或目录挂载到 Pod 内.

spec:
volumes:
- name: docker-socket
hostPath:
path: /var/run/docker.sock # 节点上的路径
type: Socket # 类型检查(可选)

containers:
- name: dind
image: docker:cli
volumeMounts:
- name: docker-socket
mountPath: /var/run/docker.sock
type 值 含义
""(空) 不检查,路径不存在也不报错
DirectoryOrCreate 目录不存在则自动创建
Directory 必须是已存在的目录
FileOrCreate 文件不存在则创建
File 必须是已存在的文件
Socket 必须是 Unix Socket

hostPath 的风险:生产慎用

hostPath 有严重的可移植性和安全问题:

  1. Pod 调度到不同节点 → 看不到之前写的数据
  2. Pod 可以读写宿主机任意目录 → 安全风险(逃逸攻击面)
  3. 多 Pod 写同一路径 → 数据竞争.

hostPath 只适用于:DaemonSet 读取节点日志(/var/log),访问节点设备(Docker socket,GPU 设备)等特权场景.普通应用永远不要用 hostPath.

configMap / secret Volume

第 06 篇已经详细讲过.本质上 ConfigMap 和 Secret 也是一种 Volume 类型:

volumes:
- name: config-vol
configMap:
name: app-config # 每个 key → 一个文件
- name: secret-vol
secret:
secretName: db-creds # 每个 key → 一个文件(权限默认 0644)
defaultMode: 0400 # 可以改成只读

Volume 类型总览

类型 生命周期 跨节点 适用场景
emptyDir 随 Pod 容器间共享,临时缓存
hostPath 随节点 DaemonSet 读节点文件(日志/socket)
configMap 随 ConfigMap 对象 配置文件注入
secret 随 Secret 对象 敏感配置注入
persistentVolumeClaim 独立 取决于后端 数据库,有状态应用
nfs 随 NFS 服务器 多 Pod 共享读写(遗留方案)
projected - - 将多个 Volume 源合并到同一目录

PV 与 PVC:持久化存储

Volume 类型仍不够完善

emptyDir 随 Pod 删除而丢失,hostPath 绑定在单个节点上.要解决"Pod 删了数据不丢,调度到任何节点都能访问"的问题,需要网络存储(云盘,NFS,Ceph 等).

K8s 确实允许你在 Pod 里直接引用网络存储,但这样做有严重的耦合问题:

# 反模式:Pod 里直接写存储后端细节
spec:
volumes:
- name: data
awsElasticBlockStore: # ← 写死了 AWS EBS
volumeID: vol-0123456789 # ← 写死了磁盘 ID
fsType: ext4

# 问题:
# 1. 应用开发者需要知道存储后端类型(AWS?GCP?Ceph?)
# 2. 应用代码和基础设施耦合(换个云平台就要改 YAML)
# 3. 磁盘生命周期和 Pod 耦合(谁来创建/删除磁盘?)
# 4. 不同团队/应用如何共享存储资源?

PV 和 PVC 是什么

K8s 用两个对象把"谁提供存储"和"谁使用存储"拆开了:

  • PV(PersistentVolume): 一块实际的存储资源.由集群管理员创建(或由 StorageClass 自动创建),描述了具体的后端类型,容量,访问模式.是集群级别的对象,不属于任何 namespace.
  • PVC(PersistentVolumeClaim): 应用对存储的"需求声明".开发者只写"我要 100Gi,读写模式",不关心背后是 EBS 还是 Ceph.属于某个 namespace,Pod 通过引用 PVC 来使用存储.

两者的关系是绑定(Binding):K8s 找到一个满足 PVC 要求的 PV,把它们关联起来.之后 Pod 引用 PVC,就能用上那块 PV 背后的真实存储.

开发者写:                          管理员/StorageClass 提供:
┌──────────────────────┐ ┌──────────────────────────────┐
│ PVC │ │ PV │
│ "我要 100Gi, RWO" │── 绑定 ──│ AWS EBS vol-0123, 100Gi, RWO │
└──────────────────────┘ └──────────────────────────────┘

Pod 引用 PVC

PV = 停车场里的车位(由物业提供,有具体位置和大小).
PVC = 租车位申请(租户只写"我要一个能停 SUV 的车位",不关心具体编号).
绑定 = 物业分配了一个具体车位给你.
开发者不需要知道车位在地下几层--拿着"停车证"(PVC)就能停车.

PersistentVolume (PV) - 存储资源的抽象

PV 是集群级别 的资源(不属于任何 namespace),代表一块真实的存储.由集群管理员预先创建,或由 StorageClass 动态创建.

apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-ebs-100g # PV 名称(集群全局唯一)
spec:
capacity:
storage: 100Gi # 1. 容量
accessModes:
- ReadWriteOnce # 2. 访问模式
persistentVolumeReclaimPolicy: Retain # 3. 回收策略
storageClassName: gp3 # 4. 关联的 StorageClass
csi: # 5. 具体存储后端(这里是 AWS EBS CSI)
driver: ebs.csi.aws.com
volumeHandle: vol-0123456789
fsType: ext4

访问模式 (Access Modes)

访问模式定义"这块存储能被多少个节点同时挂载,以什么方式访问":

模式 缩写 含义 典型后端
ReadWriteOnce RWO 只能被一个节点以读写模式挂载 云盘(EBS/云硬盘),本地 SSD
ReadOnlyMany ROX 可以被多个节点以只读模式挂载 NFS,CephFS
ReadWriteMany RWX 可以被多个节点以读写模式挂载 NFS,CephFS,GlusterFS
ReadWriteOncePod RWOP 只能被一个Pod以读写模式挂载(K8s 1.27+) 需要 CSI 驱动支持

RWO 是一个node, 不是一个 Pod

ReadWriteOnce 允许同一节点上的多个 Pod 同时挂载同一个 PV(因为块设备挂载在节点上,多 Pod 只是多个进程访问同一个挂载点).如果你需要严格的"只有一个 Pod 能访问",要用 ReadWriteOncePod(RWOP).
但注意:大多数场景用 RWO 就够了--StatefulSet 的每个 Pod 有独立 PVC,自然不会冲突.

回收策略 (Reclaim Policy)

PVC 被删除后,PV 和底层存储怎么处理?

策略 PVC 删除后的行为 适用场景
Retain PV 保留(状态变为 Released),底层存储不删除.管理员需手动清理或重新绑定 生产数据库: 宁可多占存储也不能误删数据
Delete PV 和底层存储一起删除(如 AWS EBS 卷被销毁) 临时测试环境,动态供给时的默认行为
Recycle 清空数据后 PV 重新可用(已废弃,不要用) -
Retain 策略下 PV 的状态流转:
Available → Bound → Released → (手动清理后)Available
↑ ↓
PVC 创建并绑定 PVC 删除,PV 释放但不清除数据
需要管理员手动处理:
- 备份数据
- 删除 PV 的 claimRef 字段
- PV 重新变为 Available

Delete 策略下:
Available → Bound → PVC 删除 → PV 和底层存储一起销毁
(不可恢复!)

Delete 策略意味着数据不可恢复

使用 Delete 策略的 StorageClass 创建的 PV,PVC 一旦被删除,底层云盘立即销毁.生产环境的有状态服务(数据库)务必使用 Retain 策略,或确保有独立的备份机制.

PersistentVolumeClaim (PVC) - 存储的使用申请

PVC 是namespace 级别的资源,由应用开发者创建.它描述"我需要多大的存储,什么访问模式",K8s 负责找到一个满足条件的 PV 并绑定.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data # PVC 名称
namespace: default
spec:
accessModes:
- ReadWriteOnce # 我需要 RWO 模式
resources:
requests:
storage: 100Gi # 我需要至少 100Gi
storageClassName: gp3 # 从哪个 StorageClass 供给(或匹配已有 PV)

PVC 与 PV 的绑定过程

PVC 创建后,PV Controller 执行绑定逻辑:

1. 查找匹配的 PV:
- capacity >= PVC 请求的 storage
- accessModes 包含 PVC 要求的模式
- storageClassName 匹配
- PV 状态为 Available

2. 绑定:
- PV.spec.claimRef 指向这个 PVC
- PVC.spec.volumeName 指向这个 PV
- 两者状态都变为 Bound
- 绑定是一对一的:一个 PV 只能绑定一个 PVC

3. 如果找不到匹配的 PV:
- 如果 StorageClass 支持动态供给 → 自动创建 PV(见下节)
- 如果不支持 → PVC 一直处于 Pending 状态
$ kubectl get pv,pvc
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM
persistentvolume/pv-ebs-100 100Gi RWO Retain Bound default/mysql-data

NAME STATUS VOLUME CAPACITY ACCESS MODES
persistentvolumeclaim/mysql-data Bound pv-ebs-100 100Gi RWO

在 Pod 中使用 PVC

apiVersion: v1
kind: Pod
metadata:
name: mysql
spec:
volumes:
- name: mysql-storage
persistentVolumeClaim:
claimName: mysql-data # 引用 PVC 名称

containers:
- name: mysql
image: mysql:8.0
volumeMounts:
- name: mysql-storage
mountPath: /var/lib/mysql # MySQL 数据目录
env:
- name: MYSQL_ROOT_PASSWORD
value: "secret"
完整链路(Pod → PVC → PV → 实际存储):

Pod spec:
volumes:
- persistentVolumeClaim:
claimName: mysql-data ← 引用 PVC


PVC "mysql-data":
volumeName: pv-ebs-100 ← 绑定到 PV


PV "pv-ebs-100":
csi:
driver: ebs.csi.aws.com
volumeHandle: vol-012345 ← 实际的 AWS EBS 卷 ID


AWS EBS 云盘 vol-012345
→ 挂载到 Pod 所在节点 → 格式化为 ext4 → mount 到容器的 /var/lib/mysql

StorageClass - 动态供给的模板

手动创建 PV 在大规模集群中不现实(几百个 StatefulSet 各自需要独立 PVC,管理员来不及一个个创建 PV).
StorageClass 实现动态供给(Dynamic Provisioning):PVC 一创建,StorageClass 自动调用存储后端的 API 创建一块新磁盘,然后自动创建对应的 PV 并绑定.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3 # StorageClass 名称
provisioner: ebs.csi.aws.com # 1. 谁负责创建存储?(CSI 驱动名)
parameters: # 2. 创建时的参数(传给 provisioner)
type: gp3 # EBS 卷类型
iops: "3000" # IOPS
throughput: "125" # 吞吐量 MiB/s
encrypted: "true" # 加密
reclaimPolicy: Retain # 3. 动态创建的 PV 的回收策略
allowVolumeExpansion: true # 4. 是否允许在线扩容
volumeBindingMode: WaitForFirstConsumer # 5. 延迟绑定(见下文解释)

动态供给流程

开发者创建 PVC(指定 storageClassName: gp3)

PV Controller 发现没有现成 PV 匹配

调用 StorageClass "gp3" 的 provisioner(ebs.csi.aws.com)

CSI 驱动调用 AWS API → 创建一块新的 EBS gp3 卷(100Gi)

CSI 驱动创建对应的 PV 对象(自动填入 volumeHandle)

PV Controller 将 PVC 和新 PV 绑定

Pod 调度到节点后,kubelet 通过 CSI 将 EBS 卷 attach 到节点并 mount

全过程:开发者只写了一个 PVC → 剩下全部自动完成

volumeBindingMode: WaitForFirstConsumer

默认行为 Immediate:PVC 创建后立即绑定 PV(磁盘可能创建在 zone-a).但 Pod 可能被调度到 zone-b → 跨 AZ 挂载失败.
WaitForFirstConsumer 延迟到 Pod 被调度后才创建和绑定 PV--在 Pod 所在节点的同 AZ 创建磁盘,避免跨 AZ 问题.
生产环境强烈建议用 WaitForFirstConsumer.

默认 StorageClass

# 查看集群中的 StorageClass
$ kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION
gp3 (default) ebs.csi.aws.com Retain WaitForFirstConsumer true
gp2 kubernetes.io/aws-ebs Delete Immediate false

# 标记默认 StorageClass(PVC 不指定 storageClassName 时使用)
$ kubectl annotate storageclass gp3 \
storageclass.kubernetes.io/is-default-class=true

如果 PVC 没有指定 storageClassName,K8s 使用标记为 default 的 StorageClass 进行动态供给.

PV / PVC / StorageClass 三者关系

┌──────────────────────────────────────────────────────────────────┐
│ 集群管理员 / 云平台 │
│ │
│ StorageClass "gp3" │
│ provisioner: ebs.csi.aws.com │
│ parameters: {type: gp3, encrypted: true} │
│ reclaimPolicy: Retain │
│ │
│ → 定义了"动态创建 PV 的模板" │
│ → 管理员只需要创建一次,后续全自动 │
└────────────────────────────────────┬─────────────────────────────┘
│ PVC 触发动态供给

┌──────────────────────────────────────────────────────────────────┐
│ 应用开发者 │
│ │
│ PVC "mysql-data" │
│ storageClassName: gp3 │
│ requests.storage: 100Gi │
│ accessModes: [RWO] │
│ │
│ → "我要 100Gi RWO 的存储,从 gp3 这个 StorageClass 拿" │
│ → 开发者不需要知道底层是 EBS / Ceph / NFS │
└────────────────────────────────────┬─────────────────────────────┘
│ 自动创建 + 绑定

┌──────────────────────────────────────────────────────────────────┐
│ 自动生成 │
│ │
│ PV "pvc-a1b2c3d4-..."(动态生成的名称) │
│ capacity: 100Gi │
│ csi.volumeHandle: vol-0987654321 │
│ claimRef: default/mysql-data │
│ │
│ → 指向真实的 AWS EBS 卷 │
│ → 和 PVC 一对一绑定 │
└──────────────────────────────────────────────────────────────────┘
概念 类比 谁创建 级别
StorageClass 停车场类型(地下/地面/VIP) 管理员 集群级
PVC 租车位申请("我要一个 VIP 车位") 开发者 namespace 级
PV 分配的具体车位(B2-015 号) 管理员手动 / StorageClass 自动 集群级

StatefulSet 与 volumeClaimTemplates

第 03 篇简单提过,这里结合完整的 PV/PVC 知识再看一遍.

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: mysql-svc
replicas: 3
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumeClaimTemplates: # ← 不是 volumes,而是"PVC 模板"
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: gp3
resources:
requests:
storage: 100Gi
StatefulSet 创建 3 个副本时的行为:

mysql-0 → 自动创建 PVC "data-mysql-0" → 触发动态供给 → 创建 PV + EBS 卷
mysql-1 → 自动创建 PVC "data-mysql-1" → 触发动态供给 → 创建 PV + EBS 卷
mysql-2 → 自动创建 PVC "data-mysql-2" → 触发动态供给 → 创建 PV + EBS 卷

每个 Pod 有独立的 PVC → 独立的 PV → 独立的磁盘 → 数据隔离

mysql-1 挂了重建:
新 Pod 仍叫 mysql-1 → 找到 PVC "data-mysql-1"(还在)→ 重新挂载 → 数据恢复

缩容到 1 个副本:
data-mysql-1 和 data-mysql-2 PVC 保留不删(防止误删数据)
重新扩容到 3 → 复用已有 PVC → 数据原样恢复

对比 Deployment 的存储:

维度 Deployment + PVC StatefulSet + volumeClaimTemplates
PVC 数量 所有 Pod 共享同一个 PVC 每个 Pod 独立一个 PVC
Pod 重建后 新 Pod 和旧 Pod 挂载同一个 PVC(数据共享) 新 Pod 挂载属于自己编号的 PVC(数据隔离)
适用 多 Pod 共享同一份静态文件(如模板,资源包) 每 Pod 有独立数据(数据库,分布式存储节点)

在线扩容

磁盘不够了?如果 StorageClass 设置了 allowVolumeExpansion: true,可以直接改 PVC 的 requests.storage:

# 方式一:kubectl edit
$ kubectl edit pvc mysql-data
# 把 requests.storage 从 100Gi 改为 200Gi

# 方式二:kubectl patch
$ kubectl patch pvc mysql-data -p '{"spec":{"resources":{"requests":{"storage":"200Gi"}}}}'
扩容流程:
1. PVC requests.storage: 100Gi → 200Gi
2. CSI Controller 调用云平台 API 扩容底层磁盘(EBS Modify Volume)
3. kubelet 检测到容量变化 → 在线扩展文件系统(resize2fs / xfs_growfs)
4. 完成,无需重启 Pod

注意:
- 只能扩容,不能缩容(缩容会丢数据)
- 部分存储后端需要 Pod 重启后才能完成文件系统扩展
- 扩容过程中 PVC 状态会短暂变为 FileSystemResizePending

扩容是在线的吗?

大多数现代 CSI 驱动(AWS EBS CSI,GCP PD CSI)支持在线扩容: Pod 无需重启.
但少数旧驱动或特定文件系统可能需要 Pod 重启后才能识别新容量.扩容前先确认你的 CSI 驱动是否支持 ONLINE 扩展.

CSI - 容器存储接口

CSI(Container Storage Interface)是 K8s 与存储后端之间的标准接口.K8s 不直接和 AWS/GCP/Ceph 打交道,而是通过 CSI 驱动插件间接操作.

CSI 出现之前:
存储逻辑写死在 K8s 核心代码中(in-tree)
→ 新增存储后端需要改 K8s 源码 + 等新版本发布
→ 维护困难,存储厂商无法独立迭代

CSI 出现之后:
K8s 核心只定义接口(gRPC 协议)
→ 存储厂商实现 CSI 驱动(独立部署为 Pod)
→ 新增/升级存储后端不需要动 K8s 本身

类比:
USB 接口标准 → 任何厂商按标准做 U 盘就能用
CSI 接口标准 → 任何厂商按标准做驱动就能接入 K8s

CSI 驱动架构

CSI 驱动由两部分组成:

┌─────────────────────────────────────────────────┐
│ CSI Controller(Deployment,1-2 副本) │
│ 职责: │
│ - CreateVolume:调用云 API 创建磁盘 │
│ - DeleteVolume:调用云 API 删除磁盘 │
│ - ControllerPublishVolume:Attach 磁盘到节点 │
│ - ExpandVolume:扩容 │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│ CSI Node(DaemonSet,每节点一个) │
│ 职责: │
│ - NodeStageVolume:格式化磁盘(如 mkfs.ext4) │
│ - NodePublishVolume:Mount 到容器目录 │
│ - NodeUnpublishVolume:Unmount │
└─────────────────────────────────────────────────┘

常见 CSI 驱动

下表中的"CSI 驱动"列是驱动的注册名称(driver name),不是网址.它采用域名反转的命名规范(类似 Java 包名)来保证全局唯一,你在 StorageClass 的 provisioner 字段里填这个名字,K8s 就知道该调用哪个驱动去创建/挂载磁盘.

平台 CSI 驱动 存储类型
AWS ebs.csi.aws.com EBS 块存储
AWS efs.csi.aws.com EFS 文件存储(NFS 兼容,支持 RWX)
GCP pd.csi.storage.gke.io Persistent Disk
Azure disk.csi.azure.com Azure Disk
腾讯云 com.tencent.cloud.csi.cbs CBS 云硬盘
Ceph rbd.csi.ceph.com Ceph RBD 块存储
Ceph cephfs.csi.ceph.com CephFS 文件存储
本地 local.csi.xxx 本地磁盘(不跨节点)

存储选型指南

选型决策树:

数据需要持久化吗?

├── 否 → emptyDir(临时缓存,容器间共享)

└── 是 → 需要多 Pod 同时读写吗?

├── 否(单 Pod 读写)→ RWO 块存储
│ 适合:数据库,单实例有状态服务
│ 后端:EBS / 云硬盘 / Ceph RBD

└── 是(多 Pod 读写)→ RWX 文件存储
适合:共享文件,CMS 媒体,ML 训练数据
后端:NFS / EFS / CephFS / GlusterFS
场景 推荐方案 理由
MySQL / PostgreSQL RWO 块存储 + StatefulSet 数据库需要独占磁盘,高 IOPS
Redis(持久化模式) RWO 块存储 + StatefulSet RDB/AOF 文件需要持久化
Elasticsearch RWO 块存储 + StatefulSet 每节点独立数据目录
共享静态资源 RWX 文件存储(NFS/EFS) 多 Pod 同时读取同一份文件
ML 训练数据集 RWX 文件存储 + ReadOnlyMany 大数据集多 Pod 并行读取
日志临时缓存 emptyDir 发送到 ES 后不需要保留
CI/CD 构建产物 emptyDir(或对象存储 S3) 构建完成后上传,本地不需要持久化

存储问题排查

# PVC 一直 Pending?
$ kubectl describe pvc mysql-data
# 看 Events 部分:
# "no persistent volumes available" → 没有匹配的 PV,且 StorageClass 不支持动态供给
# "waiting for first consumer" → 正常,等 Pod 调度后才绑定(WaitForFirstConsumer)
# "provisioning failed" → CSI 驱动出错,看具体错误信息

# Pod 挂载失败?
$ kubectl describe pod mysql-0
# 看 Events 部分:
# "FailedAttachVolume" → 磁盘无法 attach 到节点(跨 AZ?磁盘被其他节点占用?)
# "FailedMount" → 文件系统挂载失败(格式不对?权限问题?)

# 查看 PV 和 PVC 绑定状态
$ kubectl get pv -o wide
$ kubectl get pvc -o wide

# 查看 CSI 驱动 Pod 日志
$ kubectl logs -n kube-system -l app=ebs-csi-controller
PVC Pending 排查流程:

kubectl describe pvc → 看 Events

├── "no persistent volumes available"
│ → StorageClass 是否存在?名字是否拼对?
│ → 是否有匹配的 PV(容量,accessMode)?
│ → CSI 驱动是否正常运行?

├── "waiting for first consumer"
│ → 正常!Pod 还没被调度
│ → 检查 Pod 是否 Pending(调度问题)

└── "provisioning failed: ..."
→ 看具体错误
→ 常见:quota 超限,AZ 不可用,权限不足

小结

快速回顾

  • 容器文件系统是临时的:写入容器层的数据随容器删除而消失;Volume 是解决方案
  • emptyDir:随 Pod 存在,适合临时缓存和容器间共享;Pod 删除则丢失
  • hostPath:使用节点路径,不跨节点,有安全风险;仅 DaemonSet 特权场景使用
  • PV / PVC 解耦:PV 是存储资源(管理员/自动创建),PVC 是使用申请(开发者创建);开发者无需关心底层实现
  • 访问模式:RWO(单节点读写,块存储),ROX/RWX(多节点,文件存储),RWOP(单 Pod 独占)
  • 回收策略:Retain(保留数据,手动清理),Delete(PVC 删则磁盘销毁);生产数据库用 Retain
  • StorageClass 动态供给:PVC 一创建自动创建 PV + 底层磁盘;WaitForFirstConsumer 避免跨 AZ 问题
  • StatefulSet + volumeClaimTemplates:每 Pod 独立 PVC,缩容不删 PVC,重建自动恢复
  • CSI:K8s 与存储后端的标准接口;存储厂商实现驱动,独立部署为 Pod
  • 在线扩容:修改 PVC requests.storage 即可,大多数 CSI 支持在线扩展文件系统

动手练习

  1. 创建一个 Pod 使用 emptyDir,exec 进去写一个文件 /data/test.txt,然后 kubectl delete pod 重建,验证文件是否丢失.
  2. 创建一个 Pod 使用 emptyDir 挂载到两个容器的同一路径,在容器 A 中写文件,在容器 B 中读取,验证容器间共享.
  3. 创建一个 StorageClass(如果用 k3d,可以用 local-path-provisioner)+ PVC + Pod.写入数据后删除 Pod,重新创建引用同一 PVC 的 Pod,验证数据是否保留.
  4. 创建一个 StatefulSet(replicas=3)+ volumeClaimTemplates,观察是否自动生成 3 个独立 PVC.缩容到 1 后检查 PVC 是否保留,再扩回 3 验证数据恢复.
  5. 尝试扩容 PVC(修改 requests.storage),观察 PV capacity 是否跟着变化.
  6. 故意创建一个 PVC 引用不存在的 StorageClass,观察 PVC 状态和 Events 信息,熟悉排查流程.