第 14 章 · 存储的演进:从「手动建卷」到「CSI 外置」
一句话结论:K8s 存储的演进,是一条**「把存储从『管理员的人肉操作』一步步变成『声明式的、可插拔的自动化』」**的路——它走了四步:手动建卷 → PV/PVC 抽象 → StorageClass 动态供给 → CSI 外置。其中最后一步(in-tree → CSI 迁移)是 K8s 史上最庞大、最谨慎的一次「搬家」,横跨了 1.14 到 1.25 整整十一个版本。
1. 当年是什么样:存储全靠管理员手动
K8s 最早,存储是「运维的噩梦」:
- 你要用一个云盘?管理员得先去云平台手动创建一块磁盘,拿到磁盘 ID。
- 然后手动写一个 PV(PersistentVolume)对象,把这个磁盘 ID 填进去。
- 之后应用才能用 PVC 去绑定这个 PV。
# 早期:管理员手动建 PV,把云盘 ID 硬编码进去
apiVersion: v1
kind: PersistentVolume
metadata:
name: my-disk
spec:
capacity: { storage: 100Gi }
gcePersistentDisk: # 云厂商的磁盘,ID 写死
pdName: my-cloud-disk
fsType: ext4痛点极其直白:每个存储申请,都要「给运维提个工单,等人工创建」。 这和第 1 章「为什么需要编排」的逻辑一脉相承——人肉操作,在规模面前必崩。而且这还是「动态的云盘」,要提前知道用多大、在哪个区。
2. 第一步进化:PV/PVC 抽象(把「申请」和「供给」分开)
K8s 引入 PV(持久卷,管理员管)+ PVC(持久卷声明,用户提),核心是分离了两个角色:
- 管理员:负责「供给」——提前建好一批 PV(或配好自动供给)。
- 用户(开发者):只负责「申请」——写一个 PVC,声明「我要 100G 的盘」。
# 用户视角:只管「我要什么」,不管「盘在哪」
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: my-claim
spec:
accessModes: [ReadWriteOnce]
resources:
requests: { storage: 100Gi }这个抽象的价值:开发者不再需要知道「云盘 ID、在哪个区、怎么建」,他只需要声明「我要 100G」。把「供给」和「消费」解耦,是这一层抽象的灵魂。
类比:PV/PVC 像「酒店房间」——PV 是「酒店(管理员)准备好的房间」,PVC 是「客人(开发者)下的订单」。客人不需要关心「房间在哪层、门牌号多少」,只关心「我要一间大床房」。
3. 第二步进化:StorageClass 动态供给(1.4,把「手动建」变成「自动建」)
PV/PVC 解决了「解耦」,但没解决「PV 还是要管理员手动建」这个问题。于是 1.4 引入了 StorageClass + 动态供给:
# 定义一种「存储类别」,背后关联一个 provisioner(自动建盘器)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast
provisioner: kubernetes.io/gce-pd # 用 GCE 的 provisioner 自动建盘
parameters:
type: pd-ssd之后,用户 PVC 里指定 storageClassName: fast,K8s 就自动调 provisioner 去云平台建盘、再自动绑定。
这一步的意义:存储终于从「给运维提工单」变成了「声明式自服务」——开发者 apply 一个 PVC,盘就自动建好了。这是「声明式」思想在存储领域的落地(呼应第 2 章)。
4. 第三步进化:CSI 外置(最庞大的一次搬家,1.9 ~ 1.25)
但,前面的 provisioner(kubernetes.io/gce-pd、kubernetes.io/aws-ebs…)都是**「in-tree」的——代码直接内嵌在 K8s 核心里**。这埋下了大坑:
坑一:发布节奏被锁死。 每个云厂商的存储代码都在 K8s 核心里,意味着 K8s 每发一个版本,都要等所有云厂商一起测、一起过。一个厂商的存储 bug,能拖累全世界的 K8s 发布。
坑二:代码臃肿、漏洞风险高。 in-tree 代码越多,核心越臃肿,出错的概率越高。而且你没法只装自己需要的存储驱动——所有厂商的代码都被打包进了二进制。
坑三:厂商无法独立演进。 云厂商想加个新存储功能(比如快照),得等 K8s 官方发版,节奏完全被动。
于是有了 CSI(Container Storage Interface):一个 gRPC 标准接口,让存储驱动跑在 K8s 之外、由厂商自己维护、自己发版。
但这里有一个天大的难题:不能破坏 API 兼容性。已经用 kubernetes.io/gce-pd 写好的 PV/PVC,不能强迫用户改。于是 K8s 想出了一个极其谨慎的办法——CSI 迁移(CSI Migration):
用户仍然写旧的 in-tree API(kubernetes.io/gce-pd)
↓(一个「翻译层」自动把 in-tree 调用翻译成 CSI 调用)
真正的操作,其实已经由外置的 CSI 驱动来执行
用户完全无感知,但背后「换了引擎」。这种「旧 API + 新实现 + 翻译层」的平滑迁移,是整个演进里最值得学的工程手法。
而它的谨慎程度,从时间线就能看出来:
| 阶段 | 版本 | 说明 |
|---|---|---|
| CSI 引入 | 1.9 alpha | 新接口诞生 |
| CSI GA | 1.13 | 可以原生用 CSI |
| CSI 迁移 alpha | 1.14 | 翻译层开始做 |
| CSI 迁移 beta | 1.17 | 逐驱动推进 |
| 迁移核心 GA | 1.25 | 基本完成 |
| in-tree 插件移除 | 1.26~1.30 分批 | 逐个删掉旧代码 |
一个特性,从想法到「旧代码彻底删干净」,走了 6 年、十几个版本。 为什么这么慢?因为存储动错一下就是「数据丢失」,是不可逆的。所以宁可慢、宁可双轨维护,也要保证「迁移过程中,用户的数据万无一失」。
5. 这条演进线教给我们什么
-
抽象是分层的、渐进的:手动建卷 → PV/PVC(解耦)→ StorageClass(自动化)→ CSI(可插拔)。每一步解决「上一层的遗留问题」,而不是一步到位。 好的演进是「台阶式的」,不是「跳跃式的」。
-
「不可逆的操作」要「最谨慎的迁移」。存储迁移之所以慢,是因为它碰的是「数据」这个不可逆的东西。迁移的速度,应该和「出错后果的严重程度」成反比。
-
「平滑迁移」的黄金手法:旧 API + 新实现 + 翻译层。要换掉一个被大量用户依赖的旧实现,最稳的不是「让用户改代码」,而是「保持 API 不变,偷偷换掉实现,让用户无感知」。这个手法,值得你用在自己系统的任何「大版本重构」里。
6. 思维流重放:为什么不能「一刀切」,只能「偷偷换引擎」
上面讲了 CSI 迁移的「旧 API + 翻译层」手法,这一节重走「面对 in-tree 的坑,为什么不能直接删掉,只能慢慢翻译」这个决策时刻。
第 1 步 · 直觉:in-tree 卷代码堆在核心里,坑一堆(发布被绑架、代码臃肿、厂商被动)。最直接的想法——删掉 in-tree,让用户全部换成 CSI,一步到位、干净利落。
第 2 步 · 推演:但 in-tree 背后是什么?是用户正在用的、存着生产数据的 PV/PVC。你让用户改 API,等于让所有人在「数据」上做一次外科手术。第一个裂缝:数据不可逆,一步到位 = 拿生产数据当小白鼠。
第 3 步 · 反转:那能不能「旧 API 不变,底层悄悄换实现」?——加一个翻译层,把用户写的 in-tree 调用,自动翻译成 CSI 调用。用户连一行配置都不用改,引擎偷偷换了。
第 4 步 · 权衡代价:但「偷偷换」的代价是双轨维护好几年——in-tree 和 CSI 两套代码并存,看起来是巨大浪费。值不值?回到那个决策框架:in-tree 这个负担是「无限期、越来越重」的,双轨是「有限期、有终点」(明确 1.26~1.30 删干净)的。用「一段有终点的过渡成本」,换「永久甩掉一个无限增长的负担」,划算。
第 5 步 · 收敛:所以答案是「旧 API + 新实现 + 翻译层」,横跨 11 个版本慢慢迁。慢不是缺点,是「迁移速度应该和出错后果的严重程度成反比」——动的是数据,就必须最慢、最稳。
一句话复盘:面对「要换掉一个被大量依赖的旧实现」,最容易被忽略的一步是第 2 步的推演——先问「旧实现背后绑着什么不可逆的东西」。答案如果是「数据」,那「直接删」就是错的,唯一的正解是「保持 API 不变、偷偷换实现、让用户无感知」。
思考题
Q:CSI 迁移期间,K8s 的 in-tree 和 CSI 两套存储代码并存了好几年(双轨维护)。这看起来是巨大的浪费。但如果不做迁移、继续只维护 in-tree,会有什么更糟的后果?为什么「双轨维护几年」反而更划算?
参考答案:
「双轨维护」看似浪费,但它是在**「用一段可控的、有终点的浪费,去换掉一个无限期的、越来越大的负担」**:
-
继续只维护 in-tree 的后果:云厂商的存储代码永远堆在核心里,K8s 的发布永远被所有厂商绑架,核心永远臃肿、漏洞面永远大,且这种负担没有终点、还会随着新厂商的加入越来越重。
-
双轨维护的本质:它是「有限期的过渡态」——明确知道「in-tree 代码会在 1.26~1.30 分批删掉」,所以这份「浪费」是有终点的。过渡期一过,in-tree 代码删干净,K8s 核心永久瘦身,厂商从此各自发版。
这引出一个很实用的决策框架:面对一个「长期负担」,判断要不要承担「过渡期成本」去消除它,关键看两件事——① 这个负担是「有终点」还是「无限期」?② 过渡期之后,是不是「永久解决」? 如果负担无限期增长、而过渡成本一次性付清后永久解决,那「咬牙承担过渡成本」就是划算的。K8s 的 CSI 迁移、调度框架重构,都是这个逻辑的活案例。