找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4446

积分

0

好友

582

主题
发表于 1 小时前 | 查看: 3| 回复: 0

引言:为什么很多团队把 Pod 跑稳了,却把数据跑丢了

无状态服务迁进 Kubernetes 往往不难,真正把团队拦住的,通常是存储。

应用开发习惯把“写文件”看成一件理所当然的小事,但在 Kubernetes 里,文件写到哪里、由谁分配、何时挂载、节点挂了以后还能不能找回来、跨可用区怎么调度、扩容和快照由谁负责,这些都不再是一个 mountPath 能解释清楚的问题。

很多线上事故并不是因为团队不会写 YAML,而是没有把 Kubernetes 存储看成一条完整链路:

  • Pod 里声明的是 Volume
  • 租户申请的是 PersistentVolumeClaim
  • 集群交付的是 PersistentVolume
  • 平台抽象的是 StorageClass
  • 真正和后端存储打交道的是 CSI

这篇文章不再停留在“Volume 有哪些类型”的入门层面,而是从生产视角把这条链路讲透:对象关系、控制面协作、节点挂载路径、调度约束、动态供给、快照扩容、故障恢复,以及高并发场景下的工程治理。

全文围绕一个真实可落地的业务场景展开。如果你希望深入探讨 Kubernetes 存储架构,可以来 云栈社区 与同行交流。

1. 业务场景:电商交易平台为什么会被存储拖垮

我们以一个区域型电商平台为例。平台日常订单峰值约 1.8 万 QPS,大促时翻 4 到 6 倍,核心服务全部运行在 Kubernetes 1.30 集群中。

系统中和存储强相关的组件有四类:

  • 订单服务 order-service:Deployment,无状态,但会写本地临时文件、失败重试队列和导出中间结果。
  • 支付服务 payment-service:Deployment,需要共享证书、风控规则和审计归档目录。
  • MySQL 主从集群:StatefulSet,要求低延迟、可扩容、可备份、节点故障后可恢复。
  • Kafka 集群:StatefulSet,高吞吐写入,对顺序写和磁盘吞吐敏感,自身具备副本机制。

团队最初的方案并不复杂:

  • 临时数据全部写 emptyDir
  • 共享目录直接上 NFS
  • MySQL 先用 hostPath
  • Kafka 也先挂云盘

这套方案在测试环境跑得很好,但一上生产就暴露出四类典型问题。

1.1 第一类问题:把临时卷当持久卷

订单服务把“待上传图片分片”和“导出任务中间文件”写进 emptyDir。Pod 重建后,任务元数据还在数据库里,但文件已经没了,导致大量任务进入“状态存在、结果不存在”的半失败状态。

1.2 第二类问题:把共享文件系统当成通用解法

团队让多个服务共享 NFS 目录用于日志、配置和中间文件。大促期间 NFS 出现短抖动,应用线程大量阻塞在 IO 上,JVM 堆外内存增长,最终多个 Pod 同时被驱逐或 OOMKilled。

1.3 第三类问题:把节点本地路径当成数据库盘

MySQL 初期使用 hostPath,平时看不出问题,但节点维护或宕机后,Pod 被调度到新节点,数据根本不在,导致数据库实例无法恢复。

1.4 第四类问题:只会创建 PVC,不理解绑定与拓扑

某次新建 StatefulSet 时,PVC 始终 Pending。排查发现云盘 StorageClass 使用了立即绑定,卷提前创建在可用区 A,而 Pod 被调度候选节点主要在可用区 B,结果卷和节点永远对不上。

这些问题背后不是某个 YAML 写错了,而是团队没有把 Kubernetes 存储模型当成一套资源协作系统来理解。

2. 先建立全景图:Kubernetes 存储到底分几层

如果只记一句话,我建议记住这一句:

Pod 负责声明“我要怎么用存储”,PVC 负责声明“我要多少存储”,PV 负责代表“平台交付了什么存储”,StorageClass 负责定义“如何交付存储”,CSI 负责真正“把存储做出来并挂上去”。

对应关系如下。

层次 资源/组件 主要职责 谁来关注
应用层 volumeMountsvolumes 容器内挂载点与使用方式 应用开发
申请层 PersistentVolumeClaim 容量、访问模式、存储类、卷模式 应用开发/中间件团队
供给层 PersistentVolume 具体卷实例、后端卷标识、回收策略 平台/存储团队
模板层 StorageClass 动态供给参数、拓扑、回收、扩容能力 平台团队
插件层 CSI Driver 创建卷、附加卷、挂载卷、扩容、快照 平台/存储团队
节点层 kubelet + OS 文件系统 格式化、挂载、卸载、路径维护 节点与容器运行时

