ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

K8s集群调度与PV/PVC存储实战:从Pending排错到动态供给

K8s集群调度与PV/PVC存储实战:从Pending排错到动态供给 自己从零搭k8s集群到现在断断续续折腾了小半年。前面几篇把安装部署、namespace、Pod和工作负载这些基础聊完了今天这篇是系列第五篇主题是两件事集群调度以及PV和PVC。为什么把这俩放一起聊因为调度决定容器跑在哪台机器上存储决定数据落在哪里两者配合不好应用跑起来也是飘的。这篇内容适合已经装好集群、能正常跑Pod但一遇到Pod卡Pending、数据不知道存哪就抓瞎的同学。我会把调度器的底层逻辑、四种调度控制手段、PV/PVC的核心概念以及从静态存储到动态供给的实操过程全部拆开讲一遍最后附上我实际踩过的一些坑。跟着走一遍你至少能自己排查Pending问题并且把有状态应用的数据稳稳落盘。1. 调度器的底层逻辑Pod 是怎么被安顿到节点上的1.1 kube-scheduler 到底在干什么先纠正一个常见误解k8s里不是“创建Pod时自动找一台机器把容器跑起来”而是Pod对象先进入API Server然后由一个叫kube-scheduler的组件决定它该去哪台节点再告诉对应节点上的kubelet去拉镜像、起容器。这个调度器本身在kube-system命名空间里通常以static pod的方式运行在控制节点上。它干的活用一句话总结在所有候选节点里按规则筛掉不合格的再给剩下的按策略打分最后把Pod绑定到得分最高的那台机器上。整个决策过程对用户是透明的你只需要关心最终结果——Pod被调到了哪台节点状态是不是Running。我见过不少运维同学以为Pod里有启动命令就能直接跑起来结果一查Pod状态是Pending才意识到调度器这层的存在。所以第一篇排查心得就是Pod起不来永远先看Events别急着猜代码问题。1.2 预选与优选两阶段筛选过程调度器的工作分为两个大阶段对应两个英文词Filtering预选和Scoring优选。预选阶段的目标是排除优选阶段的目标是打分选优。预选阶段会检查一堆硬性条件比如节点是否Ready、节点上的资源剩余量是否满足Pod的requests、是否和已有Pod冲突占用同一个主机端口、节点有没有打污点而你恰好没容忍它、Pod要挂的存储卷该节点能不能访问等等。只要有一个条件不满足节点直接出局。优选阶段则是对预选幸存的节点打分分数维度包括节点CPU和内存的剩余比例倾向挑剩余多的、资源分配是否均衡避免某一项被打满、本地是否已有需要的镜像、节点的标签是否满足Pod的亲和性要求等等。每一项都有权重最后加权求和选最高分。这个机制理解起来有个很好的生活化类比筛球员进篮球队先看身高、体重、体能这些硬门槛不达标就淘汰剩下的人再按运球、投篮、协作能力逐项打分取综合最高的几个。预选是“一票否决”优选是“综合评估”。1.3 实操观察从 kubectl describe 里看调度全过程很多同学不知道调度过程是可以用命令直接观察的。看Pod为什么卡在Pending最常用的命令就是kubectl describe pod pod-name -n namespace输出末尾Events这一段非常重要。比如你会看到类似这样的内容Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning FailedScheduling 12s default-scheduler 0/3 nodes are available: 1 Insufficient cpu, 2 node(s) didnt match node selector这个信息直接告诉你集群里3台节点1台CPU不够2台不满足nodeSelector。你都不用去猜调度器已经把原因写得明明白白。我个人的习惯是再配合看节点的资源占用kubectl top nodes kubectl describe node node-name节点详情里的Allocated resources字段会列出目前已经被申请的资源量和总容量一比就知道还剩多少。调度器判断资源是否够用的是requests不是实际用量这一点特别容易让新手误解——哪怕节点CPU实际使用率只有20%只要一堆Pod的requests加起来把CPU申请满了调度器也会认为没资源了。2. 调度器不听话四种控制手段你都得会用2.1 最粗暴的方式nodeName 和 nodeSelectornodeName是在Pod的spec里直接指定节点名比如apiVersion: v1 kind: Pod metadata: name: fixed-pod spec: nodeName: node01 containers: - name: nginx image: nginx这种方式最直接但它会绕过调度器如果指定节点宕机Pod不会自动漂移所以生产环境一般不用。它的价值更多在于应急和测试场景——比如你要在一台节点上临时跑个诊断容器。nodeSelector是软一点的手段它不指定具体机器而是指定标签匹配。先把节点打上标签kubectl label nodes node01 disktypessd然后Pod里这样写spec: nodeSelector: disktype: ssd调度器就会在所有带disktypessd标签的节点里选一台。注意nodeSelector是精确匹配多个条件是“与”的关系必须全满足。它适合做简单的按机型、按区域调度比如GPU机器统一打上gputrue需要GPU的工作负载就通过nodeSelector选过去。2.2 节点亲和性比 nodeSelector 表达力强得多nodeSelector只能做精确等值匹配遇到“优先选SSD没有SSD选HDD也行”这种需求就没辙了。节点亲和性nodeAffinity就是来解决这个问题的。它分两种requiredDuringSchedulingIgnoredDuringExecution硬要求必须满足和preferredDuringSchedulingIgnoredDuringExecution软要求尽量满足但不强制。硬亲和相当于升级版nodeSelector软亲和则给调度器一个加分的理由——不满足也能调度只是分数低。一个软性调度示例spec: affinity: nodeAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: disktype operator: In values: - ssd - weight: 20 preference: matchExpressions: - key: disktype operator: In values: - hdd这里权重80和20就是在告诉调度器有SSD的节点加80分有HDD的节点加20分都没有也能调度只是分低。亲和性操作符支持In、NotIn、Exists、DoesNotExist、Gt、Lt灵活性比nodeSelector高了一个量级。实操里我经常用这个组合数据库这类延迟敏感的工作负载硬性要求调度到与存储节点同机required而日常无状态服务用软亲和让它倾向调度到性能更好的机器preferred不打乱资源池的整体分布。2.3 污点与容忍控制谁能往节点上跑前面几个手段都是Pod主动“挑节点”污点和容忍则是反过来——节点主动“挑Pod”。节点上打了污点TaintPod如果没有对应的容忍Toleration就不会被调度上去。三个常用的污点效果effecteffect行为NoSchedule不调度新的Pod已有Pod不受影响PreferNoSchedule尽量不调度但不是强制NoExecute立即驱逐节点上不容忍该污点的Pod给节点打污点kubectl taint nodes node1 keyvalue:NoSchedulePod里配容忍spec: tolerations: - key: key operator: Equal value: value effect: NoSchedule这里operator也可以是ExistsExists时不用写value。NoExecute还有专门的tolerationSeconds字段表示“我可忍你这么久超时后如果还不删掉污点那我的Pod就要被驱逐了”。污点最常见的用法是隔离专用节点。比如GPU节点一般都打上污点防止普通应用把资源占了。还有控制节点默认会被打上污点所以业务Pod不会被调度上去——这就是为什么你从不用关心master上有没有业务容器。2.4 Pod亲和与反亲和让多个Pod按你的想法聚散节点亲和是Pod和节点之间的关系Pod亲和反亲和则是Pod和Pod之间的关系。场景很典型两个服务互相调用频繁最好把它们的Pod调度到同一台节点上减少网络开销——用podAffinity实现。反过来为了让高可用应用扛住单点故障希望各副本分散到不同节点——用podAntiAffinity实现。反亲和是生产环境使用频率非常高的一个特性。比如我一个应用有3个副本希望它们分布在不同节点spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: web topologyKey: kubernetes.io/hostname这里的topologyKey是关键概念。它是拓扑域的维度kubernetes.io/hostname表示以节点维度计算反亲和也就是说同一节点上最多只放一个appweb的Pod。如果把topologyKey换成topology.kubernetes.io/zone那就是同一个可用区里只放一个——这个在跨AZ部署时特别有用。这里要提醒一点Pod反亲和尤其是required级别的在高密度集群里会显著降低调度成功率。你要求3个副本各占一个节点但集群只有2台节点符合条件那第三个Pod永远起不来一直Pending。我在实际项目里就吃过这个亏最后把required改成了preferred才恢复。3. 调度排错实战Pod 卡在 Pending 别慌3.1 常见的 Pending 原因Pending是调度问题最常见的表象。根据我的经验80%的Pending都能在Events里找到一句话答案。常见原因大致是这几种节点资源不足CPU或内存的requests超了Events里会显示Insufficient cpu、Insufficient memory。节点亲和或nodeSelector不匹配Events里直接说didnt match node selector。污点未容忍Events里会有node(s) had untolerated taint。存储卷无法被调度Pod引用了PVC但PV还没绑定或者PVC跨节点不可访问Events里通常是waiting for a volume to be created。主机端口冲突Events里提示host port already allocated。这里我要强调第4类调度和存储其实紧密相关。如果Pod用了local-path卷而provisioner只在某台节点上创建了目录那Pod只能调度到那台节点。调度器在预选阶段就会把挂载卷不满足条件的节点直接过滤掉。这也是为什么我坚持把调度和PV/PVC放在一篇文章里讲——它们互为因果。3.2 看调度器日志的正确姿势如果Events里没给明确说法就得看调度器日志。# 找到调度器Pod kubectl get pods -n kube-system | grep scheduler # 查看日志 kubectl logs -n kube-system scheduler-pod-name调度器本身也是容器日志会打印每一轮调度结果。如果你是二进制或自建方式部署的k8s调度器可能不是static pod那就得从kubelet的systemd日志里找线索。还有一个很多人不知道的细节当你发现调度器日志里报错但Events正常先查集群所有节点的kubelet是否健康因为调度器只负责“选机器”真正“执行”拉镜像起容器的是节点上的kubelet。顺带说一句很多人在集群安装阶段就遇到过类似报错master初始化时提示the api server is not healthy after 4m0.00747357s。这种其实是apiserver容器本身没起来通常和etcd状态、证书、镜像拉取有关跟调度器还没关系。基础组件不健康时后面一切功能都无从谈起所以排错顺序永远是从下层往上层看先解决节点和集群本身的健康问题再谈Pod调度和存储。3.3 一个典型排错案例从 Pending 到 Running我自己的一个真实案例某次搭建测试环境用kind快速建了3节点集群然后部署一个带PVC的工作负载结果Pod一直Pending。kubectl describe pod看Events发现调度失败原因是node(s) didnt match node selector。我一脸懵我根本没写过nodeSelector。后来检查发现问题出在StorageClass的volumeBindingMode设置成了WaitForFirstConsumer而PVC又指定了这个存储类。调度器在预选阶段发现存储卷还没创建且节点上访问不到对应存储所以被过滤。这其实印证了调度和存储的联动对于按需创建的卷调度器会参考Pod的调度条件反向决定在哪里创建卷再按该位置调度Pod。当WaitForFirstConsumer和标签条件叠加时就会互相“卡脖子”。解决办法也很简单把PVC和Pod的标签约束对齐或者把存储类改成Immediate模式提前绑定。从此以后我遇到Pending问题都会先看一遍StorageClass的binding模式这个习惯帮我省了很多排查时间。4. PV/PVC理解存储抽象层4.1 容器存储的痛点容器本身是无状态的一旦Pod被删除容器里的数据就跟着消失。这在一堆无状态Web服务里问题不大但数据库、缓存、日志采集器这类场景就完全没辙。那直接把宿主机目录挂进容器呢也有问题——如果Pod被调度到另一台机器数据还在原来的机器上新机器上的Pod看到一个空目录。所以k8s设计了持久化存储体系核心就是StorageClass、PV、PVC这三个对象。4.2 PV / PVC / StorageClass 的关系用一句话讲清三者关系PVC是用户提交的“存储申请单”PV是集群里已被纳管的“存储资源”StorageClass是“自动生产PV的工厂”。用户创建PVC声明要多大容量、什么访问模式系统要么去匹配集群里已有的可用PV静态供给要么通过指定的StorageClass动态创建一个新PV动态供给。绑定成功后PVC和PV是一对一的关系。Pod再通过PVC把卷挂载进容器。PVC是在具体命名空间里的而PV是集群级资源不归属任何命名空间。这一点和Pod Node的关系很类似理解了这个层级你就不会在错误的范围里到处找资源了。一个完整的挂载示例apiVersion: v1 kind: Pod metadata: name: app-with-storage spec: containers: - name: app image: nginx volumeMounts: - name: data mountPath: /usr/share/nginx/html volumes: - name: data persistentVolumeClaim: claimName: my-pvcPod先通过volumes声明“我要用一个PVC”再通过volumeMounts指定容器内挂载路径。这里有个容易踩的坑volumeMounts里的mountPath如果和镜像内已有目录冲突会把镜像里该目录内容覆盖掉。nginx镜像的/usr/share/nginx/html里本来有默认页面你挂载一个空PVC上去打开页面就是403或者空白。4.3 访问模式与回收策略PVC提交申请单时要写清楚访问模式也就是你想怎么用它访问模式缩写说明ReadWriteOnceRWO单个节点读写最常见ReadOnlyManyROX多节点只读ReadWriteManyRWX多节点读写NFS等网络存储支持ReadWriteOncePodRWOPk8s 1.22单个Pod独占读写访问模式能否满足取决于底层存储类型。云硬盘一般是RWONFS天然支持RWXlocal-path实际上也支持RWX但仅限同一节点。如果你创建的PVC声明RWX但底层的PV是hostPath类型绑定会失败Events里直接报AccessModes不匹配。PV的回收策略reclaimPolicy决定PVC被删除后PV怎么处理策略行为Retain保留PV和数据需要管理员手动清理Delete自动删除PV和底层存储数据动态供给默认Recycle已废弃别用了这块是最容易出“数据事故”的地方。动态供给建的PV默认回收策略是Delete意味着你删掉PVC底层存储卷会被直接销毁数据彻底没了。想保留数据要么回收策略改成Retain要么先把数据备份出去。我见过不止一个同事以为“删PVC只是解绑”结果数据库整个被清空。4.4 PV 的生命周期状态PV对象有几种状态排查时经常用得上状态含义Available空闲等待PVC绑定Bound已被某个PVC绑定Released绑定的PVC被删了但PV迁据还没被清理Retain策略Failed回收失败用kubectl get pv查看状态如果看到一大排Released的PV说明之前删过很多PVC而这些PV都在等你手动清理。注意Released的PV不能直接被新PVC绑定需要先删除这个PV对象再重建一个前提是底层数据你确定不要了。5. 存储实操从静态供给到动态供给5.1 先说环境我最近一套测试环境是Rocky Linux上装的k8s版本比较新1.29左右。存储这块的操作在1.20以上版本基本通用1.36如果已经发布流程差异也不大。实操前先确认集群健康kubectl get nodes kubectl get pods -n kube-system所有节点Ready、核心组件Running再继续。如果你的master初始化那一步都提示api server not healthy那先解决集群本身别急着往下走。5.2 静态供给手动创建 PV PVC 挂 NFS静态供给的逻辑是存储你已经有比如一台NFS服务器把它声明成k8s里的PV然后用户创建PVC去绑定。先看NFS PV长什么样apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv-001 spec: capacity: storage: 10Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain nfs: server: 192.168.1.100 path: /data/nfs01IP和路径要根据实际NFS服务端来改。然后创建PVCapiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc-001 spec: accessModes: - ReadWriteMany resources: requests: storage: 10Gi这里有个细节静态供给场景下PVC的storageClassName不要写也不要写成某个具体存储类留空或不写即可绑定已有PV。如果你把PVC的storageClassName显式写成了local-path那它就不会去绑定这个NFS PV而是等着动态创建。绑定完成后就可以在Pod里用了。静态供给的好处是可控、可预算、适合已有基础设施的环境坏处是每块存储都要手动创建PV扩容和自动化都麻烦。5.3 动态供给部署 local-path-provisioner动态供给才是k8s存储的正确打开方式。你只需要装一个provisioner组件并创建StorageClass用户创建PVC时指定存储类系统自动创建PV和底层存储绑定一气呵成。我个人在小集群和单机环境用得最多的动态供给方案是Rancher的local-path-provisioner。它不需要外部存储直接在节点本地目录上建卷部署极其简单kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml装完检查StorageClasskubectl get sc正常会看到local-path这个存储类默认还是annotations里的默认存储类。如果你希望它作为默认存储类检查一下是否标记为default即可。local-path这类方案的本质是在宿主机上创建目录比如默认/opt/local-path-provisioner然后以hostPath的方式挂给Pod。所以在多节点场景里它有天然限制PVC一旦在某节点上创建该PVC就固定在那台节点Pod也只能调度到那台节点。它适合的开发、测试、学习场景对生产环境务必谨慎评估——你让数据库跑在local-path上等于把它绑死在单台机器上节点挂了数据就挂了。5.4 StatefulSet 中使用 PVC有状态应用的标配有状态应用最标准的姿势是StatefulSet volumeClaimTemplates。这个模板会自动为每个副本生成独立PVC命名规则是PVC名称-StatefulSet名称-序号。apiVersion: apps/v1 kind: StatefulSet metadata: name: web spec: serviceName: nginx replicas: 3 volumeClaimTemplates: - metadata: name: data spec: accessModes: [ ReadWriteOnce ] storageClassName: local-path resources: requests: storage: 1Gi template: spec: containers: - name: nginx image: nginx volumeMounts: - name: data mountPath: /usr/share/nginx/html这个设计的精髓在于每个副本有稳定且独立的存储web-0的数据在web-0的PVC里web-1的数据在web-1的PVC里互不干扰。副本被删了重建还是挂同一个PVC数据还在。如果用Deployment挂同一个PVC多副本要小心访问模式限制。比如RWO的云盘同一时间只能被一个节点访问Deployment有两个副本调度到不同节点第二个Pod就会挂起Events里通常写着volume can only be used by one node at a time。这也是我建议大家区分“无状态应用用Deployment 共享存储”“有状态应用用StatefulSet 独享卷”的根本原因。6. 存储踩坑记录能救命的经验6.1 AccessModes 和 StorageClass 不匹配我踩过最典型的坑创建PVC时声明ReadWriteOnce但绑定的StorageClass是NFS动态供给底层NFS其实支持RWX只是SC里没开放RWX。PVC卡在Pending事件里提示InvalidAccessModes。排查方式是三步先看SC支持什么访问模式kubectl get sc -o yaml看每个provisioner类型再看PV的accessModes最后确认PVC里声明的模式和前两者匹配。记住一个原则PVC和PV的访问模式必须完全一致匹配不上就一直Pending。6.2 删除 PVC 连带数据清空这是数据安全里最痛的教训。之前我用云厂商的动态存储PV回收策略默认Delete本来只是想把测试环境清理一下删了PVC结果云盘也跟着销毁里面压测数据全部没了。现在的防护习惯是重要应用的PVC在创建StorageClass时就把reclaimPolicy改成RetainapiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: important-data provisioner: ... reclaimPolicy: Retain对于已经存在且手里没有对应StorageClass的控制权时至少在你删除PVC前先确认PV的回收策略是Retain或者先把数据用卷快照备份起来。这类误删数据的事故真的是一失手千古恨。6.3 local-path 与 hostPath 的误用local-path和hostPath在底层机制上非常相似都是宿主机目录直接挂载进容器。区别是local-path通过StorageClass统一管理、能按PVC自动分配目录hostPath则是手写特定路径。误用场景我见得太多了有人为了省事把MySQL的data目录用了hostPathPod启动后一切正常直到哪天节点坏了或Pod漂移了数据库完蛋。hostPath连“机器上Pod漂移后数据跟着换机器存到新路径下”都保证不了多节点环境尤其危险。如果你确实只有单节点开发环境用宿主机路径没问题但一定要明白这个选型的代价。6.4 PVC 扩容了但应用并不一定认账如果有状态应用跑着跑着磁盘不够了你会想到扩容PVC。动态供给下只要StorageClass里allowVolumeExpansion设为truePVC创建后还允许修改容量kubectl edit pvc>spec: volumeMode: Block我曾经用块设备模式跑过压测吞吐和延迟确实比挂载文件系统好不少但配置错误碧容易在Pod的Events里看到FailedScheduling/CannotMount。所以用到再说先用默认的Filesystem避免无谓的折腾。6.6 多节点共享读写的选型如果几个Pod要同时读写同一份数据选择就非常有限了。local-path和云盘基本都不支持RWXNFS是最成熟的方案其次可以考虑各类分布式文件存储。我之前用一个NFS服务挂给4个Pod同时读写稳定性还不错但在网络抖动和权限配置上要注意否则容易遇到Permission denied这类不痛但烦人的问题。NFS的核心是配置好exports的目录、IP白名单和权限不是插件的问题。7. 最后说点个人体会我把调度和存储折腾了一遍之后最大的感受是k8s的这两个模块看起来是独立的实际联动非常深。一个Pod能不能启动、启动在哪台机器跟它要用什么存储强相关而一个PVC能不能绑定、能不能扩容又直接决定工作负载的调度范围。建议初学者不要单独背概念按这个顺序去练先搭个3节点集群用nodeSelector把Pod固定到某节点再给节点打污点让Pod无法调度然后用local-path动态供给创建PVC把MySQL或nginx数据挂上去最后尝试删除Pod再重建验证数据还在不在。这个过程走完你对调度和存储的理解就会从“背概念”变成“真的会”。踩过几次坑之后我现在创建任何PVC之前都会先问自己三个问题回收策略是什么访问模式匹配不匹配节点如果挂了数据会不会丢这三个问题想清楚了k8s存储这块基本就不会出大事故。
返回列表