线上那批 IO 密集型的服务,Kubernetes 集群跑了大半年,应用层一直正常,直到某次大促压测,存储先亮了红灯。数据库实例的写入时延从 1ms 左右直接飙到 20ms 开外,日志引擎时不时报超时,压测工具的 IOPS 始终顶不上配额,更头疼的是有个主从实例因为存储延迟波动触发了切换。当时第一反应是查应用代码、查网络,折腾了两周,最后用 fio 把后端分布式存储和节点本地盘做了个对比测试,结论非常直接:瓶颈根本不在应用,而在底层存储的数据路径上。于是我们把目光投向了一直想做但没敢动手的方案——基于 App Mesh 的 Kubernetes 动态本地物理卷,把高 IO 应用和本地高性能盘在拓扑上"绑"在一起。这篇文章就把整个方案的设计思路、实现细节、踩坑过程和实测数据完整写下来,给同样被网络存储性能卡住的项目做个参考。
1. 网络存储的性能天花板:我们的压测数据一点都不好看
1.1 同样的 fio,网络存储和本地盘的差距有多大
先说压测方法。为了让对比有意义,测试机用同一批配置,fio 参数保持一致,数据路径分别是分布式块存储挂载出来的云盘设备,以及节点上直插的 NVMe SSD。测试命令大致是这样:
fio --name=randwrite --rw=randwrite --bs=4k --direct=1 --ioengine=libaio \ --iodepth=64 --numjobs=8 --size=10G --runtime=60 --time_based --group_reporting随机写 4K 块,目的是模拟数据库事务日志和消息队列这种高频小 IO 场景。结果差距非常直观:
| 指标 | 网络存储(分布式块) | 本地 NVMe SSD |
|---|---|---|
| 顺序读带宽(128k) | 约 1.2 GB/s | 约 2.8 GB/s |
| 顺序写带宽(128k) | 约 600 MB/s | 约 2.1 GB/s |
| 随机读 IOPS(4k) | 约 5 万 | 约 42 万 |
| 随机写 IOPS(4k) | 约 1.8 万 | 约 21 万 |
| 随机写 P99 延迟 | 15ms 左右 | 0.4ms 左右 |
一组数字就能解释很多问题。网络存储的随机写能力只有本地盘的十分之一不到,延迟却高了几十倍。数据库的 WAL 写入、索引页刷盘、日志聚合缓冲落盘,全部是高频小 IO,这类负载恰恰是最怕延迟波动和 IOPS 不够的。
打个生活化的比方:本地盘相当于自家厨房,锅碗瓢盆都在手边,想用立刻就能用;网络存储相当于点外卖,高峰期还要看骑手心情,路径上每一个环节——网络协议栈、存储节点 CPU、副本同步——都可能成为那多出来的几百微秒甚至几毫秒。
1.2 哪些应用该用本地卷,哪些不该用
本地卷不是万能药,这一点必须先说清楚。我们后来总结了一套判断标准,适合放上本地物理卷的应用有三类:
- 写入路径极短的应用:数据库的 WAL、etcd 的数据目录、消息队列的 segment 文件。这类应用对单次写入延迟极度敏感,数据量未必大,但 IOPS 要求高。
- 本地聚合后定期外送的负载:日志采集、监控时序数据,先写本机,再批量同步到远端,天然就是"本地缓冲"模式。
- 需要低延迟读缓存的应用:特征数据、热数据缓存,一次命中的延迟直接决定业务 RT。
不适合本地卷的也有明显特征:海量冷数据(本地盘再大也有限)、依赖底层存储做跨节点快照复制的多副本结构、以及需要频繁弹性扩缩且随节点漂移的无状态服务。后面这几类场景,老老实实用网络存储更稳妥。
1.3 静态 Local PV 的管理痛点
既然本地盘性能这么好,为什么一开始没上?因为当时我们能想到的方案是 Kubernetes 的静态本地卷供给:管理员预先在节点上建好目录,手工创建一批 Local PV,再让 PVC 去绑定。问题是这种模式在小规模集群还能忍,节点一多立刻露馅:
- 每块盘都要人工维护 PV 清单,几百个 PV 就是一场灾难;
- PV 容量是静态写死的,实际磁盘剩余空间管理员需要另外查;
- 调度器完全感知不到节点本地容量,Pod 可能被调度到一个本地卷已经被占满的节点,然后卡在 Pending。
所以我们的核心诉求很明确:要一个能感知节点剩余容量、能按需动态切分本地物理卷的供给体系。这就是后面整套方案要解决的问题。
2. App Mesh 架构拆解:调度、供给、生命周期管理是怎么串成一体的
2.1 先解释 App Mesh 是什么,不是什么
这里必须澄清一下:"App Mesh"不是某云厂商那个服务网格产品,我们内部做这套机制的时候也压根没想碰服务网格那一套。它在我们项目里指的是"应用与本地存储设备之间的亲和调度网状结构"——应用 Pod、本地物理卷、节点设备三者的绑定关系由一套统一的控制器和调度规则来编排,像一张网一样把每个有高 IO 需求的应用精确放到对应的盘上。
为什么需要单独起一个名字?因为"本地卷动态供给"和"应用调度约束"是两件不同的事,前者解决卷从哪来,后者解决 Pod 放哪去。只有把两者联动起来,才能真正做到"应用走到哪,卷跟到哪"。所以我们把整套联动机制统称为 App Mesh。
2.2 四个核心组件和一条完整调用链
整个体系由四个角色组成,各管一段:
| 组件 | 部署形态 | 职责 |
|---|---|---|
| 设备管理器 DeviceManager | DaemonSet | 识别节点裸盘、初始化、维护设备状态 |
| 供给控制器 Provisioner | Deployment | 监听 PVC,创建逻辑卷、格式化、挂载、生成 PV |
| 调度扩展 Scheduler Hook | 独立服务 | 在调度 Filter 阶段根据节点剩余容量过滤候选节点 |
| 自定义资源(CRD) | 集群资源 | 承载设备清单、节点容量、卷实例的持久化状态 |
一条 PVC 从创建到 Pod 真正跑起来的完整调用链,用文字描述是这样:
- 业务创建 PVC,声明 StorageClass 为本地卷类型,容量写 100Gi,绑定模式是延迟绑定;
- 业务创建 Pod,引用这个 PVC;调度器在给 Pod 选节点时,会把候选节点列表交给调度扩展;
- 调度扩展从 CRD 中读取每个节点当前剩余容量,把剩余容量不足的节点踢出候选列表;
- 调度器选定节点后,PVC 进入绑定阶段,供给控制器收到事件;
- 供给控制器让该节点上的设备管理器在对应的卷组里划出一个 100Gi 逻辑卷,完成格式化并挂载;
- PV 对象创建出来,与 PVC 完成绑定,Pod 正常调度到节点、挂载卷、启动。
整个过程里,调度器和供给控制器之间通过 CRD 通讯,不引入额外的一致性组件,这让故障排查变得比较简单。
2.3 设计原则:能复用 K8s 原生机制的就不要重复造轮子
架构设计时我们定了条铁律:Kubernetes 已经提供且够用的机制,一律直接复用,绝不自己另搞一套。所以 PVC/PV、StorageClass、WaitForFirstConsumer、nodeAffinity、topology spread constraints 这些原生能力全部原样使用,自研代码只集中在三块:设备发现与初始化、节点剩余容量上报、调度过滤扩展。
这样做的收益在后期非常明显。K8s 版本升级时,原生机制的变更基本不影响我们的自定义代码;社区里很多调度器的坑,因为复用的是原生调度链路,天然规避掉了。尤其要提醒的是,不要图省事自己写一个调度器替换默认调度器,调度是集群里最敏感的路径之一,自定义调度器出问题,整个集群的 Pod 都在等,代价极高。
3. 从裸盘到 PVC:动态本地卷的创建链路与参数取舍
3.1 节点上的设备识别与预处理
设备管理器启动后,第一件事是把节点上的裸盘摸清楚。我们通过lsblk -J和/proc/partitions拿到设备列表,逐块校验这些条件:
- 设备存在且无分区表;
- 未被挂载到任何目录;
- 不在 LVM 卷组里,也不属于已有 PV;
- 通过 SMART 基础健康检查。
只要有一项不满足,这块设备就不会进入可分配池。这个"宁缺毋滥"的策略非常重要:宁可少识别一块盘,也不能把系统盘或者已有数据的盘误分配给某个 PVC。我们曾经遇到过设备名漂移的问题,后来规定不能用/dev/sdX这种不稳定的名字来引用设备,而是通过/dev/disk/by-path/或者 LVM 的稳定设备路径,否则换盘、重启后设备路径一变,卷就找不回来了。
设备管理器会把相同性能和可靠性的盘归入同一个设备类(deviceClass),比如nvme、ssd、hdd。为什么这么分?因为如果高速盘和低速盘混在同一个容量池里,一旦低速盘被写入,整个池子的性能都会被拖下去,高 IO 应用根本感知不到一致性。用设备类隔离之后,StorageClass 直接映射到设备类,业务申请什么性能档就是什么档。
3.2 LVM 聚合与逻辑卷划分
单块裸盘直接切分成 PVC 是最朴素的做法,但有个现实问题:业务申请 800Gi 的卷,节点上单盘只有 480Gi,怎么办?所以我们在节点层引入 LVM 卷组(VG)做聚合。
把同设备类的多块物理盘加入同一个 VG,之后创建卷就是在这个 VG 里划逻辑卷(LV)。这样既解决单盘容量不足,又支持在线扩容。创建逻辑卷的核心命令类似:
vgcreate vg-nvme /dev/disk/by-path/pci-0000:01:00.0 /dev/disk/by-path/pci-0000:02:00.0 lvcreate -L 100G -n pvc-xxxx vg-nvme mkfs.ext4 /dev/vg-nvme/pvc-xxxx需要注意,LV 的底层是一个跨多物理盘的线性映射,如果对性能有极致要求,需要评估是否在 VG 创建时设置条带化。但条带化也意味着任意一块底层盘出问题,整个卷都不可用,我们生产环境没有做条带,靠的是单盘性能和容量足够。
还要提一下 thin provisioning。LVM 支持精简配置,允许卷组整体看起来容量很大、实际按需分配。但我们没有启用,原因很现实:本地盘空间是刚性成本,超卖带来的风险是某个节点实际剩余容量不足以创建新卷时,调度扩展的"剩余容量"判断会失真。与其在容量计算上绕弯子,不如老老实实全量分配,用完就报满。
3.3 StorageClass 配置与 WaitForFirstConsumer
StorageClass 是整个链路里最关键的配置,一个典型的定义长这样:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast-local-nvme provisioner: local.csi.internal/registry volumeBindingMode: WaitForFirstConsumer allowVolumeExpansion: true reclaimPolicy: Retain parameters: deviceClass: nvme fsType: ext4重点解释volumeBindingMode: WaitForFirstConsumer。如果不写这一行,PVC 创建时就会立即尝试绑定 PV,但这时 Pod 还没创建,供给控制器根本不知道该去哪个节点建卷,结果就是 PVC 卡在 Pending。就算用了 Immediate 模式碰巧绑定到某个 PV,Pod 后续调度的节点也不一定和卷所在节点一致,本地卷根本挂不上。
WaitForFirstConsumer 的核心逻辑是:PVC 先保持未绑定状态,等 Pod 真正进入调度流程并选定节点后,Kubernetes 才会触发卷的创建和绑定,此时供给控制器已经拿到了目标节点信息,就可以精确地在那个节点上切卷了。
回收策略建议用Retain。具体原因后面踩坑部分会讲,简单说就是动态供给的卷删除动作是自动化的,一旦误删数据就真的没了,Retain 模式至少强制管理员人工介入一次。
还有一个容易忽略的细节:PVC 请求容量和实际划出来的 LV 容量不是简单的 1:1。ext4 文件系统本身要预留块,卷组元数据也要占空间,我们实际创建 LV 时会额外多划约 5% 的 buffer,避免文件系统写满后告警一直挂在临界点。
4. 调度协同与数据粘性:Pod 必须和"它的盘"始终在一起
4.1 调度过滤阶段是怎么工作的
本地卷和网络卷最大的区别是:网络卷的可用性不绑定节点,本地卷绑定死了。所以调度器必须在决定 Pod 去哪之前,就搞清楚每个节点还剩多少本地容量。这一步我们的调度扩展是这样工作的:
调度器进入 Filter 阶段时,会把所有候选节点列表交给扩展服务。服务从 CRD 中读出每个节点的总容量、已分配容量、剩余容量,然后判断:如果候选节点的剩余容量小于当前 PVC 请求容量,直接剔除。最后把通过过滤的节点列表返回给调度器。
这里有个关键点:为什么不直接用 Kubernetes 原生 extended resource 上报容量?因为 extended resource 的值是不可动态变化的,节点上报了多少就一直是多少,Pod 删除了资源也不会自动归还。本地卷的剩余容量是随时变动的,今天创建 5 个卷明天删 2 个,容量就像一笔流动的资金,必须有一个真实反映当前余额的通道。所以我们在 CRD 里维护实时容量,调度扩展只读 CRD,数据一致性由供给控制器在创建/删除卷时原子更新,这也是模拟了"账户余额"的记账逻辑。
Pod 侧的声明非常自然,就是一个普通的 PVC 引用:
apiVersion: v1 kind: Pod metadata: name: demo-app spec: containers: - name: app image: demo:latest volumeMounts: - name: data mountPath: /var/lib/data volumes: - name: data persistentVolumeClaim: claimName:>topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: mongo这个配置保证新创建的实例尽量分散到不同节点。但要注意一个限制:对于已有 PVC 的实例,卷的 nodeAffinity 已经把 Pod 锁在原节点了,topology spread 只能对新建实例生效。所以设计多副本有状态服务时,最好在初始部署阶段就把副本数量规划好,后面靠扩副本自动调度来补容灾能力,不如一开始就保证物理分散。
4.3 删除重建、节点下线时的数据粘性处理
数据粘性这句话听着抽象,实际运维中就是三种情况:
- Pod 被删后重新创建,节点还活着:PVC 的 PV 还在原节点的 LV 上,调度器因为卷的 nodeAffinity 约束,会把新 Pod 指回原节点,挂载同一块卷,数据完好无损,这是最理想的情况;
- 节点宕机,Pod 被调度到别的节点:所有本地卷都不存在了,PVC 会一直卡在 Pending,因为没有任何其他节点能满足卷的 nodeAffinity;
- 节点永久下线,需要迁移数据:先确认原节点数据能否抢救,能抢救就先把 LV 里的数据备份出来,再在目标节点创建新卷并恢复。
针对第二种情况,我们专门在供给控制器里加了一道"回收前确认"的钩子:PVC 被删除时,LV 不会立刻销毁,而是把 PV 状态置为 Released,并打上pending-reclaim标签,等待运维确认或者备份完成后手动清理。这个机制避免了最严重的两个事故:误删 PVC 导致本地卷数据被自动清空,以及故障节点恢复后 LV 被重复创建导致数据覆盖。
5. 部署实录与 fio 验证:实测数据与一堆坑
5.1 部署顺序和关键检查点
整个体系的部署顺序很重要。第一步是在节点上打标签,区分哪些节点有本地盘、盘的性能档是什么:
kubectl label node node-01 storage-node=true kubectl label node node-01 device-class=nvme随后部署 CRD、设备管理器 DaemonSet、供给控制器 Deployment、调度扩展服务。调度扩展尤其要注意:它不是 Deployment 挂在集群内部就行,还要让 kube-scheduler 通过--extender-config知道它的地址,配置完成后需要重启所有 scheduler 的 Pod。
部署完成后,按这个顺序检查:
- CRD 是否正常列出设备和节点容量;
- 每个存储节点的设备管理器是否成功识别裸盘并上报剩余容量;
- 用
kubectl get storageclass确认 StorageClass 存在; - 创建一个测试 PVC + Pod,观察 PVC 状态从 Pending → Bound,Pod 正常 Running;
- 在 Pod 里执行
df -h验证卷确实挂载在目标节点上,而不是只在控制平面显示成功。
5.2 fio 实测对比
部署稳定后,我们把原先压测过的网络存储场景全部重跑了一遍。测试方式不变,只是数据路径从网络存储换成了通过 PVC 挂载的本地逻辑卷。仍然用 4K 随机写作为主要指标,因为它是线上最痛的部分:
| 指标 | 改造前(网络存储) | 改造后(App Mesh 本地卷) |
|---|---|---|
| 4k 随机读 IOPS | 约 5 万 | 约 40 万 |
| 4k 随机写 IOPS | 约 1.8 万 | 约 19 万 |
| 4k 随机写 P99 | 15ms | 0.5ms |
| 数据库 WAL fsync 耗时 | 12-18ms | 0.4-0.8ms |
数据库侧肉眼可见地稳定了,主从延迟告警消失,大促压测时 IO 指标不再是瓶颈。整个改造带来最直接的业务收益是:原来因为存储延迟被迫限流的高写入场景,现在可以全量承接。
顺带提醒测试时的一个细节:fio 一定要加direct=1,否则 Linux 页缓存会把写操作先吞掉,出来的数据是虚高的,只能骗自己。另外要在挂载好的本地卷路径下测,不是随便找个临时目录。
5.3 踩坑清单
方案跑通的过程不是一帆风顺,挑几个最有代表性的坑:
坑一:PVC 一直 Pending。最早部署完测试时,PVC 始终不进入 Bound。排查了一圈才发现 StorageClass 忘写volumeBindingMode: WaitForFirstConsumer,导致 PVC 在没有任何节点信息的情况下试图绑定,结果自然是永远等不到供给控制器响应。这个配置是整套方案的地基,必须优先确认。
坑二:设备路径漂移导致卷丢失。某次节点重启后,之前创建的本地卷全部"消失了"。查下来发现设备管理器用/dev/sda这类名字记录设备信息,重启后内核枚举顺序变了,原本的/dev/sda变成了/dev/sdb,卷组识别失败。解决方式是全部切换为/dev/disk/by-path/稳定路径,并在设备识别逻辑里增加"已属于某个 LVM VG 的设备绝不重复初始化"的幂等校验。
坑三:调度扩展临时失效导致 Pod 被调度到无盘节点。调度扩展服务因为内存问题重启期间,某个 Pod 恰好被创建,绕过了容量过滤,被调度到没有本地盘的节点,PVC 直接卡死。后续给调度扩展加了健康检查和缓存恢复,并约定任何变更必须在低峰期执行。
坑四:回收策略误设 Delete。有段时间为了测试方便,把 StorageClass 的回收策略改成了 Delete,结果清理测试 PVC 时,LV 被自动删除,数据彻底没了。虽然都是测试数据,但冷汗出了一身。生产环境务必保持Retain,宁可手动清理麻烦一点,也不能让自动化删除成为隐患。
坑五:并发创建 PVC 时容量超卖。两个 Pod 同时调度,调度扩展读到的节点剩余容量都是 80Gi,各自申请了 50Gi,看起来都够,实际上只有一个能成功。后来在供给控制器中加了容量扣减的事务逻辑,并在调度扩展返回结果时做了二次校验,才堵住并发窗口。
6. 动态本地卷方案的边界,以及后续可以怎么演进
6.1 哪些情况还是老老实实用网络存储
本地卷方案解决了性能问题,但它不是取代网络存储的银弹。以下几类场景请保持清醒:
- 跨可用区的多副本容灾:本地卷数据只存在于单节点,可用区级故障时无法漂移;
- 数据量级特别大、单节点放不下、必须依赖分布式存储横向扩展的业务;
- 底层靠存储快照做周期性备份的合规需求,本地卷很难提供一致性的跨节点快照。
我们现在的分工是:高 IO 热数据走本地卷,冷数据和跨节点备份走网络存储。两层存储各司其职,才让整体架构既快又稳。
6.2 运维上要补的三件事:监控、备份、应急预案
本地卷方案上线后,运维重点从"存储平台调参"变成了"设备和容量治理"。三个事必须补齐:
监控方面,除了 Pod 层面的指标,还要盯 LV 使用率、VG 剩余容量、单卷 IO 延迟、设备 SMART 健康状态。LV 快满比磁盘快满更容易被忽略,我们曾经因为备份任务把某节点的 LV 撑到 99%,业务写入直接阻塞。
备份方面,优先做应用层备份,比如数据库逻辑导出、日志按批次转储到对象存储。块级快照在本地卷上意义有限,它只能在同一节点内恢复,节点都没了快照也就没了。
应急预案必须写成 runbook:节点宕机后的第一步是判断能否快速重启,第二步是评估 LV 数据抢救价值,第三步才是重建节点和恢复服务。没有预案的本地卷方案,故障时只能靠现场猜,越猜越慌。
6.3 可以继续往前走的方向
这套体系稳定运行后,我们已经在规划几个演进方向:
- 卷级加密和配额管理,解决多业务共用存储节点时资源隔离的问题;
- 基于 cgroup 的 IO 限流,避免某个应用的疯狂写 IO 拖垮同节点其他卷;
- 自动分层:结合数据访问频率,把冷数据自动从本地卷搬迁到网络存储,让本地盘始终服务热点。
这些方向都不复杂,核心基础设施已经就位,剩下的更多是策略和工程化的事。
最后说点实在的。如果你也想在 K8s 里搞动态本地物理卷,我的建议是先在小集群用几块 NVMe 盘跑通全链路,不要一上来就铺开到所有业务。第一优先把三件事打磨好:StorageClass 的延迟绑定配置、节点剩余容量的实时上报、以及本地卷的回收确认机制。这三个地方稳了,整套体系基本就稳了。我们跑了大半年,最深的感受是:本地卷不是存储技术问题,是调度和运维的纪律问题——盘好认、卷好建,难的是让每个 Pod 精准落盘、每块数据都有兜底。