K8s学习笔记走到第五篇这一篇把集群调度和PV/PVC放在一起聊。生产环境下调度负责决定Pod落在哪个节点存储决定业务数据能不能持久保留两者一配合集群才真正算得上“可用”。如果你已经装好了K8s正卡在Pod调度、数据持久化、或者想部署Redis集群这类有状态服务这篇文章可以帮你少走很多弯路。我写这份笔记的时候用的是Rocky Linux, kubeadm方式装的集群新版K8s的调度和存储接口没有大变化所以你本地环境只要是1.20以上的版本都可以直接照着试。1. 调度和存储放在一起才能讲清楚的事1.1 为什么要拆开“调度”和“PV/PVC”再合并讲大多数人学习K8s的时候会把调度和存储当成两个独立的知识点比如今天学了nodeSelector明天学了PersistentVolume但到了实际部署一个真正有状态的应用比如Redis集群就会发现这两个概念是拧在一起的。Pod要调度到能访问对应存储的节点上PVC要能绑定到合适的PVPV又要能被调度后的节点挂载。你如果只懂一边遇到问题就会两头猜。再说个很多刚接触K8s的人容易混淆的点K8s和Docker到底有什么区别。Docker本身解决的是单机容器的创建、运行和隔离你在一台机器上docker run容器只能在这一台机器上活着而K8s是把多台机器组成一个集群它会负责把Pod调度到最合适的节点然后再通过抽象存储把数据留在Pod之外。所以“集群调度”和“PV/PVC”正是K8s对比Docker的核心价值之一这期笔记专门把这两块掰开揉碎。1.2 先明确几个对象Namespace、Node、Pod、PV/PVC在进入调度细节之前得先把资源对象的位置理清楚。Namespace是逻辑隔离单位默认有default、kube-system、kube-public这些PV和Node是集群级的对象不归属于任何Namespace而PVC和Pod是命名空间级的对象只能在使用它的Namespace里创建。我见过很多人调PVC的时候用kubectl get pv能看到PV用kubectl get pvc --all-namespaces却找不到某个PVC这就是没搞明白命名空间的作用。Pod调度时需要读取Node的信息Node有标签、有资源、有污点Pod需要存储时先通过PVC去申请PVC再和PV绑定PV背后是具体的存储后端。整个过程可以看作调度器决定Pod在哪里住存储系统决定Pod住下来以后东西存在哪里。两者都需要以API Server为中心进行协同。2. 集群调度Pod怎么找到自己该待的节点2.1 kube-scheduler的工作流程没那么神秘Kube-scheduler就是K8s默认的调度器组件它做的事情可以用一句话概括持续监听集群里没有被分配节点的Pod然后给每个Pod选一个最合适的节点。这里的“合适”不是拍脑袋而是经过两轮筛选。第一轮叫过滤把所有不满足硬性条件的节点剔除掉比如资源不足、端口冲突、不满足nodeSelector等第二轮叫打分根据一系列优先级规则给剩余节点打分比如资源均衡度、Pod聚集度等最后选择分数最高的那个。整个过程你可以当成选房子过滤阶段排除掉“价格超出预算”和“离公司太远”的房源打分阶段再选“通勤时间最短”和“生活配套最好”的选项然后再签约。实际工作中你不需要关心调度器内部每个算法但一定要知道几个核心的调度对象nodeSelector、nodeAffinity、污点与容忍、Pod亲和性与反亲和性。这四样东西基本覆盖了日常90%的调度需求。2.2 nodeSelector给节点贴标签让Pod按其选择最简单的调度方式就是给节点打标签然后在Pod的spec里用nodeSelector指定标签的值。比如我想让Pod只跑到GPU节点上apiVersion: v1 kind: Pod metadata: name: gpu-test spec: nodeSelector: gpu: true containers: - name: cuda image: nvidia/cuda:12.0-base给节点打标签只需要一条命令kubectl label node node01 gputrue这个方式够用但有几个明显的坑。第一是它只能做等值匹配不支持“不是某节点”或者“标签存在即可”这类逻辑第二是没有“尽量满足”的弹性只要没有节点带gputruePod就会一直Pending。所以对于复杂的生产场景我更推荐用nodeAffinity。2.3 nodeAffinity从“必须”到“尽量”的弹性策略NodeAffinity相比nodeSelector灵活得多它有两种策略分别对应硬性要求和软性偏好。requiredDuringSchedulingIgnoredDuringExecution表示必须满足不满足就不调度preferredDuringSchedulingIgnoredDuringExecution表示尽量满足如果满足不了调度器也会找其他节点兜底。举个实际例子。比如我想让Redis主从分到不同可用区但不强制必须分affinity: nodeAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 preference: matchExpressions: - key: topology.kubernetes.io/zone operator: In values: - zone-a很多初学者容易忽略operator的用法NodeAffinity支持的操作符包括In、NotIn、Exists、DoesNotExist、Gt、Lt。比如我想排除某一批机器就可以用NotIn。还有一个常见的坑是如果你同时写nodeSelector和nodeAffinity调度器会要求两者同时满足不是说改了affinity就可以不写selector了。2.4 污点与容忍把“坏人”请下专用的节点污点Taint和容忍Toleration是另一组对抗性设计。污点是打在节点上的标记表示“这个节点我不喜欢某些Pod”容忍是打在Pod上的标记表示“我能够忍受这个污点”。有容忍的Pod才有资格被调度到带污点的节点上。最常见的场景就是控制节点。用kubeadm安装的K8s集群控制节点默认带一个污点比如node-role.kubernetes.io/control-plane:NoSchedule所以普通的Pod不会跑到Master节点上。如果我想让一个监控Pod跑到控制节点就需要给它加对应的容忍tolerations: - key: node-role.kubernetes.io/control-plane operator: Exists effect: NoSchedule污点效果主要有三种NoSchedule表示不接受新Pod但已经运行的Pod不受影响PreferNoSchedule是一个软限制NoExecute会更狠它会驱逐节点上已有的、没有容忍的Pod。给节点手动打污点也是一行命令kubectl taint nodes node02 disktypessd:NoSchedule加上容忍后Pod就可以被调度到SSD节点上。这个组合对于专用节点特别有效比如GPU节点打上污点只有申请了GPU的Pod带容忍调度上去其他普通Pod就不会跑上去浪费资源。2.5 调试Pod一直Pending怎么排查调度的问题绝大多数都会表现为一个现象Pod卡在Pending就是不Running。这时候首先用kubectl describe pod pod-name看底部Events调度器会把原因直接写在里面。常见的Pending原因我整理成了一张速查表现象可能原因排查方式Events显示0/3 nodes available节点资源不足或节点NotReadykubectl describe nodes有nodeSelector不匹配节点没有对应标签kubectl get nodes --show-labels有污点无容忍节点taints导致拒绝调度kubectl describe nodePVC Pending存储没有绑定导致Pod无法启动kubectl describe pvcAPI Server不稳定kubectl命令正常但调度没反应查apiserver日志这里我特别想提一下热词里那个“master初始化显示the api server is not healthy after 4m0.00747357s”的问题。很多人在Rocky上装K8skubeadm init之后看到这条日志就慌了。我遇到过几次其实根因通常是kube-apiserver容器起不来常见原因是镜像拉取失败、etcd健康检查失败或者证书配置问题。优先查kubectl get pods -n kube-system看apiserver是否Running再kubectl logs -n kube-system kube-apiserver-master看具体报错不要反复重置集群。这个错误属于安装期问题但它也会直接影响调度因为调度器依赖apiserver提供的数据apiserver不健康后面所有调度动作都转不起来。3. PV/PVC把存储从Pod里真正抽离出来3.1 从emptyDir到hostPath再到PV/PVC学习PV/PVC之前我建议你先回顾一下容器存储的三个层次。第一个层次是emptyDir它只是一个Pod生命周期内的临时目录Pod删掉数据也就没了适合放缓存和临时文件不适合放数据库数据。第二个层次是hostPath它直接把宿主机目录挂载到容器里数据能留下来但问题很大如果Pod被调度到另一台节点数据就不会跟着走而且多副本Pod同时写同一个hostPath目录很容易冲突。第三个层次就是PV/PVC它把存储从具体节点抽象出来。应用不需要关心PV背后是NFS、Ceph还是云盘只需要像声明CPU和内存一样声明“我要多少存储、什么访问模式”然后系统去匹配或创建对应的PV。PV相当于集群里的存储资源PVC相当于应用的存储请求。我用一个生活化的类比PV是你家的仓库PVC是你向仓库发出的订单。PVC里写着“我要10Gi空间可读可写”系统看到订单后去仓库里找一个符合条件的PV如果PV的容量、访问模式、StorageClass标签都对得上PVC就会和PV绑定。如果找不到PVC就会一直PendingPod自然起不来。3.2 访问模式和回收策略绑定前必须看懂的两个参数PV/PVC的绑定不是随便绑至少要匹配容量、访问模式和StorageClass名称。访问模式有三种常见值ReadWriteOnce只能被单个节点以读写方式挂载适合数据库单实例。ReadOnlyMany可以被多个节点以只读方式挂载适合配置文件共享。ReadWriteMany可以被多个节点同时读写适合文件存储、共享目录。很多人在做Redis集群时想用ReadWriteMany但后端存储是本地块设备根本支持不了多节点读写结果PVC一直Pending。所以配置前要先确认你的存储后端能力NFS支持ReadWriteManyCeph RBD一般只支持ReadWriteOnce。回收策略上PV有Retain和Delete两种主流策略。Retain表示PV释放后需要管理员手动清理和重置Delete表示PV释放时直接删除底层存储资源。我建议在测试环境用Delete生产环境先设置Retain给自己留一条后悔路。3.3 手动创建PV和PVC一步一步把绑定跑通在没有StorageClass的情况下你得先手工建PV再手工建PVC最后让Pod使用PVC。下面我用NFS存储举个完整例子。首先在存储节点上准备一个目录并共享假设IP是192.168.1.100路径/data/nfs。创建PVapiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv spec: capacity: storage: 10Gi volumeMode: Filesystem accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain nfs: server: 192.168.1.100 path: /data/nfs创建PVCapiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc spec: accessModes: - ReadWriteMany resources: requests: storage: 10Gi创建之后用kubectl get pv和kubectl get pvc查看状态。如果PVC的STATUS还是Pending最常见的原因就是容量、访问模式不匹配或者PV已经被别的PVC绑定了。PV和PVC绑定成功后你再写Podspec: containers: - name: app image: busybox volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: nfs-pvc这里踩过一个坑PV的容量是10GiPVC申请也是10Gi但如果你PV里有storageClassName字段而PVC里没有指定对应的StorageClass绑定就失败。所以要么PV不写storageClassName要么两边完全一致。3.4 StorageClass动态供给从“手工匹配”到“一键生成”手工创建PV只适合测试生产里更常见的是用StorageClass实现动态供给。你只要创建一个StorageClass再创建PVC时指定storageClassName系统就会自动通过Provisioner创建PV不需要你提前手动建PV。这个过程非常像云主机自动挂云盘PVC提需求StorageClass后面的Provisioner去调用存储接口完成创建和挂载。一个简单的StorageClass定义如下apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-client provisioner: k8s-sigs.io/nfs-subdir-external-provisioner reclaimPolicy: Delete然后在PVC里指定spec: storageClassName: nfs-client accessModes: - ReadWriteMany resources: requests: storage: 5GiPV会自动生成你看不到手工创建PV的过程但kubectl get pv里会多出一个名称类似pvc-uuid的PV。这个方式适合在Redis集群这种需要大量持久化存储的场景下使用后面我会专门用一个实战例子把它串起来。4. 实战部署Redis集群同时用到调度策略和PVC4.1 需求分析和拓扑规划热词里看到很多人搜“k8s redis集群”我做这期笔记的时候也顺手在现网环境里重新部署了一遍。Redis集群是典型的有状态应用需要同时考虑调度和存储。我的需求很简单部署一个三节点的Redis集群一主两从。每个Redis实例需要独立的存储空间数据不能因为Pod重启而丢失三个实例尽可能分散到不同节点避免一台宿主机挂了导致整个集群崩溃每个Pod需要稳定的网络标识因为Redis集群通过节点地址组成Cluster Bus重启后地址不能变来变去。这三个需求对应到K8s上的方案就三个字StatefulSet。StatefulSet本身提供了稳定的网络标识可以通过volumeClaimTemplates为每个副本自动创建PVC再配合PodAntiAffinity让不同副本调度到不同节点。这就是调度和存储结合的典型场景。4.2 关键YAMLStatefulSet里如何写调度与存储下面是一个简化但能用的Redis StatefulSet片段重点看spec部分的affinity和volumeClaimTemplatesapiVersion: apps/v1 kind: StatefulSet metadata: name: redis-cluster spec: serviceName: redis-headless replicas: 3 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: redis topologyKey: kubernetes.io/hostname containers: - name: redis image: redis:7.0 command: [redis-server] args: [--appendonly, yes] volumeMounts: - name: redis-data mountPath: /data volumeClaimTemplates: - metadata: name: redis-data spec: storageClassName: nfs-client accessModes: [ ReadWriteOnce ] resources: requests: storage: 5Gi这段配置有四个关键点。第一serviceName: redis-headless表示这个StatefulSet要搭配Headless Service使用每个Pod获得一个稳定的DNS名比如redis-cluster-0.redis-headless.namespace.svc.cluster.local。第二podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution要求每个Redis Pod尽量落在不同节点上topologyKey用kubernetes.io/hostname表示按主机维度区分。这里我的测试环境有三个节点所以三个副本可以均匀分布如果你节点不够调度器会强制等待Pod就会卡在Pending这是需要留意的。第三volumeClaimTemplates会自动为每个Pod创建一个PVC名称规则是volumeClaimTemplate-name-StatefulSet名称-序号比如redis-data-redis-cluster-0、redis-data-redis-cluster-1。第四我选的访问模式是ReadWriteOnce因为每个Redis实例只需要本节点访问自己的存储。要注意的是如果你后端是NFS那么storageClassName: nfs-client对应的Provisioner能创建PV而NFS本身支持多节点读写但这里声明为ReadWriteOnce也没有问题。创建Headless ServiceapiVersion: v1 kind: Service metadata: name: redis-headless spec: clusterIP: None selector: app: redis ports: - port: 6379 targetPort: 6379然后执行kubectl apply -f redis-sts.yaml kubectl get sts kubectl get pods -o wide kubectl get pvc正常情况下三个Pod会陆续创建每个Pod都有自己对应的PVC。如果你看到某个Pod卡在ContainerCreating别急着删先看下面这个坑。4.3 踩坑记录一主两从卡在ContainerCreating我在实际操作里遇到过最典型的问题是StorageClass配好了PV也动态创建了但Redis Pod一直ContainerCreating。用kubectl describe pod看到Events里报错说挂载NFS失败mount: wrong fs type。排查起来分三步走。第一步检查PVC状态kubectl get pvc如果PVC不是Bound问题在存储绑定去查StorageClass的Provisioner是否正常运行Podnfs-client-provisioner是否在Running。第二步检查Pod挂载报错kubectl describe pod redis-cluster-0如果看到MountVolume相关的FailedMount信息重点看宿主机上有没有装nfs-utils或nfs-common。Rocky系统的NFS客户端软件包没装齐K8s向节点下发NFS挂载就会报错。第三步检查节点容忍情况后来我发现有一个Pod一直Pending原因是那个节点NotReady或者taints没排掉调度器过不去。你可以用kubectl describe node node03查看Taints如果不希望Pod跑到这个节点就不要把副本数超过可用节点数量。还有一次我把podAntiAffinity写成了required: true但集群当时只有三个节点其中一个是控制节点带污点实际可调度的只有两个工作节点结果第三个副本永远Pending。后来改成preferred或给控制节点加容忍才能跑起来。这里也引出了经验反亲和性的required策略一定要提前确认可用节点数量否则它会非常严格地拒绝调度。4.4 常见问题速查表给出一张我遇到的K8s调度和存储问题速查表方便你在实际环境里快速定位问题可能原因排查与解决Pod一直PendingEvents无可用节点CPU/内存不足节点NotReady或标签不匹配kubectl describe node查看资源与状态Pod一直PendingEvents提示有污点节点有TaintsPod无Tolerations按需添加容忍或去掉节点污点PVC一直PendingPV没有绑定容量/访问模式/StorageClass不匹配kubectl describe pvc比对PV字段PV显示Released但PVC无法复用回收策略是RetainPV没有清理手动删除PV重新创建或改Delete策略StatefulSet副本调度到同一节点没有配置PodAntiAffinity配置反亲和性或增加可用节点ContainerCreating挂载不了NFS宿主机缺少NFS客户端或存储服务不可达安装nfs-utils/nfs-common测试存储网络API Server不健康导致调度无反应apiserver容器异常、etcd异常查看kube-system的apiserver pod日志修复后再用这张表是我从实际部署过程中总结出来的基本上你在搜索“k8s redis集群”时遇到的那些问题都能在这里找到影子。5. 从“能用”到“好用”一点个人经验踩过几次坑之后我最大的感受是调度和存储这两个主题单独看书都觉得不难但组合起来就像一对舞伴任何一个节奏不对都可能摔倒。我在实际项目里经常要先问自己四个问题这个Pod能不能容忍节点故障数据要不要跨节点共享底层的存储端能不能支持多节点读写副本数量是否比可用节点数量还多这四个问题想清楚再去写YAML成功率会高很多。另外有一个小技巧分享给你。在测试环境里我从来不用现成的Helm Chart而是先手写裸的YAML一个字段一个字段地验证。比如先创建一个带nodeSelector的Pod看调度结果再创建一个PVC看绑定状态最后才把两者合到一个StatefulSet里。这个过程会逼着你理解每个字段的作用而不是依赖模板之后变成“能跑但出了问题不知道怎么查”的状态。集群调度和PV/PVC的学习笔记到这里下一步我打算把Service和Ingress的流量机制整理清楚毕竟数据落下来之后还得让请求正确进来。如果你也在看K8s学习笔记可以把这一篇当成第五站前面补上安装部署、Namespace基础后面再继续深入网络和可观测性。目前这套笔记还是在Rocky Linux加kubeadm的集群上实测过的你换Ubuntu或者二进制方式安装也大同小异核心思路不会变。
阅读完成 · 觉得有帮助?