很多人把 VolumePV 混在一起讲,这是理解错误的起点。

2.1 Volume 是 Pod 视角,不一定持久

Pod 里的 volumes 只是“容器要使用的卷定义”。它可能来自:

  • emptyDir
  • configMap
  • secret
  • projected
  • persistentVolumeClaim
  • ephemeral

也就是说,Volume 只是入口,不等于持久化。

2.2 PVC 是需求单,不是存储本体

PVC 只描述意图:

  • 需要多少容量
  • 需要什么访问模式
  • 用哪个 StorageClass
  • 是文件系统还是裸块设备

PVC 本身不存数据。真正的数据落点在绑定后的 PV 对应的后端存储上。

2.3 PV 是平台交付物,不属于某个 Pod

PV 的生命周期独立于 Pod。只要回收策略不是直接删除,Pod 删了,PV 依然可以存在。也正因为如此,PV 才能承担“持久化”的语义。

2.4 StorageClass 决定动态供给行为

StorageClass 里最关键的不是名字,而是这几类能力:

  • provisioner:谁负责创建卷
  • parameters:创建卷时传给后端驱动的参数
  • reclaimPolicy:PVC 删除后卷怎么处理
  • allowVolumeExpansion:是否允许在线扩容
  • volumeBindingMode:立即绑定还是延迟到消费者出现后绑定
  • mountOptions:挂载选项

2.5 CSI 才是 Kubernetes 存储插件化的核心

Kubernetes 早期有大量 in-tree 存储插件,云厂商和开源存储每家一套实现,升级成本高,耦合严重。CSI 的意义就在于把“存储能力接入 Kubernetes”标准化,让 Kubernetes 不再内置所有卷插件逻辑。

简单说,CSI 解决的是两件事:

  • 让 Kubernetes 不必了解每一种后端存储的细节
  • 让存储厂商只需要实现一套标准接口就能被 Kubernetes 调用

3. Pod 数据生命线:从提交 YAML 到容器真正写盘,中间发生了什么

大多数文章讲到 PVC 绑定就结束了,但真正决定系统稳定性的,是后面的控制面与节点协作过程。

下面按时间顺序走一遍完整链路。

3.1 第一步:开发者提交 Pod 和 PVC

应用侧通常会同时提交:

  • Deployment/StatefulSet
  • 直接引用 PVC 的 Pod 模板
  • 或者在 volumeClaimTemplates 中声明自动创建 PVC

此时 API Server 只是存储对象,数据还没有挂到任何节点。

3.2 第二步:PVC 绑定或触发动态供给

persistentvolume-controller 会观察 PVC:

  • 如果集群里已有匹配 PV,就直接绑定
  • 如果没有,但 PVC 指定了 StorageClass,就走动态供给

动态供给时,真正创建卷的不是 controller 本体,而是 CSI sidecar 中的 external-provisioner。它会调用 CSI CreateVolume 接口,让后端存储创建实际卷,然后再回写 PV 对象。

3.3 第三步:调度器把“算力约束”和“存储约束”一起算

这里最容易被忽略。

Pod 调度从来不只看 CPU 和内存。只要 Pod 依赖 PVC,调度器还要同时考虑:

  • 该卷能否挂到某个节点
  • 该节点所在拓扑区域是否满足卷约束
  • 卷是否已经绑定到别的节点或可用区
  • 本地盘是否就在该节点上

如果 StorageClass 使用 WaitForFirstConsumer,调度器会先选节点,再由供给逻辑在该节点可达的拓扑域内创建卷。对云盘、多可用区、本地盘,这几乎是生产必选项。

3.4 第四步:Attach 阶段把卷“送到节点边上”

对于需要附加的块存储,比如 EBS、云硬盘、Ceph RBD,控制面会先做 Attach。

这一步通常由:

  • external-attacher
  • VolumeAttachment
  • 存储后端 API

共同完成。

如果后端是 NFS、CephFS 这类网络文件系统,通常没有“附加设备”这个动作,节点可以直接挂载远端文件系统。

