第 10 章 存储:有状态应用的尊严
墨:老哥,网络那一章我们解决了”通”。这章解决”留”——Pod 随时死、随时漂,它的数据凭什么不丢? 你写过服务,日志可以丢,但数据库的数据、用户上传的文件不能丢。今天看 K8s 怎么给”会死的东西”一个”不死的盘”。
10.1 第一道坎:容器的文件系统是”一次性”的
墨:你 docker run 起个容器,往里写文件,容器删了,文件还在吗?
不在。容器镜像层是只读的,运行时写在一个可写层(writable layer),这层跟着容器生命周期走——容器删,可写层没了,数据蒸发。Pod 里容器同理:容器重启(哪怕同一 Pod)写层可能保留(同节点同层),但 Pod 被调度到别的节点、或彻底删除,数据就没了。
这对无状态服务没问题(nginx 的缓存丢了就丢了,重新生成)。但对有状态服务是灾难:MySQL 的数据文件要是随 Pod 漂走就没了,这数据库还不如不跑。
所以 K8s 必须提供”脱离 Pod 生命周期的持久存储”——盘的存在不依赖 Pod 生死,Pod 没了盘还在,新 Pod(甚至漂到别的节点)能把同一个盘挂回来。
10.2 Volume:把”盘”挂进 Pod 的抽象
K8s 用 Volume 把存储挂进 Pod 的容器。但 Volume 分两类,天差地别:
10.2.1 emptyDir:随 Pod 生的临时盘
emptyDir 是 Pod 创建时建、Pod 删除时销毁的临时目录(初期在节点磁盘,可设 medium: Memory 放内存)。用途:同 Pod 内容器共享临时文件(如一个容器写、一个容器读)。它不持久——Pod 删就没。所以叫”empty 因为每次新建是空的”。
墨:emptyDir 和”容器可写层”有啥不同? 容器可写层是 per 容器、容器重启就悬(同节点可能还在);emptyDir 是 per Pod、Pod 内多容器共享、Pod 删才没。两者都不跨 Pod 持久。emptyDir 的价值是”Pod 内容器间共享”,不是”持久”。
10.2.2 hostPath:挂宿主机的目录(坑王)
hostPath 把节点上的真实目录挂进 Pod。比如挂 /var/log 让 Pod 读节点日志(节点级 agent 常用)。
⚠️ 致命坑: hostPath 把 Pod 绑死在”有这个目录的那台节点”。Pod 漂到别的节点,那个节点的 /data 可能是空的或别的服务的——数据错乱或找不到。所以 hostPath 不能用于有状态数据(除非 DaemonSet 这类”每节点一个、本就想绑节点”的场景)。这是新手最常见的存储事故源:用 hostPath 当持久盘,结果 Pod 一调度数据就丢/串。
10.2.3 把 ConfigMap/Secret 当卷
顺带提:ConfigMap/Secret 也能以 volume 形式挂进 Pod(不只是环境变量)。好处是文件形式、可热更新(第 12 章讲)。这是”配置/密钥”和”存储”的交叉点——它们本质是”小体积的特殊卷”。
10.3 PV / PVC / SC:三层抽象,把”申请盘”变成”填表”
墨:你要一块持久盘,最朴素的办法是啥? 找运维:“给我一台机器的 /dev/sdb 挂上来。” 但这就回到”人肉”了——而且你还得知道盘在哪个节点、什么类型。K8s 用三个对象把这事”声明式化”:
- PV(PersistentVolume):集群里一块真实的存储资源(管理员预先建好,或动态建,第 10.5)。它描述”我是一块 100G 的 SSD 云盘,RWO 模式”。
- PVC(PersistentVolumeClaim):用户的申请单——“我要 50G、RWO、SSD 级别的盘”。用户不关心盘在哪、具体是什么,只声明需求。
- SC(StorageClass):盘的模板/供给方式——“SSD 类盘由云厂商 provisioner 自动创建”。
绑定(Binding): 你建一个 PVC,K8s 去找”能满足需求的 PV”(容量够、accessMode 匹配、storageClassName 一致),把 PVC 和 PV 绑定。Pod 通过 volumeMounts 引用 PVC,PVC 指向 PV,PV 指向真实存储。Pod 删了,PVC 和 PV 还在(数据在),新 Pod 引用同一个 PVC 就挂回同一个盘。
墨:为什么要多此一举分 PV 和 PVC,不能直接 Pod 挂盘? 因为解耦”供给者”和”使用者”。管理员/StorageClass 管”盘从哪来、什么类型”,用户只管”我要多大”。用户写 YAML 时根本不知道底层是 Ceph 还是云盘还是 NFS——他只写 PVC。这又是”使用者不感知实现”的接口哲学(和 CRI/CNI/CSI 一脉相承)。PV/PVC 是存储领域的”服务发现 + 依赖注入”。
10.4 静态供给 vs 动态供给:演化的关键一跃
10.4.1 静态供给(早期做法)
管理员预先手动创建一堆 PV(比如 10 块云盘),用户 PVC 来匹配。问题:
- 管理员得预判用户要多少、要多大,提前建——建少了不够用,建多了浪费钱;
- 用户 PVC 需求(如 50G)可能匹配不到恰好大小的 PV(PV 是 100G 的,PVC 要 50G,匹配但浪费);
- 完全是”人肉运维存储”,规模一大崩。
10.4.2 动态供给(Dynamic Provisioning):StorageClass 让盘”按需生”
演化(K8s 1.4+ 动态供给 GA): 引入 StorageClass。你建 PVC 时指定 storageClassName: ssd-fast,K8s 发现没有现成 PV 匹配,就去找这个 SC 关联的 provisioner(一个控制器,如云厂商的 CSI 驱动),provisioner 当场去云上创建一块盘,自动生成 PV 并绑定你的 PVC。
墨:这像不像什么? 像不像按需创建——你”声明要一块盘”,系统”现场造一块给你”。这正是声明式哲学的延伸:你不再”找现成的盘”,你”描述要什么样的盘”,供给自动化。这把存储从”预分配库存”变成”即时制造”,彻底消灭了”管理员提前建盘”的运营负担。
演化启示:从”预创建资源池”到”按需动态创建”,是云原生资源供给的通用演进方向。 不只是存储——节点也能动态供给(Cluster Autoscaler,第 16 章)、IP 也能(IPAM)。凡是”资源”,趋势都是”声明需求→自动供给”,而非”预置库存→手动分配”。
10.5 StorageClass 与 provisioner:谁是”造盘的人”
StorageClass 里关键字段 provisioner(如 kubernetes.io/aws-ebs、ebs.csi.aws.com)。它指向一个实现了”创建/删除卷”逻辑的控制器。这个控制器怎么来的?靠 CSI(下一节)。
SC 还能定义回收策略(reclaimPolicy):PVC 删了,背后的 PV(和真实盘)怎么处理?
Delete:连盘一起删(默认,云上常见——盘是按需造的,删了省钱);Retain:保留盘(数据要留存,如数据库——PVC 删了盘还在,手动回收);Recycle(已废弃):清空后复用,不安全的旧做法,别用。
墨:数据库 PVC 该用 Delete 还是 Retain? 数据库数据宝贵,用 Retain——误删 PVC 不会把数据盘一起删,你能手动找回。但 Retain 的代价是要人工清理孤儿盘(不然云上一直计费)。又是”自动省心 vs 数据安全”的权衡。重要数据宁愿多一步人工,也别自动删盘。
10.6 CSI:把”存储驱动”做成插件(和 CRI/CNI 三兄弟)
墨:K8s 要支持 AWS EBS、GCP PD、Ceph、NFS、本地盘……每种存储的”创建/挂载/卸载”命令都不同。K8s 核心该每种都写一套吗?
不该,也做不到(存储厂商太多了,且闭源)。于是 CSI(Container Storage Interface) 出现了——和 CRI(运行时)、CNI(网络)是同一个祖传哲学的三兄弟:K8s 定义接口,存储厂商实现插件。
CSI 定义一组 gRPC:
- CreateVolume / DeleteVolume(动态供给,对应 provisioner);
- ControllerPublishVolume / NodePublishVolume(把卷挂到某节点、挂进某 Pod);
- NodeStageVolume 等。
厂商提供 CSI 插件(通常以 DaemonSet + Deployment 形式部署在集群),K8s 的 external-provisioner / external-attacher 等 Sidecar 通过 CSI 调它。K8s 核心完全不碰具体存储命令。
10.6.1 CSIMigration:把内置驱动”请出去”的演化
早期 K8s 把 AWS/GCE 等存储驱动写死在核心代码里(in-tree)。这有两个问题:(1) 核心膨胀;(2) 存储厂商更新驱动得等 K8s 发版。于是 K8s 推 CSIMigration:把这些 in-tree 驱动逐步标记废弃,流量转向对应的 CSI 插件。到后来,核心里的 in-tree 驱动基本移除,所有存储走 CSI。 这又是”核心写死的特例 → 外部插件”的演化(和 Dockershim 移除、CRI 化完全同构)。你发现没?K8s 的成长就是不断”把特例赶出核心、收编进统一接口”——CRI 赶 Docker、CSI 赶内置存储、CNI 本来就是外部。
10.7 拓扑感知:盘和节点得在同一个机房
墨:动态供给造了一块云盘,但 Pod 在南京机房,盘却造在上海机房——能挂吗?
多数云盘不能跨可用区挂载。如果盘和 Pod 节点不在同一 AZ,挂载失败,Pod 起不来。所以 K8s 有拓扑感知调度(topology-aware volume scheduling):调度器在分配 Pod 时,会考虑”这个 PVC 将来要的盘能在哪造、节点能不能挂”。具体机制:StorageClass 设 volumeBindingMode: WaitForFirstConsumer(延迟绑定)——PVC 先不绑定具体 PV,等 Pod 被调度到某节点后,再根据节点所在 AZ 动态造盘并绑定。这样保证”盘造在 Pod 所在 AZ”。
墨:为什么是”等消费者出现再绑定”而不是”PVC 一建就造盘”? 因为 PVC 建的时候还不知道 Pod 落哪——若提前造盘,可能造错 AZ。延迟到”Pod 已调度”再造,就能按节点 AZ 精准造。这又是一个”决策时机”的设计:信息不足时别急着行动,等关键信息(节点位置)齐了再绑定。
10.8 StatefulSet + 存储:身份与盘的永恒绑定
回到第 7 章 StatefulSet。它的 volumeClaimTemplates 给每个序号 Pod 生成一个专属 PVC:
volumeClaimTemplates:
- metadata: { name: data }
spec: { accessModes: [ReadWriteOnce], resources: {requests: {storage: 10Gi}} }
mysql-0 得到 PVC data-mysql-0,mysql-1 得到 data-mysql-1。这些 PVC 带序号、与 Pod 身份绑定。Pod 重建(换节点)后,volumeClaimTemplates 机制保证重新挂回同一个 PVC(data-mysql-0),底层 PV(盘)跨节点可见(网络存储),数据不丢。
这就是为什么有状态负载能”漂而不乱”的两大锚点:序号身份(网络,第 7 章)+ 序号绑定的 PVC(存储,本章)。 身份是锚,网络和存储都挂在身份上,Pod 实例随便换,锚不动,数据和服务拓扑就稳。
墨:如果 data-mysql-0 的底层盘是 RWO(单节点读写),mysql-0 漂到另一节点,盘能挂吗? 能——RWO 意思是”同一时刻只能挂一个节点”,Pod 在原节点卸下、新节点挂上,串行不冲突。但若你误设两个 Pod 同时挂 RWO 同盘(如没用 StatefulSet 专属 PVC 而共享),会挂载失败。所以访问模式(accessMode)要和实际用法匹配。
10.9 accessMode 与回收:几个易混点
- RWO(ReadWriteOnce):单节点读写(一块盘挂一台机器)。最常见(云盘)。
- ROX(ReadOnlyMany):多节点只读(如共享配置盘)。
- RWX(ReadWriteMany):多节点读写(如 NFS、CephFS)。注意多数云块存储不支持 RWX——你要 RWX 得用文件存储(NFS/CephFS),别奢望 EBS 同时挂多节点写。
⚠️ 坑: 把需要 RWX 的 PVC 设成底层只支持 RWO 的 StorageClass,PVC 永远 Pending(没有满足条件的 PV)。排障看 PVC 事件”no persistent volumes available for this claim”。accessMode 和 StorageClass 能力要对应。
10.10 本章演化线小结
- 容器可写层/emptyDir 不持久,hostPath 绑死节点——有状态需脱离 Pod 生命周期的盘;
- PV(真实资源)/PVC(申请单)/SC(模板) 三层解耦供给者与使用者,PVC 是存储的依赖注入;
- 静态供给→动态供给(StorageClass + provisioner):预置库存→按需制造;
- 回收策略 Delete/Retain 是”省心 vs 数据安全”的权衡,数据库用 Retain;
- CSI 把存储驱动插件化,CSIMigration 把 in-tree 驱动赶出核心(同 CRI/CNI 哲学);
- 拓扑感知:WaitForFirstConsumer 延迟绑定,保证盘与 Pod 同 AZ;
- StatefulSet volumeClaimTemplates:序号 PVC 绑定身份,漂而不丢。
10.11 本章思考题
🔧 思考题 1
你给 MySQL 用 PVC + 云盘(RWO),设 reclaimPolicy: Delete。某天同事误删了 PVC(以为只是测试)。会发生什么?如果换成 Retain 又怎样?从数据安全角度你倾向哪个,代价是什么?
【参考答案】 reclaimPolicy: Delete:PVC 删 → 绑定的 PV(和底层云盘)被 provisioner 一并删除 → MySQL 数据永久丢失(除非云盘有独立快照)。这是运维惨案级。Retain:PVC 删 → PV 保留(状态 Released,不再绑定任何 PVC),底层云盘保留。你能手动把 PV 重新绑一个新 PVC(或恢复数据)。代价:Released 的 PV 不会自动复用,需人工处理,且云盘持续计费(孤儿盘)。倾向:数据库等核心数据用 Retain(数据无价,多一步人工清理远胜自动删盘)。配合:对关键 PVC 加保护(如 OpenShift 的 PVC 保护、或备份工具 Velero 定期快照),并规范”删 PVC 需审批”。这题考你”reclaimPolicy 直接决定误删后果”,是生产红线。
🔧 思考题 2
用 WaitForFirstConsumer 延迟绑定的 StorageClass,PVC 创建后处于 Pending 一段时间。请解释:为什么 PVC 不立刻绑定?调度器在这里扮演什么角色?如果 Pod 永远调度不上,PVC 会怎样?
【参考答案】
不立即绑定是因为 volumeBindingMode: WaitForFirstConsumer 要求”等消费这个 PVC 的 Pod 被调度到具体节点后”再动态造盘——因为造盘要知道节点所在 AZ(拓扑感知,10.7)。时机:PVC 创建时 Pod 可能还没调度,没有节点信息,若提前造盘可能造错 AZ 导致挂不上。调度器角色:它把 Pod 分配到某节点后,触发”延迟绑定”——external-provisioner 看到 Pod 已调度,按节点 AZ 造 PV 并绑定 PVC。若 Pod 永远调度不上(如资源不足 Pending):PVC 也永远不绑定(一直 Pending),因为”first consumer”没出现,造盘条件不满足。这揭示一个联动:存储绑定依赖调度结果——K8s 里”算资源”和”造存储”不是独立的两步,而是通过”延迟绑定”串成因果链。排障时 PVC 长期 Pending 要联想到”是不是关联 Pod 还没调度”。
🔧 思考题 3
你用 hostPath 给一个 Deployment 的 3 副本存用户上传文件,后来发现部分用户文件”丢失/串了”。从调度和网络角度解释为什么会乱,并给出正确做法(用 K8s 原语)。
【参考答案】 乱因:(1) hostPath 绑死节点:3 副本可能被调度到 3 台不同节点,每台节点的 /data 是各自本地盘。用户 A 上传文件写到节点1的 /data,下次请求被 Service 负载均衡到节点3 的 Pod,读节点3的 /data——文件不在,表现为”丢失”;(2) 若两个副本恰巧同节点,又可能读写同一目录互相覆盖,表现为”串”;(3) 节点宕机 Pod 漂走,原节点 /data 不可达,数据实际还在原节点但新 Pod 在新节点读不到。正确做法:用真正的共享/持久存储替代 hostPath:(a) 若是共享文件(多 Pod 同读同写),用 RWX 的 StorageClass(NFS/CephFS)的 PVC,所有副本挂同一 PVC,文件全局可见;(b) 若是用户头像等对象,存对象存储(S3/OSS),Pod 只存引用路径(第 5.3 提过 etcd 不存业务数据);(c) 若每个 Pod 要独立盘,用 StatefulSet + volumeClaimTemplates(每副本专属 PVC,且身份稳定)。核心教训:Deployment 多副本 + hostPath = 数据按节点碎片化,是典型反模式。
🔧 思考题 4(进阶)
CSI 把存储做成插件后,K8s 核心的 kubelet 怎么”挂盘”?请沿”PVC 创建 → 盘造好 → Pod 调度 → 挂载到容器”描述涉及的组件链,指出哪些在控制面、哪些在节点、哪些在外部存储系统。
【参考答案】
组件链:(1) 你建 PVC(API Server 写入 etcd,控制面);(2) external-provisioner(集群内部 Deployment,控制面侧)watch 到没绑定的 PVC + 对应 SC,通过 CSI Controller 服务(存储厂商插件,通常集群内部)调 CreateVolume,去外部存储系统(云 API / Ceph 集群)造真实卷,并创建 PV 对象绑定 PVC;(3) 调度器(控制面)把 Pod 调度到节点 A(考虑拓扑);(4) kubelet(节点 A 上,节点侧)看到 Pod 要挂这个 PVC,通过 CSI Node 服务(节点上的 DaemonSet,节点侧)调 NodePublishVolume,把外部卷挂载到节点目录并 bind mount 进容器;(5) 容器进程看到盘。外部存储系统全程在集群外(云/自建存储)。关键:控制面负责”造盘+绑 PVC”(provisioner+CSI controller),节点侧负责”把盘挂进这个 Pod”(kubelet+CSI node)。kubelet 自己不直接调云 API——它只通过 CRI/CSI 等标准接口调插件,插件再去外部系统。又是”核心只调标准接口、具体动作下沉插件和外部系统”的三层结构。
10.12 小结
- 容器可写层/emptyDir 不持久,hostPath 绑死节点——有状态需脱离 Pod 生命周期的盘;
- PV/PVC/SC 三层解耦”供给者 vs 使用者”,PVC 是存储的依赖注入;
- 动态供给(StorageClass+provisioner)把”预置库存”变”按需制造”,消灭人工建盘;
- 回收 Delete/Retain 是”省心 vs 数据安全”,数据库用 Retain;
- CSI 插件化存储驱动,CSIMigration 把 in-tree 驱动赶出核心(同 CRI/CNI);
- 拓扑感知 WaitForFirstConsumer 延迟绑定,盘随 Pod 同 AZ;
- StatefulSet volumeClaimTemplates 让序号 PVC 绑身份,漂而不丢。
下一章我们聊一个很多人忽略、但最能体现”规模逼出设计”的演化——服务发现本身怎么从 Endpoints 进化到 EndpointSlice。一个对象在大规模下”爆炸”的故事,正好照见 K8s 的演化方法论。