
线上那批 IO 密集型的服务Kubernetes 集群跑了大半年应用层一直正常直到某次大促压测存储先亮了红灯。数据库实例的写入时延从 1ms 左右直接飙到 20ms 开外日志引擎时不时报超时压测工具的 IOPS 始终顶不上配额更头疼的是有个主从实例因为存储延迟波动触发了切换。当时第一反应是查应用代码、查网络折腾了两周最后用 fio 把后端分布式存储和节点本地盘做了个对比测试结论非常直接瓶颈根本不在应用而在底层存储的数据路径上。于是我们把目光投向了一直想做但没敢动手的方案——基于 App Mesh 的 Kubernetes 动态本地物理卷把高 IO 应用和本地高性能盘在拓扑上绑在一起。这篇文章就把整个方案的设计思路、实现细节、踩坑过程和实测数据完整写下来给同样被网络存储性能卡住的项目做个参考。1. 网络存储的性能天花板我们的压测数据一点都不好看1.1 同样的 fio网络存储和本地盘的差距有多大先说压测方法。为了让对比有意义测试机用同一批配置fio 参数保持一致数据路径分别是分布式块存储挂载出来的云盘设备以及节点上直插的 NVMe SSD。测试命令大致是这样fio --namerandwrite --rwrandwrite --bs4k --direct1 --ioenginelibaio \ --iodepth64 --numjobs8 --size10G --runtime60 --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随机读 IOPS4k约 5 万约 42 万随机写 IOPS4k约 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 四个核心组件和一条完整调用链整个体系由四个角色组成各管一段组件部署形态职责设备管理器 DeviceManagerDaemonSet识别节点裸盘、初始化、维护设备状态供给控制器 ProvisionerDeployment监听 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 配置与 WaitForFirstConsumerStorageClass 是整个链路里最关键的配置一个典型的定义长这样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 模式碰巧绑定到某个 PVPod 后续调度的节点也不一定和卷所在节点一致本地卷根本挂不上。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-nodetrue kubectl label node node-01 device-classnvme随后部署 CRD、设备管理器 DaemonSet、供给控制器 Deployment、调度扩展服务。调度扩展尤其要注意它不是 Deployment 挂在集群内部就行还要让 kube-scheduler 通过--extender-config知道它的地址配置完成后需要重启所有 scheduler 的 Pod。部署完成后按这个顺序检查CRD 是否正常列出设备和节点容量每个存储节点的设备管理器是否成功识别裸盘并上报剩余容量用kubectl get storageclass确认 StorageClass 存在创建一个测试 PVC Pod观察 PVC 状态从 Pending → BoundPod 正常 Running在 Pod 里执行df -h验证卷确实挂载在目标节点上而不是只在控制平面显示成功。5.2 fio 实测对比部署稳定后我们把原先压测过的网络存储场景全部重跑了一遍。测试方式不变只是数据路径从网络存储换成了通过 PVC 挂载的本地逻辑卷。仍然用 4K 随机写作为主要指标因为它是线上最痛的部分指标改造前网络存储改造后App Mesh 本地卷4k 随机读 IOPS约 5 万约 40 万4k 随机写 IOPS约 1.8 万约 19 万4k 随机写 P9915ms0.5ms数据库 WAL fsync 耗时12-18ms0.4-0.8ms数据库侧肉眼可见地稳定了主从延迟告警消失大促压测时 IO 指标不再是瓶颈。整个改造带来最直接的业务收益是原来因为存储延迟被迫限流的高写入场景现在可以全量承接。顺带提醒测试时的一个细节fio 一定要加direct1否则 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 精准落盘、每块数据都有兜底。