3.5 第五步:kubelet 在节点上完成 Stage 和 Publish

卷真正变成容器可见目录,是 kubelet 与 CSI Node 插件配合完成的。

典型路径分两段:

  • NodeStageVolume:把设备映射到节点、格式化文件系统、做全局挂载
  • NodePublishVolume:把已经准备好的卷 bind mount 到某个 Pod 目录

这个两阶段设计的好处是:

  • 一个卷可以先在节点级准备好
  • 多个 Pod 使用时避免重复做重操作
  • 节点重建挂载状态更清晰

3.6 第六步:容器启动,应用才真正开始读写

很多人误以为“Pod 已经 Running,卷一定没问题”。不完全对。

真实生产里要继续关注:

  • 挂载后的文件系统权限
  • fsGroup 是否生效
  • 容器用户与目录属主是否匹配
  • 网络文件系统是否有长尾延迟
  • 节点断链后 IO 是否卡死

Pod Running 只是说明“启动时挂载成功”,不等于“运行期间存储一直健康”。

4. 常见卷类型不是背清单,而是理解适用边界

4.1 emptyDir:最快上手,也最容易被误用

特征:

  • Pod 创建时生成
  • Pod 删除后数据消失
  • 容器重启不丢,Pod 重建会丢
  • 可落磁盘,也可落内存 medium: Memory

适合场景:

  • 缓存
  • 转码中间文件
  • 临时排序和聚合结果
  • Sidecar 间共享运行时数据

不适合场景:

  • 任何需要跨 Pod 生命周期保留的数据
  • 任务恢复依赖本地文件的批处理系统

4.2 hostPath:不是不能用,但只能谨慎用

特征:

  • 直接挂宿主机路径
  • 性能高,简单粗暴
  • 完全绑定节点
  • 安全风险大

适合场景:

  • 采集宿主机日志
  • 节点级代理读取容器运行时目录
  • 实验环境或单节点调试

不适合场景:

  • 生产数据库
  • 可迁移的业务数据盘
  • 多租户集群中的普通业务容器

4.3 local PV:本地盘的规范化版本

很多团队会把 local PVhostPath 视为同义词,这是错误的。

local PV 的价值在于:

  • 通过 PV/PVC 体系管理本地盘
  • 可参与调度约束
  • 生命周期更清晰
  • 适合 Kafka、Elasticsearch、ClickHouse 这类自带副本机制的系统

它的代价也很明确:

  • 节点坏了,卷不会自动迁移
  • 数据恢复依赖应用自身复制能力

4.4 网络文件系统卷:RWX 不是万能药

典型代表:

  • NFS
  • CephFS
  • EFS
  • Azure Files

优势:

  • 支持 ReadWriteMany
  • 多 Pod 共享方便
  • 运维心智负担低

问题:

  • 元数据操作和小文件写入性能通常不如本地盘/块存储
  • 网络抖动会直接反映到业务线程
  • 文件锁、目录热点、单目录大文件数量都可能成为瓶颈

4.5 块存储卷:数据库的主战场

典型代表:

  • 云硬盘
  • Ceph RBD
  • SAN/LUN

优势:

  • 随机读写能力强
  • 延迟更可控
  • 更适合数据库、KV、状态机日志

限制:

  • 大多数只支持 ReadWriteOnce
  • 多副本共享写并不天然成立

5. 生产选型:不是“哪个最好”,而是“哪类业务配哪类卷”

下面给出一份更接近真实生产的存储分层设计。

5.1 推荐选型矩阵

业务类型 推荐存储 关键原因 明确边界
Web 服务临时文件 emptyDir 简单、低延迟、无需外部依赖 Pod 重建即丢
多 Pod 共享配置归档 CephFS/NFS/EFS 支持 RWX 高并发写入需评估
MySQL/PostgreSQL RBD/云盘类块存储 RWO、性能稳、可扩容、可快照 不适合多实例共享写
Kafka/Elasticsearch local PV + 本地 SSD 顺序写强、吞吐高 节点故障靠应用副本恢复
宿主机日志采集 hostPath 必须读取节点目录 只给 DaemonSet 或受控组件

5.2 不同团队的职责边界

把职责切清楚,比技术选型本身更重要。

  • 应用团队负责:PVC 规格、目录使用方式、读写模式、数据恢复语义
  • 平台团队负责:StorageClass、CSI 驱动、拓扑约束、配额、备份与监控
  • DBA/中间件团队负责:数据库或消息系统自身副本、刷盘策略、恢复流程

