
K8s系列走到第六篇终于要碰存储这块硬骨头了。前面几篇讲Deployment、Service、ConfigMap大家用起来都觉得挺顺手但一旦涉及数据库、日志系统、文件服务这些有状态应用Pod一重启数据就丢了这时候才意识到存储设计有多关键。K8s里解决这个问题的核心就是Volume、PersistentVolume(PV)、PersistentVolumeClaim(PVC)这一整套抽象再加上生产中绕不开的NFS共享存储。这篇文章我准备从概念讲到实战最后落到故障排查尽量让零基础的人也能跟着把存储跑通。不管你是在测试环境搭着玩还是在生产集群里给业务规划持久化存储这篇都值得花时间看完。1. 彻底搞懂K8s存储架构Volume、PV、PVC到底谁管谁1.1 为什么需要一套独立的存储抽象层容器本身是“用完即弃”的。最常见的翻车场景是这样的Java应用把日志写进了容器里的 /opt/app/logsPod因为内存溢出被重启重启后日志全没了——不是应用出bug而是你压根没告诉K8s要把数据持久化到哪里。很多人第一次被K8s存储坑到往往就是这个原因。K8s给出的解决方案是Volume。你可以把Volume理解成给容器加了一个“外接硬盘位”在Pod的spec里声明要挂载什么存储K8s负责把它挂载到容器的指定路径容器内的进程根本感知不到底层到底是本地磁盘、云盘还是网络存储。打个生活化的比方Pod是每天换岗的保安Volume是固定在墙上的储物柜保安换了储物柜和里面交接的记录都还在。但Volume本身依然不够。默认情况下Volume的生命周期跟随Pod——Pod被删除Volume一般也就没了除非使用持久化类型的Volume。于是K8s又在Volume之上抽象出了PersistentVolumePV和PersistentVolumeClaimPVC。核心动机一句话就能说清把“存储资源的管理”和“存储资源的使用”彻底拆开。管理员负责准备存储应用只负责声明“我要多大、什么访问模式”两边不用互相知道对方细节。1.2 四层核心概念Volume、PV、PVC、StorageClass的关系定位先把这个体系的全景放在这里后面每一层都会展开讲。VolumePod级别的存储声明生命周期与Pod绑定适合临时数据。PV集群级别的存储资源由管理员预先创建独立于任何Pod存在。PVC工作负载的存储请求描述“我要多少空间、什么访问模式”。StorageClass存储模板负责动态创建PV的“生产车间”。它们之间的关系可以这样理解。PV是已经造好、在仓库里等着被认领的货物PVC是用户提交的领货申请单StorageClass则是一台按需生产的机床——用户提交申请后它立刻按照规格生产出货物。相当直观。这里有一个新手最容易混淆的关键点PV并不直接绑定Pod。Pod通过PVC去“认领”合适的PV一旦绑定成功这个PV就被这个PVC独占。而且绑定关系是一对一的不是多对多的共享关系。这也就解释了为什么你明明建了10个PVPVC却一直Pending——因为可能没有任何一个PV完全满足这个PVC的条件或者满足条件的PV已经被别人绑走了。2. Volume实战emptyDir和hostPath怎么用才不踩坑2.1 emptyDir跟随Pod生死的临时存储2个典型用法先从最朴素的emptyDir说起。emptyDir就是一个空目录Pod创建时创建Pod被删除时跟着销毁。它的名字很形象——一开始就是个空目录。emptyDir的典型用途有两个。第一个是同一Pod内容器间共享数据。最常见的是sidecar模式主容器写日志到emptyDir日志采集容器挂同一个emptyDir去采集两边的数据通过一个临时目录交接。apiVersion: v1 kind: Pod metadata: name: emptydir-demo spec: containers: - name: app image: busybox command: [/bin/sh, -c, echo hello k8s storage /workspace/data.txt sleep 3600] volumeMounts: - name: shared-data mountPath: /workspace - name: sidecar image: busybox command: [/bin/sh, -c, cat /workspace/data.txt sleep 3600] volumeMounts: - name: shared-data mountPath: /workspace volumes: - name: shared-data emptyDir: {}第二个是临时缓存和中间结果。比如应用把计算过程中的临时文件写到emptyDir处理完就丢掉丢了也无所谓。emptyDir有个参数值得注意medium: Memory表示用内存文件系统tmpfs作为存储介质。这个选项对性能敏感的临时读写很友好但我要提醒一句tmpfs不落盘Pod被调度走数据直接消失而且它占用的是节点内存不是磁盘空间。如果你在内存只有4G的节点上用tmpfs缓存几个G的文件节点分分钟被打爆。2.2 hostPath直通宿主机目录生产环境务必想清楚emptyDir解决不了“Pod删了数据还在”的问题很多人第一反应就是用hostPath。hostPath会把宿主机的某个目录直接挂进容器Pod被删了宿主机目录里的数据还在Pod重建之后还能继续读。从操作上看hostPath是最直白的volumes: - name: host-log hostPath: # 宿主机上的路径如果存在类型问题会报错 path: /data/logshostPath最大的问题是“数据跟节点绑定”。K8s调度Pod时根本不会考虑hostPath挂在哪台机器上。一个非常典型的翻车场景你在node1上存了几个G的数据某天node1宕机Pod被重新调度到node2而node2上的 /data/logs 是空的应用一启动数据全部“消失”实际数据还在node1但Pod访问不到了。所以我的建议很直接生产环境的主业务数据不要走hostPath。它只适合特殊场景DaemonSet类型的系统组件如日志采集器、监控agent因为它们跟节点强绑定数据留在本机反而合理。节点级配置文件或socket文件。临时调试和故障排查。如果非要用hostPath务必把type设置正确比如type: DirectoryOrCreate、type: FileOrCreate明确是目录就挂目录、是文件就挂文件避免类型不匹配导致挂载失败。2.3 从Volume走向PV存储解耦的临界点在哪把需求再往前推一步数据不仅要活过Pod的生命周期还要能被调度到任意节点、能被多个副本共享、能被独立管理——这就到PersistentVolume登场的时候了。Volume和PV最大的区别在于“谁在管存储”。Volume是Pod的一部分由Pod定义驱动PV是集群级对象由管理员或存储系统动态供给Pod通过PVC发起申请。把这条边界划清楚后你会发现K8s在“资源”和“使用”之间做了极其彻底的解耦。应用不需要知道底层磁盘在哪、是什么类型只需要说“我要5GiB、可读写挂载”剩下的绑定和供给全部由K8s控制面完成。3. PersistentVolume与PersistentVolumeClaim深度实战3.1 PV/PVC绑定原理、访问模式与生命周期PV是集群中存储资源的抽象它描述了存储容量、访问模式、回收策略和挂载路径。PVC则是工作负载向集群发出的存储申请。绑定过程有几个条件必须同时满足容量PVC请求的容量必须小于等于PV的容量。访问模式PVC要求的accessModes必须被PV支持。storageClassName如果PVC指定了storageClassNamePV必须一致PVC留空而PV设置了StorageClass时则取决于集群的默认策略。状态PV必须是Available。访问模式是新手最容易懵的点。K8s定义了三种模式访问模式缩写含义典型场景ReadWriteOnceRWO只能被单个节点读写挂载数据库单实例ReadOnlyManyROX可以被多个节点同时只读挂载配置共享、只读模板ReadWriteManyRWX可以被多个节点同时读写挂载NFS共享文件服务注意访问模式是“接口协议”不是“底层能力”。比如云硬盘虽然底层是块存储K8s的RWO限制的是“节点级别”不是“Pod级别”——同一个节点上的多个Pod挂载同一个RWO卷在语义上是允许的。生命周期可以分为四个阶段AvailablePV已准备好等待PVC绑定。BoundPV已和一个PVC绑定一对一关系成立。ReleasedPVC被删除PV里的数据还在等待管理员手动处理。FailedPV的自动回收失败卷进入不可用状态。3.2 静态供给手动创建PV与PVC的完整流程先手动创建PV这是最“朴素”的静态供给方式。管理员提前把存储准备好然后通过PV对象描述它apiVersion: v1 kind: PersistentVolume metadata: name: pv-manual-001 spec: capacity: storage: 5Gi accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain storageClassName: manual-storage nfs: server: 192.168.1.100 path: /data/k8s-pv001再创建PVCapiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-manual-001 spec: accessModes: - ReadWriteOnce storageClassName: manual-storage resources: requests: storage: 5Gi创建后分别执行kubectl get pv和kubectl get pvc如果看到Bound就说明绑定成功。这里有一个我踩过的坑storageClassName是大小写敏感的哪怕差一个字符PV和PVC也不会绑定。另外PVC是命名空间资源PV是集群级资源不同namespace下的PVC可以绑同一个PV——但一旦绑定该PV就被那个namespace下的PVC独占了其他namespace再申请就只能等释放。绑定成功后在Pod里使用非常简单apiVersion: v1 kind: Pod metadata: name: pvc-pod spec: containers: - name: app image: nginx volumeMounts: - name: data mountPath: /usr/share/nginx/html volumes: - name: data persistentVolumeClaim: claimName: pvc-manual-001这个流程最大的缺点是每来一个新需求管理员就要手动创建一堆PV量大时根本管不过来。下面讲怎么自动化。3.3 StorageClass与动态供给告别手工创建PVStorageClass本质上定义了两样东西用什么插件provisioner创建PV创建出来的PV用什么参数。当用户创建PVC时K8s检测到PVC指定了storageClassName且这个StorageClass存在就会自动调用provisioner创建匹配的PV并绑定。一个最小化的StorageClass配置apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: standard provisioner: kubernetes.io/aws-ebs parameters: type: gp3 fsType: ext4创建PVC时只需指定storageClassName: standardK8s会自动完成“创建PV—绑定PVC”的全过程管理员不再需要手工数PV数量。这就是动态供给。这里提醒一个深坑如果集群里有被标记为默认default的StorageClass而你创建PVC时没写storageClassNamePVC会绑到默认StorageClass上。但如果集群没有默认StorageClassPVC就会一直Pending事件里会出现waiting for a volume to be created, either by external provisioner or manually的提示。我经常看到有人创建了一个不带storageClassName的PVC长期Pending后一脸茫然——排查第一步就该确认集群是否存在默认StorageClass。3.4 回收策略三选一Retain、Delete、Recycle怎么取舍persistentVolumeReclaimPolicy决定PVC被删除后PV里的数据怎么处理。三种策略Retain数据保留PV状态变为Released由管理员决定清除数据、备份还是重新利用。这是最安全的策略强烈推荐有状态数据使用。DeletePVC删除时底层存储资源一并删除数据无法恢复。适合临时数据卷比如云盘的动态供给。Recycle旧版本K8s里的策略会执行清理后重新变为Available现在已废弃不建议依赖。选型逻辑很简单底层是云盘Delete策略能自动释放云磁盘资源省成本底层是NFS这类共享存储Retain最稳妥——谁都不想因为误删一个PVC导致整个共享目录的数据被清空。4. NFS共享存储从环境搭建到K8s对接全流程4.1 为什么中小集群最常用NFS优劣边界要清楚生产环境里K8s存储选型一般有几个方向云厂商块存储、分布式文件系统CephFS、GlusterFS、对象存储以及最朴素的NFS。NFS虽然是老牌网络文件系统但至今仍是中小型K8s集群最常见的共享存储方案原因很实在部署简单一台Linux服务器装nfs-utils配好exports就能用不需要维护分布式集群。支持RWXNFS天然支持多个节点同时读写挂载这是很多云盘做不到的。对K8s友好K8s内置了nfs类型的Volume不需要额外安装驱动。成本友好一台旧服务器就能承载测试环境和中等负载业务。缺点也很明显单点故障、性能瓶颈、没有内置的数据一致性保障。所以我的经验是NFS适合中低要求的共享文件场景比如日志集中存储、上传文件目录、测试环境共享卷、多副本应用的无状态共享文件真正的数据库主数据还是交给云盘或专业分布式存储更稳妥。4.2 NFS服务端配置安装、导出、防火墙与SELinux避坑第一步服务端和所有K8s节点都要安装nfs-utils# CentOS/RHEL系 yum install -y nfs-utils # Ubuntu/Debian系 apt install -y nfs-common第二步配置共享目录。创建目录并设置权限mkdir -p /data/nfs-share chown -R nobody:nobody /data/nfs-share chmod 777 /data/nfs-share # 内网环境用777虽然不严谨但排查权限问题最省心生产建议按需收紧编辑 /etc/exports/data/nfs-share 192.168.1.0/24(rw,sync,no_root_squash,no_subtree_check)说明一下几个关键参数rw允许读写。sync同步写保证数据不丢性能略低于async但对K8s来说可靠性优先。no_root_squash客户端root保持root权限。测试环境用这个能少很多麻烦生产环境建议改用root_squash更安全。no_subtree_check提升性能避免目录重命名时的一些检查开销。然后重启服务并验证systemctl enable --now nfs-server exportfs -r exportfs -v showmount -e 192.168.1.100踩过的坑汇总防火墙不放行NFS端口。NFS除了2049还会动态占用rpcbind和mountd等端口建议直接放行服务firewall-cmd --permanent --add-servicenfs --add-servicerpc-bind --add-servicemountd firewall-cmd --reloadSELinux设为enforcing时NFS导出目录配置不当会导致客户端挂载后看不到内容或无法写入。测试环境可以临时setenforce 0但生产环境强烈建议为NFS目录设置正确的SELinux标签别一上来就全局关闭。客户端和服务器时间偏差过大时NFSv3下会出现莫名的陈旧文件句柄错误。生产环境建议所有节点统一配置chrony时间同步。4.3 客户端验证与NFS类型PV/PVC对接实战配置好服务端后先在客户端手工挂载验证一遍别把问题直接抛给K8smkdir -p /mnt/test-nfs mount -t nfs 192.168.1.100:/data/nfs-share /mnt/test-nfs touch /mnt/test-nfs/hello.txt umount /mnt/test-nfs验证成功后再创建K8s的NFS类型PVapiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv-001 spec: capacity: storage: 10Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: nfs-manual nfs: server: 192.168.1.100 path: /data/nfs-share对应的PVCapiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc-001 spec: accessModes: - ReadWriteMany storageClassName: nfs-manual resources: requests: storage: 10Gi注意NFS类型PV可以同时支持ReadWriteMany和ReadWriteOnce但PVC指定的accessModes必须和PV匹配。多个Pod同时读写同一个NFS目录时并发安全由应用自己控制NFS不提供文件锁级别的一致性保障。4.4 动态NFS供给nfs-subdir-external-provisioner部署要点手动创建NFS PV适合固定数量的静态卷但如果业务经常需要动态创建PVC推荐部署nfs-subdir-external-provisioner。它是一个第三方控制器监听PVC创建事件然后在NFS共享根目录下自动创建子目录作为新PV并自动绑定给PVC。部署前先创建StorageClassprovisioner指向这个外部控制器apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-dynamic provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: archiveOnDelete: truearchiveOnDelete参数很重要PVC被删除时数据不会直接清空而是被移到NFS的archive目录中防止误删。部署方式一般用Helm或直接apply官方yaml。核心配置就三个地方NFS服务端地址和路径必须和 /etc/exports 完全对应。Deployment中的环境变量PROVISIONER_NAME要和StorageClass的provisioner字段一致。NFS共享目录权限要保证provisioner Pod所在节点能写入。部署完成后你只需要创建指定storageClassName的PVCprovisioner会自动在NFS服务器上创建子目录并在K8s里创建对应PV整个过程不需要人工干预。这个方案特别适合有大量有状态需求的测试环境和动态扩容场景。5. 生产环境故障排查存储导致的事故清单5.1 PVC一直Pending三板斧定位根因我见过太多人一看到PVC Pending就手足无措。其实按顺序排查五分钟就能定位。第一板斧看事件。kubectl describe pvc pvc-nameEvents里会直接告诉你为什么Pending找不到PV、没有匹配的StorageClass或provisioner没就绪。这条信息比任何猜测都重要。第二板斧查PV状态和条件。kubectl get pv重点看STATUS、CAPACITY、STORAGECLASS三列。如果PV是Available但PVC仍Pending多半是容量、accessModes或storageClassName不匹配。如果PV是Bound说明已经被其他PVC占了。第三板斧验证StorageClass和provisioner。kubectl get sc kubectl get pods -n kube-system | grep -i provisioner如果PVC申请了动态供给但对应provisioner Pod没有Running存储控制器根本没在工作PVC自然Pending。5.2 挂载超时怎么查先手工后K8sPod一直处于ContainerCreatingdescribe里出现FailedMount错误常见原因有NFS服务端没启动或网络不通——客户端直接telnet服务端2049端口验证。防火墙拦截——按上文NFS防火墙配置放行。挂载路径不存在或权限不足——服务端执行ls -ld /data/nfs-share确认存在且权限正确。节点缺少nfs-utils包——在节点上执行mount -t nfs手动测试。NFS服务端IO hang住——这会导致全集群访问该NFS路径的应用全部卡顿。这类问题的共性教训是先物理机手工测试再让K8s参与。很多挂载问题根本就不是K8s的锅而是底层网络或NFS服务本身的问题。在节点上先手动mount一遍通了再回头看K8s配置。5.3 API server初始化不健康隐藏的存储根因这里提一个实际案例。很多人初始化集群时遇到过the api server is not healthy after 4m0.00747357s这样的报错卡在这一步干着急。这个错误直接原因是API Server的进程健康检查不通过但深挖下去有相当一部分和存储有关——etcd的数据目录在节点磁盘或网络存储上如果磁盘写满、IO延迟过高或目录权限不对etcd起不来或不稳定API Server自然健康不了。排查按这个顺序来检查etcd容器是否在运行kubectl get pods -n kube-system | grep etcd查etcd日志看有没有权限报错、损坏corrupted等字样。检查etcd数据目录所在磁盘空间和inodedf -h、df -i检查etcd目录权限通常是etcd用户拥有不是root。如果数据目录和存储有问题清理空间、修正权限后重启etcd大部分“API server not healthy”就能恢复。这个案例说明一个规律K8s很多看似和存储无关的故障根因都在存储。所以集群里所有依赖磁盘、NFS、对象存储的组件都应该纳入日常巡检。6. 存储选型与长期维护经验6.1 一张选型表四类存储怎么选按照我自己的项目经验整理了一张选型表存储方案适用场景数据可靠性运维成本推荐度emptyDir临时缓存、sidecar共享低极低测试可用hostPathDaemonSet日志、节点本地数据中(绑定节点)低有条件地用静态PVNFS手工固定目录、少量PVC高中适合共享文件动态PVNFS provisioner大量PVC自动创建高中高推荐测试/中低负载记住这个口诀无状态用emptyDir节点组件考虑hostPath共享文件优先NFSPV静态供给动态需求上NFS provisioner数据库这类核心数据交给云盘或专业分布式存储。6.2 长期维护的四个习惯几个踩坑之后养成的习惯建议收藏每周巡检一次PV/PVC状态和容量使用率防止存储写满导致集群瘫痪。重要NFS共享目录开启备份任务防止人为删除或误操作造成不可逆损失。PVC/PV的yaml一定保留备份重建PV时方便恢复绑定关系。给所有存储相关对象打上清晰标签如envprod、teampay大规模集群里找对象时这些标签能救命。最后说点个人体会。我刚开始学K8s存储时也以为“挂个目录而已”结果第一次生产事故就是NFS权限没配好误删了一整个PV目录下的数据。从那以后我对存储规划的态度就变成了永远不要怕前期多花时间权限验证、回收策略、备份机制每一项都值得提前想清楚。这篇写的是K8s存储最核心的一条线如果能把emptyDir到NFS动态供给完整跑通对PV/PVC的理解就会真正落地。后续有时间我再整理多节点NFS高可用、CephFS接入以及数据库类有状态应用的存储实践到时候我们继续聊。