1. 从单机容器到集群调度:为什么第四篇才真正摸到K8s的门
如果你跟着这个系列一路看下来,前三篇大概率已经让你把容器跑起来了、把镜像构建明白了、也把最基础的Pod概念过了一遍。但说实话,前三篇的内容,用Docker Compose基本都能平替,甚至更简单。真正让Kubernetes区别于其他容器编排工具的东西,是从你开始认真对待“调度”这件事才浮现出来的。
我自己的学习路径就是这样:前两周觉得K8s不过如此,不就是把docker run换成了kubectl apply吗?直到我尝试把一个有状态服务部署上去,Pod一会儿跑到这个节点、一会儿跑到那个节点,数据卷挂载对不上,服务发现时灵时不灵,我才意识到——K8s的核心不是“跑容器”,而是“让一群机器像一台机器一样工作”。这个认知转变,是第四篇笔记真正想记录的东西。
这篇笔记主要围绕四个问题展开:Pod到底是怎么被分配到节点上的、控制器如何保证你的期望状态不被打破、服务发现和网络在集群内部是怎么串起来的、以及资源限制和调度策略在实际操作中会给你带来哪些“惊喜”。适合已经能跑通基础Pod、但对集群行为感到困惑的读者。如果你还没跑通过第一个Pod,建议先回看前三篇的内容。
提示:这篇笔记里的所有操作都基于一个三节点集群(一个控制面、两个工作节点)的模拟环境,具体版本差异我会在涉及的地方标注出来。
2. Pod调度的真实逻辑:从Pending状态说起
2.1 一个Pending的Pod背后发生了什么
很多人第一次遇到Pod卡在Pending状态时,第一反应是“集群坏了”。其实Pending恰恰是调度器在告诉你:我找不到合适的节点放这个Pod。要理解这件事,得先知道调度器的工作流程。
K8s的默认调度器(kube-scheduler)做决策分两个阶段:过滤(Filtering)和打分(Scoring)。过滤阶段把所有不满足硬性条件的节点排除掉,比如资源不够、节点有污点、端口冲突、亲和性不匹配。打分阶段对剩下的节点按优先级排序,选最高分的那个。
我踩过的一个典型坑:创建了一个要求2核CPU的Pod,但两个工作节点各只剩1.5核可分配。Pod就一直Pending。kubectl describe pod的Events区域会明确写出“0/3 nodes are available: 3 Insufficient cpu”。这句话比任何文档都直接。
kubectl describe pod <pod-name> | tail -20这个命令应该成为你遇到Pending时的第一个动作。Events里的信息通常足够定位问题,不需要上来就翻调度器日志。
2.2 资源请求与限制:调度器眼里的“账本”
调度器做过滤时看的不是节点的实际使用量,而是已分配的资源请求(Requests)总和。这一点非常关键,也是新手最容易误解的地方。
假设一个节点有4核CPU,上面跑了三个Pod,每个Pod的requests是1核,但实际使用只有0.2核。那么调度器认为这个节点只剩1核可分配,而不是3.4核。这种“按请求记账”的机制保证了资源不会被超卖,但也意味着你如果requests设得太大,集群利用率会很低。
resources: requests: cpu: "500m" memory: "256Mi" limits: cpu: "1" memory: "512Mi"我的经验是:requests按服务稳定运行时的实际消耗来设,limits按峰值来设。requests设太小会导致节点被超卖,设太大会浪费资源。一个比较稳妥的做法是先用监控跑一周,看P95的CPU和内存使用量,然后requests取P95值,limits取P95的1.5到2倍。
注意:CPU的limits和requests不一致时,Pod可能被限流(throttling),表现为响应变慢但不会被杀掉。内存的limits如果被超过,Pod会直接被OOM Kill。这个差异在实际运维中影响很大。
2.3 节点亲和性与污点容忍:把Pod放到该去的地方
默认调度器是“哪里有空位就去哪里”,但生产环境往往需要更精细的控制。比如GPU节点只跑训练任务、SSD节点只跑数据库。这时候就要用到节点亲和性(nodeAffinity)和污点(Taint)与容忍(Toleration)。
污点的逻辑是“节点拒绝某些Pod”,容忍的逻辑是“Pod愿意接受某些污点”。两者配合使用,可以实现专用节点的效果。比如给GPU节点打上污点:
kubectl taint nodes gpu-node-1 dedicated=gpu:NoSchedule然后只有声明了对应容忍的Pod才能调度上去:
tolerations: - key: "dedicated" operator: "Equal" value: "gpu" effect: "NoSchedule"节点亲和性则更灵活,可以表达“尽量放在某类节点”或“必须放在某类节点”。requiredDuringSchedulingIgnoredDuringExecution是硬性要求,preferredDuringSchedulingIgnoredDuringExecution是软性偏好。我一般用软性偏好做负载均衡,用硬性要求做架构约束。
2.4 调度失败的排查链路
遇到调度问题,我通常按这个顺序排查:
kubectl describe pod看Events,确认是资源不足、亲和性不匹配还是污点问题kubectl describe node看节点的Allocated resources和Taintskubectl get pods -o wide确认现有Pod的分布- 如果是自定义调度器,检查调度器Pod是否正常运行
这个链路能覆盖90%以上的调度问题。剩下的10%通常是集群级别的配置问题,比如节点NotReady或者调度器本身挂了。
3. 控制器如何让“期望状态”变成现实
3.1 声明式API的本质:你只管说想要什么
K8s最核心的设计哲学是声明式API。你告诉它“我要三个副本”,它负责让三个副本一直存在。这跟命令式的“启动一个容器”有本质区别。理解这一点,才能理解控制器的作用。
控制器的工作模式是一个无限循环:观察当前状态、对比期望状态、执行动作缩小差距。这个循环叫Reconcile Loop。Deployment控制器、ReplicaSet控制器、StatefulSet控制器都是这个模式的不同实现。
我刚开始学的时候有个疑问:为什么创建Deployment之后,还会自动创建一个ReplicaSet?后来明白了,Deployment管的是“版本”,ReplicaSet管的是“副本数”。滚动更新时,Deployment创建新的ReplicaSet,逐步增加新RS的副本、减少旧RS的副本,从而实现平滑过渡。这种分层设计让每个控制器只关注一件事,逻辑清晰且可组合。
3.2 Deployment滚动更新的参数调优
滚动更新是Deployment最常用的功能,但默认参数在生产环境往往不够用。两个关键参数是maxSurge和maxUnavailable。
strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0maxSurge: 1表示更新过程中最多多出1个Pod,maxUnavailable: 0表示任何时候可用Pod数量不能少于期望值。这个组合适合对可用性要求高的服务,但更新速度会慢一些。
如果服务可以容忍短暂的能力下降,可以把maxUnavailable设为25%,更新会快很多。我的经验是:无状态Web服务用maxUnavailable: 25%,有状态服务或核心服务用maxUnavailable: 0。
还有一个容易被忽略的参数是minReadySeconds。它定义了Pod就绪后多久才被认为可用。默认是0,意味着Pod一就绪就计入可用数量。如果服务启动后需要预热(比如加载缓存),设一个合理的minReadySeconds能避免流量打到还没准备好的Pod上。
3.3 StatefulSet与有状态服务的调度难题
有状态服务是K8s里比较难处理的部分。StatefulSet给每个Pod一个稳定的网络标识(如web-0、web-1)和稳定的存储卷。但这也意味着Pod不能随意调度到任何节点,否则存储卷可能挂载不上。
如果用的是本地存储(local PV),Pod和节点的绑定关系是硬性的。节点挂了,Pod就起不来。这时候要么用网络存储(如NFS、Ceph),要么接受这个限制并做好节点故障预案。
我在模拟环境里用local PV跑了一个有状态服务,测试节点故障时发现:Pod会一直Pending,因为调度器找不到有对应PV的节点。kubectl describe pod会提示“node(s) had volume node affinity conflict”。这个提示很明确,但第一次遇到时确实会懵。
3.4 控制器不工作的常见原因
控制器不工作的表现通常是:你改了期望状态,但实际状态没变化。排查思路:
- 控制器Pod本身是否运行正常(
kubectl get pods -n kube-system) - 是否有准入控制(Admission Webhook)拦截了请求
- 资源配额(ResourceQuota)是否已满
- 控制器是否在等待某个依赖条件(比如PVC绑定)
我遇到过一次Deployment不创建Pod的情况,最后发现是ResourceQuota限制了Pod数量。kubectl describe quota能看到当前使用量和硬限制,这个命令在排查这类问题时非常有用。
4. 服务发现与集群网络:Pod之间怎么找到彼此
4.1 ClusterIP、NodePort、LoadBalancer的选择逻辑
Service是K8s里抽象网络访问的核心对象。四种类型里,ClusterIP是默认的,只在集群内部可达;NodePort在每个节点上开一个端口;LoadBalancer通常依赖云厂商的负载均衡器;ExternalName是DNS别名。
选择哪种类型,取决于你的服务需要被谁访问:
| 访问来源 | 推荐类型 | 说明 |
|---|---|---|
| 集群内其他Pod | ClusterIP | 最简单,性能最好 |
| 集群外但同一网络 | NodePort | 需要管理端口范围 |
| 公网访问 | LoadBalancer | 依赖云环境或MetalLB |
| 外部服务别名 | ExternalName | 仅做DNS映射 |
我自己的模拟环境没有云负载均衡器,所以用MetalLB来提供LoadBalancer能力。安装MetalLB之后,创建一个LoadBalancer类型的Service就能获得一个虚拟IP。这个方案在自建集群里很实用。
4.2 CoreDNS的工作机制与排查
集群内的服务发现靠的是CoreDNS。每个Service创建时,CoreDNS会自动添加一条DNS记录,格式是<service-name>.<namespace>.svc.cluster.local。Pod里的/etc/resolv.conf会指向CoreDNS的ClusterIP。
如果服务发现不工作,排查步骤:
- 确认CoreDNS Pod是否运行正常
- 在Pod里
nslookup或dig测试域名解析 - 检查Service的Endpoints是否有值(
kubectl get endpoints) - 确认NetworkPolicy没有阻断DNS流量
我遇到过一次Endpoints为空的情况,原因是Service的selector和Pod的labels不匹配。kubectl get endpoints <service-name>显示<none>时,基本就是selector问题。这个检查应该成为服务发现问题时的标准动作。
4.3 NetworkPolicy:默认全通,按需收紧
K8s默认允许所有Pod之间互相通信。这在开发环境很方便,但在生产环境是安全隐患。NetworkPolicy用来定义Pod级别的防火墙规则。
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: deny-from-other-namespaces spec: podSelector: {} policyTypes: - Ingress ingress: - from: - podSelector: {}这个策略只允许同一命名空间内的Pod互相访问,拒绝其他命名空间的流量。注意podSelector: {}表示选择所有Pod,ingress.from.podSelector: {}表示允许来自同一命名空间所有Pod的流量。
注意:NetworkPolicy需要CNI插件支持。Calico、Cilium、Weave Net都支持,但Flannel默认不支持。如果你的集群用的是Flannel,NetworkPolicy创建了也不会生效。这个坑我踩过,排查了半天才发现是CNI的问题。
4.4 Ingress:七层流量的入口管理
Ingress是七层负载均衡的抽象,通常配合Ingress Controller使用。它的价值在于:用一个入口暴露多个服务,基于域名或路径做路由。
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: web-ingress spec: rules: - host: app.example.com http: paths: - path: /api pathType: Prefix backend: service: name: api-service port: number: 8080 - path: / pathType: Prefix backend: service: name: web-service port: number: 80这个配置把app.example.com/api路由到api-service,其他路径路由到web-service。Ingress Controller(如Nginx Ingress)会监听Ingress资源的变化,动态更新自己的配置。
我用的Nginx Ingress Controller,安装后需要创建一个LoadBalancer类型的Service来接收外部流量。在模拟环境里配合MetalLB,整个链路就通了。
5. 资源管理与调度策略的实战取舍
5.1 LimitRange与ResourceQuota的配合使用
LimitRange作用于单个Pod或容器,设置默认的requests和limits,以及最大最小值。ResourceQuota作用于命名空间,限制总资源消耗。
apiVersion: v1 kind: LimitRange metadata: name: default-limits namespace: dev spec: limits: - default: cpu: "500m" memory: "256Mi" defaultRequest: cpu: "200m" memory: "128Mi" type: Container这个配置让dev命名空间里所有没写resources的容器自动获得默认值。配合ResourceQuota,可以防止某个团队把整个集群的资源吃光。
我的经验是:开发环境用LimitRange给默认值,降低使用门槛;生产环境用ResourceQuota做硬限制,防止意外。两者配合,既方便又安全。
5.2 水平Pod自动伸缩的触发条件
HPA(Horizontal Pod Autoscaler)根据CPU或内存使用率自动调整副本数。但它的工作前提是:Pod必须设置了requests,否则HPA无法计算使用率。
kubectl autoscale deployment web --cpu-percent=70 --min=2 --max=10这个命令创建一个HPA,当CPU平均使用率超过70%时扩容,最低2个副本,最高10个。
实际使用中,CPU使用率的波动可能很剧烈,导致频繁扩缩容。可以设置behavior字段来控制扩缩容的速度:
behavior: scaleDown: stabilizationWindowSeconds: 300这个配置让缩容决策等待5分钟,避免因为短暂的负载下降就立刻缩容。
5.3 节点压力驱逐与Pod优先级
当节点资源不足时,kubelet会驱逐一些Pod来保证节点稳定。驱逐顺序取决于Pod的QoS等级:BestEffort最先被驱逐,Burstable其次,Guaranteed最后。
QoS等级由requests和limits的配置决定:
- Guaranteed:所有容器的requests等于limits
- BestEffort:所有容器都没有设置requests和limits
- Burstable:其他情况
核心服务应该配置成Guaranteed,确保资源不足时不会被优先驱逐。非核心服务可以设成Burstable,允许一定的资源弹性。
PriorityClass是另一个控制驱逐顺序的机制。高优先级的Pod可以抢占低优先级的Pod。这个功能要谨慎使用,因为抢占会导致低优先级Pod被杀死。
5.4 实际项目中的资源规划思路
在一个模拟项目中,我负责规划一个包含Web层、API层和数据库层的应用。资源规划的思路是:
- Web层:无状态,requests设小,limits设中等,用HPA自动伸缩
- API层:有状态但可水平扩展,requests按P95设,limits按P99设
- 数据库层:有状态,Guaranteed QoS,固定副本数,不用HPA
这个规划的核心逻辑是:无状态服务追求弹性,有状态服务追求稳定。资源请求的设置要基于实际监控数据,而不是拍脑袋。
6. 那些文档里不会写的踩坑记录
6.1 镜像拉取失败的几种面孔
Pod卡在ImagePullBackOff或ErrImagePull,原因可能有很多:
- 镜像名拼写错误
- 镜像不存在或tag错误
- 私有仓库认证失败
- 网络不通
排查时先看Events:kubectl describe pod会显示具体的错误信息。如果是认证问题,需要创建imagePullSecrets并挂载到ServiceAccount上。
我遇到过一次镜像拉取失败,错误信息是“manifest unknown”,最后发现是tag写成了latest但仓库里没有这个tag。这个错误信息其实很明确,但第一次看到时容易忽略。
6.2 服务端口配置的常见错误
Service的targetPort和Pod的containerPort不匹配,是服务不通的常见原因。containerPort是Pod声明自己监听的端口,targetPort是Service转发到的端口。如果targetPort写错了,流量就到不了容器。
还有一个容易混淆的是port和nodePort。port是Service的ClusterIP上暴露的端口,nodePort是每个节点上暴露的端口。ClusterIP类型的Service没有nodePort。
6.3 存储卷挂载的权限问题
挂载存储卷时,容器内的进程可能因为权限不足而无法写入。这通常是因为存储卷的属主和容器内运行的用户不匹配。
解决方法有两种:一是在Pod的securityContext里设置fsGroup,让K8s自动修改卷的属组;二是在容器启动命令里手动chown。fsGroup的方式更优雅:
securityContext: fsGroup: 1000这个配置让存储卷的属组变成1000,容器内GID为1000的用户就有写权限了。
6.4 集群DNS解析超时的排查
CoreDNS解析超时通常表现为服务间调用偶尔失败。排查时先在Pod里测试DNS:
kubectl exec -it <pod> -- nslookup kubernetes.default如果解析慢或失败,检查CoreDNS的日志和资源使用情况。CoreDNS的CPU requests设得太低会导致解析延迟。我一般给CoreDNS设200m CPU requests和100Mi内存requests,这个配置在中小集群里足够用。
还有一个隐藏问题是conntrack表满导致DNS丢包。dmesg里如果有“nf_conntrack: table full”的提示,就需要调整节点的conntrack参数。
6.5 滚动更新卡住的排查思路
Deployment滚动更新卡住,通常是因为新Pod一直不就绪。排查步骤:
kubectl rollout status deployment/<name>看更新状态kubectl get pods看新Pod的状态kubectl describe pod看新Pod的Eventskubectl logs看新Pod的日志
常见原因包括:就绪探针配置不当、镜像启动失败、资源不足导致调度失败。我遇到过一次就绪探针的path写错了,Pod一直不就绪,滚动更新就卡住了。把探针配置改对之后,更新立刻完成。
提示:就绪探针的initialDelaySeconds要设得合理。设太短会导致Pod还没启动完就被判定为不就绪,设太长会拖慢更新速度。一般根据服务启动时间来定,留20%的余量。
7. 从能跑到跑得稳:我的学习路径复盘
回头看这个系列的四篇笔记,前三篇解决的是“能不能跑起来”的问题,第四篇解决的是“能不能跑得稳”的问题。调度、控制器、服务发现、资源管理这四块内容,构成了K8s从玩具到工具的分水岭。
我自己的体会是:K8s的学习曲线陡峭,不是因为概念多,而是因为概念之间的关联复杂。单独看Pod、Service、Deployment都能理解,但把它们放在一起,加上调度器、控制器、网络插件、存储插件的交互,就容易迷失。我的建议是:每学一个概念,就动手搭一个最小可复现的环境,观察它的行为,然后故意制造一些故障,看系统怎么反应。这种“破坏性学习”比看文档有效得多。
还有一个实用技巧:养成看Events的习惯。kubectl describe和kubectl get events能解决大部分日常问题。Events里的信息是K8s组件直接输出的,比任何二手资料都准确。我现在的排查流程基本是:先看Events,再看日志,最后才去翻文档。
后续如果继续深入,下一步应该是自定义控制器和Operator开发,以及集群的监控和日志体系。但那又是另一个故事了。