如果团队把“存储可靠性”全部甩给 Kubernetes,本身就是错误预期。Kubernetes 负责调度和抽象,不负责替你的应用设计数据冗余模型。

6. 生产级 YAML:从临时缓存到 StatefulSet 持久化

这一节给几组可直接作为生产基线的配置。

6.1 订单服务:临时文件使用 emptyDir,避免误当持久卷

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  labels:
    app: order-service
spec:
  replicas: 6
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
      - name: app
        image: registry.example.com/order-service:1.4.2
        ports:
        - containerPort: 8080
        env:
        - name: TMP_DIR
          value: /data/tmp
        volumeMounts:
        - name: tmp-data
          mountPath: /data/tmp
        resources:
          requests:
            cpu: "500m"
            memory: "1Gi"
          limits:
            cpu: "2"
            memory: "2Gi"
      volumes:
      - name: tmp-data
        emptyDir:
          sizeLimit: 8Gi

这里有两个生产细节常被漏掉:

  • emptyDir 设置 sizeLimit,避免把节点磁盘吃穿
  • 让应用显式区分“临时目录”和“业务结果目录”,不要把临时数据和持久数据混写

6.2 共享归档目录:RWX 卷使用独立 StorageClass

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: shared-nfs
provisioner: nfs.csi.k8s.io
parameters:
  server: 10.10.20.15
  share: /k8s-shared
reclaimPolicy: Retain
mountOptions:
  - nfsvers=4.1
  - hard
  - timeo=600
  - retrans=2
volumeBindingMode: Immediate
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: payment-archive-pvc
spec:
  accessModes:
  - ReadWriteMany
  resources:
    requests:
      storage: 200Gi
  storageClassName: shared-nfs

如果多个副本同时写共享目录,建议再加两层约束:

  • 业务目录按实例分层,例如 /archive/${POD_NAME}/
  • 归档写入异步化,不要让主请求线程直接阻塞在网络文件系统上

6.3 MySQL:使用 StatefulSet + volumeClaimTemplates + RWO 块存储

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rook-ceph-block
provisioner: rook-ceph.rbd.csi.ceph.com
parameters:
  clusterID: rook-ceph
  pool: mysql-replicapool
  imageFormat: "2"
  imageFeatures: layering
  csi.storage.k8s.io/provisioner-secret-name: rook-csi-rbd-provisioner
  csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
  csi.storage.k8s.io/node-stage-secret-name: rook-csi-rbd-node
  csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
  csi.storage.k8s.io/controller-expand-secret-name: rook-csi-rbd-provisioner
  csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
  labels:
    app: mysql
spec:
  serviceName: mysql
  replicas: 1
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      securityContext:
        fsGroup: 999
      containers:
      - name: mysql
        image: mysql:8.4
        ports:
        - containerPort: 3306
        env:
        - name: MYSQL_ROOT_PASSWORD
          valueFrom:
            secretKeyRef:
              name: mysql-secret
              key: root-password
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
        resources:
          requests:
            cpu: "1"
            memory: "2Gi"
          limits:
            cpu: "2"
            memory: "4Gi"
        readinessProbe:
          exec:
            command: ["sh", "-c", "mysqladmin ping -uroot -p$MYSQL_ROOT_PASSWORD"]
          initialDelaySeconds: 20
          periodSeconds: 10
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes:
      - ReadWriteOnce
      storageClassName: rook-ceph-block
      resources:
        requests:
          storage: 200Gi

这套配置背后的工程理由是:

  • StatefulSet 固定网络身份和卷身份
  • volumeClaimTemplates 保证 Pod 与 PVC 一一对应
  • Retain 防止误删 PVC 直接清空底层数据
  • WaitForFirstConsumer 避免拓扑错绑

6.4 Kafka:本地 SSD 更合适,但要接受节点绑定

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-ssd
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: kafka
spec:
  serviceName: kafka-headless
  replicas: 3
  selector:
    matchLabels:
      app: kafka
  template:
    metadata:
      labels:
        app: kafka
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchLabels:
                app: kafka
            topologyKey: kubernetes.io/hostname
      containers:
      - name: kafka
        image: bitnami/kafka:3.8
        volumeMounts:
        - name: data
          mountPath: /bitnami/kafka
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes:
      - ReadWriteOnce
      storageClassName: local-ssd
      resources:
        requests:
          storage: 500Gi

这里最核心的不是 YAML,而是架构前提:

  • Kafka 自己有副本和 ISR
  • 本地盘坏了允许 Broker 失效后重建
  • 你接受“数据可在集群层面恢复,但单卷不可迁移”

如果业务不能接受这个前提,就不要为了追求吞吐盲目选本地盘。

7. CSI 到底怎么工作:控制器、Sidecar 和 Node 插件各自做什么

很多文章一句“CSI 是存储接口标准”就带过去了,但真正落地时,运维和平台团队必须知道每个组件在做什么。

一个典型 CSI 驱动通常包含三部分。

7.1 Controller Service:负责卷的控制面动作

常见接口包括:

  • CreateVolume
  • DeleteVolume
  • ControllerPublishVolume
  • ControllerUnpublishVolume
  • ControllerExpandVolume
  • CreateSnapshot

这部分通常以 Deployment 跑在控制面可达区域,由多个 sidecar 协同。

7.2 Node Service:负责节点上的实际挂载

常见接口包括:

  • NodeStageVolume
  • NodePublishVolume
  • NodeUnpublishVolume
  • NodeUnstageVolume
  • NodeExpandVolume

它通常以 DaemonSet 跑在每个工作节点上,因为真正的设备映射和文件系统挂载必须在节点本地执行。

7.3 Identity Service:告诉外界“我是谁、我支持什么”

常见接口包括:

  • GetPluginInfo
  • GetPluginCapabilities
  • Probe

7.4 Sidecar 不是可有可无,而是 CSI 生态的一部分

常见 sidecar:

  • external-provisioner:动态供给
  • external-attacher:创建和维护 VolumeAttachment
  • external-resizer:卷扩容
  • external-snapshotter:卷快照
  • node-driver-registrar:向 kubelet 注册节点驱动
  • livenessprobe:健康检查

也就是说,CSI 驱动并不是“一个容器”这么简单,而是一组协作组件。

8. 一个更接近生产的 CSI 实现骨架:至少要做到幂等、可观测、可恢复

如果你的公司有自研存储系统,最终通常要自己实现 CSI 驱动。下面给一个比“伪代码”更接近生产要求的 CreateVolume 实现骨架,重点展示三个原则:

  • 幂等:相同卷名重复请求不能重复创建
  • 参数校验:不相信上游输入
  • 可观测:日志和错误要能支撑排障
package controller

import (
    "context"
    "errors"
    "fmt"
    "strings"

    "github.com/container-storage-interface/spec/lib/go/csi"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "k8s.io/klog/v2"
)

type VolumeStore interface {
    FindByName(ctx context.Context, name string) (*Volume, error)
    Create(ctx context.Context, req CreateVolumeInput) (*Volume, error)
}

type Volume struct {
    ID            string
    Name          string
    CapacityBytes int64
}

type CreateVolumeInput struct {
    Name          string
    CapacityBytes int64
    Parameters    map[string]string
}

type ControllerServer struct {
    csi.UnimplementedControllerServer
    store VolumeStore
}

func (s *ControllerServer) CreateVolume(
    ctx context.Context,
    req *csi.CreateVolumeRequest,
) (*csi.CreateVolumeResponse, error) {
    if req.GetName() == "" {
        return nil, status.Error(codes.InvalidArgument, "volume name is required")
    }

    requiredBytes := req.GetCapacityRange().GetRequiredBytes()
    if requiredBytes <= 0 {
        return nil, status.Error(codes.InvalidArgument, "required bytes must be greater than zero")
    }

    volumeName := sanitizeVolumeName(req.GetName())
    logger := klog.FromContext(ctx)
    logger.Info("create volume requested", "name", volumeName, "requiredBytes", requiredBytes)

    existing, err := s.store.FindByName(ctx, volumeName)
    if err != nil {
        logger.Error(err, "failed to query existing volume", "name", volumeName)
        return nil, status.Error(codes.Internal, "query volume failed")
    }

    if existing != nil {
        if existing.CapacityBytes < requiredBytes {
            return nil, status.Error(codes.AlreadyExists, "existing volume capacity is smaller than requested")
        }

        logger.Info("volume already exists, return existing handle", "volumeID", existing.ID)
        return buildCreateVolumeResponse(existing), nil
    }

    volume, err := s.store.Create(ctx, CreateVolumeInput{
        Name:          volumeName,
        CapacityBytes: requiredBytes,
        Parameters:    req.GetParameters(),
    })
    if err != nil {
        logger.Error(err, "failed to create volume", "name", volumeName)
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, status.Error(codes.DeadlineExceeded, "backend create volume timeout")
        }
        return nil, status.Error(codes.Internal, "backend create volume failed")
    }

    logger.Info("volume created", "volumeID", volume.ID, "name", volume.Name)
    return buildCreateVolumeResponse(volume), nil
}

func sanitizeVolumeName(name string) string {
    name = strings.TrimSpace(name)
    name = strings.ReplaceAll(name, "/", "-")
    return fmt.Sprintf("k8s-%s", name)
}

func buildCreateVolumeResponse(v *Volume) *csi.CreateVolumeResponse {
    return &csi.CreateVolumeResponse{
        Volume: &csi.Volume{
            VolumeId:      v.ID,
            CapacityBytes: v.CapacityBytes,
        },
    }
}

这段代码仍然只是核心骨架,但它已经体现了生产级最小要求:

  • 同名卷重试返回已有卷,而不是重复创建
  • 后端异常有明确错误分类
  • 关键日志能串起请求与卷标识

真正上线时还要继续补齐:

  • ControllerPublishVolume
  • DeleteVolume
  • ControllerExpandVolume
  • 节点侧 NodeStageVolumeNodePublishVolume
  • 指标埋点和 trace 上下文
  • 后端 API 的超时、重试、熔断

9. 高并发与可扩展:大促时真正会出问题的,不只是卷本身

用户特别提到要做工程化升级,这里必须强调一件事:

高并发场景下,Kubernetes 存储问题往往表现为“应用故障”,而不是“磁盘故障”。

9.1 共享卷抖动会放大成线程池阻塞

当应用把主请求链路直接绑定到共享文件系统写入时,存储抖动会传导为:

  • Tomcat/Netty 工作线程阻塞
  • 连接池占满
  • 上游超时重试
  • 下游雪崩

正确做法通常是:

  • 把主请求和文件落盘解耦
  • 先写数据库状态或消息队列
  • 异步工作进程再执行文件归档
  • 对共享存储写入设置超时与隔离线程池

9.2 存储扩容不是只改 PVC 容量

在线扩容真正涉及三层:

  • Kubernetes 资源规格变更
  • CSI 控制器扩容后端卷
  • 节点文件系统扩容

所以生产变更前要确认:

  • StorageClass 开启了 allowVolumeExpansion
  • 驱动支持 ControllerExpandVolumeNodeExpandVolume
  • 文件系统支持在线扩容

9.3 容量治理要前移,不要等磁盘打满才发现

建议平台默认建立三类监控:

  • PVC 使用率
  • inode 使用率
  • 挂载错误和 IO 延迟

emptyDir 还应额外监控:

  • 节点 ephemeral-storage
  • 单 Pod 本地临时目录增长速度

9.4 调度层要和存储层一起设计

高并发状态系统不能只看存储,也要看调度策略:

  • StatefulSet Pod 反亲和,避免副本落在同一节点
  • 本地盘服务绑定 nodeAffinity
  • 跨可用区部署时保证存储拓扑一致
  • 使用 PodDisruptionBudget 降低维护期同时驱逐风险

10. 数据一致性与故障恢复:Kubernetes 不替应用兜底的部分

很多人误把“有 PV”理解成“数据就安全”。这在数据库、消息队列、搜索系统里尤其危险。

10.1 存储持久化不等于业务一致性

即使卷没丢,应用仍可能出现:

  • 已写 binlog 但事务未对外可见
  • Kafka 日志写入磁盘但副本未同步
  • 文件写完但数据库状态未更新

所以要把“卷持久化”与“业务一致性”拆开看。

10.2 关键恢复流程建议

生产恢复建议按优先级拆成三层:

  • 第一层:卷级恢复
    例如重建挂载、从快照恢复 PVC、手动重绑 PV。
  • 第二层:实例级恢复
    例如 MySQL 从备份恢复、Kafka 重新拉起 Broker、ES 分片重分配。
  • 第三层:业务级恢复
    例如重新跑补偿任务、根据审计表重放归档、修复状态与文件不一致。

也就是说,Kubernetes 存储恢复只是整个恢复流程的中间一环,不是全部。

11. 可观测性与运维基线:想把状态服务跑稳,至少要盯住这些信号

11.1 Kubernetes 资源面

  • PVC Pending 持续时间
  • VolumeAttachment 失败事件
  • Pod 挂载相关 Warning 事件
  • 节点 DiskPressure

11.2 CSI 驱动面

  • external-provisioner 创建卷失败率
  • external-attacher 附加卷耗时
  • Node 插件挂载失败次数
  • CSI sidecar 资源使用率

11.3 存储后端面

  • 卷延迟
  • IOPS / 吞吐
  • 后端容量池剩余量
  • 网络文件系统连接数与元数据延迟

11.4 应用面

  • 文件写入耗时分位值
  • 归档队列积压量
  • 数据库慢刷盘或 checkpoint 延迟
  • 任务状态与文件状态不一致数量

如果监控只做到 PVC 容量告警,基本等于没有做到存储治理。

12. 最容易踩的坑,我按“真实伤害值”排个序

12.1 用 Delete 回收策略承载核心数据

这是最危险的默认配置之一。对于数据库、消息日志、关键业务文件,优先使用:

  • reclaimPolicy: Retain
  • 快照
  • 备份

否则删 PVC 就可能把底层卷也一并删掉。

12.2 忽略 WaitForFirstConsumer

在本地盘、多可用区云盘、带拓扑约束的后端里,不开延迟绑定,迟早遇到“卷在 A 区,Pod 想去 B 区”的问题。

12.3 把 subPath 当成目录隔离万能钥匙

subPath 很好用,但要知道两个边界:

  • 用于 ConfigMap/Secret 时,热更新行为和直接挂载不同
  • 目录不存在、权限不匹配时,排障成本高

12.4 把网络文件系统写入放在同步主链路

这类问题平时不出事,一出事就是整个服务 RT 飙升。

12.5 忽略 fsGroup、目录权限和容器用户

很多“挂载成功但应用起不来”的问题,本质不是卷坏了,而是目录属主不对。

13. 一条更合理的演进路线:别一上来就追求“最全能的存储平台”

不同阶段的团队,合理路径通常不同。

13.1 小规模阶段

  • 无状态应用用 emptyDir
  • 少量共享场景用托管 NFS/EFS
  • 数据库先用托管数据库,而不是自己在集群里跑

13.2 中等规模阶段

  • 开始把中间件迁入集群
  • 建立标准化 StorageClass
  • 引入 CSI 快照、扩容、监控
  • 规范 PVC 配额与命名

13.3 平台化阶段

  • 建立分层存储体系
  • 建立存储准入规范
  • 为不同应用模板预设存储默认值
  • 将备份恢复、扩缩容、故障演练流程平台化

不是每个团队都必须上 Ceph,也不是每个状态应用都适合自建。真正专业的架构设计,不是堆能力,而是知道什么时候不该引入复杂度。

14. 总结:把 Pod 存储看成链路,而不是字段

如果要给这篇文章收一个结论,我会总结成四句话。

第一,VolumePVCPVStorageClassCSI 不是五个孤立概念,而是一条从“应用意图”到“后端实现”的完整链路。

第二,Kubernetes 解决的是存储抽象、调度协作和挂载编排,不替应用解决一致性、副本语义和业务恢复。

第三,生产选型的关键不在“有没有持久卷”,而在“业务是否匹配卷模型、拓扑模型和故障模型”。

第四,真正把状态服务跑稳,靠的不是一份 YAML,而是对象模型、CSI 原理、调度约束、恢复流程和运维治理一起闭环。

当你下次再看到一个 Pod 里写着:

volumeMounts:
- name: data
  mountPath: /data

你看到的就不该只是一个挂载点,而应该是这条完整的数据生命线:

  • 数据是否会丢
  • 卷由谁创建
  • 会不会跨区错绑
  • 节点挂了能不能恢复
  • 应用线程会不会被慢存储拖死
  • 删除 PVC 会不会把底层数据一起删掉

这时你才算真正理解了 Kubernetes 存储。




上一篇:服务器迁移揪出9年“幽灵攻击”,竟是爬虫陷入点赞死循环
下一篇:用 Mac 打造全屋透明代理网关:导入规则、设备分流、DHCP/DNS 自动接管
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-7-28 04:49 , Processed in 1.651208 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表